From d414ec0bb607cd7dad361dcb61447be14217b4e4 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Fri, 24 Jul 2026 12:59:48 +0700 Subject: [PATCH] test(e2e): gate per-tab scans on the panel's own content The a11y and palette sweeps clicked a tab and scanned immediately, so the two round-trip panels could be scanned while still skeletons. Skeletons carry no axe or palette violations, so those scans passed on an empty panel instead of the content they exist to check. Tabs now carry a readiness locator beside the name and both sweeps wait for it. Ordering is part of the same defect: the flow auto-opens the Assistant panel when it has no message logs, so an Assistant-first sweep clicked a tab that was already open and asserted a marker that predated the click. Dashboard leads the left-hand pair, and the sweep asserts each panel is absent before its own click so a future reordering that makes an iteration a no-op fails loudly. Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/routes.ts | 27 +++++++++++++++++++++--- frontend/e2e/specs/cross/a11y.spec.ts | 7 +++--- frontend/e2e/specs/cross/palette.spec.ts | 7 +++--- frontend/e2e/specs/flows/tabs.spec.ts | 24 ++++++--------------- 4 files changed, 39 insertions(+), 26 deletions(-) diff --git a/frontend/e2e/routes.ts b/frontend/e2e/routes.ts index dcccbb10..9de0908e 100644 --- a/frontend/e2e/routes.ts +++ b/frontend/e2e/routes.ts @@ -7,7 +7,7 @@ import type { Cassette } from './mocks/cassette.ts'; import { apiTokensCassette } from './mocks/cassettes/api-tokens.ts'; import { dashboardCassette } from './mocks/cassettes/dashboard.ts'; -import { flowsCassette, flowTabsCassette } from './mocks/cassettes/flows.ts'; +import { flowsCassette, flowTabsCassette, TABS_FILE_NAME, TABS_SCREENSHOT_URL } from './mocks/cassettes/flows.ts'; import { knowledgesCassette } from './mocks/cassettes/knowledges.ts'; import { resourcesCassette } from './mocks/cassettes/resources.ts'; import { settingsPromptsCassette } from './mocks/cassettes/settings-prompts.ts'; @@ -27,9 +27,30 @@ export interface RouteManifestEntry { */ sources: string[]; /** Radix unmounts inactive tab panels, so one scan of the default view sees none of them. */ - tabs?: string[]; + tabs?: RouteTab[]; } +export interface RouteTab { + name: string; + /** + * Must be absent while the panel loads: a marker that also renders on the + * skeleton lets every per-tab scan pass on an empty panel. + */ + ready: (page: Page) => Locator; +} + +/** Order matters: the flow auto-opens Assistant, so tabs.spec pins that no entry is already open. */ +export const FLOW_DETAIL_TABS: RouteTab[] = [ + { name: 'Dashboard', ready: (page) => page.getByText('Usage by Model & Provider') }, + { name: 'Assistant', ready: (page) => page.getByText('New assistant', { exact: true }) }, + { name: 'Tasks', ready: (page) => page.getByText('E2E Task Alpha') }, + { name: 'Agents', ready: (page) => page.getByText('E2E agent reconnaissance') }, + { name: 'Searches', ready: (page) => page.getByText('E2E search for the CVE') }, + { name: 'Vector Store', ready: (page) => page.getByText('E2E recall prior findings') }, + { name: 'Files', ready: (page) => page.getByText(TABS_FILE_NAME) }, + { name: 'Screenshots', ready: (page) => page.getByText(TABS_SCREENSHOT_URL) }, +]; + /** * The routes swept by NAV/visual/a11y specs and CI diff-scoping. This is a * subset of the app's routes — route-manifest.unit.test.ts pins the excluded @@ -75,7 +96,7 @@ export const ROUTE_MANIFEST: RouteManifestEntry[] = [ 'src/components/dashboard', 'src/features/resources', ], - tabs: ['Assistant', 'Dashboard', 'Tasks', 'Agents', 'Searches', 'Vector Store', 'Files', 'Screenshots'], + tabs: FLOW_DETAIL_TABS, }, { cassette: templatesCassette, diff --git a/frontend/e2e/specs/cross/a11y.spec.ts b/frontend/e2e/specs/cross/a11y.spec.ts index 9e9fdb77..184e7f54 100644 --- a/frontend/e2e/specs/cross/a11y.spec.ts +++ b/frontend/e2e/specs/cross/a11y.spec.ts @@ -52,12 +52,13 @@ for (const theme of THEMES) { }); for (const tab of entry.tabs ?? []) { - test(`tab "${tab}" has no axe violations`, async ({ page }) => { + test(`tab "${tab.name}" has no axe violations`, async ({ page }) => { await page.goto(entry.path); await expect(entry.ready(page)).toBeVisible(); await expect(page.locator('html')).toHaveClass(theme === 'dark' ? /dark/ : /light/); - await page.getByRole('tab', { name: tab }).click(); - await scanA11y(page, `${entry.path} [${tab}]`, entry.a11yWaivers); + await page.getByRole('tab', { name: tab.name }).click(); + await expect(tab.ready(page)).toBeVisible(); + await scanA11y(page, `${entry.path} [${tab.name}]`, entry.a11yWaivers); }); } }); diff --git a/frontend/e2e/specs/cross/palette.spec.ts b/frontend/e2e/specs/cross/palette.spec.ts index 98a6b7f9..241f0609 100644 --- a/frontend/e2e/specs/cross/palette.spec.ts +++ b/frontend/e2e/specs/cross/palette.spec.ts @@ -84,13 +84,14 @@ test.describe('palette compliance', { tag: '@cross' }, () => { // Tabs mount their panels lazily (Radix unmounts inactive ones), so the default-view scan // above never sees them — sweep each panel like the a11y gate does. for (const tab of entry.tabs ?? []) { - test(`tab "${tab}" carries no off-palette colour`, async ({ page }) => { + test(`tab "${tab.name}" carries no off-palette colour`, async ({ page }) => { await page.goto(entry.path); await expect(entry.ready(page)).toBeVisible(); - await page.getByRole('tab', { name: tab }).click(); + await page.getByRole('tab', { name: tab.name }).click(); + await expect(tab.ready(page)).toBeVisible(); const offenders = await scanOffenders(page); - const key = `${entry.path} [${tab}]`; + const key = `${entry.path} [${tab.name}]`; expect([...new Set(offenders)], `off-palette colours on ${key}`).toEqual(ACCEPTED[key] ?? []); }); diff --git a/frontend/e2e/specs/flows/tabs.spec.ts b/frontend/e2e/specs/flows/tabs.spec.ts index 216d75b3..0adbb7d5 100644 --- a/frontend/e2e/specs/flows/tabs.spec.ts +++ b/frontend/e2e/specs/flows/tabs.spec.ts @@ -1,20 +1,7 @@ import { expect, test } from '../../fixtures/test.ts'; import { expectCleanPage } from '../../helpers/errors.ts'; -import { - flowTabsCassette, - TABS_FILE_NAME, - TABS_SCREENSHOT_NAME, - TABS_SCREENSHOT_URL, -} from '../../mocks/cassettes/flows.ts'; - -const TABS = [ - { marker: 'E2E Task Alpha', name: 'Tasks' }, - { marker: 'E2E agent reconnaissance', name: 'Agents' }, - { marker: 'E2E search for the CVE', name: 'Searches' }, - { marker: 'E2E recall prior findings', name: 'Vector Store' }, - { marker: TABS_FILE_NAME, name: 'Files' }, - { marker: TABS_SCREENSHOT_URL, name: 'Screenshots' }, -] as const; +import { flowTabsCassette, TABS_SCREENSHOT_NAME } from '../../mocks/cassettes/flows.ts'; +import { FLOW_DETAIL_TABS } from '../../routes.ts'; test.describe('flow detail tabs', { tag: '@flows' }, () => { test.use({ cassette: flowTabsCassette() }); @@ -23,9 +10,12 @@ test.describe('flow detail tabs', { tag: '@flows' }, () => { await page.goto('/flows/5'); await expect(page.locator('header').getByRole('button', { name: 'Toggle favorite' })).toBeEnabled(); - for (const { marker, name } of TABS) { + for (const { name, ready } of FLOW_DETAIL_TABS) { + // Pins that the click is what reveals the panel. Ordering this sweep so a tab is already + // open when its turn comes (the flow auto-opens Assistant) makes its iteration a no-op. + await expect(ready(page), `"${name}" must not be on screen before its own click`).not.toBeAttached(); await page.getByRole('tab', { name }).click(); - await expect(page.getByText(marker)).toBeVisible(); + await expect(ready(page)).toBeVisible(); } expectCleanPage(pageErrorLog);