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) 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) })