From 8a6eecd6e6fdd148c8456fb15fa7d2b7319d15b0 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Fri, 24 Jul 2026 01:22:58 +0700 Subject: [PATCH] test(e2e): sweep tab panels in the palette gate, not just the default view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette gate scanned only each route's default view, so off-palette colours behind a tab (which Radix unmounts while inactive) were never checked — the a11y gate iterates tabs, this one did not, and they had drifted. The flow Files tab carries a live off-palette node (file-manager's expand-all control, hover:text-blue-400) that went green purely because the panel was unmounted. Adds a per-tab scan mirroring the a11y gate, with tab-scoped waivers keyed `${path} [${tab}]`. The file-manager control is waived on the Files tab under the same "goes with the design pass" rationale it already carries on /resources. Proven: the Files-tab scan passes against the exact waived offender (a non-empty `toEqual`), so the scan reaches the panel — the old default-view scan could not. Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/specs/cross/palette.spec.ts | 59 +++++++++++++++++------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/frontend/e2e/specs/cross/palette.spec.ts b/frontend/e2e/specs/cross/palette.spec.ts index c4ed6574..98a6b7f9 100644 --- a/frontend/e2e/specs/cross/palette.spec.ts +++ b/frontend/e2e/specs/cross/palette.spec.ts @@ -1,7 +1,10 @@ +import type { Page } from '@playwright/test'; + import type { BadgeVariant } from '@/components/ui/badge'; import { badgeVariants } from '@/components/ui/badge'; import { buttonVariants } from '@/components/ui/button'; +import { routes } from '@/lib/routes'; import { expect, test } from '../../fixtures/test.ts'; import { ROUTE_MANIFEST } from '../../routes.ts'; @@ -36,12 +39,32 @@ const SANCTIONED = new Set( const PALETTE_UTILITY = /^(?:[a-z-]+:)*(?:bg|text|border|ring|from|via|to|fill|stroke|shadow|outline|decoration|divide|accent|caret|placeholder)-(?:\[[^\]]*\]|(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}(?:\/\d{1,3})?)$/; -/** Off-palette colours already on the page, by route. Exact strings: they waive one node, not a rule. */ +/** + * Off-palette colours already on the page, keyed by scan surface. A route's default view is keyed by + * its path; a tab panel by `${path} [${tab}]`. Exact strings: they waive one node, not a rule. + */ const ACCEPTED: Record = { // file-manager.tsx expand-all control; changing it moves pixels, so it goes with the design pass. + // Same control renders in the flow Files tab, so it is waived there under the same rationale. '/resources': ['button: hover:text-blue-400'], + [`${routes.flow('5')} [Files]`]: ['button: hover:text-blue-400'], }; +const scanOffenders = (page: Page) => + page.evaluate( + ({ pattern, sanctioned }) => { + const palette = new RegExp(pattern); + const allowed = new Set(sanctioned); + + return [...document.querySelectorAll('[data-slot="badge"],[data-slot="button"]')].flatMap((element) => + [...element.classList] + .filter((token) => palette.test(token) && !allowed.has(token)) + .map((token) => `${element.getAttribute('data-slot')}: ${token}`), + ); + }, + { pattern: PALETTE_UTILITY.source, sanctioned: [...SANCTIONED] }, + ); + test.describe('palette compliance', { tag: '@cross' }, () => { for (const entry of ROUTE_MANIFEST) { test.describe(entry.path, () => { @@ -51,25 +74,27 @@ test.describe('palette compliance', { tag: '@cross' }, () => { await page.goto(entry.path); await expect(entry.ready(page)).toBeVisible(); - const offenders = await page.evaluate( - ({ pattern, sanctioned }) => { - const palette = new RegExp(pattern); - const allowed = new Set(sanctioned); + const offenders = await scanOffenders(page); - return [...document.querySelectorAll('[data-slot="badge"],[data-slot="button"]')].flatMap( - (element) => - [...element.classList] - .filter((token) => palette.test(token) && !allowed.has(token)) - .map((token) => `${element.getAttribute('data-slot')}: ${token}`), - ); - }, - { pattern: PALETTE_UTILITY.source, sanctioned: [...SANCTIONED] }, + expect([...new Set(offenders)], `off-palette colours on ${entry.path}`).toEqual( + ACCEPTED[entry.path] ?? [], ); - - const accepted = ACCEPTED[entry.path] ?? []; - - expect([...new Set(offenders)], `off-palette colours on ${entry.path}`).toEqual(accepted); }); + + // 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 }) => { + await page.goto(entry.path); + await expect(entry.ready(page)).toBeVisible(); + await page.getByRole('tab', { name: tab }).click(); + + const offenders = await scanOffenders(page); + const key = `${entry.path} [${tab}]`; + + expect([...new Set(offenders)], `off-palette colours on ${key}`).toEqual(ACCEPTED[key] ?? []); + }); + } }); } });