From abeb98cefcffbde27b801aaf9269c2ae3b41e51f Mon Sep 17 00:00:00 2001 From: Stefan Date: Wed, 5 Aug 2026 18:51:48 +0100 Subject: [PATCH 01/11] Add Cucumber evidence capture declarations --- scripts/specs/evidence/declarations.ts | 17 +++++++++++++++++ test/specs/support/by-hand.ts | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/scripts/specs/evidence/declarations.ts b/scripts/specs/evidence/declarations.ts index 8d0a9849df..cd9bf610ee 100644 --- a/scripts/specs/evidence/declarations.ts +++ b/scripts/specs/evidence/declarations.ts @@ -115,6 +115,23 @@ export const EVIDENCE_CAPTURES = [ ".page-regions.admin-page", "/admin/site/pages", ), + brandedMobileCapture( + "editors.the-listings-show-no-money", + "editor-listings-without-takings", + ".page-regions.admin-page", + "/admin/listings", + ), + brandedMobileCapture( + "backup.restore-brings-back-bookings", + "backup-restore", + ".page-regions.admin-page", + "/admin/backup", + ), + brandedMobileCapture( + "download.the-chosen-length-not-the-maximum", + "attendee-csv-export", + ".page-regions.admin-page", + ), brandedMobileCapture( "add-ons.it-appears-in-the-list-with-its-own-link", "add-on-in-the-list", diff --git a/test/specs/support/by-hand.ts b/test/specs/support/by-hand.ts index 22f3876f49..0ab083d4b7 100644 --- a/test/specs/support/by-hand.ts +++ b/test/specs/support/by-hand.ts @@ -6,6 +6,7 @@ // jscpd:ignore-start import { expect } from "@std/expect"; +import { leaveEvidencePage } from "#scripts/specs/evidence/pages.ts"; import { openAdminPage } from "#test/specs/support/browser.ts"; import { listingIdNamed } from "#test/specs/support/listings.ts"; import type { BookingChoices } from "#test/specs/support/public-booking.ts"; @@ -51,7 +52,9 @@ export const organiserAddsBooking = async ( * text of the file itself. Followed from the link on the page, so a story can * never read a file the organiser has no way to reach. */ export const downloadAttendeeList: ReadAboutOneThing = async (world, name) => { - const browser = await openAdminPage(world, rosterPath(world, name)); + const path = rosterPath(world, name); + leaveEvidencePage(world, ["attendee-csv-export"], path); + const browser = await openAdminPage(world, path); const download = browser.links.find(({ href }) => href.includes("/export")); if (!download) { throw new Error(`The ${name} roster offers no attendee list to download`); From e58d310935695ef30fd911c952140744c7d45708 Mon Sep 17 00:00:00 2001 From: Stefan Date: Wed, 5 Aug 2026 19:06:57 +0100 Subject: [PATCH 02/11] Fix attendee evidence page selector --- scripts/specs/evidence/declarations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/specs/evidence/declarations.ts b/scripts/specs/evidence/declarations.ts index cd9bf610ee..5b5fd5fae1 100644 --- a/scripts/specs/evidence/declarations.ts +++ b/scripts/specs/evidence/declarations.ts @@ -130,7 +130,7 @@ export const EVIDENCE_CAPTURES = [ brandedMobileCapture( "download.the-chosen-length-not-the-maximum", "attendee-csv-export", - ".page-regions.admin-page", + ".page-regions.entity-page", ), brandedMobileCapture( "add-ons.it-appears-in-the-list-with-its-own-link", From e3c86622abaab561d643831fec76570aef70d61e Mon Sep 17 00:00:00 2001 From: Stefan Date: Wed, 5 Aug 2026 19:14:21 +0100 Subject: [PATCH 03/11] Capture evidence with scenario sessions --- scripts/specs/evidence/capture-flow.ts | 10 +++++++--- scripts/specs/evidence/hook.ts | 1 + scripts/specs/evidence/pages.ts | 6 +++++- test/scripts/specs/evidence-capture.test.ts | 18 ++++++++++++++++++ test/scripts/specs/evidence-hook.test.ts | 1 + test/scripts/specs/evidence/pages.test.ts | 14 ++++++++++++++ test/specs/steps/editors.ts | 12 +++++++++++- test/specs/support/hooks.ts | 1 + 8 files changed, 58 insertions(+), 5 deletions(-) diff --git a/scripts/specs/evidence/capture-flow.ts b/scripts/specs/evidence/capture-flow.ts index f0b97715c4..584e2366b1 100644 --- a/scripts/specs/evidence/capture-flow.ts +++ b/scripts/specs/evidence/capture-flow.ts @@ -44,9 +44,9 @@ interface EvidenceCaptureDependencies { const browserCookie = async ( baseUrl: string, - getCookie: () => Promise, + cookie: string, ): Promise<{ name: string; url: string; value: string }> => { - const [pair] = (await getCookie()).split(";", 1); + const [pair] = cookie.split(";", 1); if (!pair) throw new Error("Test owner cookie is malformed"); const splitAt = pair.indexOf("="); if (splitAt < 1 || splitAt === pair.length - 1) { @@ -104,7 +104,11 @@ const captureProfile = async ( const blocked = new Set(); await blockOutboundRequests(context, baseUrl, blocked); await context.addCookies([ - await browserCookie(baseUrl, dependencies.getCookie), + await browserCookie( + baseUrl, + world.evidenceCookies.get(declaration.id) ?? + (await dependencies.getCookie()), + ), ]); const page = await context.newPage(); page.setDefaultTimeout(CAPTURE_TIMEOUT_MS); diff --git a/scripts/specs/evidence/hook.ts b/scripts/specs/evidence/hook.ts index 3f392a6f23..53a849864b 100644 --- a/scripts/specs/evidence/hook.ts +++ b/scripts/specs/evidence/hook.ts @@ -10,6 +10,7 @@ export interface EvidenceWorld { data: Buffer, options: { fileName: string; mediaType: "image/png" }, ): void | Promise; + evidenceCookies: ReadonlyMap; evidencePages: ReadonlyMap; } diff --git a/scripts/specs/evidence/pages.ts b/scripts/specs/evidence/pages.ts index cf69c49331..98b06a8db2 100644 --- a/scripts/specs/evidence/pages.ts +++ b/scripts/specs/evidence/pages.ts @@ -14,6 +14,7 @@ import { /** The pages a running story has left behind, one for each screenshot it is * setting up. */ export interface EvidencePages { + evidenceCookies: Map; evidencePages: Map; } @@ -23,10 +24,13 @@ export const leaveEvidencePage = ( world: EvidencePages, captureIds: readonly EvidenceCaptureId[], path: string, + cookie?: string, ): void => { const address = v.parse(EvidencePathSchema, path); - for (const captureId of captureIds) + for (const captureId of captureIds) { world.evidencePages.set(captureId, address); + if (cookie !== undefined) world.evidenceCookies.set(captureId, cookie); + } }; /** The address to open for one screenshot: the one its declaration fixes, or diff --git a/test/scripts/specs/evidence-capture.test.ts b/test/scripts/specs/evidence-capture.test.ts index 015ed4821a..c8318d3583 100644 --- a/test/scripts/specs/evidence-capture.test.ts +++ b/test/scripts/specs/evidence-capture.test.ts @@ -40,6 +40,7 @@ interface CaptureFixtureOptions { feature?: string; hookPickle?: number; launchError?: Error; + leftCookies?: ReadonlyArray; leftPages?: ReadonlyArray; serverCloseError?: Error; } @@ -193,6 +194,7 @@ const captureFixture = ( options: attachmentOptions, }); }, + evidenceCookies: new Map(options.leftCookies), evidencePages: new Map( options.leftPages ?? [[declaration.id, PAYMENT_RESULT_PAGE]], ), @@ -276,6 +278,22 @@ describe("Cucumber evidence capture", () => { expectCaptureClosed(calls); }); + test("uses the session the story left for a capture", async () => { + const { calls, capture, hook, world } = captureFixture({ + leftCookies: [[declaration.id, "session=editor; Path=/"]], + }); + + await capture(world, hook); + + expect(calls.cookies).toEqual([ + { + name: "session", + url: "http://127.0.0.1:4321", + value: "editor", + }, + ]); + }); + test("rejects a request blocked while the screenshot is being prepared", async () => { const { calls, capture, hook, world } = captureFixture({ blockedUrl: "https://example.com/tracker.js", diff --git a/test/scripts/specs/evidence-hook.test.ts b/test/scripts/specs/evidence-hook.test.ts index 682c607fb9..c292cdbbe7 100644 --- a/test/scripts/specs/evidence-hook.test.ts +++ b/test/scripts/specs/evidence-hook.test.ts @@ -14,6 +14,7 @@ import { const world = { attach: () => Promise.resolve(), + evidenceCookies: new Map(), evidencePages: new Map(), }; diff --git a/test/scripts/specs/evidence/pages.test.ts b/test/scripts/specs/evidence/pages.test.ts index ad910e8aab..73d09d1ce8 100644 --- a/test/scripts/specs/evidence/pages.test.ts +++ b/test/scripts/specs/evidence/pages.test.ts @@ -9,6 +9,7 @@ import { import { PAYMENT_RESULT_CAPTURE as declaration } from "#test/scripts/specs/evidence-fixture.ts"; const emptyWorld = (): EvidencePages => ({ + evidenceCookies: new Map(), evidencePages: new Map(), }); @@ -39,6 +40,19 @@ describe("Evidence pages a story leaves", () => { ); }); + test("keeps the session the story left for a capture", () => { + const world = emptyWorld(); + + leaveEvidencePage( + world, + ["listing-ledger"], + "/admin/ledger/revenue/1", + "session=editor", + ); + + expect(world.evidenceCookies.get("listing-ledger")).toBe("session=editor"); + }); + test("refuses a page that is not a whole address", () => { for (const path of ["admin/settings", "/ticket/{bundleSlug}", ""]) { expect(() => diff --git a/test/specs/steps/editors.ts b/test/specs/steps/editors.ts index d6955330e3..c9f2db78c2 100644 --- a/test/specs/steps/editors.ts +++ b/test/specs/steps/editors.ts @@ -3,6 +3,7 @@ import { Given, Then, When } from "@cucumber/cucumber"; import { expect } from "@std/expect"; import { t } from "#i18n"; +import { leaveEvidencePage } from "#scripts/specs/evidence/pages.ts"; import { formatCurrency, toMinorUnits } from "#shared/currency.ts"; import { editorAddsListing, @@ -145,7 +146,16 @@ Then( When( "{word} opens the listings", async function (this: TicketsWorld, _who: string): Promise { - await editorBrowser(this).visit("/admin/listings"); + const browser = editorBrowser(this); + await browser.visit("/admin/listings"); + leaveEvidencePage( + this, + ["editor-listings-without-takings"], + "/admin/listings", + [...browser.debugCookies()] + .map(([name, value]) => `${name}=${value}`) + .join("; "), + ); }, ); diff --git a/test/specs/support/hooks.ts b/test/specs/support/hooks.ts index dc1abdab34..d95fdf795c 100644 --- a/test/specs/support/hooks.ts +++ b/test/specs/support/hooks.ts @@ -15,6 +15,7 @@ import { addDatabaseCleanup, type TicketsWorld } from "./world.ts"; Before(async function (this: TicketsWorld): Promise { this.cleanup = putsThingsBack(); + this.evidenceCookies = new Map(); this.evidencePages = new Map(); this.things = namedThings(); const cleanupDb = await setupTestDbEnvironment(true).catch((error) => { From 99f40044cd5792539c705feaa8a0baf67f78cb35 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 6 Aug 2026 05:48:09 +0100 Subject: [PATCH 04/11] Capture evidence outcomes --- scripts/specs/evidence/declarations.ts | 3 +-- scripts/specs/evidence/schema.ts | 7 ++++--- test/scripts/specs/evidence/pages.test.ts | 9 +++++++++ test/specs/steps/backup-restore.ts | 8 ++++++++ test/specs/steps/editors.ts | 5 ++--- test/specs/support/browser.ts | 10 ++++++++++ test/specs/support/by-hand.ts | 18 ++++++++++++++++-- 7 files changed, 50 insertions(+), 10 deletions(-) diff --git a/scripts/specs/evidence/declarations.ts b/scripts/specs/evidence/declarations.ts index 5b5fd5fae1..06b88ce39a 100644 --- a/scripts/specs/evidence/declarations.ts +++ b/scripts/specs/evidence/declarations.ts @@ -125,12 +125,11 @@ export const EVIDENCE_CAPTURES = [ "backup.restore-brings-back-bookings", "backup-restore", ".page-regions.admin-page", - "/admin/backup", ), brandedMobileCapture( "download.the-chosen-length-not-the-maximum", "attendee-csv-export", - ".page-regions.entity-page", + "main", ), brandedMobileCapture( "add-ons.it-appears-in-the-list-with-its-own-link", diff --git a/scripts/specs/evidence/schema.ts b/scripts/specs/evidence/schema.ts index f9e189ca64..6786610ac6 100644 --- a/scripts/specs/evidence/schema.ts +++ b/scripts/specs/evidence/schema.ts @@ -45,10 +45,11 @@ const EvidenceProfilesSchema = v.pipe( * handing the finished address over with leaveEvidencePage. */ export const EvidencePathSchema = v.pipe( TrimmedTextSchema, - v.startsWith("/", "Evidence path must start with /"), v.check( - (path) => !path.includes("{"), - "Evidence path must be a whole address, not a placeholder", + (path) => + path.startsWith("data:text/html,") || + (path.startsWith("/") && !path.includes("{")), + "Evidence path must be a whole address or HTML data page", ), ); diff --git a/test/scripts/specs/evidence/pages.test.ts b/test/scripts/specs/evidence/pages.test.ts index 73d09d1ce8..1af34fa638 100644 --- a/test/scripts/specs/evidence/pages.test.ts +++ b/test/scripts/specs/evidence/pages.test.ts @@ -61,6 +61,15 @@ describe("Evidence pages a story leaves", () => { } }); + test("accepts an HTML data page for a downloaded outcome", () => { + const world = emptyWorld(); + const page = "data:text/html,%3Cmain%3ECSV%3C%2Fmain%3E"; + + leaveEvidencePage(world, ["listing-ledger"], page); + + expect(world.evidencePages.get("listing-ledger")).toBe(page); + }); + test("opens the address the declaration fixes, ignoring what was left", () => { expect( evidencePagePath( diff --git a/test/specs/steps/backup-restore.ts b/test/specs/steps/backup-restore.ts index f5ad449d08..12f2216878 100644 --- a/test/specs/steps/backup-restore.ts +++ b/test/specs/steps/backup-restore.ts @@ -2,11 +2,13 @@ import { Given, Then, When } from "@cucumber/cucumber"; import { expect } from "@std/expect"; +import { leaveEvidencePage } from "#scripts/specs/evidence/pages.ts"; import { restoreFromZip } from "#shared/db/backup.ts"; import { adminBrowser, resetScenarioBrowser, scenarioBrowser, + sessionCookie, } from "#test/specs/support/browser.ts"; import { rememberListing } from "#test/specs/support/listings.ts"; import { @@ -133,5 +135,11 @@ Then( ); expect(browser.containsText(CUSTOMER)).toBe(true); expect(browser.containsText(CUSTOMER_EMAIL)).toBe(true); + leaveEvidencePage( + this, + ["backup-restore"], + browser.currentUrl, + sessionCookie(browser), + ); }, ); diff --git a/test/specs/steps/editors.ts b/test/specs/steps/editors.ts index c9f2db78c2..9777dd3de9 100644 --- a/test/specs/steps/editors.ts +++ b/test/specs/steps/editors.ts @@ -5,6 +5,7 @@ import { expect } from "@std/expect"; import { t } from "#i18n"; import { leaveEvidencePage } from "#scripts/specs/evidence/pages.ts"; import { formatCurrency, toMinorUnits } from "#shared/currency.ts"; +import { sessionCookie } from "#test/specs/support/browser.ts"; import { editorAddsListing, editorBrowser, @@ -152,9 +153,7 @@ When( this, ["editor-listings-without-takings"], "/admin/listings", - [...browser.debugCookies()] - .map(([name, value]) => `${name}=${value}`) - .join("; "), + sessionCookie(browser), ); }, ); diff --git a/test/specs/support/browser.ts b/test/specs/support/browser.ts index ec572e1ed8..c5081ddf7c 100644 --- a/test/specs/support/browser.ts +++ b/test/specs/support/browser.ts @@ -1,5 +1,6 @@ // jscpd:ignore-start import { t } from "#i18n"; +import { getSessionCookieName } from "#shared/cookies.ts"; import { type RowOnList, rowsOnList, @@ -34,6 +35,15 @@ export const rememberBrowser = ( browser: TestBrowser, ): TestBrowser => world.things.remember("browser", who, browser); +/** The one cookie that authenticates a captured page. Other browser cookies do + * not change which admin account sees it. */ +export const sessionCookie = (browser: TestBrowser): string => { + const name = getSessionCookieName(); + const value = browser.debugCookies().get(name); + if (!value) throw new Error("The browser has no admin session cookie"); + return `${name}=${value}`; +}; + /** The window somebody is already looking at. A story that never gave them one * has nothing to read, so it says so rather than opening a fresh window and * reporting on a page nobody was ever shown. */ diff --git a/test/specs/support/by-hand.ts b/test/specs/support/by-hand.ts index 0ab083d4b7..2bbb5909a2 100644 --- a/test/specs/support/by-hand.ts +++ b/test/specs/support/by-hand.ts @@ -21,6 +21,17 @@ import type { const rosterPath = (world: TicketsWorld, name: string): string => `/admin/listing/${listingIdNamed(world, name)}/attendees`; +const escapeHtml = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +/** The CSV is the outcome this story reads. Render that exact text in the + * evidence browser so its screenshot proves the date range in the download. */ +const csvEvidencePage = (name: string, csv: string): string => + `data:text/html,${encodeURIComponent(`

${escapeHtml(name)} attendee CSV

${escapeHtml(csv)}
`)}`; + /** The organiser adds a booking through the form on the listing's roster. Keeps * the page they land on, so a story can read what they were told. */ export const organiserAddsBooking = async ( @@ -53,11 +64,14 @@ export const organiserAddsBooking = async ( * never read a file the organiser has no way to reach. */ export const downloadAttendeeList: ReadAboutOneThing = async (world, name) => { const path = rosterPath(world, name); - leaveEvidencePage(world, ["attendee-csv-export"], path); const browser = await openAdminPage(world, path); const download = browser.links.find(({ href }) => href.includes("/export")); if (!download) { throw new Error(`The ${name} roster offers no attendee list to download`); } - return new TextDecoder().decode(await browser.downloadBytes(download.href)); + const csv = new TextDecoder().decode( + await browser.downloadBytes(download.href), + ); + leaveEvidencePage(world, ["attendee-csv-export"], csvEvidencePage(name, csv)); + return csv; }; From d961ec68a5d436f8079cc18a526296074a5b7011 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 6 Aug 2026 05:53:00 +0100 Subject: [PATCH 05/11] Fix backup evidence selector --- scripts/specs/evidence/declarations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/specs/evidence/declarations.ts b/scripts/specs/evidence/declarations.ts index 06b88ce39a..8829e395b9 100644 --- a/scripts/specs/evidence/declarations.ts +++ b/scripts/specs/evidence/declarations.ts @@ -124,7 +124,7 @@ export const EVIDENCE_CAPTURES = [ brandedMobileCapture( "backup.restore-brings-back-bookings", "backup-restore", - ".page-regions.admin-page", + ".page-regions.entity-page", ), brandedMobileCapture( "download.the-chosen-length-not-the-maximum", From 02adc233c836c0dc3e019eaa6354f100529de816 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 6 Aug 2026 06:27:15 +0100 Subject: [PATCH 06/11] Harden evidence capture previews --- .github/workflows/spec-evidence.yml | 3 +- scripts/specs/evidence/capture-flow.ts | 15 +++++---- test/scripts/specs/evidence-capture.test.ts | 18 +++++++++++ test/specs/steps/backup-restore.ts | 2 +- test/specs/steps/editors.ts | 2 +- test/specs/support/browser.test.ts | 34 +++++++++++++++++++++ test/specs/support/browser.ts | 10 ------ test/specs/support/by-hand.test.ts | 16 ++++++++++ test/specs/support/by-hand.ts | 15 ++++++--- test/specs/support/evidence.ts | 22 +++++++++++++ 10 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 test/specs/support/browser.test.ts create mode 100644 test/specs/support/by-hand.test.ts create mode 100644 test/specs/support/evidence.ts diff --git a/.github/workflows/spec-evidence.yml b/.github/workflows/spec-evidence.yml index dcef52abbc..5413a7edcd 100644 --- a/.github/workflows/spec-evidence.yml +++ b/.github/workflows/spec-evidence.yml @@ -70,9 +70,10 @@ jobs: run: deno task specs:evidence - name: Upload specification evidence + if: github.event_name != 'pull_request' uses: actions/upload-artifact@v4 with: name: tickets-evidence path: reports/evidence if-no-files-found: error - retention-days: 90 + retention-days: 7 diff --git a/scripts/specs/evidence/capture-flow.ts b/scripts/specs/evidence/capture-flow.ts index 584e2366b1..e253d978b6 100644 --- a/scripts/specs/evidence/capture-flow.ts +++ b/scripts/specs/evidence/capture-flow.ts @@ -91,11 +91,8 @@ const captureProfile = async ( dependencies: EvidenceCaptureDependencies, ): Promise => { const profile = SCREENSHOT_PROFILES[profileName]; - await storeEvidenceCss( - declaration, - await dependencies.readTheme(declaration.id), - dependencies.writeCss, - ); + const theme = await dependencies.readTheme(declaration.id); + await storeEvidenceCss(declaration, theme, dependencies.writeCss); const context = await browser.newContext({ baseURL: baseUrl, ...screenshotContextOptions(profile), @@ -112,9 +109,15 @@ const captureProfile = async ( ]); const page = await context.newPage(); page.setDefaultTimeout(CAPTURE_TIMEOUT_MS); - await page.goto(evidencePagePath(declaration, world.evidencePages), { + const path = evidencePagePath(declaration, world.evidencePages); + await page.goto(path, { waitUntil: "domcontentloaded", }); + // Download previews are self-contained documents, so they cannot read the + // custom CSS stored for application pages. + if (path.startsWith("data:text/html,")) { + await page.addStyleTag({ content: theme }); + } await dependencies.waitForPage(page); const { png } = await dependencies.capturePage(page, declaration.element); assertNoBlockedRequests(blocked); diff --git a/test/scripts/specs/evidence-capture.test.ts b/test/scripts/specs/evidence-capture.test.ts index c8318d3583..7fc009c559 100644 --- a/test/scripts/specs/evidence-capture.test.ts +++ b/test/scripts/specs/evidence-capture.test.ts @@ -26,6 +26,7 @@ interface CaptureCalls { goto: unknown[]; page: unknown; serverClosed: number; + styles: string[]; timeout: number[]; waited: number; } @@ -78,11 +79,16 @@ const captureFixture = ( goto: [], page: null, serverClosed: 0, + styles: [], timeout: [], waited: 0, }; let routeRequest: ((url: string) => Promise) | undefined; const page = { + addStyleTag: ({ content }: { content: string }) => { + calls.styles.push(content); + return Promise.resolve(null); + }, goto: (path: string, navigation: unknown) => { calls.goto.push({ navigation, path }); return Promise.resolve(null); @@ -278,6 +284,18 @@ describe("Cucumber evidence capture", () => { expectCaptureClosed(calls); }); + test("applies the selected theme to an HTML data page", async () => { + const { calls, capture, hook, world } = captureFixture({ + leftPages: [ + [declaration.id, "data:text/html,%3Cmain%3ECSV%3C%2Fmain%3E"], + ], + }); + + await capture(world, hook); + + expect(calls.styles).toEqual([":root { --test-colour: blue; }"]); + }); + test("uses the session the story left for a capture", async () => { const { calls, capture, hook, world } = captureFixture({ leftCookies: [[declaration.id, "session=editor; Path=/"]], diff --git a/test/specs/steps/backup-restore.ts b/test/specs/steps/backup-restore.ts index 12f2216878..0587e946bd 100644 --- a/test/specs/steps/backup-restore.ts +++ b/test/specs/steps/backup-restore.ts @@ -8,8 +8,8 @@ import { adminBrowser, resetScenarioBrowser, scenarioBrowser, - sessionCookie, } from "#test/specs/support/browser.ts"; +import { sessionCookie } from "#test/specs/support/evidence.ts"; import { rememberListing } from "#test/specs/support/listings.ts"; import { requiredWorldValue, diff --git a/test/specs/steps/editors.ts b/test/specs/steps/editors.ts index 9777dd3de9..6669b6b715 100644 --- a/test/specs/steps/editors.ts +++ b/test/specs/steps/editors.ts @@ -5,7 +5,6 @@ import { expect } from "@std/expect"; import { t } from "#i18n"; import { leaveEvidencePage } from "#scripts/specs/evidence/pages.ts"; import { formatCurrency, toMinorUnits } from "#shared/currency.ts"; -import { sessionCookie } from "#test/specs/support/browser.ts"; import { editorAddsListing, editorBrowser, @@ -28,6 +27,7 @@ import { somethingSoldAndPaidFor, TAKINGS, } from "#test/specs/support/editors.ts"; +import { sessionCookie } from "#test/specs/support/evidence.ts"; import { requiredWorldValue, type TicketsWorld, diff --git a/test/specs/support/browser.test.ts b/test/specs/support/browser.test.ts new file mode 100644 index 0000000000..fb4a84d819 --- /dev/null +++ b/test/specs/support/browser.test.ts @@ -0,0 +1,34 @@ +// jscpd:ignore-start +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { getSessionCookieName } from "#shared/cookies.ts"; +import { sessionCookie } from "#test/specs/support/evidence.ts"; + +// jscpd:ignore-end + +const browserWithCookies = (cookies: ReadonlyMap) => ({ + debugCookies: () => new Map(cookies), +}); + +describe("sessionCookie", () => { + test("returns only the configured admin session cookie", () => { + const name = getSessionCookieName(); + + expect( + sessionCookie( + browserWithCookies( + new Map([ + ["theme", "dark"], + [name, "editor-session"], + ]), + ), + ), + ).toBe(`${name}=editor-session`); + }); + + test("refuses a browser without an admin session cookie", () => { + expect(() => sessionCookie(browserWithCookies(new Map()))).toThrow( + "The browser has no admin session cookie", + ); + }); +}); diff --git a/test/specs/support/browser.ts b/test/specs/support/browser.ts index c5081ddf7c..ec572e1ed8 100644 --- a/test/specs/support/browser.ts +++ b/test/specs/support/browser.ts @@ -1,6 +1,5 @@ // jscpd:ignore-start import { t } from "#i18n"; -import { getSessionCookieName } from "#shared/cookies.ts"; import { type RowOnList, rowsOnList, @@ -35,15 +34,6 @@ export const rememberBrowser = ( browser: TestBrowser, ): TestBrowser => world.things.remember("browser", who, browser); -/** The one cookie that authenticates a captured page. Other browser cookies do - * not change which admin account sees it. */ -export const sessionCookie = (browser: TestBrowser): string => { - const name = getSessionCookieName(); - const value = browser.debugCookies().get(name); - if (!value) throw new Error("The browser has no admin session cookie"); - return `${name}=${value}`; -}; - /** The window somebody is already looking at. A story that never gave them one * has nothing to read, so it says so rather than opening a fresh window and * reporting on a page nobody was ever shown. */ diff --git a/test/specs/support/by-hand.test.ts b/test/specs/support/by-hand.test.ts new file mode 100644 index 0000000000..a02e2f8e78 --- /dev/null +++ b/test/specs/support/by-hand.test.ts @@ -0,0 +1,16 @@ +// jscpd:ignore-start +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { csvDateColumn } from "#test/specs/support/evidence.ts"; + +// jscpd:ignore-end + +describe("csvDateColumn", () => { + test("keeps the exported dates without attendee personal data", () => { + expect( + csvDateColumn( + "Date,Name,Email,Phone\n2026-08-16 to 2026-08-17,Jane Doe,jane@example.com,07123456789", + ), + ).toBe("Date\n2026-08-16 to 2026-08-17"); + }); +}); diff --git a/test/specs/support/by-hand.ts b/test/specs/support/by-hand.ts index 2bbb5909a2..00a4cae9f6 100644 --- a/test/specs/support/by-hand.ts +++ b/test/specs/support/by-hand.ts @@ -8,6 +8,7 @@ import { expect } from "@std/expect"; import { leaveEvidencePage } from "#scripts/specs/evidence/pages.ts"; import { openAdminPage } from "#test/specs/support/browser.ts"; +import { csvDateColumn } from "#test/specs/support/evidence.ts"; import { listingIdNamed } from "#test/specs/support/listings.ts"; import type { BookingChoices } from "#test/specs/support/public-booking.ts"; import type { @@ -27,10 +28,10 @@ const escapeHtml = (value: string): string => .replaceAll("<", "<") .replaceAll(">", ">"); -/** The CSV is the outcome this story reads. Render that exact text in the - * evidence browser so its screenshot proves the date range in the download. */ -const csvEvidencePage = (name: string, csv: string): string => - `data:text/html,${encodeURIComponent(`

${escapeHtml(name)} attendee CSV

${escapeHtml(csv)}
`)}`; +/** The CSV is the outcome this story reads. Render its date column in the + * evidence browser so its screenshot proves the exported date range. */ +const csvEvidencePage = (name: string, dates: string): string => + `data:text/html,${encodeURIComponent(`

${escapeHtml(name)} attendee CSV

${escapeHtml(dates)}
`)}`; /** The organiser adds a booking through the form on the listing's roster. Keeps * the page they land on, so a story can read what they were told. */ @@ -72,6 +73,10 @@ export const downloadAttendeeList: ReadAboutOneThing = async (world, name) => { const csv = new TextDecoder().decode( await browser.downloadBytes(download.href), ); - leaveEvidencePage(world, ["attendee-csv-export"], csvEvidencePage(name, csv)); + leaveEvidencePage( + world, + ["attendee-csv-export"], + csvEvidencePage(name, csvDateColumn(csv)), + ); return csv; }; diff --git a/test/specs/support/evidence.ts b/test/specs/support/evidence.ts new file mode 100644 index 0000000000..dc4319d8ad --- /dev/null +++ b/test/specs/support/evidence.ts @@ -0,0 +1,22 @@ +import { getSessionCookieName } from "#shared/cookies.ts"; +import type { TestBrowser } from "#test-utils/test-browser.ts"; + +/** The one cookie that authenticates a captured page. Other browser cookies do + * not change which admin account sees it. */ +export const sessionCookie = ( + browser: Pick, +): string => { + const name = getSessionCookieName(); + const value = browser.debugCookies().get(name); + if (!value) throw new Error("The browser has no admin session cookie"); + return `${name}=${value}`; +}; + +/** The date column is what this story proves. Do not put attendee names or + * contact details into a screenshot artifact. */ +export const csvDateColumn = (csv: string): string => + csv + .split(/\r?\n/) + .filter((line) => line !== "") + .map((line) => line.split(",", 1)[0]) + .join("\n"); From c81b973985bc236290f6232fa52362d73d069ed6 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 6 Aug 2026 11:08:39 +0100 Subject: [PATCH 07/11] Skip empty evidence theme injection --- scripts/specs/evidence/capture-flow.ts | 2 +- test/scripts/specs/evidence-capture.test.ts | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/specs/evidence/capture-flow.ts b/scripts/specs/evidence/capture-flow.ts index e253d978b6..ca42cf4205 100644 --- a/scripts/specs/evidence/capture-flow.ts +++ b/scripts/specs/evidence/capture-flow.ts @@ -115,7 +115,7 @@ const captureProfile = async ( }); // Download previews are self-contained documents, so they cannot read the // custom CSS stored for application pages. - if (path.startsWith("data:text/html,")) { + if (path.startsWith("data:text/html,") && theme !== "") { await page.addStyleTag({ content: theme }); } await dependencies.waitForPage(page); diff --git a/test/scripts/specs/evidence-capture.test.ts b/test/scripts/specs/evidence-capture.test.ts index 7fc009c559..24f2168bba 100644 --- a/test/scripts/specs/evidence-capture.test.ts +++ b/test/scripts/specs/evidence-capture.test.ts @@ -44,6 +44,7 @@ interface CaptureFixtureOptions { leftCookies?: ReadonlyArray; leftPages?: ReadonlyArray; serverCloseError?: Error; + theme?: string; } const twoCaseFeature = validFeature.replace( @@ -164,7 +165,8 @@ const captureFixture = ( ? Promise.reject(options.launchError) : Promise.resolve(browser as never), readCatalog: () => Promise.resolve(fixture.catalog), - readTheme: () => Promise.resolve(":root { --test-colour: blue; }"), + readTheme: () => + Promise.resolve(options.theme ?? ":root { --test-colour: blue; }"), startServer: () => ({ baseUrl: "http://127.0.0.1:4321", close: () => { @@ -296,6 +298,19 @@ describe("Cucumber evidence capture", () => { expect(calls.styles).toEqual([":root { --test-colour: blue; }"]); }); + test("does not inject an empty default theme into an HTML data page", async () => { + const { calls, capture, hook, world } = captureFixture({ + leftPages: [ + [declaration.id, "data:text/html,%3Cmain%3ECSV%3C%2Fmain%3E"], + ], + theme: "", + }); + + await capture(world, hook); + + expect(calls.styles).toEqual([]); + }); + test("uses the session the story left for a capture", async () => { const { calls, capture, hook, world } = captureFixture({ leftCookies: [[declaration.id, "session=editor; Path=/"]], From bbb1117f9b142dcc5050c87b30d58c53ce772351 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 6 Aug 2026 11:18:54 +0100 Subject: [PATCH 08/11] Cover CSV evidence preview --- .github/workflows/spec-evidence.yml | 2 +- README.md | 2 +- test/specs/support/by-hand.test.ts | 18 +++++++++++++++++- test/specs/support/by-hand.ts | 16 ++++------------ test/specs/support/evidence.ts | 11 +++++++++++ 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.github/workflows/spec-evidence.yml b/.github/workflows/spec-evidence.yml index 5413a7edcd..844bb126b2 100644 --- a/.github/workflows/spec-evidence.yml +++ b/.github/workflows/spec-evidence.yml @@ -76,4 +76,4 @@ jobs: name: tickets-evidence path: reports/evidence if-no-files-found: error - retention-days: 7 + retention-days: 35 diff --git a/README.md b/README.md index 6841608658..a8ad2e2940 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,7 @@ and capture IDs. It includes the app commit, image hash and dimensions, browser profile, viewport, and presentation type. Raw Cucumber messages and reports are not part of this evidence folder. The app workflow verifies the capture on pull requests. Main pushes and a monthly refresh upload this folder as the stable -`tickets-evidence` artifact with GitHub's 90-day retention. The Tickets website +`tickets-evidence` artifact with GitHub's 35-day retention. The Tickets website imports that artifact into a reviewed pull request and keeps its ordinary site build offline. diff --git a/test/specs/support/by-hand.test.ts b/test/specs/support/by-hand.test.ts index a02e2f8e78..1cbaa5ec7b 100644 --- a/test/specs/support/by-hand.test.ts +++ b/test/specs/support/by-hand.test.ts @@ -1,10 +1,16 @@ // jscpd:ignore-start import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; -import { csvDateColumn } from "#test/specs/support/evidence.ts"; +import { + csvDateColumn, + csvEvidencePage, +} from "#test/specs/support/evidence.ts"; // jscpd:ignore-end +const previewHtml = (path: string): string => + decodeURIComponent(path.slice("data:text/html,".length)); + describe("csvDateColumn", () => { test("keeps the exported dates without attendee personal data", () => { expect( @@ -13,4 +19,14 @@ describe("csvDateColumn", () => { ), ).toBe("Date\n2026-08-16 to 2026-08-17"); }); + + test("renders escaped text in the data-page preview", () => { + const path = csvEvidencePage("Retreat & ", "Date & time"); + + expect(path.startsWith("data:text/html,")).toBe(true); + expect(previewHtml(path)).toContain( + "Retreat & <stay> attendee CSV", + ); + expect(previewHtml(path)).toContain("Date & time"); + }); }); diff --git a/test/specs/support/by-hand.ts b/test/specs/support/by-hand.ts index 00a4cae9f6..5a62b01664 100644 --- a/test/specs/support/by-hand.ts +++ b/test/specs/support/by-hand.ts @@ -8,7 +8,10 @@ import { expect } from "@std/expect"; import { leaveEvidencePage } from "#scripts/specs/evidence/pages.ts"; import { openAdminPage } from "#test/specs/support/browser.ts"; -import { csvDateColumn } from "#test/specs/support/evidence.ts"; +import { + csvDateColumn, + csvEvidencePage, +} from "#test/specs/support/evidence.ts"; import { listingIdNamed } from "#test/specs/support/listings.ts"; import type { BookingChoices } from "#test/specs/support/public-booking.ts"; import type { @@ -22,17 +25,6 @@ import type { const rosterPath = (world: TicketsWorld, name: string): string => `/admin/listing/${listingIdNamed(world, name)}/attendees`; -const escapeHtml = (value: string): string => - value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">"); - -/** The CSV is the outcome this story reads. Render its date column in the - * evidence browser so its screenshot proves the exported date range. */ -const csvEvidencePage = (name: string, dates: string): string => - `data:text/html,${encodeURIComponent(`

${escapeHtml(name)} attendee CSV

${escapeHtml(dates)}
`)}`; - /** The organiser adds a booking through the form on the listing's roster. Keeps * the page they land on, so a story can read what they were told. */ export const organiserAddsBooking = async ( diff --git a/test/specs/support/evidence.ts b/test/specs/support/evidence.ts index dc4319d8ad..f75182fd3f 100644 --- a/test/specs/support/evidence.ts +++ b/test/specs/support/evidence.ts @@ -20,3 +20,14 @@ export const csvDateColumn = (csv: string): string => .filter((line) => line !== "") .map((line) => line.split(",", 1)[0]) .join("\n"); + +const escapeHtml = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +/** The CSV is the outcome this story reads. Render its date column in the + * evidence browser so its screenshot proves the exported date range. */ +export const csvEvidencePage = (name: string, dates: string): string => + `data:text/html,${encodeURIComponent(`

${escapeHtml(name)} attendee CSV

${escapeHtml(dates)}
`)}`; From afbc16eb646cbedb335e011225504107e8316001 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 6 Aug 2026 16:40:47 +0100 Subject: [PATCH 09/11] Parse CSV evidence safely --- test/specs/support/by-hand.test.ts | 8 ++++++++ test/specs/support/evidence.ts | 9 ++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/test/specs/support/by-hand.test.ts b/test/specs/support/by-hand.test.ts index 1cbaa5ec7b..fdea138e1d 100644 --- a/test/specs/support/by-hand.test.ts +++ b/test/specs/support/by-hand.test.ts @@ -20,6 +20,14 @@ describe("csvDateColumn", () => { ).toBe("Date\n2026-08-16 to 2026-08-17"); }); + test("keeps quoted attendee line breaks out of the date column", () => { + expect( + csvDateColumn( + 'Date,Name,Email\n2026-08-16 to 2026-08-17,"Jane\nDoe",jane@example.com', + ), + ).toBe("Date\n2026-08-16 to 2026-08-17"); + }); + test("renders escaped text in the data-page preview", () => { const path = csvEvidencePage("Retreat & ", "Date & time"); diff --git a/test/specs/support/evidence.ts b/test/specs/support/evidence.ts index f75182fd3f..7449739e9f 100644 --- a/test/specs/support/evidence.ts +++ b/test/specs/support/evidence.ts @@ -1,3 +1,4 @@ +import { parse } from "@std/csv/parse"; import { getSessionCookieName } from "#shared/cookies.ts"; import type { TestBrowser } from "#test-utils/test-browser.ts"; @@ -15,11 +16,9 @@ export const sessionCookie = ( /** The date column is what this story proves. Do not put attendee names or * contact details into a screenshot artifact. */ export const csvDateColumn = (csv: string): string => - csv - .split(/\r?\n/) - .filter((line) => line !== "") - .map((line) => line.split(",", 1)[0]) - .join("\n"); + ["Date", ...parse(csv, { skipFirstRow: true }).map((row) => row.Date)].join( + "\n", + ); const escapeHtml = (value: string): string => value From 7884e2173ee84ea326860a71c85a53f5a59b7135 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 6 Aug 2026 17:27:31 +0100 Subject: [PATCH 10/11] Reject CSV evidence without dates --- test/specs/support/by-hand.test.ts | 6 ++++++ test/specs/support/evidence.ts | 13 ++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/test/specs/support/by-hand.test.ts b/test/specs/support/by-hand.test.ts index fdea138e1d..2470c92ca2 100644 --- a/test/specs/support/by-hand.test.ts +++ b/test/specs/support/by-hand.test.ts @@ -28,6 +28,12 @@ describe("csvDateColumn", () => { ).toBe("Date\n2026-08-16 to 2026-08-17"); }); + test("fails when the export has no date field", () => { + expect(() => + csvDateColumn("Name,Email\nJane Doe,jane@example.com"), + ).toThrow("Attendee CSV is missing the Date field"); + }); + test("renders escaped text in the data-page preview", () => { const path = csvEvidencePage("Retreat & ", "Date & time"); diff --git a/test/specs/support/evidence.ts b/test/specs/support/evidence.ts index 7449739e9f..a92663f645 100644 --- a/test/specs/support/evidence.ts +++ b/test/specs/support/evidence.ts @@ -16,9 +16,16 @@ export const sessionCookie = ( /** The date column is what this story proves. Do not put attendee names or * contact details into a screenshot artifact. */ export const csvDateColumn = (csv: string): string => - ["Date", ...parse(csv, { skipFirstRow: true }).map((row) => row.Date)].join( - "\n", - ); + [ + "Date", + ...parse(csv, { skipFirstRow: true }).map((row) => { + const date = row.Date; + if (typeof date !== "string") { + throw new Error("Attendee CSV is missing the Date field"); + } + return date; + }), + ].join("\n"); const escapeHtml = (value: string): string => value From 525770bee3fcfe35ae1d3383c6ffb4478587ce61 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 6 Aug 2026 18:42:47 +0100 Subject: [PATCH 11/11] Reject blank CSV evidence dates --- test/specs/support/by-hand.test.ts | 12 ++++++++++++ test/specs/support/evidence.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/test/specs/support/by-hand.test.ts b/test/specs/support/by-hand.test.ts index 2470c92ca2..cebe35828a 100644 --- a/test/specs/support/by-hand.test.ts +++ b/test/specs/support/by-hand.test.ts @@ -34,6 +34,18 @@ describe("csvDateColumn", () => { ).toThrow("Attendee CSV is missing the Date field"); }); + test("fails when the export has an empty date", () => { + expect(() => csvDateColumn("Date,Name\n,Jane Doe")).toThrow( + "Attendee CSV is missing the Date field", + ); + }); + + test("fails when the export has a whitespace-only date", () => { + expect(() => csvDateColumn("Date,Name\n ,Jane Doe")).toThrow( + "Attendee CSV is missing the Date field", + ); + }); + test("renders escaped text in the data-page preview", () => { const path = csvEvidencePage("Retreat & ", "Date & time"); diff --git a/test/specs/support/evidence.ts b/test/specs/support/evidence.ts index a92663f645..e95f081a11 100644 --- a/test/specs/support/evidence.ts +++ b/test/specs/support/evidence.ts @@ -20,7 +20,7 @@ export const csvDateColumn = (csv: string): string => "Date", ...parse(csv, { skipFirstRow: true }).map((row) => { const date = row.Date; - if (typeof date !== "string") { + if (typeof date !== "string" || date.trim() === "") { throw new Error("Attendee CSV is missing the Date field"); } return date;