diff --git a/packages/app/e2e/regression/pwa-safe-area.spec.ts b/packages/app/e2e/regression/pwa-safe-area.spec.ts new file mode 100644 index 00000000000..11a48bf540d --- /dev/null +++ b/packages/app/e2e/regression/pwa-safe-area.spec.ts @@ -0,0 +1,104 @@ +import { expect, test } from "@playwright/test" +import { fixture } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" + +const cases = [ + { + name: "mobile browser", + viewport: { width: 390, height: 844 }, + insets: { top: 47, right: 0, bottom: 34, left: 0 }, + standalone: false, + bottom: false, + }, + { + name: "iOS standalone", + viewport: { width: 390, height: 844 }, + insets: { top: 47, right: 0, bottom: 34, left: 0 }, + standalone: true, + bottom: false, + }, + { + name: "iOS standalone landscape", + viewport: { width: 844, height: 390 }, + insets: { top: 0, right: 47, bottom: 21, left: 47 }, + standalone: true, + bottom: false, + }, + { + name: "iOS standalone with bottom tabs", + viewport: { width: 390, height: 844 }, + insets: { top: 47, right: 0, bottom: 34, left: 0 }, + standalone: true, + bottom: true, + }, +] + +for (const input of cases) { + test.describe(input.name, () => { + test.use({ viewport: input.viewport, hasTouch: true }) + + test("keeps app navigation clear of native chrome", async ({ page }) => { + const cdp = await page.context().newCDPSession(page) + await cdp.send("Emulation.setSafeAreaInsetsOverride", { insets: input.insets }) + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + provider: fixture.provider, + sessions: fixture.sessions, + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript( + ({ bottom, directory, server, sessions, standalone }) => { + Object.defineProperty(navigator, "standalone", { value: standalone }) + localStorage.setItem( + "settings.v3", + JSON.stringify({ general: { mobileTitlebarPosition: bottom ? "bottom" : "top" } }), + ) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))), + ) + }, + { + bottom: input.bottom, + directory: fixture.directory, + server: fixture.serverKey, + sessions: fixture.sessions, + standalone: input.standalone, + }, + ) + + await page.goto("/") + + const titlebar = page.locator('[data-slot="titlebar-v2"]') + const top = input.insets.top + (input.standalone ? 32 : 0) + await expect(titlebar).toHaveCSS("padding-left", `${input.insets.left}px`) + await expect(titlebar).toHaveCSS("padding-right", `${input.insets.right}px`) + + if (input.bottom) { + await expect(titlebar).toHaveCSS("padding-bottom", `${input.insets.bottom}px`) + await expect(page.getByRole("main")).toHaveCSS("padding-top", `${top}px`) + } else { + await expect(titlebar).toHaveCSS("padding-top", `${top}px`) + } + + const navigation = + input.viewport.width < 768 + ? titlebar.getByRole("button", { name: "Tabs", exact: true }) + : titlebar.getByRole("button", { name: "Home", exact: true }) + await expect(navigation).toBeInViewport({ ratio: 1 }) + const bounds = await navigation.boundingBox() + expect(bounds).not.toBeNull() + expect(bounds!.x).toBeGreaterThanOrEqual(input.insets.left) + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(input.viewport.width - input.insets.right) + + if (input.viewport.width >= 768) return + await navigation.tap() + await expect(navigation).toHaveAttribute("aria-expanded", "true") + }) + }) +} diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index df99ffa8ec0..51ca11720cb 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -31,6 +31,7 @@ export default function Layout(props: ParentProps) { }) const verticalTabs = () => preferences.appearance.tabLayout() === "vertical" && !mobile() const bottomTitlebar = () => mobile() && preferences.general.mobileTitlebarPosition() === "bottom" + const iosStandalone = platform.platform === "web" && "standalone" in navigator && navigator.standalone === true const update: TitlebarUpdate = { get state() { @@ -60,9 +61,13 @@ export default function Layout(props: ParentProps) {