Skip to content
3 changes: 2 additions & 1 deletion .github/workflows/spec-evidence.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: 35
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
25 changes: 16 additions & 9 deletions scripts/specs/evidence/capture-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ interface EvidenceCaptureDependencies {

const browserCookie = async (
baseUrl: string,
getCookie: () => Promise<string>,
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) {
Expand Down Expand Up @@ -91,11 +91,8 @@ const captureProfile = async (
dependencies: EvidenceCaptureDependencies,
): Promise<void> => {
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),
Expand All @@ -104,13 +101,23 @@ const captureProfile = async (
const blocked = new Set<string>();
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);
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,") && theme !== "") {
await page.addStyleTag({ content: theme });
}
await dependencies.waitForPage(page);
const { png } = await dependencies.capturePage(page, declaration.element);
assertNoBlockedRequests(blocked);
Expand Down
16 changes: 16 additions & 0 deletions scripts/specs/evidence/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,22 @@ 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.entity-page",
),
brandedMobileCapture(
"download.the-chosen-length-not-the-maximum",
"attendee-csv-export",
"main",
),
brandedMobileCapture(
"add-ons.it-appears-in-the-list-with-its-own-link",
"add-on-in-the-list",
Expand Down
1 change: 1 addition & 0 deletions scripts/specs/evidence/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface EvidenceWorld {
data: Buffer,
options: { fileName: string; mediaType: "image/png" },
): void | Promise<void>;
evidenceCookies: ReadonlyMap<string, string>;
evidencePages: ReadonlyMap<string, string>;
}

Expand Down
6 changes: 5 additions & 1 deletion scripts/specs/evidence/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EvidenceCaptureId, string>;
evidencePages: Map<EvidenceCaptureId, string>;
}

Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions scripts/specs/evidence/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
);

Expand Down
53 changes: 52 additions & 1 deletion test/scripts/specs/evidence-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface CaptureCalls {
goto: unknown[];
page: unknown;
serverClosed: number;
styles: string[];
timeout: number[];
waited: number;
}
Expand All @@ -40,8 +41,10 @@ interface CaptureFixtureOptions {
feature?: string;
hookPickle?: number;
launchError?: Error;
leftCookies?: ReadonlyArray<readonly [string, string]>;
leftPages?: ReadonlyArray<readonly [string, string]>;
serverCloseError?: Error;
theme?: string;
}

const twoCaseFeature = validFeature.replace(
Expand Down Expand Up @@ -77,11 +80,16 @@ const captureFixture = (
goto: [],
page: null,
serverClosed: 0,
styles: [],
timeout: [],
waited: 0,
};
let routeRequest: ((url: string) => Promise<void>) | 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);
Expand Down Expand Up @@ -157,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: () => {
Expand Down Expand Up @@ -193,6 +202,7 @@ const captureFixture = (
options: attachmentOptions,
});
},
evidenceCookies: new Map(options.leftCookies),
evidencePages: new Map(
options.leftPages ?? [[declaration.id, PAYMENT_RESULT_PAGE]],
),
Expand Down Expand Up @@ -276,6 +286,47 @@ 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("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=/"]],
});

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",
Expand Down
1 change: 1 addition & 0 deletions test/scripts/specs/evidence-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {

const world = {
attach: () => Promise.resolve(),
evidenceCookies: new Map<string, string>(),
evidencePages: new Map<string, string>(),
};

Expand Down
23 changes: 23 additions & 0 deletions test/scripts/specs/evidence/pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { PAYMENT_RESULT_CAPTURE as declaration } from "#test/scripts/specs/evidence-fixture.ts";

const emptyWorld = (): EvidencePages => ({
evidenceCookies: new Map<EvidenceCaptureId, string>(),
evidencePages: new Map<EvidenceCaptureId, string>(),
});

Expand Down Expand Up @@ -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(() =>
Expand All @@ -47,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(
Expand Down
8 changes: 8 additions & 0 deletions test/specs/steps/backup-restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

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,
} from "#test/specs/support/browser.ts";
import { sessionCookie } from "#test/specs/support/evidence.ts";
import { rememberListing } from "#test/specs/support/listings.ts";
import {
requiredWorldValue,
Expand Down Expand Up @@ -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),
);
},
);
11 changes: 10 additions & 1 deletion test/specs/steps/editors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +27,7 @@ import {
somethingSoldAndPaidFor,
TAKINGS,
} from "#test/specs/support/editors.ts";
import { sessionCookie } from "#test/specs/support/evidence.ts";
import {
requiredWorldValue,
type TicketsWorld,
Expand Down Expand Up @@ -145,7 +147,14 @@ Then(
When(
"{word} opens the listings",
async function (this: TicketsWorld, _who: string): Promise<void> {
await editorBrowser(this).visit("/admin/listings");
const browser = editorBrowser(this);
await browser.visit("/admin/listings");
leaveEvidencePage(
this,
["editor-listings-without-takings"],
"/admin/listings",
sessionCookie(browser),
);
},
);

Expand Down
34 changes: 34 additions & 0 deletions test/specs/support/browser.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) => ({
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",
);
});
});
Loading