/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/app/src/components/AppView/AppView.test.tsx
1 494 строки
45 KB
Ken McGrady
Fix sidebar collapsing on mobile when clicking portaled elements (#13653)
28 янв 2026, 00:14
Не верифицирован
28 янв 2026, 00:14
94c5a51
Код
Авторство
О чём код?
/** * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { fireEvent, screen } from "@testing-library/react" import { shouldShowNavigation } from "@streamlit/app/src/components/Navigation" import { AppRoot, BlockNode, ComponentRegistry, ElementNode, FileUploadClient, makeElementWithInfoText, mockEndpoints, mockSessionInfo, mockTheme, NavigationContextProps, WidgetStateManager, } from "@streamlit/lib" import { render, renderWithContexts, RenderWithContextsOptions, } from "@streamlit/lib/testing" import { Block as BlockProto, Element, ForwardMsgMetadata, Logo as LogoProto, Navigation, PageConfig, } from "@streamlit/protobuf" import AppView, { AppViewProps } from "./AppView" const FAKE_SCRIPT_HASH = "fake_script_hash" function getSidebarConfigContextOutput( context: RenderWithContextsOptions["sidebarConfigContext"] = {} ): NonNullable<RenderWithContextsOptions["sidebarConfigContext"]> { return { initialSidebarState: PageConfig.SidebarState.AUTO, appLogo: null, sidebarChevronDownshift: 0, expandSidebarNav: false, hideSidebarNav: false, ...context, } } function getNavigationContextOutput( context: Partial<NavigationContextProps> = {} ): NavigationContextProps { return { pageLinkBaseUrl: "", currentPageScriptHash: FAKE_SCRIPT_HASH, onPageChange: vi.fn(), navSections: [], appPages: [{ pageName: "streamlit_app", pageScriptHash: "page_hash" }], ...context, } } const buildMediaURL = vi.fn((url: string) => url) const sendClientErrorToHost = vi.fn() const mockEndpointProp = mockEndpoints({ buildMediaURL, sendClientErrorToHost, }) function getProps(props: Partial<AppViewProps> = {}): AppViewProps { const sessionInfo = mockSessionInfo() return { endpoints: mockEndpointProp, elements: AppRoot.empty(FAKE_SCRIPT_HASH, true), sendMessageToHost: vi.fn(), widgetMgr: new WidgetStateManager({ sendRerunBackMsg: vi.fn(), formsDataChanged: vi.fn(), }), uploadClient: new FileUploadClient({ sessionInfo, endpoints: mockEndpointProp, formsWithPendingRequestsChanged: () => {}, requestFileURLs: vi.fn(), }), wideMode: false, embedded: false, widgetsDisabled: false, showToolbar: true, showPadding: false, disableScrolling: false, navigationPosition: Navigation.Position.SIDEBAR, addScriptFinishedHandler: vi.fn(), removeScriptFinishedHandler: vi.fn(), componentRegistry: new ComponentRegistry(mockEndpointProp), ...props, } } // Helper to render AppView with proper context function renderAppView( props: Partial<AppViewProps> = {}, overrides?: { sidebarConfigContext?: RenderWithContextsOptions["sidebarConfigContext"] navigationContext?: Partial<NavigationContextProps> } ): ReturnType<typeof renderWithContexts> { const sidebarConfigContextValues = getSidebarConfigContextOutput( overrides?.sidebarConfigContext || {} ) const navigationContextValues = getNavigationContextOutput( overrides?.navigationContext || {} ) return renderWithContexts(<AppView {...getProps(props)} />, { sidebarConfigContext: sidebarConfigContextValues, navigationContext: navigationContextValues, }) } describe("AppView element", () => { it("renders without crashing", () => { render(<AppView {...getProps()} />) const appViewContainer = screen.getByTestId("stAppViewContainer") expect(appViewContainer).toBeInTheDocument() expect(appViewContainer).toHaveClass("stAppViewContainer") }) it("does not render a sidebar when there are no elements and only one page", () => { render(<AppView {...getProps()} />) const sidebar = screen.queryByTestId("stSidebar") expect(sidebar).not.toBeInTheDocument() }) it("renders a sidebar when there are elements and only one page", () => { const sidebarElement = new ElementNode( makeElementWithInfoText("sidebar!"), ForwardMsgMetadata.create({}), "no script run id", FAKE_SCRIPT_HASH ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [sidebarElement], new BlockProto({ allowEmpty: true }) ) const main = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const event = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const bottom = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const props = getProps({ elements: new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [main, sidebar, event, bottom]) ), }) render(<AppView {...props} />) const sidebarDOMElement = screen.queryByTestId("stSidebar") expect(sidebarDOMElement).toBeInTheDocument() }) it("renders a sidebar when there are no elements but multiple pages", () => { renderAppView( {}, { navigationContext: { appPages: [ { pageName: "streamlit_app", pageScriptHash: "page_hash" }, { pageName: "page2", pageScriptHash: "page2_hash" }, ], }, } ) const sidebarDOMElement = screen.queryByTestId("stSidebar") expect(sidebarDOMElement).toBeInTheDocument() }) it("does not render a sidebar when there are no elements, multiple pages, and hideSidebarNav is true", () => { renderAppView( {}, { sidebarConfigContext: { hideSidebarNav: true }, navigationContext: { appPages: [ { pageName: "streamlit_app", pageScriptHash: "page_hash" }, { pageName: "page2", pageScriptHash: "page2_hash" }, ], }, } ) const sidebar = screen.queryByTestId("stSidebar") expect(sidebar).not.toBeInTheDocument() }) it("renders a sidebar when there are elements and multiple pages", () => { const sidebarElement = new ElementNode( makeElementWithInfoText("sidebar!"), ForwardMsgMetadata.create({}), "no script run id", FAKE_SCRIPT_HASH ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [sidebarElement], new BlockProto({ allowEmpty: true }) ) const main = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const event = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const bottom = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) renderAppView( { elements: new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [main, sidebar, event, bottom]) ), }, { navigationContext: { appPages: [ { pageName: "streamlit_app", pageScriptHash: "page_hash" }, { pageName: "page2", pageScriptHash: "page2_hash" }, ], }, } ) const sidebarDOMElement = screen.queryByTestId("stSidebar") expect(sidebarDOMElement).toBeInTheDocument() }) it("does not render the sidebar if there are no elements, multiple pages but hideSidebarNav is true", () => { renderAppView( {}, { sidebarConfigContext: { hideSidebarNav: true }, navigationContext: { appPages: [ { pageName: "streamlit_app", pageScriptHash: "page_hash" }, { pageName: "page2", pageScriptHash: "page2_hash" }, ], }, } ) const sidebar = screen.queryByTestId("stSidebar") expect(sidebar).not.toBeInTheDocument() }) it("does not render the wide class", () => { const main = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const event = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const bottom = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const props = getProps({ elements: new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [main, sidebar, event, bottom]) ), }) render(<AppView {...props} />) const style = window.getComputedStyle( screen.getByTestId("stMainBlockContainer") ) expect(style.maxWidth).not.toEqual("initial") }) it("does render the wide class when specified", () => { render(<AppView {...getProps({ wideMode: true })} />) const style = window.getComputedStyle( screen.getByTestId("stMainBlockContainer") ) expect(style.maxWidth).toEqual("initial") }) it("disables scrolling when disableScrolling is true", () => { render(<AppView {...getProps({ disableScrolling: true })} />) const style = window.getComputedStyle(screen.getByTestId("stMain")) expect(style.overflow).toEqual("hidden") }) it("allows scrolling when disableScrolling is false", () => { render(<AppView {...getProps({ disableScrolling: false })} />) const style = window.getComputedStyle(screen.getByTestId("stMain")) expect(style.overflow).toEqual("auto") }) describe("top padding logic", () => { const getMainBlockContainerStyle = (): CSSStyleDeclaration => { return window.getComputedStyle( screen.getByTestId("stMainBlockContainer") ) } describe("non-embedded apps", () => { it("uses 6rem top padding by default", () => { render(<AppView {...getProps({ embedded: false })} />) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") }) it("uses 6rem top padding regardless of showPadding", () => { render( <AppView {...getProps({ embedded: false, showPadding: true })} /> ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") }) it("uses 6rem top padding regardless of showToolbar", () => { render( <AppView {...getProps({ embedded: false, showToolbar: true })} /> ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") }) it("uses 8rem top padding when top nav is showing (>1 page)", () => { renderAppView( { embedded: false, navigationPosition: Navigation.Position.TOP, }, { navigationContext: { appPages: [ { pageName: "page1", pageScriptHash: "hash1" }, { pageName: "page2", pageScriptHash: "hash2" }, ], }, } ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("8rem") }) it("uses 6rem top padding when top nav is not showing (single page)", () => { renderAppView( { embedded: false, navigationPosition: Navigation.Position.TOP, }, { navigationContext: { appPages: [{ pageName: "page1", pageScriptHash: "hash1" }], }, } ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") }) it("uses 6rem top padding regardless of sidebar content (hasSidebar does not affect non-embedded)", () => { const sidebarElement = new ElementNode( makeElementWithInfoText("sidebar!"), ForwardMsgMetadata.create({}), "no script run id", FAKE_SCRIPT_HASH ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [sidebarElement], new BlockProto({ allowEmpty: true }) ) const empty = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) renderAppView( { elements: new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [empty, sidebar, empty, empty]) ), embedded: false, // Non-embedded }, { navigationContext: { appPages: [{ pageName: "page1", pageScriptHash: "hash1" }], // Single page, no top nav }, } ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") // Should be 6rem, not affected by sidebar }) }) describe("embedded apps", () => { describe("with show_padding option", () => { it("uses 6rem top padding when showPadding=true", () => { render( <AppView {...getProps({ embedded: true, showPadding: true })} /> ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") expect(style.paddingBottom).toEqual("10rem") }) it("uses 6rem top padding when showPadding=true regardless of header content", () => { // Create elements that would trigger hasHeader=true const logo = LogoProto.create({ image: "https://example.com/logo.png", }) renderAppView( { embedded: true, showPadding: true, navigationPosition: Navigation.Position.TOP, }, { sidebarConfigContext: { appLogo: logo }, } ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") }) it("uses 6rem top padding even with top nav (never 8rem for embedded apps)", () => { render( <AppView {...getProps({ embedded: true, showPadding: true, navigationPosition: Navigation.Position.TOP, })} /> ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") }) }) describe("with show_toolbar option", () => { it("uses 6rem top padding when showToolbar=true", () => { render( <AppView {...getProps({ embedded: true, showToolbar: true })} /> ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") }) it("uses 6rem top padding when showToolbar=true regardless of header content", () => { // Create elements that would trigger hasHeader=true const logo = LogoProto.create({ image: "https://example.com/logo.png", }) renderAppView( { embedded: true, navigationPosition: Navigation.Position.TOP, showToolbar: true, }, { sidebarConfigContext: { appLogo: logo }, } ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") }) }) describe("with both show_padding and show_toolbar options", () => { it("uses 6rem top padding when both showPadding=true and showToolbar=true", () => { render( <AppView {...getProps({ embedded: true, showPadding: true, showToolbar: true, })} /> ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") }) }) describe("without show_padding or show_toolbar options", () => { it("uses 2.25rem top padding when no header content", () => { render( <AppView {...getProps({ embedded: true, showPadding: false, showToolbar: false, navigationPosition: Navigation.Position.SIDEBAR, })} /> ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("2.25rem") expect(style.paddingBottom).toEqual("1rem") }) it("uses 4.5rem top padding when header content exists (logo)", () => { const logo = LogoProto.create({ image: "https://example.com/logo.png", }) renderAppView( { embedded: true, showPadding: false, showToolbar: false, }, { sidebarConfigContext: { appLogo: logo }, } ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("4.5rem") expect(style.paddingBottom).toEqual("1rem") }) it("uses 4.5rem top padding when header content exists (navigation)", () => { renderAppView( { embedded: true, showPadding: false, navigationPosition: Navigation.Position.TOP, showToolbar: false, }, { sidebarConfigContext: { appLogo: null, }, navigationContext: { appPages: [ { pageName: "page1", pageScriptHash: "hash1" }, { pageName: "page2", pageScriptHash: "hash2" }, ], }, } ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("4.5rem") expect(style.paddingBottom).toEqual("1rem") }) it("uses 4.5rem top padding when header content exists (sidebar expand button)", () => { const sidebarElement = new ElementNode( makeElementWithInfoText("sidebar!"), ForwardMsgMetadata.create({}), "no script run id", FAKE_SCRIPT_HASH ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [sidebarElement], new BlockProto({ allowEmpty: true }) ) const empty = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const props = getProps({ elements: new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [empty, sidebar, empty, empty]) ), embedded: true, showPadding: false, }) renderAppView( { ...props, showToolbar: false }, { sidebarConfigContext: { initialSidebarState: PageConfig.SidebarState.COLLAPSED, }, } ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("4.5rem") expect(style.paddingBottom).toEqual("1rem") }) it("uses 4.5rem top padding when sidebar content exists (hasSidebar=true)", () => { const sidebarElement = new ElementNode( makeElementWithInfoText("sidebar!"), ForwardMsgMetadata.create({}), "no script run id", FAKE_SCRIPT_HASH ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [sidebarElement], new BlockProto({ allowEmpty: true }) ) const empty = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const props = getProps({ showToolbar: false, elements: new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [empty, sidebar, empty, empty]) ), embedded: true, showPadding: false, }) render(<AppView {...props} />) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("4.5rem") expect(style.paddingBottom).toEqual("1rem") }) }) }) describe("edge cases", () => { it("prioritizes showPadding over header content", () => { const logo = LogoProto.create({ image: "https://example.com/logo.png", }) renderAppView( { embedded: true, showPadding: true, }, { sidebarConfigContext: { appLogo: logo }, } ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") }) it("prioritizes showToolbar over header content", () => { const logo = LogoProto.create({ image: "https://example.com/logo.png", }) renderAppView( { embedded: true, showPadding: false, showToolbar: true, }, { sidebarConfigContext: { appLogo: logo }, } ) const style = getMainBlockContainerStyle() expect(style.paddingTop).toEqual("6rem") }) }) }) describe("handles logo rendering with no sidebar", () => { const imageOnly = LogoProto.create({ image: "https://global.discourse-cdn.com/business7/uploads/streamlit/original/2X/8/8cb5b6c0e1fe4e4ebfd30b769204c0d30c332fec.png", }) const imageWithLink = LogoProto.create({ image: "https://global.discourse-cdn.com/business7/uploads/streamlit/original/2X/8/8cb5b6c0e1fe4e4ebfd30b769204c0d30c332fec.png", link: "www.example.com", }) const imageWithSize = LogoProto.create({ image: "https://global.discourse-cdn.com/business7/uploads/streamlit/original/2X/8/8cb5b6c0e1fe4e4ebfd30b769204c0d30c332fec.png", size: "large", }) const fullAppLogo = LogoProto.create({ image: "https://global.discourse-cdn.com/business7/uploads/streamlit/original/2X/8/8cb5b6c0e1fe4e4ebfd30b769204c0d30c332fec.png", link: "www.example.com", iconImage: "https://docs.streamlit.io/logo.svg", }) it("doesn't render if no logo provided", () => { renderAppView(getProps(), { sidebarConfigContext: { appLogo: null }, }) expect(screen.queryByTestId("stHeaderLogo")).not.toBeInTheDocument() }) it("uses iconImage if provided", () => { const sourceSpy = vi.spyOn(mockEndpointProp, "buildMediaURL") renderAppView({}, { sidebarConfigContext: { appLogo: fullAppLogo } }) const collapsedLogo = screen.getByTestId("stHeaderLogo") expect(collapsedLogo).toBeInTheDocument() expect(sourceSpy).toHaveBeenCalledWith( "https://docs.streamlit.io/logo.svg" ) expect(collapsedLogo).toHaveClass("stLogo") }) it("defaults to image if no iconImage", () => { const sourceSpy = vi.spyOn(mockEndpointProp, "buildMediaURL") renderAppView({}, { sidebarConfigContext: { appLogo: imageOnly } }) const collapsedLogo = screen.getByTestId("stHeaderLogo") expect(collapsedLogo).toBeInTheDocument() expect(sourceSpy).toHaveBeenCalledWith( "https://global.discourse-cdn.com/business7/uploads/streamlit/original/2X/8/8cb5b6c0e1fe4e4ebfd30b769204c0d30c332fec.png" ) }) it("default no link with image size medium", () => { renderAppView({}, { sidebarConfigContext: { appLogo: imageOnly } }) expect(screen.queryByTestId("stLogoLink")).not.toBeInTheDocument() expect(screen.getByTestId("stHeaderLogo")).toHaveStyle({ height: "1.5rem", }) }) it("link with image if provided", () => { renderAppView({}, { sidebarConfigContext: { appLogo: imageWithLink } }) expect(screen.getByTestId("stLogoLink")).toHaveAttribute( "href", "www.example.com" ) }) it("renders logo - large size when specified", () => { renderAppView({}, { sidebarConfigContext: { appLogo: imageWithSize } }) expect(screen.getByTestId("stHeaderLogo")).toHaveStyle({ height: "2rem", }) }) it("sends an CLIENT_ERROR message when the logo source fails to load", () => { const props = getProps({}) renderAppView(props, { sidebarConfigContext: { appLogo: imageOnly } }) const logoElement = screen.getByTestId("stHeaderLogo") expect(logoElement).toBeInTheDocument() fireEvent.error(logoElement) expect(sendClientErrorToHost).toHaveBeenCalledWith( "Header Logo", "Logo source failed to load", "onerror triggered", "https://global.discourse-cdn.com/business7/uploads/streamlit/original/2X/8/8cb5b6c0e1fe4e4ebfd30b769204c0d30c332fec.png" ) }) }) describe("when window.location.hash changes", () => { let originalLocation: Location beforeEach(() => (originalLocation = window.location)) afterEach(() => { Object.defineProperty(window, "location", { value: originalLocation, writable: true, configurable: true, }) }) it("sends UPDATE_HASH message to host", () => { const sendMessageToHost = vi.fn() render(<AppView {...getProps({ sendMessageToHost })} />) window.location.hash = "mock_hash" window.dispatchEvent(new HashChangeEvent("hashchange")) expect(sendMessageToHost).toHaveBeenCalledWith({ hash: "#mock_hash", type: "UPDATE_HASH", }) }) }) it("does not render a Scroll To Bottom container when no bottom container is present", () => { const props = getProps() render(<AppView {...props} />) const stbContainer = screen.queryByTestId("stAppScrollToBottomContainer") expect(stbContainer).not.toBeInTheDocument() }) it("renders a Scroll To Bottom container if there is an element in the bottom container.", () => { const chatInputElement = new ElementNode( new Element({ chatInput: { id: "123", placeholder: "Enter Text Here", disabled: false, default: "", }, }), ForwardMsgMetadata.create({}), "no script run id", FAKE_SCRIPT_HASH ) const main = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const event = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const bottom = new BlockNode( FAKE_SCRIPT_HASH, [chatInputElement], new BlockProto({ allowEmpty: true }) ) const props = getProps({ elements: new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [main, sidebar, event, bottom]) ), }) render(<AppView {...props} />) const stbContainer = screen.queryByTestId("stAppScrollToBottomContainer") expect(stbContainer).toBeInTheDocument() }) describe("navigation position rendering", () => { it("renders sidebar navigation when navigationPosition=SIDEBAR", () => { renderAppView( { navigationPosition: Navigation.Position.SIDEBAR }, { navigationContext: { appPages: [ { pageName: "page1", pageScriptHash: "hash1" }, { pageName: "page2", pageScriptHash: "hash2" }, ], }, } ) expect(screen.queryByTestId("stSidebar")).toBeInTheDocument() expect(screen.getByText("page1")).toBeInTheDocument() expect(screen.getByText("page2")).toBeInTheDocument() }) it("renders top navigation when navigationPosition=TOP", () => { renderAppView( { navigationPosition: Navigation.Position.TOP }, { navigationContext: { appPages: [ { pageName: "page1", pageScriptHash: "hash1" }, { pageName: "page2", pageScriptHash: "hash2" }, ], }, } ) // Check that nav is in the header area const header = screen.getByTestId("stHeader") expect(header).toBeInTheDocument() // Navigation should be rendered in the header // Elements might be hidden in overflow menu, so just verify the navigation container exists const toolbar = screen.getByTestId("stToolbar") expect(toolbar).toBeInTheDocument() // No sidebar should be present expect(screen.queryByTestId("stSidebar")).not.toBeInTheDocument() }) it("renders neither sidebar nor top nav when navigationPosition=HIDDEN", () => { render( <AppView {...getProps({ navigationPosition: Navigation.Position.HIDDEN })} /> ) expect(screen.queryByTestId("stSidebar")).not.toBeInTheDocument() expect(screen.queryByText("page1")).not.toBeInTheDocument() expect(screen.queryByText("page2")).not.toBeInTheDocument() }) it("does not render top nav with single page when navigationPosition=TOP", () => { render( <AppView {...getProps({ navigationPosition: Navigation.Position.TOP })} /> ) expect(screen.queryByText("page1")).not.toBeInTheDocument() expect(screen.queryByTestId("stSidebar")).not.toBeInTheDocument() }) it("renders top nav when there is one section with multiple pages", () => { const appPages = [ { pageName: "page1", pageScriptHash: "hash1", sectionHeader: "Section 1", }, { pageName: "page2", pageScriptHash: "hash2", sectionHeader: "Section 1", }, ] const navSections = ["Section 1"] // Verify the business logic: navigation should be shown when there's one section with multiple pages expect(shouldShowNavigation(appPages, navSections)).toBe(true) }) it("does not render top nav when there is one section with one page", () => { const appPages = [ { pageName: "page1", pageScriptHash: "hash1", sectionHeader: "Section 1", }, ] const navSections = ["Section 1"] // Verify the business logic: navigation should not be shown when there's only one page expect(shouldShowNavigation(appPages, navSections)).toBe(false) }) }) describe("header transparency and padding logic", () => { it("header has transparent background when no content is shown", () => { // Minimal setup with no logo, no sidebar, no navigation, no toolbar render( <AppView {...getProps({ navigationPosition: Navigation.Position.SIDEBAR, })} /> ) const header = screen.getByTestId("stHeader") // The Header component should be rendered with isTransparentBackground=true // when no content is shown expect(header).toBeInTheDocument() expect(header).toHaveStyle("background-color: rgba(0, 0, 0, 0)") }) it("header has solid background when logo is shown", () => { const logo = LogoProto.create({ image: "https://example.com/logo.png", }) renderAppView({}, { sidebarConfigContext: { appLogo: logo } }) const header = screen.getByTestId("stHeader") expect(header).toBeInTheDocument() // Logo should be visible in header when sidebar is collapsed expect(screen.getByTestId("stHeaderLogo")).toBeInTheDocument() }) it("header has solid background when navigation is shown", () => { renderAppView( { navigationPosition: Navigation.Position.TOP }, { navigationContext: { appPages: [ { pageName: "page1", pageScriptHash: "hash1" }, { pageName: "page2", pageScriptHash: "hash2" }, ], }, } ) const header = screen.getByTestId("stHeader") expect(header).toBeInTheDocument() expect(header).not.toHaveStyle({ backgroundColor: "transparent" }) // Navigation should be present in the header const allPage2Elements = screen.getAllByText("page2") expect(allPage2Elements.length).toBeGreaterThan(0) }) it("header shows logo and sidebar button in embed mode", () => { const logo = LogoProto.create({ image: "https://example.com/logo.png", }) const sidebarElement = new ElementNode( makeElementWithInfoText("sidebar!"), ForwardMsgMetadata.create({}), "no script run id", FAKE_SCRIPT_HASH ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [sidebarElement], new BlockProto({ allowEmpty: true }) ) const empty = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const props = getProps({ elements: new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [empty, sidebar, empty, empty]) ), embedded: true, }) // Mock embed mode (showToolbar = false) renderAppView( { ...props, showToolbar: false }, { sidebarConfigContext: { initialSidebarState: PageConfig.SidebarState.COLLAPSED, // Ensure sidebar starts collapsed appLogo: logo, }, } ) // Header should be visible expect(screen.getByTestId("stHeader")).toBeInTheDocument() // Logo should be visible (when sidebar is collapsed) expect(screen.getByTestId("stHeaderLogo")).toBeInTheDocument() // Expand sidebar button should be visible expect(screen.getByTestId("stExpandSidebarButton")).toBeInTheDocument() }) it("header shows navigation in embed mode with top nav", () => { // Mock embed mode (showToolbar = false) and multiple pages renderAppView( { navigationPosition: Navigation.Position.TOP, embedded: true, showToolbar: false, }, { navigationContext: { appPages: [ { pageName: "page1", pageScriptHash: "hash1" }, { pageName: "page2", pageScriptHash: "hash2" }, ], }, } ) // Header should be visible expect(screen.getByTestId("stHeader")).toBeInTheDocument() // Navigation should still be shown in embed mode const allPage2Elements = screen.getAllByText("page2") expect(allPage2Elements.length).toBeGreaterThan(0) }) it("header does NOT show toolbar actions in embed mode without show_toolbar", () => { render( <AppView {...getProps({ embedded: true, showToolbar: false, topRightContent: <div data-testid="toolbar-actions">Toolbar</div>, })} /> ) // Header should be visible expect(screen.getByTestId("stHeader")).toBeInTheDocument() // Toolbar actions should NOT be visible expect(screen.queryByTestId("toolbar-actions")).not.toBeInTheDocument() }) it("header shows toolbar actions in embed mode WITH show_toolbar", () => { render( <AppView {...getProps({ embedded: true, showToolbar: true, topRightContent: <div data-testid="toolbar-actions">Toolbar</div>, })} /> ) // Header should be visible expect(screen.getByTestId("stHeader")).toBeInTheDocument() // Toolbar actions SHOULD be visible expect(screen.getByTestId("toolbar-actions")).toBeInTheDocument() }) }) describe("sidebar flicker prevention", () => { it("responds to initialSidebarState changes from AUTO to COLLAPSED", () => { const sidebarElement = new ElementNode( makeElementWithInfoText("sidebar content"), ForwardMsgMetadata.create({}), "no script run id", FAKE_SCRIPT_HASH ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [sidebarElement], new BlockProto({ allowEmpty: true }) ) const empty = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const props = getProps({ elements: new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [empty, sidebar, empty, empty]) ), }) // Mock the context with AUTO state // Use renderWithContexts to get the rerender with ability to update context values const { rerenderWithContexts } = renderWithContexts( <AppView {...props} />, { sidebarConfigContext: { initialSidebarState: PageConfig.SidebarState.AUTO, }, themeContext: { activeTheme: mockTheme, setTheme: vi.fn(), availableThemes: [], }, } ) // Sidebar should be rendered and expanded when initialSidebarState is AUTO const sidebarDOMElement = screen.getByTestId("stSidebar") expect(sidebarDOMElement).toBeInTheDocument() expect(sidebarDOMElement).toHaveAttribute("aria-expanded", "true") // Now simulate receiving page config with collapsed state rerenderWithContexts(<AppView {...props} />, { sidebarConfigContext: { initialSidebarState: PageConfig.SidebarState.COLLAPSED, }, }) // Now sidebar should be rendered but collapsed const sidebarAfterConfig = screen.getByTestId("stSidebar") expect(sidebarAfterConfig).toBeInTheDocument() expect(sidebarAfterConfig).toHaveAttribute("aria-expanded", "false") }) it("renders sidebar immediately when initialSidebarState is COLLAPSED", () => { const sidebarElement = new ElementNode( makeElementWithInfoText("sidebar content"), ForwardMsgMetadata.create({}), "no script run id", FAKE_SCRIPT_HASH ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [sidebarElement], new BlockProto({ allowEmpty: true }) ) const empty = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const props = getProps({ elements: new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [empty, sidebar, empty, empty]) ), }) renderAppView(props, { sidebarConfigContext: { initialSidebarState: PageConfig.SidebarState.COLLAPSED, }, }) // Sidebar should be rendered immediately when state is known const sidebarDOMElement = screen.getByTestId("stSidebar") expect(sidebarDOMElement).toBeInTheDocument() expect(sidebarDOMElement).toHaveAttribute("aria-expanded", "false") }) it("renders sidebar immediately when initialSidebarState is EXPANDED", () => { const sidebarElement = new ElementNode( makeElementWithInfoText("sidebar content"), ForwardMsgMetadata.create({}), "no script run id", FAKE_SCRIPT_HASH ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [sidebarElement], new BlockProto({ allowEmpty: true }) ) const empty = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const props = getProps({ elements: new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [empty, sidebar, empty, empty]) ), }) renderAppView(props, { sidebarConfigContext: { initialSidebarState: PageConfig.SidebarState.EXPANDED, }, }) // Sidebar should be rendered immediately when state is known const sidebarDOMElement = screen.getByTestId("stSidebar") expect(sidebarDOMElement).toBeInTheDocument() expect(sidebarDOMElement).toHaveAttribute("aria-expanded", "true") }) it("shows sidebar when multiple pages exist even with AUTO state", () => { renderAppView( { navigationPosition: Navigation.Position.SIDEBAR }, { sidebarConfigContext: { initialSidebarState: PageConfig.SidebarState.AUTO, }, navigationContext: { appPages: [ { pageName: "page1", pageScriptHash: "hash1" }, { pageName: "page2", pageScriptHash: "hash2" }, ], }, } ) // Sidebar should be rendered and expanded initially const sidebarDOMElement = screen.getByTestId("stSidebar") expect(sidebarDOMElement).toBeInTheDocument() expect(sidebarDOMElement).toHaveAttribute("aria-expanded", "true") }) it("sidebar shows after first script run when no page config is set", () => { const sidebarElement = new ElementNode( makeElementWithInfoText("sidebar content"), ForwardMsgMetadata.create({}), "no script run id", FAKE_SCRIPT_HASH ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [sidebarElement], new BlockProto({ allowEmpty: true }) ) const empty = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const props = getProps({ elements: new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [empty, sidebar, empty, empty]) ), }) // Initially AUTO state, sidebar should be rendered and expanded renderAppView(props, { sidebarConfigContext: { initialSidebarState: PageConfig.SidebarState.AUTO, }, }) const sidebarDOMElement = screen.getByTestId("stSidebar") expect(sidebarDOMElement).toBeInTheDocument() expect(sidebarDOMElement).toHaveAttribute("aria-expanded", "true") // Simulate script finished event without page config change // This tests the showSidebarOverride logic would apply // (In the real app, this would be handled by scriptFinishedHandler) // Since we can't easily trigger the script finished handler in the test, // we'll verify the initial behavior is correct (no sidebar with AUTO) // The actual fix will ensure sidebar shows after script finishes }) }) describe("sidebar toggle state persistence", () => { let elementsWithSidebar: AppRoot beforeEach(() => { window.localStorage.clear() const sidebarElement = new ElementNode( makeElementWithInfoText("sidebar content"), ForwardMsgMetadata.create({}), "no script run id", FAKE_SCRIPT_HASH ) const sidebar = new BlockNode( FAKE_SCRIPT_HASH, [sidebarElement], new BlockProto({ allowEmpty: true }) ) const main = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const event = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) const bottom = new BlockNode( FAKE_SCRIPT_HASH, [], new BlockProto({ allowEmpty: true }) ) elementsWithSidebar = new AppRoot( FAKE_SCRIPT_HASH, new BlockNode(FAKE_SCRIPT_HASH, [main, sidebar, event, bottom]) ) }) afterEach(() => { vi.restoreAllMocks() window.localStorage.clear() }) const renderAppViewWithSidebar = ( initialSidebarState: PageConfig.SidebarState ): ReturnType<typeof renderWithContexts> => { return renderAppView( { elements: elementsWithSidebar }, { sidebarConfigContext: { initialSidebarState } } ) } it("uses initial sidebar config when no localStorage value exists", () => { expect(window.localStorage.getItem("stSidebarCollapsed-")).toBeNull() renderAppViewWithSidebar(PageConfig.SidebarState.EXPANDED) const sidebarDOMElement = screen.getByTestId("stSidebar") expect(sidebarDOMElement).toHaveAttribute("aria-expanded", "true") }) it("uses initial sidebar config for collapsed state when no localStorage value exists", () => { expect(window.localStorage.getItem("stSidebarCollapsed-")).toBeNull() renderAppViewWithSidebar(PageConfig.SidebarState.COLLAPSED) const sidebarDOMElement = screen.getByTestId("stSidebar") expect(sidebarDOMElement).toHaveAttribute("aria-expanded", "false") }) it("restores collapsed state from localStorage on initial load", () => { window.localStorage.setItem("stSidebarCollapsed-", "true") renderAppViewWithSidebar(PageConfig.SidebarState.EXPANDED) const sidebarDOMElement = screen.getByTestId("stSidebar") expect(sidebarDOMElement).toHaveAttribute("aria-expanded", "false") }) it("restores expanded state from localStorage on initial load", () => { window.localStorage.setItem("stSidebarCollapsed-", "false") renderAppViewWithSidebar(PageConfig.SidebarState.COLLAPSED) const sidebarDOMElement = screen.getByTestId("stSidebar") expect(sidebarDOMElement).toHaveAttribute("aria-expanded", "true") }) it("handles invalid localStorage values gracefully", () => { window.localStorage.setItem("stSidebarCollapsed-", "invalid") renderAppViewWithSidebar(PageConfig.SidebarState.EXPANDED) const sidebarDOMElement = screen.getByTestId("stSidebar") expect(sidebarDOMElement).toHaveAttribute("aria-expanded", "true") }) }) })