SPB Git

Repository Public

public.portal.spec.ts 23.7 KB 340 lines typescript

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
Files main
Menu

Theme