From cdf102f8ade7b66915afc8f0945be65e6e63ea51 Mon Sep 17 00:00:00 2001 From: Nicolas CHAUVIN Date: Fri, 11 Sep 2026 14:32:24 +0200 Subject: [PATCH] feat(e2e): capture the carousel as evidence --- apps/web/e2e/capture.spec.ts | 80 ++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/apps/web/e2e/capture.spec.ts b/apps/web/e2e/capture.spec.ts index d53fe6c..e9bfb4a 100644 --- a/apps/web/e2e/capture.spec.ts +++ b/apps/web/e2e/capture.spec.ts @@ -14,6 +14,8 @@ import { readFile } from 'node:fs/promises' import { expect, test, type Page } from '@playwright/test' import { hashOf, push, pushes, type Recording, type Shot } from './push' +import { seed } from './fixture' +import { SIGNED_IN } from './session' // Signed out, deliberately: these screens exist before anybody is. test.use({ storageState: { cookies: [], origins: [] } }) @@ -204,3 +206,81 @@ test('a dead link, kept as evidence', async ({ page, browser }) => { await push('a dead link is turned away', shots, recordings) }) + +/** + * Judging a capture in the carousel (#243): ozalid's core surface, stable by + * construction — step names and variant labels are fixed fixture strings, the + * stage shows a deterministic fixture PNG, and the carousel covers the page + * underneath, so no case id or date reaches the pixels. + * + * Signed in, unlike the file's other flows: judging is a reviewer's act. + * Every walk seeds its own case, so the acceptance taken in step three never + * leaks into the next walk's pixels. + */ +test.describe('judging a capture', () => { + test.use({ storageState: SIGNED_IN }) + + async function walkCarousel(page: Page, theme: 'light' | 'dark'): Promise { + const variant = { theme } + await page.emulateMedia({ colorScheme: theme }) + const seeded = await seed(page) + await page.goto(`/projects/e2e/cases/${seeded.caseId}`) + + // Into the carousel on the variant matching the UI theme, first step. + await page + .locator(`tbody button[aria-label$="${theme}·desktop in the carousel"]`) + .first() + .click() + const carousel = page.getByRole('dialog', { name: 'capture' }) + await expect(carousel.getByRole('button', { name: 'accept', exact: true })).toBeVisible() + const shots: Shot[] = [{ step: 'faces the capture', variant, bytes: await freshPaint(page) }] + + // The refusal sheet: the remark empty, the variant ticks visible. Blurred + // so no caret ever blinks into the bytes. + await carousel.getByRole('button', { name: 'refuse', exact: true }).click() + await expect(carousel.getByText('Refuse the capture')).toBeVisible() + await carousel.locator('textarea').blur() + shots.push({ step: 'weighs a refusal', variant, bytes: await freshPaint(page) }) + + // Cancel is a true no-op; accepting fills the half and tints the edge. + await page.keyboard.press('Escape') + await carousel.getByRole('button', { name: 'accept', exact: true }).click() + await expect(carousel.getByRole('button', { name: '\u2713 accepted' })).toBeVisible() + shots.push({ step: 'has accepted', variant, bytes: await freshPaint(page) }) + + return shots + } + + test('judging a capture, kept as evidence', async ({ page, browser }) => { + const shots = [...(await walkCarousel(page, 'light')), ...(await walkCarousel(page, 'dark'))] + expect(shots).toHaveLength(6) + + const again = [...(await walkCarousel(page, 'light')), ...(await walkCarousel(page, 'dark'))] + expect(again.map((s) => `${s.step}\u00b7${s.variant.theme}\u00b7${hashOf(s.bytes)}`)).toEqual( + shots.map((s) => `${s.step}\u00b7${s.variant.theme}\u00b7${hashOf(s.bytes)}`), + ) + + const recordings: Recording[] = [] + for (const theme of ['light', 'dark'] as const) { + const filming = await browser.newContext({ + recordVideo: { dir: test.info().outputPath('videos'), size: { width: 1280, height: 720 } }, + viewport: { width: 1280, height: 720 }, + storageState: SIGNED_IN, + colorScheme: theme, + baseURL: test.info().project.use.baseURL, + }) + const filmed = await filming.newPage() + await walkCarousel(filmed, theme) + const video = filmed.video() + await filming.close() + if (video) { + const saved = test.info().outputPath(`videos/carousel-${theme}.webm`) + await video.saveAs(saved) + recordings.push({ variant: { theme }, bytes: await readFile(saved) }) + } + } + expect(recordings).toHaveLength(2) + + await push('judging a capture', shots, recordings) + }) +})