From 608238a413e9a4312233666d1bcb49c25b523f88 Mon Sep 17 00:00:00 2001 From: T04435 Date: Thu, 22 Oct 2026 03:11:49 +1100 Subject: [PATCH 1/3] test(e2e): read the plan stream through a route Co-Authored-By: Claude Opus 5.5 --- tests/e2e/one-song-one-artist.spec.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/e2e/one-song-one-artist.spec.ts b/tests/e2e/one-song-one-artist.spec.ts index dcd90bf5..4763bb22 100644 --- a/tests/e2e/one-song-one-artist.spec.ts +++ b/tests/e2e/one-song-one-artist.spec.ts @@ -73,11 +73,19 @@ test.describe("one Song, one Artist (unify rule)", () => { test("a busk (/p/[id]): the setlist, the crowd and the tool cards use the one Song and one Artist", async ({ page }) => { await page.goto("/"); const example = EXAMPLES[0]; - const plan = page.waitForResponse((r) => r.url().endsWith("/api/plan") && r.request().method() === "POST"); + // Read the plan stream through a route: after the client navigates to /p/[id] the browser drops the + // response body, so response.text() can fail under load. + let body = ""; + await page.route("**/api/plan", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + const response = await route.fetch(); + body = await response.text(); + await route.fulfill({ response, body }); + }); await page.getByRole("link", { name: new RegExp(`^${example.label} `) }).click(); await expect(page).toHaveURL(/\/p\/[0-9A-Za-z]{9}$/); - const body = await (await plan).text(); await waitForPlan(page); + await expect.poll(() => body.length).toBeGreaterThan(0); const found = collect(body.split("\n").filter(Boolean).map((line) => JSON.parse(line) as unknown)); for (const s of example.performer.songs) found.songs.add(s.title); for (const a of example.performer.artists) found.artists.add(a); From b8174407713a4f5cfb8eb92800d4c10fa2a8d0ef Mon Sep 17 00:00:00 2001 From: T04435 Date: Thu, 22 Oct 2026 03:11:50 +1100 Subject: [PATCH 2/3] test(agent): give the week sweep a 20 s timeout Co-Authored-By: Claude Opus 5.5 --- tests/unit/agent/week.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/agent/week.test.ts b/tests/unit/agent/week.test.ts index 033cde16..9ad50e4f 100644 --- a/tests/unit/agent/week.test.ts +++ b/tests/unit/agent/week.test.ts @@ -87,7 +87,7 @@ describe("week planner (offline)", () => { const evening = await buildWeekPlan(request, OFFLINE, "2026-10-15", false, new Date("2026-10-15T09:30:00Z")); expect(evening.grid.filter((c) => c.past).map((c) => c.startHour)).toEqual([10, 13, 16, 19]); expect(evening.sessions.every((s) => s.date !== "2026-10-15")).toBe(true); - }); + }, 20_000); it("stops before spending anything when Qloo is down", async () => { const { buildWeekPlan, WeekQlooUnavailableError } = await load(); From a13d073c068408164ab2c3ac2c416656d377ae60 Mon Sep 17 00:00:00 2001 From: T04435 Date: Thu, 22 Oct 2026 03:11:50 +1100 Subject: [PATCH 3/3] test(e2e): one layout and axe test per width, desktop only Each width is its own test (shared WIDTHS in layout-helpers), skipped outside the desktop project; drops test.slow() and the CI-only workers line. Longer waits on the examples map and the on-top check, which timed out under load. Co-Authored-By: Claude Opus 5.5 --- playwright.config.ts | 2 - tests/e2e/examples.spec.ts | 4 +- tests/e2e/landing.spec.ts | 6 +- tests/e2e/layout-helpers.ts | 5 +- tests/e2e/plan-inputs.spec.ts | 6 +- tests/e2e/poster.spec.ts | 3 +- tests/e2e/repertoire-planners.spec.ts | 108 +++++----- tests/e2e/repertoire-research.spec.ts | 293 +++++++++++++++++--------- tests/e2e/repertoire.spec.ts | 59 +++--- 9 files changed, 292 insertions(+), 194 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 5ae98d96..c5a083c2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,8 +23,6 @@ export default defineConfig({ fullyParallel: true, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, - // CI runners have 4 vCPUs; the default (half the cores) leaves two idle. - workers: process.env.CI ? "100%" : undefined, reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list", use: { baseURL: externalBaseUrl ?? `http://localhost:${port}`, diff --git a/tests/e2e/examples.spec.ts b/tests/e2e/examples.spec.ts index a74270a3..02fa5d64 100644 --- a/tests/e2e/examples.spec.ts +++ b/tests/e2e/examples.spec.ts @@ -23,7 +23,7 @@ for (const example of EXAMPLES) { const pitches = page.getByRole("region", { name: "Pitches", exact: true }).getByRole("listitem"); await expect(pitches).toHaveCount(3); - await expect(page.getByRole("region", { name: "Map of pitches and Qloo taste affinity" })).toBeVisible(); + await expect(page.getByRole("region", { name: "Map of pitches and Qloo taste affinity" })).toBeVisible({ timeout: 15_000 }); // The pitch's details (card, setlist, crowd, weights) open in a sheet, not inline. await expect(page.getByRole("heading", { name: /^Suggested set for / })).toHaveCount(0); const sheet = await openDetails(page); @@ -76,7 +76,7 @@ for (const example of EXAMPLES) { // The link reopens the sheet on that pitch (shareable deep link). await page.reload(); - await expect(page.getByRole("dialog").getByRole("heading", { name: /^Suggested set for / })).toBeVisible(); + await expect(page.getByRole("dialog").getByRole("heading", { name: /^Suggested set for / })).toBeVisible({ timeout: 15_000 }); }); }); } diff --git a/tests/e2e/landing.spec.ts b/tests/e2e/landing.spec.ts index bafbd3b5..e4e4e6fb 100644 --- a/tests/e2e/landing.spec.ts +++ b/tests/e2e/landing.spec.ts @@ -1,11 +1,13 @@ import { ASSET_COUNT, COMPONENT_COUNT } from "../../src/components/brand/brand-tables"; import { callKinds } from "../../src/lib/qloo-map"; import { expect, test } from "./fixtures"; +import { WIDTHS } from "./layout-helpers"; // The landing against the design deck's Main board (QA's audit, B-018..B-026). test.describe("landing, as the deck draws it", () => { - for (const width of [390, 768, 1440]) { - test(`the headline keeps "What to play." on one line at ${width}px (B-025)`, async ({ page }) => { + for (const width of WIDTHS) { + test(`the headline keeps "What to play." on one line at ${width}px (B-025)`, async ({ page }, info) => { + test.skip(info.project.name !== "desktop", "width tests run once, in the desktop project"); await page.setViewportSize({ width, height: 900 }); await page.goto("/"); const h1 = page.getByRole("heading", { level: 1 }); diff --git a/tests/e2e/layout-helpers.ts b/tests/e2e/layout-helpers.ts index df5d36f4..cda1b17a 100644 --- a/tests/e2e/layout-helpers.ts +++ b/tests/e2e/layout-helpers.ts @@ -78,7 +78,7 @@ export async function expectOnTop(control: Locator, what: string) { const hit = document.elementFromPoint(x, y); return hit && (hit === el || el.contains(hit)) ? "on top" : `covered by ${hit?.outerHTML.slice(0, 140) ?? "nothing"}`; }), - { message: `${what} is covered or out of view`, timeout: 5_000 }, + { message: `${what} is covered or out of view`, timeout: 15_000 }, ) .toBe("on top"); } @@ -107,3 +107,6 @@ export async function holdAgentRunning(page: Page) { }); } + +/** The three widths every layout and axe test runs at, one test per width, in the desktop project only. */ +export const WIDTHS = [390, 768, 1440] as const; diff --git a/tests/e2e/plan-inputs.spec.ts b/tests/e2e/plan-inputs.spec.ts index 50241587..342a1788 100644 --- a/tests/e2e/plan-inputs.spec.ts +++ b/tests/e2e/plan-inputs.spec.ts @@ -1,6 +1,7 @@ import AxeBuilder from "@axe-core/playwright"; import { SERVER_TODAY } from "./clock"; import { expect, LOCAL, openDetails, SATURDAY, shortDay, test, waitForPlan } from "./fixtures"; +import { WIDTHS } from "./layout-helpers"; /** * Plan inputs S5/S6 (docs/plan-inputs-architecture.md): the busk form's one time control, the read-only Repertoire block, and an any-time plan with a window per pitch. @@ -207,8 +208,9 @@ test.describe("an any-time plan shows each pitch's hours (offline)", () => { test.describe("layout and axe on the busk form and an any-time plan", () => { test.skip(!LOCAL, "Local offline server only"); - for (const width of [390, 768, 1440]) { - test(`the form and an any-time plan at ${width}px: no horizontal overflow, axe clean`, async ({ page }) => { + for (const width of WIDTHS) { + test(`layout and axe: the form and an any-time plan at ${width}px`, async ({ page }, info) => { + test.skip(info.project.name !== "desktop", "width tests run once, in the desktop project"); await page.setViewportSize({ width, height: 900 }); await seed(page); await page.goto("/busks"); diff --git a/tests/e2e/poster.spec.ts b/tests/e2e/poster.spec.ts index f320bf35..b2df64cc 100644 --- a/tests/e2e/poster.spec.ts +++ b/tests/e2e/poster.spec.ts @@ -3,7 +3,8 @@ import { expect, test } from "./fixtures"; // Poster size regression (QA Landing v5): the poster variant owns its heights, 76px in the hero and 64px in the closing call to action. test.describe("poster buttons", () => { for (const width of [390, 1440]) { - test(`hero 76px and closing 64px, outline focus ring 3px at 10px offset (${width}px)`, async ({ page }) => { + test(`hero 76px and closing 64px, outline focus ring 3px at 10px offset (${width}px)`, async ({ page }, info) => { + test.skip(info.project.name !== "desktop", "width tests run once, in the desktop project"); await page.setViewportSize({ width, height: 900 }); await page.goto("/"); const hero = page.locator('[data-variant="poster"][data-size="poster-lg"]').first(); diff --git a/tests/e2e/repertoire-planners.spec.ts b/tests/e2e/repertoire-planners.spec.ts index c546d631..726ac9c1 100644 --- a/tests/e2e/repertoire-planners.spec.ts +++ b/tests/e2e/repertoire-planners.spec.ts @@ -1,5 +1,6 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, LOCAL, openDetails, test, waitForPlan } from "./fixtures"; +import { WIDTHS } from "./layout-helpers"; /** Stage R2: the planners read the Repertoire (docs/playlist-architecture.md ยง3). Offline, memory store, one cookie per context. */ const WCAG = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"]; @@ -132,68 +133,69 @@ test.describe("planners read the Repertoire (offline)", () => { expect(await state(page)).toEqual(before); }); - test("layout and axe at 390, 768 and 1440: the form with the Repertoire, the plan", async ({ page }, info) => { - await seed(page); - const check = async (label: string) => { - const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); - expect(violations.map((v) => `${label} ${v.impact} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - }; - const shot = async (name: string, width: number) => { - if (SHOTS && info.project.name === "desktop") await page.screenshot({ path: `${SHOTS}/r2-${name}-${width}.png`, fullPage: true }); - }; - const widths = [390, 768, 1440]; - await page.goto("/busks"); - for (const width of widths) { - await page.setViewportSize({ width, height: 900 }); - await check(`form-${width}`); - await form(page).scrollIntoViewIfNeeded(); - await shot("busk-form", width); - } - await page.goto("/busks"); - await form(page).getByRole("button", { name: "Plan my busk" }).click(); - await waitForPlan(page); - const learnHere = (await openDetails(page)).getByRole("region", { name: "Songs you should learn" }); - await learnHere.getByRole("button", { name: /^Add / }).first().click(); - await expect(learnHere.getByRole("status").filter({ hasText: "Added to Songs you should learn." })).toBeVisible(); - for (const width of widths) { - await page.setViewportSize({ width, height: 900 }); - await check(`plan-${width}`); - await shot("plan", width); - } - // No Repertoire: the plan shows the save nudge. - const other = await page.context().browser()!.newContext({ baseURL: info.project.use.baseURL }); - const p2 = await other.newPage(); - await p2.goto("/plan?name=Nudge%20Duo&artists=Paul%20Kelly&songs=Gravy~Paul%20Kelly&date=2099-01-03&from=18&to=21"); - await waitForPlan(p2); - await expect(p2.getByTestId("save-as-repertoire")).toBeVisible(); - for (const width of widths) { - await p2.setViewportSize({ width, height: 900 }); - const { violations } = await new AxeBuilder({ page: p2 }).withTags(WCAG).analyze(); - expect(violations.map((v) => `nudge-${width} ${v.id}`)).toEqual([]); - if (SHOTS && info.project.name === "desktop") await p2.screenshot({ path: `${SHOTS}/r2-plan-save-nudge-${width}.png`, fullPage: true }); - } - await other.close(); - }); + for (const width of WIDTHS) { + test.describe(`at ${width}px`, () => { + test.use({ viewport: { width, height: 900 } }); + + const axeClean = async (page: Page, label: string) => { + const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); + expect(violations.map((v) => `${label} ${v.impact} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + }; + + test("layout and axe: the busk form with the Repertoire", async ({ page }, info) => { + test.skip(info.project.name !== "desktop", "width tests run once, in the desktop project"); + await seed(page); + await page.goto("/busks"); + await axeClean(page, `form-${width}`); + await form(page).scrollIntoViewIfNeeded(); + if (SHOTS) await page.screenshot({ path: `${SHOTS}/r2-busk-form-${width}.png`, fullPage: true }); + }); + + test("layout and axe: the plan, with a song added to Songs you should learn", async ({ page }, info) => { + test.skip(info.project.name !== "desktop", "width tests run once, in the desktop project"); + await seed(page); + await page.goto("/busks"); + await form(page).getByRole("button", { name: "Plan my busk" }).click(); + await waitForPlan(page); + const learnHere = (await openDetails(page)).getByRole("region", { name: "Songs you should learn" }); + await learnHere.getByRole("button", { name: /^Add / }).first().click(); + await expect(learnHere.getByRole("status").filter({ hasText: "Added to Songs you should learn." })).toBeVisible(); + await axeClean(page, `plan-${width}`); + if (SHOTS) await page.screenshot({ path: `${SHOTS}/r2-plan-${width}.png`, fullPage: true }); + }); + + test("layout and axe: the plan with no Repertoire shows the save nudge", async ({ page }, info) => { + test.skip(info.project.name !== "desktop", "width tests run once, in the desktop project"); + await page.goto("/plan?name=Nudge%20Duo&artists=Paul%20Kelly&songs=Gravy~Paul%20Kelly&date=2099-01-03&from=18&to=21"); + await waitForPlan(page); + await expect(page.getByTestId("save-as-repertoire")).toBeVisible(); + const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); + expect(violations.map((v) => `nudge-${width} ${v.id}`)).toEqual([]); + if (SHOTS) await page.screenshot({ path: `${SHOTS}/r2-plan-save-nudge-${width}.png`, fullPage: true }); + }); + + test("layout and axe: R4 words, Suggested set and Songs you should learn", async ({ page }, info) => { + test.skip(info.project.name !== "desktop", "width tests run once, in the desktop project"); + await page.goto("/plan?name=Words%20Duo&artists=Paul%20Kelly&songs=Gravy~Paul%20Kelly&date=2099-01-03&from=18&to=21"); + await waitForPlan(page); + const card = (await openDetails(page)).getByRole("region", { name: "Suggested set" }); + await card.scrollIntoViewIfNeeded(); + await axeClean(page, `words-${width}`); + if (SHOTS) await card.screenshot({ path: `${SHOTS}/r4-suggested-set-${width}.png` }); + }); + }); + } - test("R4 words: the plan says Suggested set and Songs you should learn, never Setlist or Learn these (3 widths, axe, screenshots)", async ({ page }, info) => { + test("R4 words: the plan says Suggested set and Songs you should learn, never Setlist or Learn these", async ({ page }) => { await page.goto("/plan?name=Words%20Duo&artists=Paul%20Kelly&songs=Gravy~Paul%20Kelly&date=2099-01-03&from=18&to=21"); await waitForPlan(page); const sheet = await openDetails(page); const heading = sheet.getByRole("heading", { name: /^Suggested set for / }); await expect(heading).toBeVisible(); - const card = sheet.getByRole("region", { name: "Suggested set" }); const learn = sheet.getByRole("region", { name: "Songs you should learn" }); await expect(learn.getByRole("heading", { name: "Songs you should learn" })).toBeVisible(); await expect(page.getByRole("heading", { name: /Setlist|Learn these/ })).toHaveCount(0); await expect(page.getByText(/\bsetlist\b|learn these|playlist/i)).toHaveCount(0); - for (const width of [390, 768, 1440]) { - await page.setViewportSize({ width, height: 900 }); - await card.scrollIntoViewIfNeeded(); - const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); - expect(violations.map((v) => `${width} ${v.impact} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - if (SHOTS && info.project.name === "desktop") await card.screenshot({ path: `${SHOTS}/r4-suggested-set-${width}.png` }); - } }); }); diff --git a/tests/e2e/repertoire-research.spec.ts b/tests/e2e/repertoire-research.spec.ts index 129f6a2b..b1f16ca3 100644 --- a/tests/e2e/repertoire-research.spec.ts +++ b/tests/e2e/repertoire-research.spec.ts @@ -1,6 +1,7 @@ import AxeBuilder from "@axe-core/playwright"; import { OFFLINE_PICKED_ARTISTS, OFFLINE_TEST_ACTS, OFFLINE_TEST_ARTIST } from "../../src/lib/repertoire-research"; import { expect, LOCAL, test } from "./fixtures"; +import { WIDTHS } from "./layout-helpers"; /** * /repertoire as Repertoire research (docs/repertoire-research.md), on the offline mocks: one link field, a streamed run with @@ -8,7 +9,6 @@ import { expect, LOCAL, test } from "./fixtures"; * (act-profile, learn-run, the /repertoire half of profile-header). */ const WCAG = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"]; -const WIDTHS = [390, 768, 1440]; const SHOTS = process.env.REPERTOIRE_SHOTS; const LINK = `https://open.spotify.com/artist/${OFFLINE_TEST_ARTIST.spotifyId}`; type Page = import("@playwright/test").Page; @@ -158,11 +158,6 @@ test.describe("Repertoire research (offline)", () => { await expect(page.getByRole("list", { name: "Songs you can sing" }).getByRole("listitem").first()).toBeVisible(); await expect(page.getByTestId("research-to-learn")).toHaveCount(0); // The prompt is accessible at the three widths. - for (const width of WIDTHS) { - await page.setViewportSize({ width, height: 900 }); - const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); - expect(violations.map((v) => `candidates-${width} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); - } // A reload still shows the question (it is saved with the research). await page.reload(); await expect(page.getByTestId("research-candidates")).toBeVisible(); @@ -196,11 +191,6 @@ test.describe("Repertoire research (offline)", () => { await expect(page.getByTestId("research-candidates")).toHaveCount(0); await expect(page.getByTestId("research-fans-love")).toBeVisible(); await expect(page.getByRole("list", { name: "Songs you should learn" }).getByTestId("learn-card").first()).toBeVisible(); - for (const width of WIDTHS) { - await page.setViewportSize({ width, height: 900 }); - const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); - expect(violations.map((v) => `fallback-${width} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); - } // "Find more songs to learn" works from the fallback, and a refresh doesn't ask again. await page.getByRole("button", { name: "Refresh my research" }).click(); await done(page); @@ -226,11 +216,6 @@ test.describe("Repertoire research (offline)", () => { const body = (await (await page.request.get("/api/playlist")).json()).playlist; expect(body.soundsLike.map((a: { name: string; foundBy?: string }) => `${a.name}:${a.foundBy}`)).toEqual(["Paul Kelly:hive", "Courtney Barnett:hive", "Missy Higgins:hive"]); expect(body.qlooArtistId).toBeNull(); - for (const width of WIDTHS) { - await page.setViewportSize({ width, height: 900 }); - const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); - expect(violations.map((v) => `found-${width} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); - } }); test("no taste signal anywhere (the Victoria Queen case): the run waits at Who you sound like and asks in the step", async ({ page }) => { @@ -248,12 +233,6 @@ test.describe("Repertoire research (offline)", () => { await expect(stream).not.toContainText("Qloo found no artists like you"); await expect(stream.getByTestId("agent-tag")).toHaveText("Waiting"); await expect(page.getByTestId("research-end")).toHaveCount(0); - for (const width of WIDTHS) { - await page.setViewportSize({ width, height: 900 }); - const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); - expect(violations.map((v) => `waiting-${width} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), `waiting-${width} scrolls sideways`).toBe(true); - } // RAW: the calls each step made, and no description lines. await stream.getByTestId("raw-switch").click(); await expect(stream.getByTestId("agent-step-calls").first()).toBeVisible(); @@ -321,11 +300,6 @@ test.describe("Repertoire research (offline)", () => { await expect(ask).toContainText("Paul Kelly"); await page.waitForTimeout(500); expect(inputs, "nothing is sent before Carry on").toEqual([]); - for (const width of WIDTHS) { - await page.setViewportSize({ width, height: 900 }); - const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); - expect(violations.map((v) => `selection-${width} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); - } await carryOn.click(); await done(page); expect(inputs).toHaveLength(1); @@ -379,11 +353,6 @@ test.describe("Repertoire research (offline)", () => { await expect(alikes.getByRole("list", { name: "Artists you picked" }).locator("li img")).toHaveCount(2); const saved = (await (await page.request.get("/api/playlist")).json()).playlist.soundsLike; expect(saved).toMatchObject([{ name: "Mariah Carey", image: OFFLINE_PICKED_ARTISTS.mariah.spotifyImage }, { name: "Celia Cruz", image: OFFLINE_PICKED_ARTISTS.celia.qlooImage }]); - for (const width of WIDTHS) { - await page.setViewportSize({ width, height: 900 }); - const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); - expect(violations.map((v) => `picked-${width} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); - } }); test("still nothing Qloo knows: it asks again in the same step, then stops after three asks in one line", async ({ page }) => { @@ -398,11 +367,6 @@ test.describe("Repertoire research (offline)", () => { await page.getByTestId("research-genre-pick").getByRole("button", { name: "Latin" }).click(); await page.getByTestId("research-ask-carry-on").click(); await expect(line).toHaveText("Qloo has no tag for Latin. Pick another genre, or add artists you sound like.", { timeout: 20_000 }); - for (const width of WIDTHS) { - await page.setViewportSize({ width, height: 900 }); - const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); - expect(violations.map((v) => `ask-again-${width} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); - } await page.getByTestId("research-genre-pick").getByRole("button", { name: "Latin" }).click(); await page.getByTestId("research-ask-carry-on").click(); await expect(page.getByTestId("research-end")).toHaveAttribute("data-code", "no_taste_data", { timeout: 20_000 }); @@ -433,76 +397,195 @@ test.describe("Repertoire research (offline)", () => { await expect(page.getByTestId("research-notice")).toContainText("runs this hour"); }); - test("the three states at 390, 768 and 1440: axe, no sideways scroll, screenshots", async ({ page }, info) => { - test.setTimeout(120_000); - const check = async (name: string) => { - for (const width of WIDTHS) { - await page.setViewportSize({ width, height: 900 }); + for (const width of WIDTHS) { + test.describe(`at ${width}px`, () => { + test.use({ viewport: { width, height: 900 } }); + test.skip(!LOCAL, "Writes to the local offline server's memory store and uses the mock artist"); + + /** Axe over the page as it is now, named like a failure should be (state and width). */ + const axeClean = async (page: Page, name: string, sideways = false) => { const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); expect(violations.map((v) => `${name}-${width} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), `${name}-${width} scrolls sideways`).toBe(true); - if (SHOTS && info.project.name === "desktop") await page.screenshot({ path: `${SHOTS}/research-${name}-${width}.png`, fullPage: true }); - } - }; - await page.goto("/repertoire"); - await check("empty"); - await page.route("**/api/playlist/research", async (route) => { - await new Promise((r) => setTimeout(r, 3000)); - return route.continue(); - }); - await field(page).fill(LINK); - await page.getByRole("button", { name: "Research my act" }).click(); - await expect(page.getByTestId("research-page")).toHaveAttribute("data-phase", "running"); - await check("running"); - await done(page); - await check("done"); - }); - test("nothing scrolls below the footer (the phone nav's own padding aside): empty, after a run, waiting for input, and the other pages", async ({ page }) => { - test.setTimeout(120_000); - // Exactly one footer once the page has streamed in: React holds a streamed boundary in a hidden div (S:n) until it swaps it in. - const oneFooter = async () => { - await expect(page.locator("footer[data-slot=site-footer]")).toHaveCount(1); - await expect(page.locator("footer[data-slot=site-footer]")).toBeVisible(); - }; - // The footer's bottom against the scrollable height; under 721px the body keeps the fixed bottom nav's height (plus the safe area) as padding on purpose. - const below = () => - page.evaluate(() => { - const footer = document.querySelector("footer[data-slot=site-footer]")!; - const pad = parseFloat(getComputedStyle(document.body).paddingBottom) || 0; - return document.scrollingElement!.scrollHeight - (footer.getBoundingClientRect().bottom + window.scrollY) - pad; + if (sideways) { + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), `${name}-${width} scrolls sideways`).toBe(true); + } + }; + const onlyDesktop = (info: { project: { name: string } }) => test.skip(info.project.name !== "desktop", "width tests run once, in the desktop project"); + + test("layout and axe: the 'is one of these you?' prompt", async ({ page }, info) => { + onlyDesktop(info); + await research(page, linkOf(OFFLINE_TEST_ACTS.candidates.spotifyId)); + await expect(page.getByTestId("research-candidates")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("list", { name: "Songs you can sing" }).getByRole("listitem").first()).toBeVisible(); + await axeClean(page, "candidates"); + }); + + test("layout and axe: the fallback from artists like you", async ({ page }, info) => { + onlyDesktop(info); + test.setTimeout(90_000); + await research(page, linkOf(OFFLINE_TEST_ACTS.candidates.spotifyId)); + await expect(page.getByTestId("research-candidates")).toBeVisible({ timeout: 20_000 }); + const saved = (await (await page.request.get("/api/playlist")).json()).playlist; + const patched = await page.request.patch("/api/playlist", { data: { revision: saved.revision, soundsLike: [{ name: "Paul Kelly" }, { name: "Courtney Barnett" }] } }); + expect(patched.ok()).toBe(true); + await page.reload(); + await page.getByRole("button", { name: "None of these" }).click(); + await done(page); + await expect(page.getByTestId("research-fans-love")).toBeVisible(); + await expect(page.getByRole("list", { name: "Songs you should learn" }).getByTestId("learn-card").first()).toBeVisible(); + await axeClean(page, "fallback"); + }); + + test("layout and axe: sound-alikes found by the Hive", async ({ page }, info) => { + onlyDesktop(info); + test.setTimeout(90_000); + await research(page, linkOf(OFFLINE_TEST_ACTS.unknown.spotifyId)); + await done(page); + await expect(page.getByTestId("research-fans-love")).toBeVisible(); + await expect(page.getByRole("list", { name: "Songs you should learn" }).getByTestId("learn-card").first()).toBeVisible(); + await axeClean(page, "found"); }); - for (const width of [390, 1440]) { - await page.setViewportSize({ width, height: 900 }); - await page.context().clearCookies(); - await page.goto("/repertoire"); - await expect(field(page)).toBeVisible(); - await oneFooter(); - expect(await below(), `/repertoire empty at ${width}`).toBeLessThanOrEqual(2); - await field(page).fill(LINK); - await page.getByRole("button", { name: "Research my act" }).click(); - await done(page); - await oneFooter(); - expect(await below(), `/repertoire after a run at ${width}`).toBeLessThanOrEqual(2); - // Paused for input (no taste signal), after a reload, and once the answer has carried the run on. - await page.context().clearCookies(); - await research(page, linkOf(OFFLINE_TEST_ACTS.silent.spotifyId)); - await expect(page.getByTestId("research-ask")).toBeVisible({ timeout: 20_000 }); - await oneFooter(); - expect(await below(), `/repertoire waiting at ${width}`).toBeLessThanOrEqual(2); - await page.reload(); - await expect(page.getByTestId("research-ask")).toBeVisible({ timeout: 20_000 }); - await oneFooter(); - expect(await below(), `/repertoire waiting, reloaded, at ${width}`).toBeLessThanOrEqual(2); - await page.getByTestId("research-ask").getByPlaceholder("Search Spotify, e.g. Paul Kelly").fill("Paul Kelly, Courtney Barnett"); - await page.getByTestId("research-ask-carry-on").click(); - await done(page); - await oneFooter(); - expect(await below(), `/repertoire after the answer at ${width}`).toBeLessThanOrEqual(2); - for (const path of ["/", "/busks", "/brain", "/brand", "/p/ZZZZZZZZZ"]) { - await page.goto(path); + + test("layout and axe: the run waiting at Who you sound like", async ({ page }, info) => { + onlyDesktop(info); + test.setTimeout(120_000); + await research(page, linkOf(OFFLINE_TEST_ACTS.silent.spotifyId)); + const stream = page.getByTestId("agent-stream"); + await expect(stream.getByTestId("research-ask")).toBeVisible({ timeout: 20_000 }); + await expect(stream.getByTestId("agent-tag")).toHaveText("Waiting"); + await expect(page.getByTestId("research-end")).toHaveCount(0); + await axeClean(page, "waiting", true); + }); + + test("layout and axe: the pause as a selection (artists and a genre)", async ({ page }, info) => { + onlyDesktop(info); + test.setTimeout(120_000); + await research(page, linkOf(OFFLINE_TEST_ACTS.silent.spotifyId)); + const ask = page.getByTestId("research-ask"); + await expect(ask).toBeVisible({ timeout: 20_000 }); + const picker = ask.getByPlaceholder("Search Spotify, e.g. Paul Kelly"); + await picker.fill("Paul Kelly, Courtney Barnett"); + // The artist search list belongs to typing; close it so axe reads the resting selection. + await picker.press("Escape"); + await expect(page.getByRole("listbox", { name: "Artist results" })).toHaveCount(0); + await page.getByTestId("research-genre-pick").getByRole("button", { name: "Pop" }).click(); + await expect(page.getByTestId("research-genre-pick").getByRole("button", { name: "Pop" })).toHaveAttribute("aria-pressed", "true"); + // Axe reads the resting state: a hovered pressed button is a different colour, not what this test is about. + await page.mouse.move(0, 0); + await axeClean(page, "selection"); + }); + + test("layout and axe: artists picked from Spotify and two genres", async ({ page }, info) => { + onlyDesktop(info); + test.setTimeout(120_000); + // The offline photos are https URLs nothing serves: answer them with a small picture. + const picture = ''; + await page.route(/i\.scdn\.co|images\.qloo\.test/, (route) => route.fulfill({ contentType: "image/svg+xml", body: picture })); + await research(page, linkOf(OFFLINE_TEST_ACTS.silent.spotifyId)); + const ask = page.getByTestId("research-ask"); + await expect(ask).toBeVisible({ timeout: 20_000 }); + const picker = ask.getByPlaceholder("Search Spotify, e.g. Paul Kelly"); + for (const artist of [OFFLINE_PICKED_ARTISTS.mariah, OFFLINE_PICKED_ARTISTS.celia]) { + await picker.fill(artist.name); + await page.getByRole("option", { name: new RegExp(artist.name) }).filter({ hasNotText: "as typed" }).first().click(); + } + const genres = page.getByTestId("research-genre-pick"); + await genres.getByRole("button", { name: "Folk" }).click(); + await genres.getByRole("button", { name: "Pop" }).click(); + await page.getByTestId("research-ask-carry-on").click(); + await done(page); + const alikes = page.getByTestId("research-sounds-like"); + await expect(alikes.getByRole("list", { name: "Artists you picked" }).locator("li img")).toHaveCount(2); + await axeClean(page, "picked"); + }); + + test("layout and axe: asking again in the same step", async ({ page }, info) => { + onlyDesktop(info); + test.setTimeout(120_000); + await research(page, linkOf(OFFLINE_TEST_ACTS.silent.spotifyId)); + const line = page.getByTestId("research-ask-line"); + await expect(line).toBeVisible({ timeout: 20_000 }); + await page.getByTestId("research-ask").getByPlaceholder("Search Spotify, e.g. Paul Kelly").fill("Qzv Nobody"); + await page.getByTestId("research-ask-carry-on").click(); + await expect(line).toHaveText("Qloo doesn't know Qzv Nobody either. Try better-known artists, or pick a genre.", { timeout: 20_000 }); + await page.getByTestId("research-genre-pick").getByRole("button", { name: "Latin" }).click(); + await page.getByTestId("research-ask-carry-on").click(); + await expect(line).toHaveText("Qloo has no tag for Latin. Pick another genre, or add artists you sound like.", { timeout: 20_000 }); + await axeClean(page, "ask-again"); + }); + + test("layout and axe: the empty, running and done states", async ({ page }, info) => { + onlyDesktop(info); + test.setTimeout(120_000); + const check = async (name: string) => { + await axeClean(page, name, true); + if (SHOTS) await page.screenshot({ path: `${SHOTS}/research-${name}-${width}.png`, fullPage: true }); + }; + await page.goto("/repertoire"); + await check("empty"); + await page.route("**/api/playlist/research", async (route) => { + await new Promise((r) => setTimeout(r, 3000)); + return route.continue(); + }); + await field(page).fill(LINK); + await page.getByRole("button", { name: "Research my act" }).click(); + await expect(page.getByTestId("research-page")).toHaveAttribute("data-phase", "running"); + await check("running"); + await done(page); + await check("done"); + }); + }); + } + + for (const width of [390, 1440]) { + test.describe(`at ${width}px`, () => { + test.use({ viewport: { width, height: 900 } }); + + test("layout: nothing scrolls below the footer (the phone nav's own padding aside): empty, after a run, waiting for input, and the other pages", async ({ page }, info) => { + test.skip(info.project.name !== "desktop", "width tests run once, in the desktop project"); + test.setTimeout(120_000); + // Exactly one footer once the page has streamed in: React holds a streamed boundary in a hidden div (S:n) until it swaps it in. + const oneFooter = async () => { + await expect(page.locator("footer[data-slot=site-footer]")).toHaveCount(1); + await expect(page.locator("footer[data-slot=site-footer]")).toBeVisible(); + }; + // The footer's bottom against the scrollable height; under 721px the body keeps the fixed bottom nav's height (plus the safe area) as padding on purpose. + const below = () => + page.evaluate(() => { + const footer = document.querySelector("footer[data-slot=site-footer]")!; + const pad = parseFloat(getComputedStyle(document.body).paddingBottom) || 0; + return document.scrollingElement!.scrollHeight - (footer.getBoundingClientRect().bottom + window.scrollY) - pad; + }); + await page.goto("/repertoire"); + await expect(field(page)).toBeVisible(); await oneFooter(); - expect(await below(), `${path} at ${width}`).toBeLessThanOrEqual(2); - } - } - }); + expect(await below(), `/repertoire empty at ${width}`).toBeLessThanOrEqual(2); + await field(page).fill(LINK); + await page.getByRole("button", { name: "Research my act" }).click(); + await done(page); + await oneFooter(); + expect(await below(), `/repertoire after a run at ${width}`).toBeLessThanOrEqual(2); + // Paused for input (no taste signal), after a reload, and once the answer has carried the run on. + await page.context().clearCookies(); + await research(page, linkOf(OFFLINE_TEST_ACTS.silent.spotifyId)); + await expect(page.getByTestId("research-ask")).toBeVisible({ timeout: 20_000 }); + await oneFooter(); + expect(await below(), `/repertoire waiting at ${width}`).toBeLessThanOrEqual(2); + await page.reload(); + await expect(page.getByTestId("research-ask")).toBeVisible({ timeout: 20_000 }); + await oneFooter(); + expect(await below(), `/repertoire waiting, reloaded, at ${width}`).toBeLessThanOrEqual(2); + await page.getByTestId("research-ask").getByPlaceholder("Search Spotify, e.g. Paul Kelly").fill("Paul Kelly, Courtney Barnett"); + await page.getByTestId("research-ask-carry-on").click(); + await done(page); + await oneFooter(); + expect(await below(), `/repertoire after the answer at ${width}`).toBeLessThanOrEqual(2); + for (const path of ["/", "/busks", "/brain", "/brand", "/p/ZZZZZZZZZ"]) { + await page.goto(path); + await oneFooter(); + expect(await below(), `${path} at ${width}`).toBeLessThanOrEqual(2); + } + }); + }); + } }); diff --git a/tests/e2e/repertoire.spec.ts b/tests/e2e/repertoire.spec.ts index 07e004e9..c981e89b 100644 --- a/tests/e2e/repertoire.spec.ts +++ b/tests/e2e/repertoire.spec.ts @@ -1,5 +1,6 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, LOCAL, test } from "./fixtures"; +import { WIDTHS } from "./layout-helpers"; const WCAG = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"]; const SHOTS = process.env.REPERTOIRE_SHOTS; @@ -189,30 +190,36 @@ test.describe("Repertoire storage (offline, memory store)", () => { await expect(page).toHaveURL(/\/repertoire$/); }); - test("passes axe at 390, 768 and 1440 (empty and with songs)", async ({ page }, info) => { - await page.goto("/repertoire"); - await emptyPage(page); - const check = async () => { - const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); - expect(violations.map((v) => `${v.impact} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); - }; - const widths = [390, 768, 1440]; - for (const width of widths) { - await page.setViewportSize({ width, height: 900 }); - await check(); - if (SHOTS && info.project.name === "desktop") await page.screenshot({ path: `${SHOTS}/r1-repertoire-empty-${width}.png`, fullPage: true }); - } - for (const text of ["Axe Song - Axe Artist", "Second Song - Another Artist (own)"]) await add(page, text); - await expect(page.getByRole("list", { name: "Songs you can sing" })).toContainText("Axe Song"); - await page.request.patch(`/api/playlist/songs/${(await (await page.request.get("/api/playlist")).json()).playlist.songs[1].id}`, { data: { state: "learned" } }); - await page.reload(); - await expect(page.getByRole("list", { name: "Learned", exact: true })).toBeVisible(); - for (const width of widths) { - await page.setViewportSize({ width, height: 900 }); - await check(); - // No horizontal page scroll. - expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - if (SHOTS && info.project.name === "desktop") await page.screenshot({ path: `${SHOTS}/r1-repertoire-songs-${width}.png`, fullPage: true }); - } - }); + for (const width of WIDTHS) { + test.describe(`at ${width}px`, () => { + test.use({ viewport: { width, height: 900 } }); + const axeClean = async (page: Page) => { + const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze(); + expect(violations.map((v) => `${v.impact} ${v.id}: ${v.nodes.slice(0, 3).map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); + }; + + test("layout and axe: the empty Repertoire", async ({ page }, info) => { + test.skip(info.project.name !== "desktop", "width tests run once, in the desktop project"); + await page.goto("/repertoire"); + await emptyPage(page); + await axeClean(page); + if (SHOTS) await page.screenshot({ path: `${SHOTS}/r1-repertoire-empty-${width}.png`, fullPage: true }); + }); + + test("layout and axe: the Repertoire with songs", async ({ page }, info) => { + test.skip(info.project.name !== "desktop", "width tests run once, in the desktop project"); + await page.goto("/repertoire"); + await emptyPage(page); + for (const text of ["Axe Song - Axe Artist", "Second Song - Another Artist (own)"]) await add(page, text); + await expect(page.getByRole("list", { name: "Songs you can sing" })).toContainText("Axe Song"); + await page.request.patch(`/api/playlist/songs/${(await (await page.request.get("/api/playlist")).json()).playlist.songs[1].id}`, { data: { state: "learned" } }); + await page.reload(); + await expect(page.getByRole("list", { name: "Learned", exact: true })).toBeVisible(); + await axeClean(page); + // No horizontal page scroll. + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + if (SHOTS) await page.screenshot({ path: `${SHOTS}/r1-repertoire-songs-${width}.png`, fullPage: true }); + }); + }); + } });