Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions clients/dashboard/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion clients/dashboard/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ export default defineConfig<ConfigOptions>({
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
}
}
Expand Down
66 changes: 66 additions & 0 deletions clients/dashboard/test/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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 `<h1>` — 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<object, Record<'warmUp', void>>({
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 }
17 changes: 1 addition & 16 deletions clients/dashboard/test/e2e/smoke.e2e.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -55,21 +55,6 @@ test.afterEach(({ page }) => {
'console errors and Vue warnings').toEqual([])
})

/** The page's own `<h1>` — 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)
Expand Down
21 changes: 14 additions & 7 deletions clients/dashboard/test/nuxt/useProductList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
})
Expand Down
Loading