Repository Public
1// Design review screenshots (not a test suite): one real account per project, real uploads from the e2e fixtures, then2// a picture of every main screen. Run against the development stack:3// pnpm exec playwright test -c tests/shots/playwright.config.ts --output /tmp/spd-shots-out4// Pictures land in $SPD_SHOTS_DIR (default /tmp/sp-drive-shots)/<project>/NN-name.png.5import { mkdirSync, readFileSync } from 'node:fs';6import { dirname, join } from 'node:path';7import { fileURLToPath } from 'node:url';8import { expect, type Page, test } from '@playwright/test';9import { apiSignUp } from '../e2e/helpers.ts';1011test.use({ locale: 'fr-CA' });12const FIX = join(dirname(fileURLToPath(import.meta.url)), '../e2e/fixtures');13const OUT = process.env.SPD_SHOTS_DIR ?? '/tmp/sp-drive-shots';14const fx = (name: string, as = name) => ({ name: as, mimeType: 'application/octet-stream', buffer: readFileSync(join(FIX, name)) });15const row = (page: Page, name: string) => page.locator(`[role=row][data-id]:has([data-name]:text-is("${name}"))`);16const call = <T = any>(page: Page, method: string, path: string, body?: unknown): Promise<T> =>17 page.evaluate(async ([m, p, b]) => {18 const r = await fetch(`/api/v1${p}`, { method: m as string, headers: { 'Content-Type': 'application/json', 'X-SPD-CSRF': '1' }, body: m === 'GET' ? undefined : JSON.stringify(b ?? {}) });19 if (!r.ok) throw new Error(`${m} ${p}: ${r.status} ${await r.text()}`);20 return r.json();21 }, [method, path, body] as const);2223test('screens', async ({ page, context }, info) => {24 test.setTimeout(Number(process.env.SPD_SHOTS_TIMEOUT ?? 300_000));25 const dark = info.project.name.includes('dark');26 const phone = info.project.name.includes('phone');27 const dir = join(OUT, info.project.name);28 mkdirSync(dir, { recursive: true });29 let n = 0;30 const shot = async (name: string, full = false) => { await page.waitForTimeout(450); await page.screenshot({ path: join(dir, `${String(++n).padStart(2, '0')}-${name}.png`), fullPage: full }); };31 await context.addInitScript((d) => { try { localStorage.setItem('spd.theme', d ? 'dark' : 'light'); } catch { /* opaque origin */ } }, dark);3233 await page.goto('/login');34 await expect(page.getByRole('button', { name: /passkey/i }).first()).toBeVisible();35 await shot('login');3637 await apiSignUp(context, page);38 await page.goto('/');39 await expect(page.getByRole('heading', { name: 'Ce dossier est vide' })).toBeVisible();40 await shot('empty');4142 for (const name of ['Projets', 'Photos de voyage', 'Factures 2026', 'Contrats']) await call(page, 'POST', '/folders', { parentId: 'root', name });43 await page.getByTestId('upload-input').setInputFiles([44 fx('sample.jpg', 'Coucher de soleil.jpg'), fx('sample.png', 'Maquette accueil.png'), fx('sample.pdf', 'Rapport annuel 2026.pdf'), fx('sample.docx', 'Lettre de mission.docx'),45 fx('sample.xlsx', 'Budget prévisionnel.xlsx'), fx('sample.mp4', 'Visite du chantier.mp4'), fx('sample.mp3', 'Mémo vocal.mp3'), fx('sample.zip', 'Archives 2025.zip'),46 fx('sample.txt', 'Notes de réunion.txt'), fx('sample.csv', 'Ventes par région.csv'),47 ]);48 await expect(page.getByTestId('upload-panel')).toBeVisible();49 await expect(page.locator('[data-upload-state=done]')).toHaveCount(10, { timeout: 90_000 });50 await shot('uploads-done');51 await page.getByRole('button', { name: 'Tout effacer' }).first().click().catch(() => {});52 const files = (await call(page, 'GET', '/folders/root')).files as { id: string; name: string }[];53 const byName = (s: string) => files.find((f) => f.name === s)!;54 await call(page, 'PATCH', `/files/${byName('Rapport annuel 2026.pdf').id}`, { starred: true });55 await call(page, 'PATCH', `/files/${byName('Coucher de soleil.jpg').id}`, { starred: true });56 // thumbnails come from the worker a moment later57 await expect.poll(async () => page.locator('[data-testid=thumb][data-state=image]').count(), { timeout: 60_000 }).toBeGreaterThan(1).catch(() => {});58 await page.reload();59 await expect(row(page, 'Projets')).toBeVisible();60 await page.waitForTimeout(1500);6162 const toggle = page.getByTestId('view-toggle');63 if ((await toggle.getAttribute('data-mode')) !== 'list') await toggle.click();64 await shot('explorer-list');65 const bottom = () => page.locator('[data-testid=listing]').evaluate((el) => { const s = el.parentElement!; s.scrollTop = s.scrollHeight; });66 const top = () => page.locator('[data-testid=listing]').evaluate((el) => { el.parentElement!.scrollTop = 0; });67 await bottom(); await shot('explorer-list-bottom'); await top();68 await toggle.click();69 await page.waitForTimeout(1200);70 await shot('explorer-grid');71 await bottom(); await shot('explorer-grid-bottom'); await top();72 await page.waitForTimeout(300);7374 // actions menu and details75 await row(page, 'Rapport annuel 2026.pdf').locator('[data-more]').click();76 await expect(page.getByRole('menu')).toBeVisible();77 await shot('menu');78 await page.getByRole('menuitem', { name: 'Détails' }).click();79 await expect(page.getByTestId('details')).toBeVisible();80 await shot('details');81 if (phone) await page.getByRole('dialog').getByRole('button', { name: 'Fermer' }).click(); else await page.getByRole('complementary', { name: 'Détails' }).getByRole('button', { name: 'Fermer' }).click();82 await page.waitForTimeout(400);8384 if (await page.getByTestId('selection-bar').count()) await page.getByTestId('selection-bar').getByRole('button').first().click();85 // "New" menu86 await page.getByRole('button', { name: 'Nouveau', exact: true }).click();87 await shot('new-menu');88 await page.keyboard.press('Escape');8990 // an upload in progress: hold the blocks of a larger file91 await page.evaluate(() => {92 const orig = window.fetch.bind(window);93 window.fetch = async (u, o) => { if (o?.method === 'PATCH' && String(u).includes('/uploads/')) await new Promise((r) => setTimeout(r, 1400)); return orig(u, o); };94 });95 const big = Buffer.alloc(28 * 1024 * 1024, 7);96 await page.getByTestId('upload-input').setInputFiles([{ name: 'Présentation client.key', mimeType: 'application/octet-stream', buffer: big }, { name: 'Plans architecte.dwg', mimeType: 'application/octet-stream', buffer: big.subarray(0, 15 * 1024 * 1024) }]);97 await expect(page.locator('[data-upload-state=uploading]').first()).toBeVisible({ timeout: 30_000 });98 await page.waitForTimeout(3200);99 await shot('upload-progress');100 await expect(page.locator('[data-upload-state=uploading],[data-upload-state=queued],[data-upload-state=finishing]')).toHaveCount(0, { timeout: 120_000 });101 await page.getByRole('button', { name: 'Tout effacer' }).first().click().catch(() => {});102103 // selection bar104 if (!phone) { await row(page, 'Coucher de soleil.jpg').click(); await row(page, 'Budget prévisionnel.xlsx').click({ modifiers: ['ControlOrMeta'] }); await shot('selection'); await page.keyboard.press('Escape'); }105106 // viewer: image then PDF107 const open = async (name: string) => { if (phone) await row(page, name).locator('[data-name]').tap(); else await row(page, name).dblclick(); };108 await open('Coucher de soleil.jpg');109 await expect(page.getByRole('dialog')).toBeVisible();110 await page.waitForTimeout(1500);111 await shot('viewer-image');112 await page.keyboard.press('Escape');113 await open('Rapport annuel 2026.pdf');114 await expect(page.getByRole('dialog')).toBeVisible();115 await page.waitForTimeout(3000);116 await shot('viewer-pdf');117 await page.keyboard.press('Escape');118119 // share dialog120 await row(page, 'Rapport annuel 2026.pdf').locator('[data-more]').click();121 await page.getByRole('menuitem', { name: 'Partager' }).click();122 await expect(page.getByRole('dialog')).toBeVisible();123 await page.waitForTimeout(600);124 await shot('share-dialog');125 await page.keyboard.press('Escape');126 await page.keyboard.press('Escape');127128 // public pages need links129 const photos = ((await call(page, 'GET', '/folders/root')).folders as { id: string; name: string }[]).find((f) => f.name === 'Photos de voyage')!;130 for (const f of ['sample.jpg', 'sample.png', 'sample.pdf']) void f;131 const sFile = await call(page, 'POST', '/shares', { fileId: byName('Rapport annuel 2026.pdf').id });132 const projets = ((await call(page, 'GET', '/folders/root')).folders as { id: string; name: string }[]).find((f) => f.name === 'Projets')!;133 await call(page, 'POST', '/move', { fileIds: [byName('Coucher de soleil.jpg').id, byName('Maquette accueil.png').id, byName('Lettre de mission.docx').id, byName('Ventes par région.csv').id], folderIds: [], destinationId: projets.id, onConflict: 'fail' });134 const sFolder = await call(page, 'POST', '/shares', { folderId: projets.id });135 const req = await call(page, 'POST', '/requests', { folderId: photos.id, title: 'Documents pour votre dossier', brandName: 'Cabinet Tremblay', message: 'Merci de déposer vos pièces justificatives : avis de cotisation, relevés et pièce d’identité.' });136137 for (const [href, name] of [['/recent', 'recent'], ['/starred', 'starred'], ['/shares', 'shares'], ['/requests', 'requests'], ['/trash', 'trash-empty'], ['/settings', 'settings'], ['/search?q=rapport', 'search']] as const) {138 await page.goto(href);139 await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible();140 await page.waitForTimeout(700);141 await shot(name, name === 'settings');142 }143 if (phone) { await page.goto('/'); await page.getByRole('button', { name: 'Plus', exact: true }).click(); await shot('more-sheet'); await page.keyboard.press('Escape'); }144 else { await page.goto('/'); await page.keyboard.press('ControlOrMeta+k'); await page.waitForTimeout(300); await page.keyboard.type('rap'); await shot('palette'); await page.keyboard.press('Escape'); }145146 // anonymous pages, in a context without the session147 const anon = await context.browser()!.newContext({ ...info.project.use, baseURL: info.project.use.baseURL, locale: 'fr-CA', extraHTTPHeaders: { 'X-Forwarded-For': `10.9.${Math.floor(Math.random() * 250)}.${Math.floor(Math.random() * 250)}` } });148 const pub = await anon.newPage();149 const pshot = async (name: string) => { await pub.waitForTimeout(900); await pub.screenshot({ path: join(dir, `${String(++n).padStart(2, '0')}-${name}.png`), fullPage: true }); };150 await pub.goto(new URL(sFile.url).pathname);151 await expect(pub.getByRole('heading', { level: 1 })).toBeVisible();152 await pub.waitForTimeout(2500);153 await pshot('public-share-file');154 await pub.goto(new URL(sFolder.url).pathname);155 await expect(pub.getByRole('heading', { level: 1 })).toBeVisible();156 await pub.waitForTimeout(1500);157 await pshot('public-share-folder');158 await pub.goto(new URL(req.url).pathname);159 await expect(pub.getByRole('heading', { level: 1 })).toBeVisible();160 await pshot('public-portal');161 await pub.locator('input[type=file]').first().setInputFiles([fx('sample.pdf', 'Avis de cotisation.pdf'), fx('sample.jpg', 'Pièce identité.jpg')]).catch(() => {});162 await pshot('public-portal-files');163 await pub.goto('/s/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');164 await pub.waitForTimeout(800);165 await pshot('public-unavailable');166 await pub.goto('/invite/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');167 await pshot('invite-invalid');168 await anon.close();169});170