Repository Public
1// The deposit portal as an anonymous depositor uses it (Chromium desktop, Chromium phone, WebKit iPhone). The owner is2// a real account driven through the API from a separate Chromium context (passkeys need Chromium's virtual authenticator).3import { type Browser, type BrowserContext, chromium, expect, type Page, test } from '@playwright/test';4import { mkdirSync } from 'node:fs';5import { apiSignUp } from './helpers.ts';67test.use({ locale: 'fr-CA' });8const BASE = process.env.SPD_E2E_BASE ?? 'http://localhost:5183';9const SHOTS = process.env.SPD_SHOTS ? '/tmp/spd-shots/sharing' : '';1011let ownerBrowser: Browser, ownerCtx: BrowserContext, owner: Page;12/** Call the owner API from the signed-in owner page. */13async function call<T = any>(method: string, path: string, body?: unknown): Promise<T> {14 return owner.evaluate(async ([m, p, b]) => {15 const r = await fetch(`/api/v1${p}`, { method: m as string, headers: { 'X-SPD-CSRF': '1', ...(b !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: b !== undefined ? JSON.stringify(b) : m === 'GET' ? undefined : '{}' });16 const j = await r.json().catch(() => null);17 if (!r.ok) throw new Error(`${m} ${p}: ${r.status} ${JSON.stringify(j)}`);18 return j;19 }, [method, path, body] as const);20}21let n = 0;22/** A fresh target folder and a portal on it; returns the public path of the portal. */23async function portal(settings: Record<string, unknown> = {}): Promise<{ id: string; folderId: string; path: string; token: string }> {24 const folder = await call('POST', '/folders', { parentId: 'root', name: `Dépôts ${Date.now()}-${++n}` });25 const r = await call('POST', '/requests', { folderId: folder.id, title: 'Documents pour votre dossier', brandName: 'Cabinet Tremblay', ...settings });26 const token = new URL(r.url).pathname.split('/').pop()!;27 return { id: r.request.id, folderId: folder.id, path: `/request/${token}`, token };28}29const file = (name: string, content: string | Buffer, mimeType = 'application/octet-stream') => ({ name, mimeType, buffer: typeof content === 'string' ? Buffer.from(content) : content });30const big = (mb: number) => { const b = Buffer.alloc(mb * 1024 * 1024); for (let i = 0; i < b.length; i += 4096) b.writeUInt32LE((i * 2654435761) >>> 0, i); return b; };31const PDF = Buffer.from('%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n');32/** Everything deposited under a folder (recursively), as file names. */33async function deposited(folderId: string): Promise<string[]> {34 const l = await call('GET', `/folders/${folderId}`);35 const below = await Promise.all(l.folders.map((d: { id: string }) => deposited(d.id)));36 return [...l.files.map((f: { name: string }) => f.name), ...below.flat()];37}38const shot = async (page: Page, name: string, project: string) => { if (SHOTS) { mkdirSync(SHOTS, { recursive: true }); await page.screenshot({ path: `${SHOTS}/portal-${name}-${project}.png`, fullPage: true }); } };3940/** Make the page wait before sending the upload blocks selected by `when`, until `window.__release()` is called. */41async function holdChunks(page: Page, when: (offset: number) => boolean): Promise<void> {42 await page.addInitScript(`(() => {43 const when = ${when.toString()}, orig = window.fetch.bind(window);44 window.__gate = new Promise((r) => { window.__release = r; });45 window.fetch = async (u, o) => {46 const m = /\\/public\\/uploads\\/[^?]+\\?offset=(\\d+)/.exec(String(u));47 if (m && o && o.method === 'PATCH' && when(Number(m[1]))) { window.__held = true; await window.__gate; }48 return orig(u, o);49 };50 })()`);51}52test.beforeAll(async () => {53 ownerBrowser = await chromium.launch();54 ownerCtx = await ownerBrowser.newContext({ baseURL: BASE });55 owner = await ownerCtx.newPage();56 await apiSignUp(ownerCtx, owner);57});58test.afterAll(async () => { await ownerBrowser?.close(); });59// The API limits anonymous calls per address and every browser here comes from 127.0.0.1: give each test its own60// client address (the development proxy is a trusted hop, as the production gateway is).61const clientIp = () => `10.${Math.floor(Math.random() * 250) + 1}.${Math.floor(Math.random() * 250) + 1}.${Math.floor(Math.random() * 250) + 1}`;62test.beforeEach(async ({ context }) => { await context.setExtraHTTPHeaders({ 'X-Forwarded-For': clientIp() }); });6364test('full deposit of two files, one sent in several blocks, ends with a receipt and lands in the owner’s folder', async ({ page }, info) => {65 const p = await portal({ description: 'Transmettez vos pièces justificatives.', instructions: 'Un fichier par document.\nFormats PDF ou image de préférence.', confirmationMessage: 'Merci ! Nous vous écrirons sous 48 h.', fields: { name: 'required', email: 'optional', reference: 'off', custom: [] } });66 const api: string[] = [], patches: string[] = [];67 page.on('request', (r) => { const u = new URL(r.url()); if (u.pathname.startsWith('/api/')) { api.push(u.pathname); if (r.method() === 'PATCH') patches.push(u.pathname + u.search); } });68 await page.goto(p.path);69 await expect(page.getByRole('heading', { level: 1, name: 'Documents pour votre dossier' })).toBeVisible();70 await expect(page.getByTestId('portal-banner')).toContainText('Cabinet Tremblay');71 await expect(page.getByText('Un fichier par document.')).toBeVisible();72 await expect(page.getByTestId('portal-rules')).toContainText('200 Mo maximum par fichier');73 await expect(page).toHaveTitle(/Documents pour votre dossier/);74 expect(await page.content()).not.toContain(p.token); // the token is never displayed75 await shot(page, 'empty', info.project.name);7677 await page.getByLabel('Votre nom').fill('Marie Côté');78 await page.getByTestId('file-input').setInputFiles([file('releve.pdf', PDF, 'application/pdf'), file('numérisation.bin', big(13))]);79 await expect(page.getByTestId('picked').getByRole('listitem')).toHaveCount(2);80 await shot(page, 'selected', info.project.name);81 const admitted = page.waitForResponse((r) => r.url().includes('/submissions') && r.request().method() === 'POST');82 await page.getByRole('button', { name: /Envoyer 2 fichiers/ }).click();83 expect((await admitted).status()).toBe(200);8485 const receipt = page.getByTestId('receipt');86 await expect(receipt).toBeVisible({ timeout: 90_000 });87 await expect(receipt.getByRole('heading', { name: 'Dépôt reçu' })).toBeFocused();88 const ref = (await page.getByTestId('receipt-ref').innerText()).trim();89 expect(ref).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}$/);90 await expect(receipt.getByTestId('receipt-files').locator('[data-status="accepted"]')).toHaveCount(2);91 await expect(page.getByTestId('confirmation')).toHaveText('Merci ! Nous vous écrirons sous 48 h.');92 await shot(page, 'receipt', info.project.name);9394 expect(patches.filter((u) => /offset=[1-9]/.test(u)).length).toBeGreaterThan(0); // the 13 MB file went in several blocks95 expect(api.filter((u) => !u.startsWith('/api/v1/public/'))).toEqual([]); // the page never asked for an owner endpoint96 expect((await deposited(p.folderId)).sort()).toEqual(['numérisation.bin', 'releve.pdf']);97 const subs = await call('GET', `/requests/${p.id}/submissions`);98 expect(subs).toHaveLength(1);99 expect(subs[0]).toMatchObject({ ref, status: 'completed', name: 'Marie Côté', placed: 2, rejected: 0 });100101 // a reload of the tab shows the same receipt again; "another deposit" starts from a clean form102 await page.reload();103 await expect(page.getByTestId('receipt-ref')).toHaveText(ref);104 await page.getByRole('button', { name: 'Faire un autre dépôt' }).click();105 await expect(page.getByTestId('dropzone')).toBeVisible();106 // English is complete107 await page.getByRole('button', { name: 'English' }).click();108 await expect(page.getByRole('button', { name: 'Choose files' })).toBeVisible();109 await expect(page.getByTestId('portal-rules')).toContainText('200 MB maximum per file');110 await page.getByRole('button', { name: 'Français' }).click();111});112113test('required fields and consent are enforced before anything is sent', async ({ page }) => {114 const p = await portal({ fields: { name: 'required', email: 'required', reference: 'optional', custom: [{ key: 'no_dossier', label: 'Numéro de dossier', required: true }] }, requireConsent: true, consentText: 'J’autorise le Cabinet Tremblay à conserver ces documents.', retentionNote: 'Conservés 12 mois.' });115 let posts = 0;116 page.on('request', (r) => { if (r.method() === 'POST' && /\/requests\/[^/]+\/submissions/.test(r.url())) posts++; });117 await page.goto(p.path);118 await page.getByTestId('file-input').setInputFiles(file('notes.txt', 'bonjour'));119 await page.getByRole('button', { name: /Envoyer 1 fichier/ }).click();120 await expect(page.getByText('Ce champ est obligatoire.')).toHaveCount(3);121 await expect(page.getByText('Vous devez donner votre consentement pour envoyer.')).toBeVisible();122 await expect(page.getByLabel('Votre nom')).toBeFocused();123 await expect(page.getByLabel('Votre nom')).toHaveAttribute('aria-invalid', 'true');124 expect(posts).toBe(0);125126 await page.getByLabel('Votre nom').fill('Luc Roy');127 await page.getByLabel('Votre courriel').fill('pas-un-courriel');128 await page.getByLabel('Numéro de dossier').fill('D-2026-041');129 await page.getByRole('button', { name: /Envoyer 1 fichier/ }).click();130 await expect(page.getByText('Entrez une adresse courriel valide.')).toBeVisible();131 await expect(page.getByText('Vous devez donner votre consentement pour envoyer.')).toBeVisible();132 expect(posts).toBe(0);133134 await page.getByLabel('Votre courriel').fill('luc.roy@example.test');135 await page.getByLabel(/J’autorise le Cabinet Tremblay/).check();136 await expect(page.getByText('Conservés 12 mois.')).toBeVisible();137 await page.getByRole('button', { name: /Envoyer 1 fichier/ }).click();138 await expect(page.getByTestId('receipt')).toBeVisible({ timeout: 60_000 });139 const [s] = await call('GET', `/requests/${p.id}/submissions`);140 const d = await call('GET', `/submissions/${s.id}`);141 expect(d).toMatchObject({ name: 'Luc Roy', email: 'luc.roy@example.test', meta: { no_dossier: 'D-2026-041' } });142 expect(d.consentAt).toBeTruthy();143});144145test('a disallowed extension and an oversized file are refused on the device', async ({ page }) => {146 const p = await portal({ allowedExtensions: ['pdf', 'jpg'], maxFileBytes: 1024 * 1024, maxFilesPerSubmission: 3 });147 let posts = 0;148 page.on('request', (r) => { if (r.method() === 'POST' && /\/requests\/[^/]+\/submissions/.test(r.url())) posts++; });149 await page.goto(p.path);150 await expect(page.getByTestId('portal-rules')).toContainText('Types acceptés : PDF, JPG');151 await page.getByTestId('file-input').setInputFiles([file('contrat.pdf', PDF), file('notes.txt', 'texte'), file('plan.pdf', big(2)), file('installeur.exe', 'MZ')]);152 const rows = page.getByTestId('picked').getByRole('listitem');153 await expect(rows).toHaveCount(4);154 await expect(rows.filter({ hasText: 'notes.txt' })).toContainText('Type de fichier non accepté (.txt)');155 await expect(rows.filter({ hasText: 'plan.pdf' })).toContainText('Trop volumineux : 2 Mo (maximum 1 Mo)');156 await expect(rows.filter({ hasText: 'installeur.exe' })).toContainText('Les programmes et les scripts ne sont jamais acceptés');157 await expect(page.getByText('Ce portail accepte au plus 3 fichiers par dépôt ; vous en avez sélectionné 4.')).toBeVisible();158 await page.getByRole('button', { name: /Envoyer 4 fichiers/ }).click();159 expect(posts).toBe(0);160 await page.getByRole('button', { name: 'Retirer installeur.exe' }).click();161 await expect(page.getByText('Retirez les fichiers refusés pour continuer.')).toBeVisible();162 await page.getByRole('button', { name: 'Retirer les fichiers refusés' }).click();163 await expect(rows).toHaveCount(1);164 await page.getByRole('button', { name: /Envoyer 1 fichier/ }).click();165 await expect(page.getByTestId('receipt')).toBeVisible({ timeout: 60_000 });166 expect(posts).toBe(1);167 expect(await deposited(p.folderId)).toEqual(['contrat.pdf']);168});169170test('a protected portal refuses a wrong password, then accepts the right one', async ({ page }) => {171 const p = await portal({ password: 'tournesol-42', fields: { name: 'off', email: 'off', reference: 'off', custom: [] } });172 await page.goto(p.path);173 await page.getByTestId('file-input').setInputFiles(file('piece.pdf', PDF));174 await page.getByRole('button', { name: /Envoyer 1 fichier/ }).click();175 await expect(page.getByText('Entrez le mot de passe.', { exact: true })).toBeVisible();176 await page.getByLabel('Mot de passe').fill('mauvais');177 await page.getByRole('button', { name: /Envoyer 1 fichier/ }).click();178 await expect(page.getByText('Mot de passe incorrect.')).toBeVisible();179 await expect(page.getByLabel('Mot de passe')).toBeFocused();180 expect(await deposited(p.folderId)).toEqual([]);181 await page.getByLabel('Mot de passe').fill('tournesol-42');182 await page.getByRole('button', { name: /Envoyer 1 fichier/ }).click();183 await expect(page.getByTestId('receipt')).toBeVisible({ timeout: 60_000 });184 expect(await deposited(p.folderId)).toEqual(['piece.pdf']);185});186187test('a portal revoked during a transfer stops it: the depositor is told and nothing lands', async ({ page }, info) => {188 const p = await portal();189 // The first block passes; the next one is held in the page until the owner has revoked the portal. (Held in the page190 // rather than with page.route: WebKit does not replay an intercepted request that carries a multi-megabyte body.)191 await holdChunks(page, (offset) => offset > 0);192 await page.goto(p.path);193 await page.getByTestId('file-input').setInputFiles(file('archive-volumineuse.bin', big(20)));194 await page.getByRole('button', { name: /Envoyer 1 fichier/ }).click();195 await expect(page.getByTestId('keep-open')).toContainText('Ne fermez pas cette page');196 await expect(page.getByRole('progressbar', { name: 'Progression totale' })).toBeVisible();197 await page.waitForFunction(() => (window as any).__held === true);198 await shot(page, 'uploading', info.project.name);199 await call('DELETE', `/requests/${p.id}`);200 await page.evaluate(() => (window as any).__release());201 await expect(page.getByRole('heading', { name: 'Le portail a été fermé pendant l’envoi' })).toBeVisible({ timeout: 30_000 });202 await expect(page.getByTestId('receipt')).toHaveCount(0);203 await shot(page, 'closed-mid', info.project.name);204 expect(await deposited(p.folderId)).toEqual([]);205 const [s] = await call('GET', `/requests/${p.id}/submissions`);206 expect(s).toMatchObject({ status: 'expired', placed: 0 });207 // the link itself is dead for the next visitor, without saying why208 await page.reload();209 await expect(page.getByRole('heading', { name: 'Ce lien de dépôt n’est pas disponible' })).toBeVisible();210});211212test('a tab reloaded during a transfer resumes the same deposit from the server’s offset', async ({ page }) => {213 const p = await portal();214 await holdChunks(page, (offset) => offset > 0);215 await page.goto(p.path);216 const data = big(13);217 await page.getByTestId('file-input').setInputFiles([file('plans.bin', data), file('note.txt', 'petit')]);218 await page.getByRole('button', { name: /Envoyer 2 fichiers/ }).click();219 await page.waitForFunction(() => (window as any).__held === true); // first block of the large file is on the server220 await expect(page.getByTestId('sending').getByRole('listitem').filter({ hasText: 'note.txt' })).toContainText('Envoyé');221 page.once('dialog', (d) => void d.accept()); // "leave the page?" warning222 await page.reload();223 const resume = page.getByTestId('resume');224 await expect(resume.getByRole('heading', { name: 'Un dépôt est en cours' })).toBeVisible();225 await expect(page.getByTestId('dropzone')).toHaveCount(0);226 await page.getByTestId('resume-input').setInputFiles(file('autre.bin', 'pas le bon'));227 await expect(resume.getByRole('alert')).toContainText('autre.bin');228 await page.getByTestId('resume-input').setInputFiles([file('plans.bin', data), file('note.txt', 'petit')]);229 const sent: string[] = [];230 page.on('request', (r) => { if (r.method() === 'PATCH') sent.push(new URL(r.url()).search); });231 await resume.getByRole('button', { name: 'Continuer l’envoi' }).click();232 await page.waitForFunction(() => (window as any).__held === true);233 await page.evaluate(() => (window as any).__release());234 await expect(page.getByTestId('receipt')).toBeVisible({ timeout: 90_000 });235 await expect(page.getByTestId('receipt-files').locator('[data-status="accepted"]')).toHaveCount(2);236 expect(sent.some((q) => q === '?offset=0')).toBe(false); // nothing already received was sent again237 const subs = await call('GET', `/requests/${p.id}/submissions`);238 expect(subs).toHaveLength(1); // the same deposit, not a second one239 expect(subs[0]).toMatchObject({ status: 'completed', placed: 2, bytes: data.length + 5 });240});241242test('a paused portal says it no longer accepts deposits; an unknown link stays neutral', async ({ page }, info) => {243 const p = await portal();244 await call('PATCH', `/requests/${p.id}`, { active: false });245 await page.goto(p.path);246 await expect(page.getByRole('heading', { name: 'Ce portail n’accepte plus de dépôts' })).toBeVisible();247 await expect(page.getByTestId('dropzone')).toHaveCount(0);248 await shot(page, 'closed', info.project.name);249 await page.goto(`/request/${'A'.repeat(43)}`);250 await expect(page.getByRole('heading', { name: 'Ce lien de dépôt n’est pas disponible' })).toBeVisible();251 await expect(page.getByText('SP Drive', { exact: true })).toBeVisible();252 await expect(page.locator('a[href]')).toHaveCount(0); // no way into the application253});254255test('an executable disguised as photo.jpg is refused on the receipt while the legitimate file is accepted', async ({ page }) => {256 const p = await portal({ allowedExtensions: ['jpg', 'pdf'] });257 await page.goto(p.path);258 const exe = Buffer.concat([Buffer.from('MZ\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00\xff\xff', 'latin1'), Buffer.alloc(2048, 7)]);259 await page.getByTestId('file-input').setInputFiles([file('photo.jpg', exe, 'image/jpeg'), file('facture.pdf', PDF, 'application/pdf')]);260 await page.getByRole('button', { name: /Envoyer 2 fichiers/ }).click();261 const receipt = page.getByTestId('receipt');262 await expect(receipt).toBeVisible({ timeout: 60_000 });263 await expect(receipt.getByRole('heading', { name: 'Dépôt reçu en partie' })).toBeVisible();264 const rows = receipt.getByTestId('receipt-files').getByRole('listitem');265 await expect(rows.filter({ hasText: 'photo.jpg' })).toContainText('Refusé — Le contenu est un programme ou un script, ce qui n’est jamais accepté.');266 await expect(rows.filter({ hasText: 'facture.pdf' })).toContainText('Accepté');267 expect(await deposited(p.folderId)).toEqual(['facture.pdf']);268});269270test('the submission token can send its own files and nothing else', async ({ page, request }) => {271 const p = await portal();272 const secret = await owner.evaluate(async (name) => {273 const h = { 'X-SPD-CSRF': '1' };274 const u = await (await fetch('/api/v1/uploads', { method: 'POST', headers: { ...h, 'Content-Type': 'application/json' }, body: JSON.stringify({ folderId: 'root', name, size: 6 }) })).json();275 await fetch(`/api/v1/uploads/${u.id}?offset=0`, { method: 'PATCH', headers: { ...h, 'Content-Type': 'application/octet-stream' }, body: new TextEncoder().encode('secret') });276 return (await fetch(`/api/v1/uploads/${u.id}/complete`, { method: 'POST', headers: { ...h, 'Content-Type': 'application/json' }, body: '{}' })).json();277 }, `secret-${Date.now()}.txt`);278 await page.goto(p.path);279 await page.getByTestId('file-input').setInputFiles(file('a.txt', 'aaa'));280 const admitted = page.waitForResponse((r) => r.url().includes('/submissions') && r.request().method() === 'POST');281 await page.getByRole('button', { name: /Envoyer 1 fichier/ }).click();282 const mine = await (await admitted).json();283 await expect(page.getByTestId('receipt')).toBeVisible({ timeout: 60_000 });284 const auth = { Authorization: `Bearer ${mine.submissionToken}` };285 // another depositor's submission on the same portal286 const other = await request.post(`/api/v1/public/requests/${p.token}/submissions`, { headers: { 'X-SPD-CSRF': '1', Origin: BASE }, data: { files: [{ name: 'b.txt', size: 3 }] } });287 expect(other.status()).toBe(200);288 const theirs = await other.json();289290 expect((await request.get('/api/v1/folders/root', { headers: auth })).status()).toBe(401);291 expect((await request.get(`/api/v1/files/${secret.file.id}/content`, { headers: auth })).status()).toBe(401);292 expect((await request.get(`/api/v1/requests/${p.id}/submissions`, { headers: auth })).status()).toBe(401);293 expect([401, 404, 410]).toContain((await request.get(`/api/v1/public/uploads/${theirs.items[0].uploadId}`, { headers: auth })).status());294 const steal = await request.patch(`/api/v1/public/uploads/${theirs.items[0].uploadId}?offset=0`, { headers: { ...auth, 'X-SPD-CSRF': '1', Origin: BASE, 'Content-Type': 'application/octet-stream' }, data: Buffer.from('xxx') });295 expect([401, 404, 410]).toContain(steal.status());296 // the other depositor is untouched and still at offset 0297 const still = await request.get(`/api/v1/public/uploads/${theirs.items[0].uploadId}`, { headers: { Authorization: `Bearer ${theirs.submissionToken}` } });298 expect(await still.json()).toMatchObject({ received: 0 });299 // and the token only describes its own deposit300 const status = await (await request.get('/api/v1/public/submissions/status', { headers: auth })).json();301 expect(status.items.map((i: { name: string }) => i.name)).toEqual(['a.txt']);302 expect(JSON.stringify(status)).not.toContain(p.folderId);303});304305test('screenshots (SPD_SHOTS=1): light and dark', async ({ browser }, info) => {306 test.skip(!SHOTS, 'set SPD_SHOTS=1');307 const p = await portal({ description: 'Transmettez vos pièces justificatives pour l’ouverture de votre dossier.', instructions: 'Un fichier par document.\nNommez vos fichiers clairement (ex. « Avis de cotisation 2025 »).', brandColor: '#0f766e', expiresAt: new Date(Date.now() + 20 * 86_400_000).toISOString(),308 fields: { name: 'required', email: 'optional', reference: 'optional', custom: [] }, requireConsent: true, retentionNote: 'Les documents sont conservés 12 mois puis détruits.', confirmationMessage: 'Merci ! Nous communiquerons avec vous sous 48 heures.' });309 for (const scheme of ['light', 'dark'] as const) {310 const ctx = await browser.newContext({ ...info.project.use, colorScheme: scheme, locale: 'fr-CA', baseURL: BASE, extraHTTPHeaders: { 'X-Forwarded-For': clientIp() } });311 const page = await ctx.newPage();312 const tag = `${info.project.name}-${scheme}`;313 await holdChunks(page, () => true);314 await page.goto(p.path);315 await expect(page.getByTestId('dropzone')).toBeVisible();316 await shot(page, 'empty', tag);317 await page.getByLabel('Votre nom').fill('Marie Côté');318 await page.getByTestId('file-input').setInputFiles([file('Avis de cotisation 2025.pdf', PDF), file('Relevé bancaire.pdf', big(3)), file('macro.js', 'x')]);319 await shot(page, 'selected', tag);320 await page.getByRole('button', { name: 'Retirer les fichiers refusés' }).click();321 await page.getByRole('checkbox').check();322 await page.getByRole('button', { name: /Envoyer 2 fichiers/ }).click();323 await page.waitForFunction(() => (window as any).__held === true); await page.waitForTimeout(300);324 await shot(page, 'uploading', tag);325 await page.evaluate(() => (window as any).__release());326 await expect(page.getByTestId('receipt')).toBeVisible({ timeout: 60_000 });327 await shot(page, 'receipt', tag);328 await ctx.close();329 }330 await call('PATCH', `/requests/${p.id}`, { active: false });331 for (const scheme of ['light', 'dark'] as const) {332 const ctx = await browser.newContext({ ...info.project.use, colorScheme: scheme, locale: 'fr-CA', baseURL: BASE, extraHTTPHeaders: { 'X-Forwarded-For': clientIp() } });333 const page = await ctx.newPage();334 await page.goto(p.path);335 await expect(page.getByRole('heading', { name: 'Ce portail n’accepte plus de dépôts' })).toBeVisible();336 await shot(page, 'closed', `${info.project.name}-${scheme}`);337 await ctx.close();338 }339});340