SPB Git

Repository Public

explorer.spec.ts 28.3 KB 486 lines typescript

1// File explorer, end to end against the development stack: real account, real uploads, real downloads.2import { execFileSync } from 'node:child_process';3import { createHash, randomBytes } from 'node:crypto';4import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';5import { tmpdir } from 'node:os';6import { join } from 'node:path';7import { expect, type Page, test } from '@playwright/test';8import { apiSignUp } from './helpers.ts';910test.use({ locale: 'fr-CA' });1112const SHOTS = '/tmp/spd-shots/explorer';13mkdirSync(SHOTS, { recursive: true });14const txt = (name: string, body: string) => ({ name, mimeType: 'text/plain', buffer: Buffer.from(body) });15const row = (page: Page, name: string) => page.locator(`[role=row][data-id]:has([data-name]:text-is("${name}"))`);16const panel = (page: Page) => page.getByTestId('upload-panel');17const uploadFiles = (page: Page, files: Parameters<Page['setInputFiles']>[1]) => page.getByTestId('upload-input').setInputFiles(files);18/** Open the action menu of an item with its "…" button and choose an action. */19async function act(page: Page, name: string, action: string | RegExp): Promise<void> {20  await row(page, name).locator('[data-more]').click();21  await page.getByRole('menuitem', { name: action }).click();22}23/** Open an item the way the device does: double-click with a mouse, tap on a touch screen. */24async function open(page: Page, name: string, mobile: boolean): Promise<void> {25  if (mobile) await row(page, name).locator('[data-name]').tap(); else await row(page, name).dblclick();26}27const call = <T = any>(page: Page, method: string, path: string, body?: unknown): Promise<T> =>28  page.evaluate(async ([m, p, b]) => {29    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 ?? {}) });30    if (!r.ok) throw new Error(`${m} ${p}: ${r.status} ${await r.text()}`);31    return r.json();32  }, [method, path, body] as const);33const rootFiles = async (page: Page) => (await call(page, 'GET', '/folders/root')).files as { id: string; name: string; size: number; sha256: string; versions: number; starred: boolean; tagIds: string[] }[];34async function start(page: Page, context: Parameters<typeof apiSignUp>[0], path = '/'): Promise<void> {35  await apiSignUp(context, page);36  await page.goto(path);37  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();38}39const sha = (b: Buffer) => createHash('sha256').update(b).digest('hex');4041test('dossiers, envoi multiple, collisions, renommer, déplacer, favoris, étiquettes, recherche, détails', async ({ page, context }, info) => {42  const mobile = info.project.name === 'mobile';43  await start(page, context);44  await expect(page.getByRole('heading', { name: 'Ce dossier est vide' })).toBeVisible();4546  // new folder, and the same name again: refused in place47  const newFolder = async (name: string) => {48    await page.getByRole('button', { name: 'Nouveau', exact: true }).click();49    await page.getByRole('menuitem', { name: 'Nouveau dossier' }).click();50    await page.getByLabel('Nom du dossier').fill(name);51    await page.getByRole('button', { name: 'Créer', exact: true }).click();52  };53  await newFolder('Projets');54  await expect(row(page, 'Projets')).toBeVisible();55  await newFolder('projets');56  await expect(page.getByRole('alert').filter({ hasText: 'Un élément porte déjà ce nom' })).toBeVisible();57  await page.getByRole('button', { name: 'Annuler' }).click();5859  // several files at once60  await uploadFiles(page, [txt('note.txt', 'version un'), txt('liste.csv', 'a,b\n1,2\n'), txt('lisez-moi.md', '# Bonjour\n')]);61  for (const n of ['note.txt', 'liste.csv', 'lisez-moi.md']) await expect(row(page, n)).toBeVisible();62  await expect(panel(page)).toContainText('3 fichiers téléversés');63  await panel(page).getByRole('button', { name: 'Tout effacer' }).click();64  await expect(panel(page)).toBeHidden();6566  // name collision → new version67  await uploadFiles(page, [txt('note.txt', 'version deux, plus longue')]);68  const ask = page.getByRole('dialog', { name: 'Ce nom existe déjà' });69  await expect(ask).toContainText('note.txt');70  await ask.getByRole('button', { name: 'Remplacer (nouvelle version)' }).click();71  await expect(panel(page)).toContainText('Nouvelle version enregistrée');72  await expect(row(page, 'note.txt').getByTitle('2 versions')).toBeVisible();73  // name collision → keep both74  await uploadFiles(page, [txt('note.txt', 'un autre fichier')]);75  await ask.getByRole('button', { name: 'Garder les deux' }).click();76  await expect(row(page, 'note (2).txt')).toBeVisible();77  await expect(panel(page)).toContainText('Enregistré sous « note (2).txt »');78  let files = await rootFiles(page);79  expect(files.find((f) => f.name === 'note.txt')).toMatchObject({ versions: 2, size: Buffer.byteLength('version deux, plus longue') });80  expect(files.find((f) => f.name === 'note (2).txt')).toMatchObject({ versions: 1, size: 16 });81  await panel(page).getByRole('button', { name: 'Tout effacer' }).click();8283  // rename: a taken name is refused in place, then a free one is accepted84  await act(page, 'note (2).txt', 'Renommer');85  await page.getByLabel('Nouveau nom').fill('note.txt');86  await page.getByRole('dialog').getByRole('button', { name: 'Renommer' }).click();87  await expect(page.getByRole('alert').filter({ hasText: 'Un élément porte déjà ce nom' })).toBeVisible();88  await page.getByLabel('Nouveau nom').fill('brouillon.txt');89  await page.getByLabel('Nouveau nom').press('Enter');90  await expect(row(page, 'brouillon.txt')).toBeVisible();91  await expect(row(page, 'note (2).txt')).toHaveCount(0);9293  // move with the picker94  await act(page, 'brouillon.txt', 'Déplacer vers…');95  const picker = page.getByRole('dialog', { name: 'Déplacer 1 élément' });96  await picker.locator('[data-folder-name="Projets"]').click();97  await picker.getByRole('button', { name: 'Déplacer ici' }).click();98  await expect(row(page, 'brouillon.txt')).toHaveCount(0);99  // the same name moved again: 409, then "keep both"100  await uploadFiles(page, [txt('brouillon.txt', 'homonyme')]);101  await expect(row(page, 'brouillon.txt')).toBeVisible();102  await act(page, 'brouillon.txt', 'Déplacer vers…');103  await picker.locator('[data-folder-name="Projets"]').click();104  await picker.getByRole('button', { name: 'Déplacer ici' }).click();105  await expect(picker.getByRole('alert')).toContainText('Un élément nommé « brouillon.txt » existe déjà');106  await picker.getByRole('button', { name: 'Garder les deux' }).click();107  await expect(row(page, 'brouillon.txt')).toHaveCount(0);108  // a new folder made inside the picker, used as the destination of a copy109  await act(page, 'liste.csv', 'Copier vers…');110  const copier = page.getByRole('dialog', { name: 'Copier 1 élément' });111  await copier.locator('[data-folder-name="Projets"]').click();112  await copier.getByRole('button', { name: 'Nouveau dossier' }).click();113  await copier.getByLabel('Nouveau dossier dans « Projets »').fill('Archives');114  await copier.getByRole('button', { name: 'Créer', exact: true }).click();115  await expect(copier).toContainText('Destination : Archives');116  await copier.getByRole('button', { name: 'Copier ici' }).click();117  await expect(copier).toBeHidden();118119  await open(page, 'Projets', mobile);120  await expect(page).toHaveURL(/\/folder\//);121  for (const n of ['brouillon.txt', 'brouillon (2).txt', 'Archives']) await expect(row(page, n)).toBeVisible();122  await expect(page.getByRole('navigation', { name: 'Fil d’Ariane' }).getByRole('link', { name: 'Mon Drive' })).toBeVisible();123  await open(page, 'Archives', mobile);124  await expect(row(page, 'liste.csv')).toBeVisible();125  await page.goBack();126127  // star, tag128  await act(page, 'brouillon.txt', 'Ajouter aux favoris');129  await expect(row(page, 'brouillon.txt').getByTitle('Favori')).toBeVisible();130  await act(page, 'brouillon.txt', 'Étiquettes…');131  const tagDlg = page.getByRole('dialog', { name: /Étiquettes de/ });132  await tagDlg.getByLabel('Nouvelle étiquette').fill('impôts');133  await tagDlg.getByRole('radio', { name: 'Vert' }).click();134  await tagDlg.getByRole('button', { name: 'Créer et appliquer' }).click();135  await expect(tagDlg.getByRole('checkbox')).toBeChecked();136  await tagDlg.getByLabel('Nouvelle étiquette').fill('Impôts');137  await tagDlg.getByRole('button', { name: 'Créer et appliquer' }).click();138  await expect(tagDlg.getByRole('alert')).toContainText('Une étiquette porte déjà ce nom');139  await tagDlg.getByRole('button', { name: 'Modifier l’étiquette impôts' }).click();140  await tagDlg.getByLabel('Nom de l’étiquette').fill('fiscalité');141  await tagDlg.getByRole('button', { name: 'Enregistrer' }).click();142  await expect(tagDlg.getByText('fiscalité')).toBeVisible();143  await tagDlg.getByRole('button', { name: 'Terminé' }).click();144145  // details: checksum, location, tag146  await act(page, 'brouillon.txt', 'Détails');147  const details = page.getByTestId('details');148  await expect(details.getByTestId('sha256')).toHaveText(sha(Buffer.from('un autre fichier')));149  await expect(details).toContainText('Mon Drive / Projets');150  await expect(details).toContainText('fiscalité');151  await expect(details).toContainText('Versions (1)');152  await page.getByRole('button', { name: 'Fermer' }).first().click();153154  // starred view, then search by name (accent-insensitive on the server), tag and kind155  await page.goto('/starred');156  await expect(row(page, 'brouillon.txt')).toBeVisible();157  await expect(page.locator('[role=row][data-id]')).toHaveCount(1);158  await page.goto('/search?q=BROUIL');159  await expect(row(page, 'brouillon.txt')).toBeVisible();160  await expect(row(page, 'brouillon (2).txt')).toBeVisible();161  if (mobile) await page.getByRole('button', { name: 'Filtres' }).click(); // folded away on phones162  await page.getByLabel('Étiquette').selectOption({ label: 'fiscalité' });163  await expect(page.locator('[role=row][data-id]')).toHaveCount(1);164  await expect(page).toHaveURL(/tag=/);165  await page.getByLabel('Type').selectOption({ label: 'Images' });166  await expect(page.getByRole('heading', { name: 'Aucun résultat' })).toBeVisible();167  await page.getByLabel('Type').selectOption({ label: 'Tous' });168  await page.getByLabel('Étiquette').selectOption({ label: 'Toutes' });169  await page.getByLabel('Nom', { exact: true }).fill('lisez');170  await expect(row(page, 'lisez-moi.md')).toBeVisible();171  await expect(page.locator('[role=row][data-id]')).toHaveCount(1);172  await expect(page).toHaveURL(/q=lisez/);173174  // versions: the earlier content can be restored, which adds a version175  await page.goto('/');176  await act(page, 'note.txt', 'Détails');177  await expect(details).toContainText('Versions (2)');178  await details.getByRole('button', { name: 'Restaurer la version 1' }).click();179  await expect(details).toContainText('Versions (3)');180  await expect(details.getByTestId('sha256')).toHaveText(sha(Buffer.from('version un')));181  // upload of a new version from the panel182  await details.getByTestId('version-input').setInputFiles(txt('peu-importe.txt', 'version quatre'));183  await expect(details).toContainText('Versions (4)');184  files = await rootFiles(page);185  expect(files.find((f) => f.name === 'note.txt')).toMatchObject({ versions: 4, sha256: sha(Buffer.from('version quatre')) });186  // an old version downloads with its own bytes187  const dl = page.waitForEvent('download');188  await details.getByRole('button', { name: 'Télécharger la version 2' }).click();189  expect(readFileSync(await (await dl).path()).toString()).toBe('version deux, plus longue');190191  // deep link to a file opens its preview192  const note = files.find((f) => f.name === 'note.txt')!;193  await page.goto(`/?file=${note.id}`);194  await expect(page.getByTestId('viewer')).toBeVisible();195  await expect(page.getByTestId('viewer-name')).toContainText('note.txt');196  await page.getByTestId('viewer-close').click();197  await expect(page.getByTestId('viewer')).toBeHidden();198  await expect(page).not.toHaveURL(/file=/);199  // an unknown folder says so200  await page.goto('/folder/00000000-0000-4000-8000-000000000000');201  await expect(page.getByRole('heading', { name: 'Dossier introuvable' }).last()).toBeVisible();202  await page.getByRole('link', { name: 'Retour à Mon Drive' }).click();203  await expect(row(page, 'note.txt')).toBeVisible();204});205206test('gros fichier : coupure réseau au milieu, puis téléchargement identique octet pour octet', async ({ page, context }) => {207  await start(page, context);208  const big = randomBytes(19 * 1024 * 1024 + 4321); // three chunks209  let cut = 0;210  await page.route('**/api/v1/uploads/*?offset=*', async (route) => {211    if (route.request().method() === 'PATCH' && !route.request().url().endsWith('offset=0') && cut++ === 0) await route.abort('connectionreset'); else await route.continue();212  });213  await uploadFiles(page, [{ name: 'gros.bin', mimeType: 'application/octet-stream', buffer: big }]);214  await expect(panel(page)).toContainText('1 fichier téléversé', { timeout: 60_000 });215  expect(cut).toBeGreaterThan(1); // the chunk was cut once, then sent again216  await page.unroute('**/api/v1/uploads/*?offset=*');217  const f = (await rootFiles(page)).find((x) => x.name === 'gros.bin')!;218  expect(f.size).toBe(big.length);219  expect(f.sha256).toBe(sha(big));220221  const dl = page.waitForEvent('download');222  await act(page, 'gros.bin', 'Télécharger');223  const got = await dl;224  expect(got.suggestedFilename()).toBe('gros.bin');225  expect(readFileSync(await got.path()).equals(big)).toBe(true);226});227228test('pause et reprise, puis reprise après rechargement de la page en choisissant le même fichier', async ({ page, context }) => {229  await start(page, context);230  const dir = mkdtempSync(join(tmpdir(), 'spd-e2e-'));231  const body = randomBytes(18 * 1024 * 1024);232  const path = join(dir, 'reprise.bin');233  writeFileSync(path, body);234  // the second chunk never gets through until the route is removed235  let hold = true;236  const stalled: (() => void)[] = [];237  await page.route('**/api/v1/uploads/*?offset=*', async (route) => {238    if (hold && route.request().method() === 'PATCH' && !route.request().url().endsWith('offset=0')) await new Promise<void>((r) => stalled.push(r));239    await route.continue().catch(() => {});240  });241  await uploadFiles(page, path);242  await expect(panel(page)).toContainText('8 Mo sur 18 Mo');243  // pause, then resume: still stuck on the second chunk, nothing lost244  await panel(page).getByRole('button', { name: 'Mettre en pause' }).click();245  await expect(panel(page)).toContainText('En pause');246  await panel(page).getByRole('button', { name: 'Reprendre' }).click();247  await expect(panel(page)).toContainText('8 Mo sur 18 Mo');248  await expect(panel(page).getByRole('button', { name: 'Mettre en pause' })).toBeVisible();249250  // reload in the middle: the page warns, and afterwards the panel says what can be resumed and how251  page.on('dialog', (d) => void d.accept());252  await page.reload();253  hold = false; stalled.forEach((r) => r());254  await expect(panel(page)).toContainText('1 envoi interrompu');255  await expect(panel(page)).toContainText('Choisissez de nouveau les mêmes fichiers');256  await expect(row(page, 'reprise.bin')).toHaveCount(0);257  await uploadFiles(page, path);258  await expect(panel(page)).toContainText('1 fichier téléversé', { timeout: 60_000 });259  await expect(panel(page)).toContainText('repris à 44 %');260  const f = (await rootFiles(page)).find((x) => x.name === 'reprise.bin')!;261  expect(f).toMatchObject({ size: body.length, sha256: sha(body), versions: 1 });262  expect(await page.evaluate(() => localStorage.getItem('spd.uploads.pending'))).toBe('[]');263264  // cancel removes the session on the server265  hold = true;266  await uploadFiles(page, [{ name: 'annule.bin', mimeType: 'application/octet-stream', buffer: randomBytes(9 * 1024 * 1024) }]);267  await expect(panel(page)).toContainText('8 Mo sur 9 Mo');268  await panel(page).getByRole('button', { name: 'Annuler le téléversement' }).click();269  hold = false; stalled.forEach((r) => r());270  await expect(panel(page)).not.toContainText('annule.bin');271  await page.waitForTimeout(500);272  expect((await rootFiles(page)).map((x) => x.name)).toEqual(['reprise.bin']);273});274275test('corbeille : annuler, restaurer, supprimer définitivement, vider', async ({ page, context }, info) => {276  await start(page, context);277  await uploadFiles(page, [txt('a.txt', 'a'), txt('b.txt', 'b'), txt('c.txt', 'c')]);278  await expect(row(page, 'c.txt')).toBeVisible();279  // trash + undo from the toast280  await act(page, 'a.txt', 'Mettre à la corbeille');281  await expect(row(page, 'a.txt')).toHaveCount(0);282  const toast = page.getByRole('status').filter({ hasText: '« a.txt » mis à la corbeille' });283  await toast.getByRole('button', { name: 'Annuler' }).click();284  await expect(row(page, 'a.txt')).toBeVisible();285  // trash, then restore from the trash view286  await act(page, 'b.txt', 'Mettre à la corbeille');287  await act(page, 'c.txt', 'Mettre à la corbeille');288  await page.goto('/trash');289  await expect(row(page, 'b.txt')).toBeVisible();290  await expect(row(page, 'c.txt')).toBeVisible();291  await page.screenshot({ path: `${SHOTS}/${info.project.name}-light-trash.png` });292  await act(page, 'b.txt', 'Restaurer');293  await expect(row(page, 'b.txt')).toHaveCount(0);294  // permanent delete, confirmed295  await act(page, 'c.txt', 'Supprimer définitivement');296  const confirm = page.getByRole('dialog', { name: 'Supprimer définitivement ?' });297  await expect(confirm).toContainText('c.txt');298  await confirm.getByRole('button', { name: 'Supprimer définitivement' }).click();299  await expect(page.getByRole('heading', { name: 'La corbeille est vide' })).toBeVisible();300  expect((await rootFiles(page)).map((f) => f.name).sort()).toEqual(['a.txt', 'b.txt']);301  // empty the trash302  await call(page, 'POST', '/trash', { fileIds: (await rootFiles(page)).filter((f) => f.name === 'a.txt').map((f) => f.id), folderIds: [] });303  await page.reload();304  await expect(row(page, 'a.txt')).toBeVisible();305  await page.getByRole('button', { name: 'Vider la corbeille' }).click();306  await page.getByRole('dialog', { name: 'Vider la corbeille' }).getByRole('button', { name: 'Vider la corbeille' }).click();307  await expect(page.getByRole('heading', { name: 'La corbeille est vide' })).toBeVisible();308  expect((await call(page, 'GET', '/trash')).files).toHaveLength(0);309});310311test('ZIP d’un dossier, sélection multiple, doublons', async ({ page, context }, info) => {312  const mobile = info.project.name === 'mobile';313  await start(page, context);314  const dossier = await call(page, 'POST', '/folders', { parentId: 'root', name: 'Dossier été' });315  await page.goto(`/folder/${dossier.id}`);316  await uploadFiles(page, [txt('un.txt', 'premier'), txt('deux.txt', 'second')]);317  await expect(row(page, 'deux.txt')).toBeVisible();318  await page.getByRole('navigation', { name: 'Fil d’Ariane' }).getByRole('link', { name: 'Mon Drive' }).click();319  await expect(row(page, 'Dossier été')).toBeVisible();320  await uploadFiles(page, [txt('copie.txt', 'premier')]);321  await expect(row(page, 'copie.txt')).toBeVisible();322323  const dl = page.waitForEvent('download');324  await act(page, 'Dossier été', 'Télécharger (ZIP)');325  const zip = await dl;326  expect(zip.suggestedFilename()).toBe('Dossier été.zip');327  const zipPath = await zip.path();328  const names = execFileSync('unzip', ['-Z1', zipPath]).toString().trim().split('\n').sort();329  expect(names).toEqual(['Dossier été/deux.txt', 'Dossier été/un.txt']);330  expect(execFileSync('unzip', ['-p', zipPath, 'Dossier été/un.txt']).toString()).toBe('premier');331332  // selection bar: both items, one ZIP333  if (mobile) {334    await row(page, 'copie.txt').locator('[data-more]').click(); // the "…" button selects the item and opens its actions335    await page.keyboard.press('Escape');336    await row(page, 'Dossier été').locator('[data-name]').tap(); // with a selection, a tap adds to it337  } else {338    await row(page, 'copie.txt').click();339    await row(page, 'Dossier été').click({ modifiers: ['ControlOrMeta'] });340  }341  const bar = page.getByTestId('selection-bar');342  await expect(bar).toContainText('2 sélectionnés');343  const dl2 = page.waitForEvent('download');344  await bar.getByRole('button', { name: 'Télécharger (ZIP)' }).click();345  expect(execFileSync('unzip', ['-Z1', await (await dl2).path()]).toString().trim().split('\n').sort()).toEqual(['Dossier été/deux.txt', 'Dossier été/un.txt', 'copie.txt']);346  await bar.getByRole('button', { name: 'Tout désélectionner' }).click();347  await expect(bar).toBeHidden();348349  // duplicates: same content in two places350  await page.goto('/duplicates');351  const group = page.getByTestId('dup-group');352  await expect(group).toHaveCount(1);353  await expect(group).toContainText('2 copies');354  await expect(group).toContainText('Mon Drive / Dossier été');355  await group.getByRole('button', { name: 'Mettre la copie en trop à la corbeille' }).click();356  await expect(page.getByRole('heading', { name: 'Aucun doublon' })).toBeVisible();357  expect((await call(page, 'GET', '/trash')).files).toHaveLength(1);358});359360test('clavier seul : flèches, espace, F2, suppression ; glisser vers un dossier à la souris', async ({ page, context }, info) => {361  await start(page, context);362  await call(page, 'POST', '/folders', { parentId: 'root', name: 'Cible' });363  await uploadFiles(page, [txt('k1.txt', '1'), txt('k2.txt', '2'), txt('k3.txt', '3')]);364  await expect(row(page, 'k3.txt')).toBeVisible();365  await panel(page).getByRole('button', { name: 'Tout effacer' }).click();366  const listing = page.getByTestId('listing');367  await expect(listing).toHaveAttribute('aria-multiselectable', 'true');368  if ((await listing.getAttribute('data-mode')) !== 'list') await page.getByTestId('view-toggle').click();369  await expect(listing).toHaveAttribute('data-mode', 'list');370371  // one tab stop in the listing; arrows move the focus, Space selects372  await expect(page.locator('[role=row][data-id][tabindex="0"]')).toHaveCount(1);373  await row(page, 'Cible').focus();374  await page.keyboard.press('ArrowDown');375  await expect(row(page, 'k1.txt')).toBeFocused();376  await page.keyboard.press('ArrowDown');377  await page.keyboard.press('Space');378  await expect(row(page, 'k2.txt')).toHaveAttribute('aria-selected', 'true');379  await expect(row(page, 'k1.txt')).toHaveAttribute('aria-selected', 'false');380  // F2 renames; focus comes back to the row381  await page.keyboard.press('F2');382  await expect(page.getByLabel('Nouveau nom')).toBeFocused();383  await page.keyboard.type('renomme-au-clavier');384  await page.keyboard.press('Enter');385  await expect(row(page, 'renomme-au-clavier.txt')).toBeVisible();386  await expect(row(page, 'renomme-au-clavier.txt')).toBeFocused();387  // Shift+arrow extends, Escape clears, select all388  await page.keyboard.press('Escape');389  await expect(page.getByTestId('selection-bar')).toBeHidden();390  await page.keyboard.press('ControlOrMeta+a');391  await expect(page.getByTestId('selection-bar')).toContainText('4 sélectionnés');392  await page.keyboard.press('Escape');393  // Delete sends the focused item to the trash394  await page.keyboard.press('Delete');395  await expect(row(page, 'renomme-au-clavier.txt')).toHaveCount(0);396  await expect(page.getByRole('status').filter({ hasText: 'mis à la corbeille' })).toBeVisible();397  // Enter opens a folder398  await row(page, 'Cible').focus();399  await page.keyboard.press('Enter');400  await expect(page).toHaveURL(/\/folder\//);401  await expect(page.getByRole('heading', { name: 'Ce dossier est vide' })).toBeVisible();402  await page.goBack();403404  if (info.project.name === 'desktop') {405    // right-click opens the same menu; drag a file onto a folder row moves it406    await row(page, 'k1.txt').click({ button: 'right' });407    await expect(page.getByRole('menu', { name: 'Actions pour k1.txt' })).toBeVisible();408    await page.keyboard.press('Escape');409    await row(page, 'k1.txt').dragTo(row(page, 'Cible'));410    await expect(row(page, 'k1.txt')).toHaveCount(0);411    await expect(page.getByRole('status').filter({ hasText: 'déplacé vers « Cible »' })).toBeVisible();412    // files dropped from the desktop are uploaded here413    const dt = await page.evaluateHandle(() => { const d = new DataTransfer(); d.items.add(new File(['depuis le bureau'], 'depose.txt', { type: 'text/plain' })); return d; });414    await page.getByTestId('listing').dispatchEvent('dragenter', { dataTransfer: dt });415    await expect(page.getByTestId('drop-overlay')).toBeVisible();416    await page.getByTestId('listing').dispatchEvent('drop', { dataTransfer: dt });417    await expect(row(page, 'depose.txt')).toBeVisible();418  }419});420421test('grille / liste : le choix est conservé ; captures dans les deux thèmes', async ({ page, context }, info) => {422  const size = info.project.name;423  if (size === 'mobile') await page.setViewportSize({ width: 390, height: 844 });424  await start(page, context);425  const theme = async (th: string) => page.evaluate((x) => { localStorage.setItem('spd.theme', x); document.documentElement.classList.toggle('dark', x === 'dark'); }, th);426  const both = async (name: string) => { for (const th of ['light', 'dark']) { await theme(th); await page.waitForTimeout(250); await page.screenshot({ path: `${SHOTS}/${size}-${th}-${name}.png` }); } await theme('light'); };427  await both('empty');428429  const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAQ0lEQVR4nO3PQQ0AIBDAsAP/nuGNAvZoFSzZOjPzs10HvGpINaQaUg2phlRDqiHVkGpINaQaUg2phlRDqiHVkGrIBfWOAV+3pZzQAAAAAElFTkSuQmCC', 'base64');430  for (const n of ['Contrats', 'Photos de chantier', 'Reçus 2026']) await call(page, 'POST', '/folders', { parentId: 'root', name: n });431  await uploadFiles(page, [432    { name: 'plan.png', mimeType: 'image/png', buffer: png }, txt('Compte rendu de réunion du comité.md', '# CR\n'), txt('budget-2026.csv', 'poste,montant\nloyer,1200\n'),433    { name: 'bail-signé.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n%%EOF\n') }, { name: 'archive.zip', mimeType: 'application/zip', buffer: Buffer.from('PK\u0005\u0006' + '\u0000'.repeat(18), 'latin1') }, txt('notes.txt', 'à faire'),434  ]);435  await expect(row(page, 'notes.txt')).toBeVisible();436  await expect(panel(page)).toContainText('6 fichiers téléversés');437  await both('upload-panel');438  await panel(page).getByRole('button', { name: 'Tout effacer' }).click();439  const tag = await call(page, 'POST', '/tags', { name: 'À signer', color: 'orange' });440  const files = await rootFiles(page);441  await call(page, 'PATCH', `/files/${files.find((f) => f.name === 'bail-signé.pdf')!.id}`, { starred: true, tagIds: [tag.id] });442  await page.reload();443444  const listing = page.getByTestId('listing');445  const first = (await listing.getAttribute('data-mode'))!;446  expect(first).toBe(size === 'mobile' ? 'list' : 'grid'); // default: cards on a large screen, rows on a phone447  await both(first);448  await page.getByTestId('view-toggle').click();449  const second = first === 'grid' ? 'list' : 'grid';450  await expect(listing).toHaveAttribute('data-mode', second);451  await page.reload();452  await expect(listing).toHaveAttribute('data-mode', second); // kept across a reload453  await expect(row(page, 'notes.txt')).toBeVisible();454  await both(second);455  // nothing scrolls sideways456  expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);457458  // sort: by size, descending, done by the server459  await page.getByRole('button', { name: /^Trier/ }).click();460  await page.getByRole('menuitem', { name: 'Taille' }).click();461  await page.getByRole('button', { name: /^Trier/ }).click();462  await page.getByRole('menuitem', { name: 'Ordre décroissant' }).click();463  await expect(page.locator('[role=row][data-type=file] [data-name]').first()).toHaveText('plan.png');464  await page.getByRole('button', { name: /^Trier/ }).click();465  await page.getByRole('menuitem', { name: 'Nom' }).click();466  await page.getByRole('button', { name: /^Trier/ }).click();467  await page.getByRole('menuitem', { name: 'Ordre croissant' }).click();468  await expect(page.locator('[role=row][data-type=file] [data-name]').first()).toHaveText('archive.zip');469470  await act(page, 'bail-signé.pdf', 'Détails');471  await expect(page.getByTestId('details')).toContainText('Versions (1)');472  await both('details');473  await page.getByRole('button', { name: 'Fermer' }).first().click();474  await row(page, 'notes.txt').locator('[data-more]').click();475  await expect(page.getByRole('menu')).toBeVisible();476  await both('menu');477  await page.keyboard.press('Escape');478  await page.goto('/search?q=20');479  await expect(row(page, 'budget-2026.csv')).toBeVisible();480  await both('search');481  await call(page, 'POST', '/trash', { fileIds: files.slice(0, 2).map((f) => f.id), folderIds: [] });482  await page.goto('/trash');483  await expect(page.locator('[role=row][data-id]')).toHaveCount(2);484  await both('trash');485});486
Files main
Menu

Theme