Les 3 confirmations destructives de la poubelle (suppression définitive unitaire, suppression groupée, Empty Trash) remplacent le confirm() du navigateur par une fenêtre FlowDeck : - réutilise les classes flowdeck-modal-overlay / .flowdeck-modal déjà présentes dans base.html (variables --bg-secondary / --border / --accent, rayon 12 px, boutons Cancel + confirmation coloré) → aucun nouveau CSS ; - overlay cliquable pour annuler, Échap pour annuler, × pour annuler ; - bouton de confirmation porteur du libellé exact (Delete / Delete all / Empty Trash) et message reprenant le nom de la page ou le nombre de pages ; - role="alertdialog" aria-modal + aria-labelledby ; - composant dans trashData() : askConfirm / cancelConfirm / acceptConfirm, mutations de propriétés individuelles (pitfall réactivité Alpine) ; - id #fd-trash-confirm (la classe flowdeck-modal-overlay sert déjà à #fd-global-folder-modal → locator unique côté test) ; - aucun confirm() natif ne subsiste dans trash.html. Gates : e2e/trash_ui.spec.js étendu — ouverture + titre + message de la fenêtre, ANNULATION vérifiée (page toujours présente), confirmation (suppression réelle : plus dans la poubelle + contenu effacé), fenêtre Empty Trash ouverte/annulée quel que soit le contenu, purge réelle seulement si la poubelle ne contient que des pages E2E (instance partagée) ; le handler dialog du test échoue si un dialog natif revient (NATIVE_DIALOG). Suite e2e 11 passed · pytest 1099 passed · ruff OK · OpenAPI 510 chemins / 7.45.6.
178 lines
8.3 KiB
JavaScript
178 lines
8.3 KiB
JavaScript
/**
|
|
* 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 || '[email protected]';
|
|
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);
|
|
});
|