From 66319f0901e23a5adc75bb5bcadaf07143bc36e7 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Fri, 6 Nov 2026 20:27:08 +0200 Subject: [PATCH 1/2] Wait for the products' answer, not their request, in useProductList's tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search test waited until the fake endpoint had recorded `search=desk` and then read the rows. The endpoint records a request before it answers, so at that moment the page still held page 2 of everything, [3, 4], until the answer landed — which is the AssertionError main's CI failed with on Oct 28 (`expected [ 3, 4 ] to deeply equal [ 1, 3 ]`). The category test had the same race on the same rows, and its clear waited for rows that are [1, 2] on both sides of it, so that wait proved nothing. Each step now waits on what the answer changes: the rows, or for the clear, the page count (two pages of category 1, three of everything). The page-2 setup steps wait on page 2's rows too, so each change starts from a settled page. What the tests assert is unchanged. Verified by adding a 100 ms delay to the fake endpoint: before, the search and category tests fail every time with exactly CI's [3, 4]; after, all five pass at 100 ms and at 400 ms, and `npm test` passes 15 of 15 three runs running. Co-Authored-By: Claude Opus 5.5 --- .../test/nuxt/useProductList.spec.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/clients/dashboard/test/nuxt/useProductList.spec.ts b/clients/dashboard/test/nuxt/useProductList.spec.ts index 0dc11fa..73cfdf5 100644 --- a/clients/dashboard/test/nuxt/useProductList.spec.ts +++ b/clients/dashboard/test/nuxt/useProductList.spec.ts @@ -112,7 +112,7 @@ describe('useProductList', () => { const list = await mountList() list.page.value = 2 - await vi.waitFor(() => expect(reads.at(-1)?.page).toBe('2')) + await vi.waitFor(() => expect(list.products.value.map(p => p.id)).toEqual([3, 4])) const before = reads.length // Four keystrokes, each in a tick of its own as typing is, and closer together than the @@ -122,34 +122,41 @@ describe('useProductList', () => { await new Promise(resolve => setTimeout(resolve, 50)) } - await vi.waitFor(() => expect(reads.at(-1)?.search).toBe('desk')) + // Waited for on the rows, not on `reads`: the endpoint records a request before it answers, + // so the moment the search is in `reads` the page still holds page 2 of everything — [3, 4] — + // until the answer lands. Asserting the rows then fails whenever the answer is slower than the + // check, which on a cold CI runner it sometimes is. + await vi.waitFor(() => expect(list.products.value.map(p => p.id)).toEqual([1, 3])) // One request for the whole word, not one per key, and on page 1: page 2 of everything is // past the end of three desks at two a page only by luck. expect(reads.slice(before).map(read => read.search)).toEqual(['desk']) expect(list.page.value).toBe(1) expect(list.isFiltered.value).toBe(true) - expect(list.products.value.map(p => p.id)).toEqual([1, 3]) }) it('filters by category, and clears back to the whole listing', async () => { const list = await mountList() list.page.value = 2 - await vi.waitFor(() => expect(reads.at(-1)?.page).toBe('2')) + await vi.waitFor(() => expect(list.products.value.map(p => p.id)).toEqual([3, 4])) // A category with two pages of its own, so landing on page 1 is the reset and not the // last-page clamp doing the same thing by coincidence. list.categoryId.value = 1 - await vi.waitFor(() => expect(reads.at(-1)?.categoryId).toBe('1')) + // On the answer, not the request — see the search test above. + await vi.waitFor(() => expect(list.products.value.map(p => p.id)).toEqual([1, 2])) + expect(reads.at(-1)?.categoryId).toBe('1') expect(list.page.value).toBe(1) - expect(list.products.value.map(p => p.id)).toEqual([1, 2]) expect(list.pageCount.value).toBe(2) list.clearFilters() - await vi.waitFor(() => expect(list.products.value.map(p => p.id)).toEqual([1, 2])) + // The rows are [1, 2] either side of the clear, so they cannot say the answer has landed. The + // page count can: two pages of category 1, three of everything. + await vi.waitFor(() => expect(list.pageCount.value).toBe(3)) + expect(list.products.value.map(p => p.id)).toEqual([1, 2]) expect(reads.at(-1)?.categoryId).toBeUndefined() expect(list.isFiltered.value).toBe(false) }) From ef839f9c19191c15768807a9b84722f86af309df Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Fri, 6 Nov 2026 20:27:09 +0200 Subject: [PATCH 2/2] Warm every page once before the browser smoke test, so a cold runner passes The smoke test runs against the dev server, which compiles each module the first time a browser asks for it. On a GitHub runner a page's first visit took longer than the five seconds `toHaveText` waits, so its test failed with the page still on its loading screen. And it never recovered: a failed test makes Playwright replace its worker, the new worker starts a new Nuxt, and Vite ("Re-optimizing dependencies because vite config has changed") starts cold again. In run 42091746069 that was all nine page tests failing on both attempts, while the three tests after them, on a server the toast test had warmed, passed in about 3 s each. A production build would remove the compiling, but it would also remove what half the page tests look for: Vue only warns in development. So the dev server stays, and `test/e2e/fixtures.ts` extends @nuxt/test-utils' `test` with a worker-scoped auto fixture that, once the worker's Nuxt is up, opens each page in a browser of its own with the API stubbed and waits for its title. It asserts nothing and swallows nothing a test would see: a page that never draws here is left for its own test to report. The page list moves there, so the warm-up and the tests visit the same pages. No assertion or timeout in smoke.e2e.ts changes. Verified locally with the Vite cache removed, and with the run put on macOS's background QoS (`taskpolicy -b`) to stand in for a slow runner. Before: 9 failed, 3 passed in 8.2 minutes, CI's exact pattern. After: 12 passed (one needed its retry at that throttle) in 2.7 minutes. At normal priority 12 of 12 pass from cold in 25 s. Taking vue-sonner's stylesheet out still fails the toast test with "viewport ratio 0". Lint and the typecheck pass. Co-Authored-By: Claude Opus 5.5 --- clients/dashboard/CLAUDE.md | 5 +- clients/dashboard/playwright.config.ts | 4 +- clients/dashboard/test/e2e/fixtures.ts | 66 +++++++++++++++++++++++++ clients/dashboard/test/e2e/smoke.e2e.ts | 17 +------ 4 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 clients/dashboard/test/e2e/fixtures.ts diff --git a/clients/dashboard/CLAUDE.md b/clients/dashboard/CLAUDE.md index 970d2cd..5624569 100644 --- a/clients/dashboard/CLAUDE.md +++ b/clients/dashboard/CLAUDE.md @@ -44,6 +44,7 @@ header, ⌘K command palette. `npm run lint`, `npm run typecheck`, `npm test`, `npm run test:e2e` — all four run in CI. The last is Playwright against the dev server with every `/api/**` call stubbed in `test/e2e/api.ts`: a page that -calls a new endpoint needs a stub there, and a new dependency imported from a page needs a line in -`vite.optimizeDeps.include`. Before calling UI work done, open it in the browser pane and look at it +calls a new endpoint needs a stub there, a new dependency imported from a page needs a line in +`vite.optimizeDeps.include`, and a new page needs a line in `pages` in `test/e2e/fixtures.ts`, which +is what the suite warms up and then tests. Before calling UI work done, open it in the browser pane and look at it in light and dark, in English and Arabic, and at 375px. diff --git a/clients/dashboard/playwright.config.ts b/clients/dashboard/playwright.config.ts index d3d2bb3..6aba40c 100644 --- a/clients/dashboard/playwright.config.ts +++ b/clients/dashboard/playwright.config.ts @@ -31,7 +31,9 @@ export default defineConfig({ nuxt: { rootDir: fileURLToPath(new URL('.', import.meta.url)), // The dev server, not a production build: Vue only warns in development, and a warning — - // "Failed to resolve component" — is half of what the page tests are looking for. + // "Failed to resolve component" — is half of what the page tests are looking for. The price is + // that the dev server compiles each module on first request, which on a CI runner is longer + // than an assertion waits; `test/e2e/fixtures.ts` visits every page once before the tests. dev: true } } diff --git a/clients/dashboard/test/e2e/fixtures.ts b/clients/dashboard/test/e2e/fixtures.ts new file mode 100644 index 0000000..9e0a802 --- /dev/null +++ b/clients/dashboard/test/e2e/fixtures.ts @@ -0,0 +1,66 @@ +import type { Page } from '@playwright/test' +import { expect, test as nuxtTest } from '@nuxt/test-utils/playwright' +import { stubApi } from './api' + +/** + * Every page of the dashboard, with the title its header draws. The smoke test visits each one, and + * the warm-up below visits each one first. + */ +export const pages = [ + { path: '/', title: 'Overview' }, + { path: '/catalog', title: 'Catalog' }, + { path: '/catalog?tab=products', title: 'Catalog' }, + { path: '/media', title: 'Media' }, + { path: '/languages', title: 'Languages' }, + { path: '/audit', title: 'Audit' }, + { path: '/settings/branding', title: 'Settings' }, + { path: '/settings/catalog', title: 'Settings' }, + { path: '/settings/auditing', title: 'Settings' } +] + +/** The page's own `

` — the one in `DashboardPage`'s header. */ +export const heading = (page: Page) => page.getByRole('heading', { level: 1 }) + +/** Long enough for a cold page on a slow runner; it bounds the warm-up only, never a test. */ +const COLD_PAGE_TIMEOUT = 60_000 + +/** + * `@nuxt/test-utils/playwright`'s `test`, with every page visited once before any test runs. + * + * The suite runs against the dev server (Vue only warns in development), and the dev server + * compiles a module the first time a browser asks for it. A page's first visit therefore pays for + * the few hundred modules under it, one import at a time. On a CI runner that is longer than the + * five seconds an assertion waits, so the first test of each page failed with the page still on + * its loading screen. + * + * And it never got better, because a failed test makes Playwright replace its worker, which starts + * a NEW Nuxt — and Vite, finding its config changed, re-optimizes and starts cold again. One slow + * page was every page failing, twice. + * + * So the worker pays for the compiling up front: once its Nuxt is up, this opens each page in a + * browser of its own and waits for the title. It asserts nothing. It only leaves Vite with every + * module compiled, so the tests' own five-second waits measure the page and not the compiler. A + * page that never draws its title here is left for its test to report, with that test's trace. + */ +export const test = nuxtTest.extend>({ + warmUp: [async ({ _nuxtHooks, browser }, use) => { + const context = await browser.newContext({ baseURL: _nuxtHooks.ctx.url }) + const page = await context.newPage() + await stubApi(page) + + for (const { path } of pages) { + try { + await page.goto(path) + await expect(heading(page)).toBeVisible({ timeout: COLD_PAGE_TIMEOUT }) + await expect(page.locator('[data-slot="skeleton"]')).toHaveCount(0, { timeout: COLD_PAGE_TIMEOUT }) + } catch (error) { + console.warn(`warm-up: ${path} did not settle; its test will say why.`, error) + } + } + + await context.close() + await use() + }, { scope: 'worker', auto: true, timeout: 10 * 60_000 }] +}) + +export { expect } diff --git a/clients/dashboard/test/e2e/smoke.e2e.ts b/clients/dashboard/test/e2e/smoke.e2e.ts index 66faaa2..aed628f 100644 --- a/clients/dashboard/test/e2e/smoke.e2e.ts +++ b/clients/dashboard/test/e2e/smoke.e2e.ts @@ -1,6 +1,6 @@ import type { Page } from '@playwright/test' -import { expect, test } from '@nuxt/test-utils/playwright' import { stubApi } from './api' +import { expect, heading, pages, test } from './fixtures' /** * The dashboard in a real browser, against a stubbed API. @@ -55,21 +55,6 @@ test.afterEach(({ page }) => { 'console errors and Vue warnings').toEqual([]) }) -/** The page's own `

` — the one in `DashboardPage`'s header. */ -const heading = (page: Page) => page.getByRole('heading', { level: 1 }) - -const pages = [ - { path: '/', title: 'Overview' }, - { path: '/catalog', title: 'Catalog' }, - { path: '/catalog?tab=products', title: 'Catalog' }, - { path: '/media', title: 'Media' }, - { path: '/languages', title: 'Languages' }, - { path: '/audit', title: 'Audit' }, - { path: '/settings/branding', title: 'Settings' }, - { path: '/settings/catalog', title: 'Settings' }, - { path: '/settings/auditing', title: 'Settings' } -] - for (const { path, title } of pages) { test(`${path} renders its title`, async ({ page, goto }) => { await goto(path)