diff --git a/e2e/v1341-navigation-and-insights-mobile.spec.ts b/e2e/v1341-navigation-and-insights-mobile.spec.ts new file mode 100644 index 000000000..2c446c677 --- /dev/null +++ b/e2e/v1341-navigation-and-insights-mobile.spec.ts @@ -0,0 +1,151 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { mockPopulatedInsights } from "./utils/mock-populated-insights"; +import { settleBeforeMeasure } from "./utils/settle"; + +test.describe("v1.34.1 navigation geometry", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + for (const viewport of [ + { name: "desktop", width: 1440, height: 900 }, + { name: "short desktop", width: 1280, height: 600 }, + { name: "tablet", width: 900, height: 700 }, + ]) { + for (const route of ["/settings/account", "/admin/system-status"]) { + test(`${route} aligns and pins its sidebar at ${viewport.name}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await page.goto(route, { waitUntil: "domcontentloaded" }); + + const heading = page.getByRole("heading", { level: 1 }); + const sidebar = page.locator("aside").first(); + await settleBeforeMeasure(page, heading); + await expect(sidebar).toBeVisible(); + + const before = await page.evaluate(() => { + const main = document.getElementById("main-content"); + const heading = main?.querySelector("h1"); + const aside = main?.querySelector("aside"); + const wrapper = main?.firstElementChild; + if (!main || !heading || !aside || !wrapper) return null; + return { + headingTop: heading.getBoundingClientRect().top, + asideTop: aside.getBoundingClientRect().top, + wrapperLeft: wrapper.getBoundingClientRect().left, + mainWidth: main.clientWidth, + documentScrollable: + document.documentElement.scrollHeight > + document.documentElement.clientHeight + 1, + }; + }); + + expect(before).not.toBeNull(); + if (!before) return; + expect( + Math.abs(before.asideTop - before.headingTop), + ).toBeLessThanOrEqual(2); + expect(before.documentScrollable).toBe(false); + + await page.locator("#main-content").evaluate((main) => { + main.scrollTop = Math.min(320, main.scrollHeight - main.clientHeight); + }); + await page.waitForFunction(() => { + const main = document.getElementById("main-content"); + return ( + !main || + main.scrollTop > 0 || + main.scrollHeight <= main.clientHeight + ); + }); + + const after = await page.evaluate(() => { + const main = document.getElementById("main-content"); + const aside = main?.querySelector("aside"); + const wrapper = main?.firstElementChild; + if (!main || !aside || !wrapper) return null; + return { + asideTop: aside.getBoundingClientRect().top, + wrapperLeft: wrapper.getBoundingClientRect().left, + mainWidth: main.clientWidth, + }; + }); + + expect(after).not.toBeNull(); + if (!after) return; + expect(Math.abs(after.asideTop - before.asideTop)).toBeLessThanOrEqual( + 2, + ); + expect(after.wrapperLeft).toBe(before.wrapperLeft); + expect(after.mainWidth).toBe(before.mainWidth); + }); + } + } +}); + +test.describe("v1.34.1 mobile Insights pill row", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("has no unexplained gap above the row and preserves focus, safe bounds, and 44px targets", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockPopulatedInsights(page); + await page.goto("/insights", { waitUntil: "domcontentloaded" }); + + const firstPill = page + .locator( + '[data-slot="insights-tab-strip-pill"], [data-slot="insights-tab-strip-group"]', + ) + .first(); + await settleBeforeMeasure(page, firstPill); + + const geometry = await page.evaluate(() => { + const main = document.getElementById("main-content"); + const strip = document.querySelector( + '[data-slot="insights-tab-strip"]', + ); + const pill = document.querySelector( + '[data-slot="insights-tab-strip-pill"], [data-slot="insights-tab-strip-group"]', + ); + if (!main || !strip || !pill) return null; + const mainRect = main.getBoundingClientRect(); + const stripRect = strip.getBoundingClientRect(); + const pillRect = pill.getBoundingClientRect(); + return { + gapAbove: stripRect.top - mainRect.top, + stripLeft: stripRect.left, + stripRight: stripRect.right, + mainLeft: mainRect.left, + mainRight: mainRect.right, + pillHeight: pillRect.height, + mainOverflows: main.scrollWidth > main.clientWidth + 1, + documentOverflows: + document.documentElement.scrollWidth > + document.documentElement.clientWidth + 1, + }; + }); + + expect(geometry).not.toBeNull(); + if (!geometry) return; + expect(geometry.gapAbove).toBeLessThanOrEqual(4); + expect(geometry.stripLeft).toBeGreaterThanOrEqual(geometry.mainLeft); + expect(geometry.stripRight).toBeLessThanOrEqual(geometry.mainRight); + expect(geometry.pillHeight).toBeGreaterThanOrEqual(44); + expect(geometry.mainOverflows).toBe(false); + expect(geometry.documentOverflows).toBe(false); + + await firstPill.focus(); + await expect(firstPill).toBeFocused(); + expect( + await firstPill.evaluate((element) => { + const style = getComputedStyle(element); + return ( + element.matches(":focus-visible") && + (style.outlineStyle !== "none" || style.boxShadow !== "none") + ); + }), + ).toBe(true); + }); +}); diff --git a/e2e/v1341-ui-evidence.spec.ts b/e2e/v1341-ui-evidence.spec.ts new file mode 100644 index 000000000..845b04143 --- /dev/null +++ b/e2e/v1341-ui-evidence.spec.ts @@ -0,0 +1,81 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import type { Page, TestInfo } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + LONG_HEADLINE_BRIEFING, + mockDashboardSnapshot, +} from "./utils/mock-dashboard-snapshot"; +import { mockPopulatedInsights } from "./utils/mock-populated-insights"; +import { settleBeforeMeasure } from "./utils/settle"; + +const EVIDENCE_DIR = join(process.cwd(), "test-results", "v1341"); + +async function settleAndCapture( + page: Page, + testInfo: TestInfo, + filename: string, +): Promise { + await settleBeforeMeasure(page, page.locator("#main-content")); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.addStyleTag({ + content: + "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}", + }); + await page.evaluate(() => document.fonts?.ready); + const path = join(EVIDENCE_DIR, filename); + await page.screenshot({ path, animations: "disabled" }); + await testInfo.attach(filename, { path, contentType: "image/png" }); +} + +test.describe("v1.34.1 deterministic UI evidence", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "one canonical capture per named evidence file", + ); + }); + + test("captures the five release-review surfaces", async ({ + page, + }, testInfo) => { + await mkdir(EVIDENCE_DIR, { recursive: true }); + + await page.setViewportSize({ width: 1440, height: 900 }); + await mockDashboardSnapshot(page, { + briefing: LONG_HEADLINE_BRIEFING, + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.locator("#main-content")).toBeVisible(); + await settleAndCapture(page, testInfo, "dashboard-desktop.png"); + + await page.setViewportSize({ width: 390, height: 844 }); + await mockPopulatedInsights(page); + await page.goto("/insights", { waitUntil: "domcontentloaded" }); + await expect( + page.locator('[data-slot="insights-tab-strip"]'), + ).toBeVisible(); + await settleAndCapture(page, testInfo, "insights-mobile.png"); + + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/settings/anamnesis", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await settleAndCapture(page, testInfo, "anamnesis-desktop.png"); + + await page.setViewportSize({ width: 1280, height: 600 }); + await page.goto("/settings/account", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await settleAndCapture(page, testInfo, "settings-short-desktop.png"); + + await page.setViewportSize({ width: 900, height: 800 }); + await page.goto("/admin/system-status", { + waitUntil: "domcontentloaded", + }); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await settleAndCapture(page, testInfo, "admin-tablet.png"); + }); +}); diff --git a/src/components/admin/__tests__/admin-shell.test.tsx b/src/components/admin/__tests__/admin-shell.test.tsx index 6f0ac9348..1247145a4 100644 --- a/src/components/admin/__tests__/admin-shell.test.tsx +++ b/src/components/admin/__tests__/admin-shell.test.tsx @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; /** * `` mirrors ``: a sticky desktop sidebar @@ -34,9 +36,6 @@ vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: mockUserRef.value }), })); -import { readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; - import { I18nProvider } from "@/lib/i18n/context"; import { ADMIN_SECTION_SLUGS } from "../section-slugs"; import { ADMIN_SECTIONS, AdminShell } from "../admin-shell"; @@ -59,6 +58,30 @@ function renderShell(props: { } describe("", () => { + it("groups operational domains with non-interactive desktop headings and quiet mobile separators", () => { + const html = renderShell({ active: "system-status" }); + const groupHeaders = + html.match(/data-slot="admin-nav-group"[^>]*data-group="[^"]+"/g) ?? []; + const separators = + html.match(/data-slot="admin-nav-group-separator"/g) ?? []; + + expect(groupHeaders.length).toBeGreaterThanOrEqual(4); + expect(new Set(groupHeaders).size).toBe(groupHeaders.length); + expect(separators).toHaveLength(groupHeaders.length - 1); + }); + + it("starts the sticky desktop sidebar on the heading row", () => { + const source = readFileSync( + join(process.cwd(), "src", "components", "admin", "admin-shell.tsx"), + "utf8", + ); + + expect(source).toMatch( + / { const html = renderShell({ active: "system-status" }); for (const section of ADMIN_SECTIONS) { diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index a4bb432a7..b37d1a57a 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; // `` reads the active route from `usePathname()`. We stub // next/navigation so the SSR test render works without an App-Router @@ -153,6 +155,43 @@ describe("SETTINGS_SECTION_SLUGS", () => { }); describe("", () => { + it("keeps meaningful non-interactive desktop groups while the mobile strip uses quiet separators", () => { + const html = renderShell({ active: "account" }); + for (const label of [ + "Account", + "Your data", + "Display", + "Health profile", + "AI", + "Connectivity", + "System", + ]) { + expect(html).toContain(`>${label}`); + } + // Seven groups produce six quiet separators in the mobile strip. + expect( + html.match(/aria-hidden="true"/g)?.length ?? 0, + ).toBeGreaterThanOrEqual(6); + }); + + it("starts the sticky desktop sidebar on the heading row", () => { + const source = readFileSync( + join( + process.cwd(), + "src", + "components", + "settings", + "settings-shell.tsx", + ), + "utf8", + ); + + expect(source).toMatch( + / { const html = renderShell({ active: "account" }); // Every slug — including `about`, which returned to the shell nav