Repository Public
1// The in-app preview viewer, on real files uploaded through the API and previews produced by the real worker. The2// viewer is mounted by its development harness (apps/web/preview-harness.html), which exists only on the dev server.3import { randomBytes } from 'node:crypto';4import { mkdirSync, readFileSync } from 'node:fs';5import { dirname, join } from 'node:path';6import { fileURLToPath } from 'node:url';7import { type BrowserContext, expect, type Page, test, type TestInfo } from '@playwright/test';8import { makePdf } from './fixtures/pdf-gen.mjs';9import { apiSignUp } from './helpers.ts';1011const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');12const SHOTS = '/tmp/spd-shots/preview';13mkdirSync(SHOTS, { recursive: true });1415// one account and one copy of each file per worker: tests sign in with the saved cookies and upload what they miss16let cookies: Awaited<ReturnType<BrowserContext['cookies']>> | null = null;17const ids = new Map<string, string>();18async function signIn(context: BrowserContext, page: Page): Promise<void> {19 if (cookies) { await context.addCookies(cookies); await page.goto('/pwa/favicon.svg'); return; }20 await apiSignUp(context, page);21 cookies = await context.cookies();22}23async function upload(page: Page, name: string, data: Buffer): Promise<string> {24 const id = await page.evaluate(async ({ name, b64 }) => {25 const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));26 const call = async (method: string, path: string, body?: BodyInit, type = 'application/json') => {27 const r = await fetch(`/api/v1${path}`, { method, headers: { 'X-SPD-CSRF': '1', 'Content-Type': type }, body });28 if (!r.ok) throw new Error(`${method} ${path}: ${r.status} ${await r.text()}`);29 return r.json();30 };31 const up = await call('POST', '/uploads', JSON.stringify({ folderId: 'root', name, size: bytes.length }));32 if (bytes.length) await call('PATCH', `/uploads/${up.id}?offset=0`, bytes, 'application/octet-stream');33 const done = await call('POST', `/uploads/${up.id}/complete`, '{}');34 return done.file.id as string;35 }, { name, b64: data.toString('base64') });36 ids.set(name, id);37 return id;38}39/** Ids of the named fixtures (or generated files registered under that name), uploading the missing ones. */40async function need(page: Page, ...names: string[]): Promise<string[]> {41 const out: string[] = [];42 for (const n of names) out.push(ids.get(n) ?? await upload(page, n, readFileSync(join(FIXTURES, n))));43 return out;44}45async function open(page: Page, fileIds: string[], params: Record<string, string> = {}): Promise<void> {46 await page.goto(`/preview-harness.html?${new URLSearchParams({ ids: fileIds.join(','), ...params })}`);47 await expect(page.getByTestId('viewer')).toBeVisible();48}49const shot = async (page: Page, info: TestInfo, name: string) => { await page.waitForTimeout(250); await page.screenshot({ path: join(SHOTS, `${info.project.name}-${name}.png`) }); }; // after the opening fade50/** Records every request a hostile document tries to make, and reads the flag its scripts try to set. */51function watch(page: Page) {52 const calls: string[] = [];53 page.on('request', (r) => { if (r.url().includes('__pwned')) calls.push(r.url()); });54 return async () => {55 await page.waitForTimeout(400);56 expect(calls, 'a document made the browser call out').toEqual([]);57 expect(await page.evaluate(() => (window as unknown as { __pwned?: number }).__pwned), 'a script from a document ran').toBeUndefined();58 expect(await page.locator('[data-testid=viewer] :is(iframe, object, embed, script)').count()).toBe(0);59 };60}61const body = (page: Page) => page.getByTestId('viewer-body');6263test.beforeEach(async ({ context, page }) => { await signIn(context, page); });6465test.describe('pdf', () => {66 test('pages, jump, zoom, text layer and search', async ({ page }, info) => {67 await open(page, await need(page, 'sample.pdf'));68 const scroll = page.getByTestId('pdf-scroll');69 await expect(scroll).toHaveAttribute('data-pages', '3');70 await expect(page.getByTestId('pdf-page-count')).toHaveText('/ 3');71 const first = page.locator('[data-testid=pdf-page][data-page-number="1"]');72 await expect(first).toHaveAttribute('data-rendered', '1');73 // the canvas really holds the page (dark glyph pixels on white), at device resolution74 const ink = await first.locator('canvas').evaluate((c: HTMLCanvasElement) => {75 const d = c.getContext('2d')!.getImageData(0, 0, c.width, Math.floor(c.height / 4)).data; let dark = 0;76 for (let i = 0; i < d.length; i += 4) if (d[i]! < 90 && d[i + 3]! > 200) dark++;77 return { dark, ratio: c.width / c.getBoundingClientRect().width };78 });79 expect(ink.dark).toBeGreaterThan(200);80 expect(ink.ratio).toBeGreaterThan((await page.evaluate(() => devicePixelRatio)) * 0.9);81 // selectable text layer82 await expect(first.locator('.textLayer')).toContainText('La marmotte dort sous la neige.');83 await shot(page, info, 'pdf');8485 // jump to page 3 with the page field86 const field = page.getByTestId('pdf-page-input');87 await field.fill('3'); await field.press('Enter');88 await expect(scroll).toHaveAttribute('data-page', '3');89 await expect(page.locator('[data-testid=pdf-page][data-page-number="3"]')).toBeInViewport();90 await expect(field).toHaveValue('3');9192 // zoom93 const zoom = page.getByTestId('pdf-zoom');94 const before = await zoom.textContent();95 await page.getByTestId('pdf-zoom-in').click();96 await expect(zoom).not.toHaveText(before!);97 await scroll.focus();98 await page.keyboard.press('Control+-');99 await expect(zoom).toHaveText(before!);100 await page.getByTestId('pdf-fit-page').click();101 const fits = await page.locator('[data-testid=pdf-page]').first().evaluate((el) => el.getBoundingClientRect().height <= el.parentElement!.parentElement!.clientHeight);102 expect(fits).toBe(true);103 await page.getByTestId('pdf-fit-width').click();104105 // search: Ctrl/⌘+F opens the field; 5 occurrences over the three pages, whatever their case106 await page.keyboard.press('Control+f');107 const input = page.getByTestId('pdf-search-input');108 await expect(input).toBeFocused();109 await input.fill('marmotte');110 const count = page.getByTestId('pdf-search-count');111 await expect(count).toHaveText(/^\d \/ 5$/);112 await expect(page.locator('[data-testid=pdf-hit][data-current="1"]').first()).toBeInViewport();113 const start = Number((await count.textContent())![0]);114 await input.press('Enter');115 await expect(count).toHaveText(`${(start % 5) + 1} / 5`);116 await input.press('Shift+Enter');117 await expect(count).toHaveText(`${start} / 5`);118 for (let i = start; i < 5; i++) await input.press('Enter');119 await expect(count).toHaveText('5 / 5');120 await expect(scroll).toHaveAttribute('data-page', '3');121 await expect(page.locator('[data-testid=pdf-page][data-page-number="3"] [data-testid=pdf-hit][data-current="1"]')).toBeInViewport();122 await shot(page, info, 'pdf-search');123 await input.fill('zzzz-absent');124 await expect(count).toHaveText('Aucun résultat');125 // Escape closes the search first, the viewer second126 await input.press('Escape');127 await expect(input).toHaveCount(0);128 await expect(page.getByTestId('viewer')).toBeVisible();129 });130131 test('a 400-page file opens by ranges and only renders the pages near the viewport', async ({ page }, info) => {132 test.slow();133 if (!ids.has('big.pdf')) {134 const pages = Array.from({ length: 400 }, (_, i) => [`Page ${i + 1} du grand document`, i === 388 ? 'Ici se cache le mot ornithorynque.' : 'Texte courant.']);135 await upload(page, 'big.pdf', makePdf(pages, { padding: 6000 }));136 }137 const ranges: string[] = [];138 page.on('response', (r) => { if (r.status() === 206 && r.url().includes('/content')) ranges.push(r.headers()['content-range'] ?? ''); });139 await open(page, [ids.get('big.pdf')!]);140 const scroll = page.getByTestId('pdf-scroll');141 await expect(scroll).toHaveAttribute('data-pages', '400');142 await expect(page.locator('[data-testid=pdf-page][data-page-number="1"]')).toHaveAttribute('data-rendered', '1');143 expect(await page.getByTestId('pdf-page').count()).toBeLessThan(12);144 const field = page.getByTestId('pdf-page-input');145 await field.fill('350'); await field.press('Enter');146 await expect(scroll).toHaveAttribute('data-page', '350');147 await expect(page.locator('[data-testid=pdf-page][data-page-number="350"]')).toHaveAttribute('data-rendered', '1');148 await expect(page.locator('[data-testid=pdf-page][data-page-number="350"] .textLayer')).toContainText('Page 350 du grand document');149 expect(await page.getByTestId('pdf-page').count()).toBeLessThan(12);150 expect(await page.locator('[data-testid=pdf-page][data-page-number="1"]').count()).toBe(0);151 expect(await page.locator('[data-testid=viewer] canvas').count()).toBeLessThan(12);152 expect(ranges.length, 'the PDF was fetched by byte ranges').toBeGreaterThan(0);153 await page.getByTestId('pdf-search-toggle').click();154 await page.getByTestId('pdf-search-input').fill('Ornithorynque');155 await expect(page.getByTestId('pdf-search-count')).toHaveText('1 / 1', { timeout: 60_000 });156 await expect(scroll).toHaveAttribute('data-page', '389');157 await expect(page.locator('[data-testid=pdf-hit][data-current="1"]')).toBeInViewport();158 await shot(page, info, 'pdf-400-pages');159 });160161 test('damaged and password-protected files say so and offer the download', async ({ page }, info) => {162 if (!ids.has('casse.pdf')) await upload(page, 'casse.pdf', randomBytes(3000));163 await open(page, [ids.get('casse.pdf')!]);164 await expect(page.getByTestId('pdf-damaged')).toContainText('Ce PDF ne peut pas être lu');165 await expect(page.getByTestId('panel-download')).toBeVisible();166 await shot(page, info, 'pdf-damaged');167 await open(page, await need(page, 'locked.pdf'));168 await expect(page.getByTestId('pdf-password')).toContainText('PDF protégé par un mot de passe');169 await expect(page.getByTestId('panel-download')).toBeVisible();170 });171});172173test.describe('office documents', () => {174 test('DOCX and PPTX are shown through their PDF conversion', async ({ page }, info) => {175 test.slow();176 const [docx, pptx] = await need(page, 'sample.docx', 'sample.pptx');177 await open(page, [docx!, pptx!]);178 await expect(page.getByTestId('converted-note')).toContainText('Le fichier d’origine n’est pas modifié', { timeout: 90_000 });179 await expect(page.locator('[data-testid=pdf-page][data-page-number="1"] .textLayer')).toContainText('ornithorynque');180 await expect(page.locator('[data-testid=pdf-page][data-page-number="1"]')).toHaveAttribute('data-rendered', '1');181 await shot(page, info, 'docx');182 await page.locator('[data-testid=viewer-next]:visible').click();183 await expect(page.getByTestId('viewer-name')).toHaveText('sample.pptx');184 await expect(page.getByTestId('pdf-scroll')).toHaveAttribute('data-pages', '2', { timeout: 90_000 });185 await expect(page.locator('[data-testid=pdf-page][data-page-number="1"] .textLayer')).toContainText('Quokka');186 await expect(page.locator('[data-testid=pdf-page][data-page-number="1"]')).toHaveAttribute('data-rendered', '1');187 await shot(page, info, 'pptx');188 });189190 test('a conversion that fails ends in a message, not in a spinner', async ({ page }, info) => {191 test.slow();192 if (!ids.has('faux.docx')) await upload(page, 'faux.docx', randomBytes(4096));193 await open(page, [ids.get('faux.docx')!]);194 const failed = page.getByTestId('preview-failed');195 await expect(failed).toBeVisible({ timeout: 110_000 });196 await expect(failed).toContainText('L’aperçu n’a pas pu être produit');197 expect((await page.getByTestId('panel-detail').textContent())!.length).toBeGreaterThan(5); // the server's reason198 await expect(page.getByTestId('preview-busy')).toHaveCount(0);199 await expect(page.getByTestId('panel-download')).toHaveAttribute('href', /download=1/);200 await shot(page, info, 'conversion-failed');201 });202203 test('while the worker has not finished, the viewer waits and polls', async ({ page }, info) => {204 test.slow();205 const [docx] = await need(page, 'sample.docx');206 let held = 0;207 await page.route('**/preview/pdf*', async (route) => { if (held++ < 2) await route.fulfill({ status: 202, contentType: 'application/json', body: '{"status":"pending"}' }); else await route.continue(); });208 await open(page, [docx!]);209 await expect(page.getByTestId('preview-busy')).toContainText('Conversion en cours…');210 await shot(page, info, 'converting');211 await expect(page.locator('[data-testid=pdf-page][data-page-number="1"] .textLayer')).toContainText('ornithorynque', { timeout: 90_000 });212 expect(held).toBeGreaterThan(2);213 });214});215216test.describe('images', () => {217 test('PNG and JPEG: fit, zoom, pan, reset', async ({ page }, info) => {218 const [png, jpg] = await need(page, 'sample.png', 'sample.jpg');219 await open(page, [png!, jpg!]);220 const img = page.getByTestId('image');221 await expect.poll(() => img.evaluate((i: HTMLImageElement) => i.naturalWidth)).toBe(320);222 await expect(img).toHaveCSS('opacity', '1');223 await shot(page, info, 'image-png');224 const scaleOf = () => img.evaluate((i) => new DOMMatrix(getComputedStyle(i).transform).a);225 expect(await scaleOf()).toBe(1);226 await page.getByRole('button', { name: 'Agrandir' }).click();227 await expect.poll(scaleOf).toBeCloseTo(1.4, 1);228 await page.getByTestId('image-stage').dblclick();229 await expect.poll(scaleOf).toBe(1);230 await page.getByTestId('image-stage').dblclick();231 await expect.poll(scaleOf).toBeCloseTo(2.5, 1);232 await page.getByRole('button', { name: 'Ajuster à l’écran' }).click();233 await expect.poll(scaleOf).toBe(1);234 await page.locator('[data-testid=viewer-next]:visible').click();235 await expect.poll(() => img.evaluate((i: HTMLImageElement) => i.naturalWidth)).toBe(640);236 await shot(page, info, 'image-jpg');237 });238239 test('HEIC is displayed through the JPEG made by the worker', async ({ page }, info) => {240 const [heic] = await need(page, 'sample.heic');241 await open(page, [heic!]);242 const img = page.getByTestId('image');243 await expect(img).toBeVisible({ timeout: 60_000 });244 await expect.poll(() => img.evaluate((i: HTMLImageElement) => i.naturalWidth), { timeout: 60_000 }).toBeGreaterThan(0);245 expect(await img.getAttribute('src')).toContain('/preview/image');246 await shot(page, info, 'heic');247 });248249 test('an SVG carrying scripts is only ever an image', async ({ page }, info) => {250 const check = watch(page);251 const [svg] = await need(page, 'hostile.svg');252 await open(page, [svg!]);253 const img = page.getByTestId('image');254 await expect.poll(() => img.evaluate((i: HTMLImageElement) => i.naturalWidth)).toBeGreaterThan(0);255 expect(await img.evaluate((i) => i.tagName)).toBe('IMG');256 expect(await page.locator('[data-testid=viewer] svg script, [data-testid=viewer] foreignObject').count()).toBe(0);257 // served inert: a sandbox policy on the file itself, should it ever be opened directly258 const res = await page.request.get(`/api/v1/files/${svg}/content`);259 expect(res.headers()['content-security-policy']).toContain('sandbox');260 await check();261 await shot(page, info, 'svg');262 });263});264265test('video and audio play with the native player', async ({ page }, info) => {266 const [mp4, mp3] = await need(page, 'sample.mp4', 'sample.mp3');267 await open(page, [mp4!, mp3!]);268 const video = page.getByTestId('video');269 await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.readyState), { timeout: 30_000 }).toBeGreaterThanOrEqual(1);270 expect(await video.evaluate((v: HTMLVideoElement) => ({ controls: v.controls, inline: v.playsInline, autoplay: v.autoplay, paused: v.paused, w: v.videoWidth }))).toEqual({ controls: true, inline: true, autoplay: false, paused: true, w: 320 });271 await shot(page, info, 'video');272 await page.locator('[data-testid=viewer-next]:visible').click();273 const audio = page.getByTestId('audio');274 await expect.poll(() => audio.evaluate((a: HTMLAudioElement) => a.readyState), { timeout: 30_000 }).toBeGreaterThanOrEqual(1);275 expect(await audio.evaluate((a: HTMLAudioElement) => a.duration)).toBeGreaterThan(0.5);276 await shot(page, info, 'audio');277});278279test.describe('text formats', () => {280 test('plain text and XML: line numbers, wrapping, markup shown as characters', async ({ page }, info) => {281 const check = watch(page);282 const [txt, xml] = await need(page, 'sample.txt', 'sample.xml');283 await open(page, [txt!, xml!]);284 const block = page.getByTestId('text-block');285 await expect(block.locator('.spd-line')).toHaveCount(6);286 await expect(block.locator('.spd-line').nth(3)).toHaveText('<script>window.__pwned = 1</script>');287 await expect(block.locator('.spd-line').nth(5)).toHaveAttribute('data-n', '6');288 const wrap = page.getByTestId('wrap-toggle');289 const initial = await block.getAttribute('data-wrap');290 await wrap.click();291 await expect(block).toHaveAttribute('data-wrap', initial === '1' ? '0' : '1');292 if (initial === '0') await wrap.click(); // leave it unwrapped: the long line then scrolls sideways293 await expect(block).toHaveAttribute('data-wrap', '0');294 expect(await page.getByTestId('text-scroll').evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true);295 await wrap.click();296 expect(await page.getByTestId('text-scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);297 await shot(page, info, 'text');298 await page.locator('[data-testid=viewer-next]:visible').click();299 await expect(page.getByTestId('text-block')).toContainText('<titre>Le castor</titre>');300 await shot(page, info, 'xml');301 await check();302 });303304 test('a file over 2 MB is cut and says so', async ({ page }, info) => {305 if (!ids.has('gros.log')) await upload(page, 'gros.log', Buffer.from(Array.from({ length: 60_000 }, (_, i) => `ligne ${i + 1} ${'abcdefghij'.repeat(4)}`).join('\n')));306 await open(page, [ids.get('gros.log')!]);307 await expect(page.getByTestId('truncated-note')).toContainText('seuls les premiers 2 Mo');308 await expect(page.getByTestId('text-block').locator('.spd-line').first()).toHaveText(/^ligne 1 /);309 const lines = await page.getByTestId('text-block').locator('.spd-line').count();310 expect(lines).toBeGreaterThan(30_000); expect(lines).toBeLessThan(60_000);311 await shot(page, info, 'text-truncated');312 });313314 test('Markdown is rendered sanitised: no script, no javascript: link, no remote image', async ({ page }, info) => {315 const check = watch(page);316 const [md] = await need(page, 'hostile.md');317 await open(page, [md!]);318 const article = page.getByTestId('markdown');319 await expect(article.locator('h1')).toHaveText('Titre du document');320 await expect(article.locator('strong')).toHaveText('gras');321 await expect(article.locator('table td').first()).toHaveText('1');322 expect(await article.locator('script, iframe, img, style, [onclick], [onerror], [style]').count()).toBe(0);323 expect(await article.locator('a[href^="javascript" i]').count()).toBe(0);324 const safe = article.getByRole('link', { name: 'lien sain' });325 await expect(safe).toHaveAttribute('href', 'https://example.org/page');326 await expect(safe).toHaveAttribute('target', '_blank');327 await expect(safe).toHaveAttribute('rel', /noopener/);328 await expect(safe).toHaveAttribute('rel', /noreferrer/);329 // the trapped links are still there as text, clicking them does nothing330 await article.getByText('lien piege').click();331 await article.getByText('ancre brute').click();332 await expect(article.locator('.spd-md-blocked')).toHaveCount(2);333 await expect(article.locator('.spd-md-blocked').last()).toContainText('image distante');334 await expect(page.getByTestId('md-blocked-note')).toBeVisible();335 await shot(page, info, 'markdown');336 await page.getByTestId('md-source').click();337 await expect(page.getByTestId('text-block')).toContainText('[lien piege](javascript:window.__pwned=1)');338 await check();339 });340341 test('CSV: quoted fields, escaped quotes, embedded line breaks, separator detection', async ({ page }, info) => {342 const [csv, semi] = await need(page, 'sample.csv', 'semicolon.csv');343 await open(page, [csv!, semi!]);344 const grid = page.getByTestId('grid');345 await expect(grid.locator('thead th')).toHaveText(['', 'nom', 'ville', 'note']);346 await expect(grid.locator('tbody tr')).toHaveCount(3);347 const firstRow = grid.locator('tbody tr').first().locator('td');348 await expect(firstRow).toHaveCount(3);349 await expect(firstRow.nth(0)).toHaveText('Tremblay, Luc'); // the comma stayed inside its cell350 await expect(firstRow.nth(2)).toHaveText('Il a dit "bonjour"');351 await expect(grid.locator('tbody tr').nth(1).locator('td').nth(0)).toHaveText('Roy; Anne');352 await grid.locator('tbody tr').nth(1).locator('td').nth(2).click();353 expect(await page.getByTestId('grid-cell').innerText()).toContain('ligne 1\nligne 2');354 await expect(page.getByTestId('csv-summary')).toContainText('3 lignes · 3 colonnes · séparateur : virgule');355 // the header stays in place while the body scrolls356 expect(await grid.locator('thead th').nth(1).evaluate((th) => getComputedStyle(th).position)).toBe('sticky');357 await shot(page, info, 'csv');358 await page.getByTestId('csv-raw').click();359 await expect(page.getByTestId('text-block')).toContainText('"Tremblay, Luc",Gatineau');360 await page.locator('[data-testid=viewer-next]:visible').click();361 await expect(page.getByTestId('grid').locator('tbody tr').first().locator('td')).toHaveText(['Gagnon; Paul', '12,50']);362 await expect(page.getByTestId('csv-summary')).toContainText('point-virgule');363 });364365 test('CSV beyond 2 000 rows is capped with a notice', async ({ page }) => {366 if (!ids.has('long.csv')) await upload(page, 'long.csv', Buffer.from(`n,v\n${Array.from({ length: 2600 }, (_, i) => `${i + 1},x${i}`).join('\n')}\n`));367 await open(page, [ids.get('long.csv')!]);368 await expect(page.getByTestId('rows-note')).toContainText('2000 premières lignes');369 await expect(page.getByTestId('csv-summary')).toContainText('2000 lignes');370 expect(await page.getByTestId('grid').locator('tbody tr').count()).toBeLessThan(120); // windowed371 await page.getByTestId('grid').evaluate((el) => { el.scrollTop = el.scrollHeight; });372 await expect(page.getByTestId('grid').locator('tbody th', { hasText: /^2001$/ })).toBeVisible();373 });374375 test('JSON: collapsible tree, raw view, and a fallback for invalid files', async ({ page }, info) => {376 const [json, broken] = await need(page, 'sample.json', 'broken.json');377 await open(page, [json!, broken!]);378 const tree = page.getByTestId('json-tree');379 await expect(tree).toContainText('titre: "SP Drive"');380 await expect(tree).toContainText('version: 3');381 await expect(tree).not.toContainText('Gatineau'); // third level starts folded382 await tree.getByRole('button', { name: /contact/ }).click();383 await expect(tree).toContainText('ville: "Gatineau"');384 await tree.getByRole('button', { name: /auteur/ }).click();385 await expect(tree).not.toContainText('Marmotte');386 await shot(page, info, 'json');387 await page.getByTestId('json-raw').click();388 await expect(page.getByTestId('text-block')).toContainText('"auteur": {"nom": "Marmotte"');389 await page.locator('[data-testid=viewer-next]:visible').click();390 await expect(page.getByTestId('json-invalid')).toContainText('n’est pas un JSON valide');391 await expect(page.getByTestId('text-block')).toContainText('{"cassé": [1, 2,');392 });393});394395test('XLSX: sheet tabs and cell values read by the worker', async ({ page }, info) => {396 const [xlsx] = await need(page, 'sample.xlsx');397 await open(page, [xlsx!]);398 const tabs = page.getByTestId('sheet-tab');399 await expect(tabs).toHaveText(['Ventes', 'Clients'], { timeout: 60_000 });400 const grid = page.getByTestId('grid');401 await expect(grid.locator('thead th')).toHaveText(['', 'A', 'B', 'C']);402 await expect(grid.locator('tbody tr').nth(1).locator('td')).toHaveText(['Sirop', '12', '8.5']);403 await expect(grid.locator('tbody tr').nth(3).locator('td').nth(1)).toHaveText('15'); // the computed value of the formula404 await expect(grid.locator('tbody tr').nth(1).locator('th')).toHaveText('2');405 await shot(page, info, 'xlsx');406 await tabs.nth(1).click();407 await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');408 await expect(grid.locator('tbody tr').nth(1).locator('td')).toHaveText(['Castor Bricoleur', 'Gatineau']);409});410411test('archives: an inventory with nested entries, nothing extracted', async ({ page }, info) => {412 const [zip, tgz] = await need(page, 'sample.zip', 'sample.tar.gz');413 await open(page, [zip!, tgz!]);414 for (const format of ['ZIP', 'TAR']) {415 await expect(page.getByTestId('archive-note')).toContainText('rien n’a été extrait', { timeout: 60_000 });416 await expect(page.getByTestId('archive-totals')).toContainText(format);417 const row = (path: string) => page.locator(`[data-testid=archive-row][data-path="${path}"]`);418 await expect(row('docs')).toBeVisible();419 await expect(row('lisezmoi.txt')).toContainText('8 o');420 await expect(row('docs/notes/2026/secret.txt')).toHaveCount(0);421 await row('docs/notes').click();422 await row('docs/notes/2026').click();423 await expect(row('docs/notes/2026/secret.txt')).toContainText('secret.txt');424 await page.getByTestId('archive-expand').click();425 await expect(row('images/photo.png')).toBeVisible();426 await shot(page, info, `archive-${format.toLowerCase()}`);427 if (format === 'ZIP') await page.locator('[data-testid=viewer-next]:visible').click();428 }429});430431test('an unsupported format gets a plain message and a working download', async ({ page }, info) => {432 const [bin] = await need(page, 'sample.bin');433 await open(page, [bin!]);434 const none = page.getByTestId('preview-none');435 await expect(none).toContainText('Aucun aperçu pour ce format');436 await expect(none).toContainText('Fichier BIN');437 await expect(none).toContainText('2 Ko');438 await shot(page, info, 'unsupported');439 for (const id of ['panel-download', 'viewer-download']) {440 const [download] = await Promise.all([page.waitForEvent('download'), page.getByTestId(id).click()]);441 expect(download.suggestedFilename()).toBe('sample.bin');442 expect(readFileSync(await download.path()).equals(readFileSync(join(FIXTURES, 'sample.bin')))).toBe(true);443 }444 // when the source does not allow downloads, no download control is offered445 await open(page, [bin!], { nodownload: '1' });446 await expect(page.getByTestId('preview-none')).toContainText('Le téléchargement n’est pas autorisé');447 await expect(page.getByTestId('viewer-download')).toHaveCount(0);448 await expect(page.getByTestId('panel-download')).toHaveCount(0);449 // English is complete450 await open(page, [bin!], { lang: 'en' });451 await expect(page.getByTestId('preview-none')).toContainText('No preview for this format');452 await expect(page.getByTestId('viewer-close')).toHaveAttribute('aria-label', 'Close');453});454455test.describe('viewer chrome', () => {456 test('modal dialog: focus trapped and restored, Escape, scroll lock, toolbar actions', async ({ page }) => {457 const [txt, json] = await need(page, 'sample.txt', 'sample.json');458 await open(page, [txt!, json!], { actions: '1' });459 const viewer = page.getByTestId('viewer');460 await expect(viewer).toHaveAttribute('role', 'dialog');461 await expect(viewer).toHaveAttribute('aria-modal', 'true');462 expect(await page.evaluate(() => document.body.style.overflow)).toBe('hidden');463 await expect(page.getByTestId('viewer-meta')).toContainText('TXT');464 await page.getByTestId('harness-action').click();465 await expect(page.getByTestId('harness-state')).toContainText('flag=1');466 for (let i = 0; i < 25; i++) { await page.keyboard.press('Tab'); expect(await page.evaluate(() => !!document.activeElement?.closest('[data-testid=viewer]'))).toBe(true); }467 await page.keyboard.press('Shift+Tab');468 expect(await page.evaluate(() => !!document.activeElement?.closest('[data-testid=viewer]'))).toBe(true);469 await page.keyboard.press('Escape');470 await expect(viewer).toHaveCount(0);471 expect(await page.evaluate(() => document.body.style.overflow)).toBe('');472 // reopened from a button: closing gives the focus back to it473 await page.getByTestId('harness-open').focus();474 await page.keyboard.press('Enter');475 await expect(viewer).toBeVisible();476 await page.getByTestId('viewer-close').click();477 await expect(viewer).toHaveCount(0);478 await expect(page.getByTestId('harness-open')).toBeFocused();479 });480481 test('previous and next: buttons, arrow keys, and swipe on touch', async ({ page }, info) => {482 const list = await need(page, 'sample.png', 'sample.json', 'sample.bin');483 await open(page, list);484 const name = page.getByTestId('viewer-name');485 await expect(name).toHaveText('sample.png');486 await expect(page.locator('[data-testid=viewer-prev]:visible')).toHaveCount(0); // nothing before the first file487 await page.locator('[data-testid=viewer-next]:visible').click();488 await expect(name).toHaveText('sample.json');489 await expect(page.getByTestId('json-tree')).toBeVisible();490 await page.keyboard.press('ArrowRight');491 await expect(name).toHaveText('sample.bin');492 await expect(page.locator('[data-testid=viewer-next]:visible')).toHaveCount(0);493 await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft');494 await expect(name).toHaveText('sample.png');495 if (info.project.name !== 'mobile') return;496 const swipe = (fromX: number, toX: number) => page.getByTestId('image-stage').evaluate((el, [a, b]) => {497 const t = (x: number) => new Touch({ identifier: 1, target: el, clientX: x, clientY: 400 });498 el.dispatchEvent(new TouchEvent('touchstart', { bubbles: true, touches: [t(a!)], changedTouches: [t(a!)] }));499 el.dispatchEvent(new TouchEvent('touchmove', { bubbles: true, touches: [t((a! + b!) / 2)], changedTouches: [t((a! + b!) / 2)] }));500 el.dispatchEvent(new TouchEvent('touchend', { bubbles: true, touches: [], changedTouches: [t(b!)] }));501 }, [fromX, toX]);502 await swipe(320, 60);503 await expect(name).toHaveText('sample.json');504 await page.locator('[data-testid=viewer-prev]:visible').click();505 await expect(name).toHaveText('sample.png');506 // zoomed in, the same gesture pans the picture instead of changing file507 await page.getByRole('button', { name: 'Agrandir' }).click();508 await swipe(320, 60);509 await page.waitForTimeout(200);510 await expect(name).toHaveText('sample.png');511 });512513 test('errors from the API are explicit and translated', async ({ page }, info) => {514 const [txt, png, zip] = await need(page, 'sample.txt', 'sample.png', 'sample.zip');515 const answer = (status: number, error: string, message: string) => async (route: import('@playwright/test').Route) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ error, message }) });516 await page.route('**/files/*/content*', answer(451, 'infected', 'this file was flagged by the antivirus'));517 await open(page, [txt!, png!]);518 await expect(page.getByTestId('preview-error')).toContainText('Ce fichier a été signalé par l’antivirus.');519 await shot(page, info, 'error-infected');520 await page.locator('[data-testid=viewer-next]:visible').click();521 await expect(page.getByTestId('preview-error')).toContainText('Ce fichier a été signalé par l’antivirus.'); // found out after the <img> failed522 await page.unroute('**/files/*/content*');523 let fail = true;524 await page.route('**/preview/archive*', async (route) => (fail ? answer(404, 'not_found', 'file not found')(route) : route.continue()));525 await open(page, [zip!]);526 await expect(page.getByTestId('preview-error')).toContainText('Élément introuvable.');527 fail = false;528 await page.getByRole('button', { name: 'Réessayer' }).click();529 await expect(page.getByTestId('archive-note')).toBeVisible();530 await page.route('**/preview/archive*', (route) => route.abort());531 await open(page, [zip!]);532 await expect(page.getByTestId('preview-error')).toContainText('Connexion impossible');533 });534535 test('dark theme renders documents readably', async ({ page }, info) => {536 const list = await need(page, 'hostile.md', 'sample.csv', 'sample.pdf', 'sample.zip', 'sample.bin');537 await open(page, list, { dark: '1' });538 await expect(page.getByTestId('markdown').locator('h1')).toBeVisible();539 for (const [i, n] of ['markdown', 'csv', 'pdf', 'archive', 'unsupported'].entries()) {540 if (i) await page.locator('[data-testid=viewer-next]:visible').click();541 if (n === 'pdf') await expect(page.locator('[data-testid=pdf-page][data-page-number="1"]')).toHaveAttribute('data-rendered', '1');542 if (n === 'archive') await expect(page.getByTestId('archive-note')).toBeVisible();543 await page.waitForTimeout(250);544 await shot(page, info, `dark-${n}`);545 }546 });547});548549test('thumbnails: the picture when it exists, an icon tile otherwise, never a broken image', async ({ page }, info) => {550 const list = await need(page, 'sample.png', 'sample.pdf', 'sample.mp4', 'sample.mp3', 'sample.docx', 'sample.xlsx', 'sample.zip', 'sample.txt', 'sample.bin');551 // wait for the worker: the PNG thumbnail exists552 await expect.poll(async () => (await page.request.get(`/api/v1/files/${list[0]}/preview/thumb`)).status(), { timeout: 60_000 }).toBe(200);553 for (const dark of ['', '1']) {554 // `allthumbs` asks a thumbnail for every file, including those that will never have one (202 or 404 from the API)555 await page.goto(`/preview-harness.html?${new URLSearchParams({ ids: list.join(','), thumbs: '1', allthumbs: '1', dark })}`);556 const wall = page.getByTestId('thumb-wall');557 await expect(wall.locator('[data-testid=thumb][data-family=image][data-state=image]')).toHaveCount(4);558 await expect(wall.locator('[data-testid=thumb][data-family=other]').first()).toHaveAttribute('data-state', 'icon');559 await expect(wall.locator('[data-testid=thumb][data-family=text][data-state=icon]')).toHaveCount(4);560 for (const family of ['folder', 'image', 'video', 'audio', 'pdf', 'document', 'sheet', 'archive', 'text', 'other']) expect(await wall.locator(`[data-testid=thumb][data-family=${family}]`).count(), family).toBeGreaterThan(0);561 // every <img> still in the page is a loaded picture562 await page.waitForTimeout(500);563 const broken = await wall.locator('img').evaluateAll((imgs) => imgs.filter((i) => (i as HTMLImageElement).complete && !(i as HTMLImageElement).naturalWidth && getComputedStyle(i).opacity !== '0').length);564 expect(broken).toBe(0);565 // no layout shift: each tile keeps the size it was given566 const sizes = await wall.locator('[data-testid=thumb][data-family=image]').evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().width)));567 expect(sizes).toEqual([32, 48, 96, 200]);568 await page.screenshot({ path: join(SHOTS, `${info.project.name}-thumbs${dark ? '-dark' : ''}.png`), fullPage: true });569 }570});571