Repository Public
1import { execSync } from "node:child_process";2import { readFileSync } from "node:fs";3import { expect, type Page } from "@playwright/test";4import AxeBuilder from "@axe-core/playwright";56export const OUTBOX = "/tmp/spcal-e2e-outbox.jsonl";7export interface Mail { to: string; subject: string; text: string; html: string; ics?: { method: string; content: string } | null }8export const outbox = (): Mail[] => readFileSync(OUTBOX, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l) as Mail);9export async function waitForMail(match: (m: Mail) => boolean, timeoutMs = 30_000): Promise<Mail> {10 const until = Date.now() + timeoutMs;11 for (;;) { const m = outbox().find(match); if (m) return m; if (Date.now() > until) throw new Error("mail not delivered by the worker in time"); await new Promise((r) => setTimeout(r, 500)); }12}1314/** Runs a project script with the environment of the e2e run. */15export const run = (cmd: string) => execSync(cmd, { encoding: "utf8", env: process.env });16export const psql = (q: string) => execSync(`psql -At "${process.env.DATABASE_URL}" -c "${q.replaceAll('"', '\\"')}"`, { encoding: "utf8" }).trim();1718export async function expectAccessible(page: Page) {19 const r = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();20 expect(r.violations.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).slice(0, 3).join(" | ")}`)).toEqual([]);21}22export async function expectNoHorizontalScroll(page: Page) {23 const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);24 expect(overflow).toBeLessThanOrEqual(1);25}2627/** Books the first free slot of the first available day; returns the management token. */28export async function bookFirstSlot(page: Page, slug: string, who: { name: string; email: string; reason?: string }) {29 await page.goto(`/book/${slug}`);30 await page.locator('[data-calendar] button:not([disabled])').first().click();31 const time = page.locator('ul[aria-label] button').first();32 const label = (await time.textContent())!.trim();33 await time.click();34 await page.getByLabel(/Nom complet|Full name/).fill(who.name);35 await page.getByLabel(/^Courriel|^E-mail/).fill(who.email);36 await page.getByLabel(/Motif|Reason/).fill(who.reason ?? "Discussion de projet");37 await page.getByRole("checkbox").check();38 await page.waitForTimeout(2700); // production refuses a form sent within 2.5 s of being issued (anti-bot)39 await page.getByRole("button", { name: /Envoyer la demande|Confirmer le rendez-vous|Send the request|Confirm the appointment/ }).click();40 const result = page.getByTestId("booking-result");41 await expect(result).toBeVisible();42 const href = await page.getByRole("link", { name: /Suivre ou gérer|Follow or manage/ }).getAttribute("href");43 return { token: href!.split("/").pop()!, timeLabel: label, status: await result.getAttribute("data-status") };44}4546/** Chromium virtual authenticator: a real WebAuthn ceremony with user verification, without hardware. */47export async function virtualAuthenticator(page: Page) {48 const cdp = await page.context().newCDPSession(page);49 await cdp.send("WebAuthn.enable");50 await cdp.send("WebAuthn.addVirtualAuthenticator", { options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } });51 return cdp;52}53/** Enrols a passkey with a fresh one-time link and lands in the admin area. */54export async function signInAsOwner(page: Page) {55 await virtualAuthenticator(page);56 const url = run("node_modules/.bin/tsx scripts/setup-token.ts").split("\n")[0].trim();57 await page.goto(url.replace(/^https?:\/\/[^/]+/, ""));58 await page.getByRole("button", { name: /Créer la clé|Create the passkey/ }).click();59 const cont = page.getByRole("button", { name: /J'ai conservé|I saved/ });60 await cont.waitFor();61 await cont.click();62 await page.waitForURL(/\/admin$/);63}64