SPB Git

Repository Public

preview.spec.ts 34.6 KB 571 lines typescript

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

Theme