Repository Public
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