/** * Page /trash (v7.45.5) : rendu, dates, sélection groupée, restauration, purge. * * Couvre aussi le fix CSRF du front : l'ancien code faisait `csrf?.[1]` alors * que getCsrf() renvoie une CHAÎNE → 403 silencieux, les boutons ne marchaient * pas. Ici on clique pour de vrai et on vérifie l'état via l'API. */ const { test, expect } = require('@playwright/test'); const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080'; const USER = process.env.FD_USER || 'e2e@flowdeck.local'; const PASS = process.env.FD_PASS || 'e2e-secret-123'; async function login(page) { await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' }); await page.fill('#email', USER); await page.fill('#password', PASS); await page.click('.btn-primary'); const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false); if (!ok) { await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } }); await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); await page.waitForURL('**/workspaces', { timeout: 15000 }); } } // Appel API depuis la page (cookies + jeton CSRF du document). async function api(page, method, url) { return page.evaluate(async ([m, u]) => { const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''; const r = await fetch(u, { method: m, headers: { 'X-CSRF-Token': csrf } }); return { ok: r.ok, status: r.status, body: await r.json().catch(() => null) }; }, [method, url]); } async function mkTrashed(page, name) { const c = await api(page, 'POST', '/board/api/pages?title=' + encodeURIComponent(name)); expect(c.ok, 'create page').toBeTruthy(); const t = await api(page, 'POST', '/api/pages/' + c.body.id + '/trash'); expect(t.ok, 'soft delete').toBeTruthy(); return c.body.id; } const trashIds = async (page) => { const r = await api(page, 'GET', '/board/api/trash'); expect(r.ok).toBeTruthy(); return (r.body || []).map((i) => i.id); }; test('trash : rendu, dates, sélection groupée, restauration, purge', async ({ page }) => { test.setTimeout(90000); const errs = []; page.on('pageerror', (e) => errs.push('PAGEERROR ' + String(e).slice(0, 160))); page.on('console', (m) => { if (m.type() === 'error') errs.push('CONSOLE ' + m.text().slice(0, 160)); }); // Aucun dialog système acceptable : on échoue si le navigateur ouvre un // confirm()/alert() natif (c'est tout l'objet du changement). page.on('dialog', (d) => { errs.push('NATIVE_DIALOG ' + d.message()); d.dismiss().catch(() => {}); }); await login(page); // DB persistante : nommage unique par exécution, on ne dépend jamais des // restes des runs précédents. const TAG = 'E2E Trash ' + Date.now().toString(36).toUpperCase(); const nameA = TAG + ' A', nameB = TAG + ' B', nameC = TAG + ' C', nameD = TAG + ' D', nameE = TAG + ' E'; const idA = await mkTrashed(page, nameA); const idB = await mkTrashed(page, nameB); await page.goto(`${FD_BASE}/trash`, { waitUntil: 'load' }); await page.waitForTimeout(1000); // On ne travaille QUE sur nos pages : la poubelle partagée avec l'instance // de test peut contenir d'autres éléments (jamais on ne touche aux leurs). await page.fill('input[placeholder="Search pages in Trash"]', TAG); await page.waitForTimeout(400); const rowA = page.locator('.trash-item', { hasText: nameA }); const rowB = page.locator('.trash-item', { hasText: nameB }); await expect(rowA).toHaveCount(1); await expect(rowB).toHaveCount(1); // Date de suppression + jours restants affichés (standard « trash ») await expect(rowA.first()).toContainText('Deleted'); await expect(rowA.first()).toContainText('left'); // Compteur + purge totale présents await expect(page.locator('label input[type=checkbox]')).toHaveCount(1); await expect(page.getByRole('button', { name: 'Empty Trash' })).toBeVisible(); // Sélection groupée (portée au filtre courant) → restauration const rowCount = await page.locator('.trash-item').count(); expect(rowCount).toBeGreaterThanOrEqual(2); await page.locator('label input[type=checkbox]').check(); await expect(page.locator('span', { hasText: rowCount + ' selected' })).toHaveCount(1); await page.locator('button.btn-sm', { hasText: 'Restore' }).click(); await page.waitForTimeout(900); await expect(page.locator('.trash-item', { hasText: nameA })).toHaveCount(0); await expect(page.locator('.trash-item', { hasText: nameB })).toHaveCount(0); let ids = await trashIds(page); expect(ids).not.toContain(idA); expect(ids).not.toContain(idB); // Restauration unitaire (le vrai test du fix CSRF : avant = 403 silencieux) const idC = await mkTrashed(page, nameC); await page.reload({ waitUntil: 'load' }); await page.waitForTimeout(900); const rowC = page.locator('.trash-item', { hasText: nameC }); await expect(rowC).toHaveCount(1); await rowC.first().locator('button[title="Restore"]').click(); await page.waitForTimeout(900); await expect(rowC).toHaveCount(0); ids = await trashIds(page); expect(ids).not.toContain(idC); // Suppression définitive unitaire → fenêtre de confirmation THÉMATIQUE // (plus de confirm() du navigateur) const idE = await mkTrashed(page, nameE); await page.reload({ waitUntil: 'load' }); await page.waitForTimeout(900); const rowE = page.locator('.trash-item', { hasText: nameE }); await expect(rowE).toHaveCount(1); await rowE.first().locator('button[title="Delete permanently"]').click(); const modal = page.locator('#fd-trash-confirm'); // id sinon 2e match #fd-global-folder-modal await expect(modal).toBeVisible(); await expect(modal.locator('h3')).toHaveText('Delete permanently'); await expect(modal.locator('.flowdeck-modal-body p')).toContainText(nameE); // Annulation → la page est toujours là await modal.locator('button.cancel').click(); await expect(modal).toBeHidden(); await expect(rowE).toHaveCount(1); // Confirmation → suppression réelle (plus dans la poubelle, contenu effacé) await rowE.first().locator('button[title="Delete permanently"]').click(); await expect(modal).toBeVisible(); await modal.locator('button.confirm').click(); await page.waitForTimeout(900); await expect(rowE).toHaveCount(0); expect(await trashIds(page)).not.toContain(idE); // Le SW transforme les réponses API non-ok (404 ici) en erreur réseau → // `api()` peut rejeter ; dans les deux cas la page n'est plus servable. const content = await api(page, 'GET', '/api/pages/' + idE + '/content').catch(() => ({ ok: false, status: 0 })); expect(content.ok).toBeFalsy(); // Purge totale — seulement si la poubelle ne contient QUE des pages E2E // (l'instance de test est partagée : on ne vide jamais la poubelle d'autrui). await mkTrashed(page, nameD); await page.reload({ waitUntil: 'load' }); await page.waitForTimeout(900); await expect(page.locator('.trash-item', { hasText: nameD })).toHaveCount(1); const all = (await api(page, 'GET', '/board/api/trash')).body || []; // Fenêtre Empty Trash : ouverture + titre + annulation — testée quel que soit // le contenu (l'instance est partagée, on n'a pas le droit de tout vider). await page.locator('button.btn-sm', { hasText: 'Empty Trash' }).click(); await expect(modal).toBeVisible(); await expect(modal.locator('h3')).toHaveText('Empty Trash?'); await expect(modal.locator('.flowdeck-modal-body p')).toContainText(String(all.length)); await modal.locator('button.cancel').click(); await expect(modal).toBeHidden(); await expect(page.locator('.trash-item', { hasText: nameD })).toHaveCount(1); if (all.every((i) => i.name.startsWith('E2E Trash'))) { await page.locator('button.btn-sm', { hasText: 'Empty Trash' }).click(); await expect(modal).toBeVisible(); await modal.locator('button.confirm').click(); await page.waitForTimeout(900); expect(await trashIds(page)).toHaveLength(0); await expect(page.locator('.lib-empty', { hasText: 'Trash is empty' })).toBeVisible(); } else { console.log('SKIP purge : la poubelle contient des pages non-E2E ' + JSON.stringify(all.filter((i) => !i.name.startsWith('E2E Trash')).map((i) => i.name))); } const fatal = errs.filter((e) => e.startsWith('PAGEERROR') || e.includes('Alpine Expression Error') || e.startsWith('NATIVE_DIALOG')); expect(fatal, fatal.join('\n')).toHaveLength(0); });