const { test, expect } = require('@playwright/test'); /** * v7.68 — colonnes rendues (slash + Turn into), tables IA sans pipe de tête, * copier/couper/coller d'une sélection multi-blocs (Ctrl+C/X/V + clic droit). * Instance de test attendue sur FD_BASE_URL (défaut 8081). */ const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081'; const USER = process.env.FD_USER || 'e2e@flowdeck.local'; const PASS = process.env.FD_PASS || 'e2e-secret-123'; test.use({ serviceWorkers: 'block', permissions: ['clipboard-read', 'clipboard-write'] }); 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) { const resp = await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' }, }); if (resp.status() !== 409) expect(resp.ok()).toBeTruthy(); await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); } } async function newPage(page, body) { const cookies = await page.context().cookies(); const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || ''; const created = await page.request.post( `${FD_BASE}/board/api/pages?title=v768 ${Date.now()}`, { headers: { 'X-CSRF-Token': csrf } } ); expect(created.status(), await created.text()).toBeLessThan(400); const { id } = await created.json(); if (body) { const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, { headers: { 'X-CSRF-Token': csrf }, data: { title: 'v768', blocks: body }, }); expect(bs.status(), await bs.text()).toBeLessThan(400); } await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 }); return id; } // VRAIE sélection multi-blocs : glisser la souris du 1er bloc jusqu'au 3e // (le navigateur seul ne franchit jamais deux contenteditable) async function dragAcross(page, last = 2) { const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox(); const b3 = await page.locator('#_blocksCt [data-bid]').nth(last).boundingBox(); // on part du tout début du 1er bloc et on va jusqu'à la fin du dernier await page.mouse.move(b1.x + 2, b1.y + b1.height / 2); await page.mouse.down(); for (let i = 1; i <= 8; i++) { await page.mouse.move(b1.x + 2, b1.y + b1.height / 2 + ((b3.y - b1.y) * i) / 8); } await page.mouse.move(b3.x + b3.width - 2, b3.y + b3.height / 2); await page.mouse.up(); } // sélection multi-blocs au CLAVIER : caret en fin de bloc 1, Maj+↓ Maj+↓ async function shiftExtend(page) { // VRAI geste : un clic met le caret dans le bloc, End va à sa fin, // Maj+↓ l'étend ensuite dans les blocs suivants. const el = page.locator('#_blocksCt [data-bid]').first(); const b = await el.boundingBox(); await page.mouse.click(b.x + 2, b.y + b.height / 2); await page.keyboard.press('End'); await page.keyboard.press('Shift+ArrowDown'); await page.keyboard.press('Shift+ArrowDown'); await page.waitForTimeout(80); } // état de la sélection côté éditeur const clipState = (page) => page.evaluate(() => window.E._clipState()); test('colonnes : slash + Turn into 2 colonnes', async ({ page }) => { await login(page); await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]); await page.click('#_blocksCt [data-bid]'); await page.keyboard.type('/'); await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 }); await page.keyboard.type('columns'); await page.locator('#_slashMenu .slash-item[data-sid="columns"]').click(); // le bloc colonnes est RENDU (il tombait dans le rendu générique = vide) await expect(page.locator('#_blocksCt .block-columns')).toHaveCount(1); await expect(page.locator('#_blocksCt .block-column')).toHaveCount(1); await page.locator('#_blocksCt .columns-add-btn').click(); await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2); await page.screenshot({ path: 'shots/v768-columns-slash.png' }); // Turn into → 2 colonnes await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Pour le menu' }]); await page.locator('#_blocksCt [data-bid]').first().click({ button: 'right' }); const menu = page.locator('#blockMenu'); await expect(menu).toBeVisible(); await menu.locator('[data-sub="turn"]').hover(); const sub = page.locator('#blockMenuSub'); await expect(sub).toBeVisible(); await sub.locator('[data-bm-act="turn-type"][data-cols="2"]').click(); await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2); }); test('table IA sans pipe de tête devient un vrai tableau', async ({ page }) => { await login(page); await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]); await page.evaluate(() => { window.E.applyAIBlocks('Nom | Rôle\n--- | ---\nAlice | Dev\nBob | Test'); }); await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1); await expect(page.locator('#_blocksCt .ftable-cell').first()).toHaveText('Nom'); await expect(page.locator('#_blocksCt .ftable-cell').last()).toHaveText('Test'); // la ligne « texte » suivie d'« --- » ne doit PAS devenir un tableau await page.evaluate(() => { window.E.applyAIBlocks('juste du texte\n---\nune phrase'); }); await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1); await page.screenshot({ path: 'shots/v768-ai-table.png' }); }); test('Ctrl+C / Ctrl+X / Ctrl+V sur une sélection multi-blocs', async ({ page }) => { await login(page); await newPage(page, [ { id: 'b1', type: 'paragraph', content: 'Premier bloc' }, { id: 'b2', type: 'heading_2', content: 'Titre deux' }, { id: 'b3', type: 'paragraph', content: 'Troisieme bloc' }, ]); // sélection multi-blocs au CLAVIER (Maj+↓) — geste réel await shiftExtend(page); const st = await clipState(page); expect(st.si).toBe(0); expect(st.ei).toBe(2); // VRAI raccourci clavier (pas execCommand) : c'est lui qui était cassé await page.keyboard.press('Control+c'); await page.waitForTimeout(250); const clip = await page.evaluate(() => navigator.clipboard.readText()); expect(clip).toContain('## Titre deux'); // le titre sort en markdown expect(clip).toContain('Troisieme bloc'); // coupe (raccourci Ctrl+X) : on recouvre les 3 blocs du début à la fin await dragAcross(page); await page.keyboard.press('Control+x'); await page.waitForTimeout(300); await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2); await expect(page.locator('#_blocksCt [data-bid]').first()).toHaveText(''); // colle : les 3 blocs reviennent APRÈS le bloc du curseur (comme l'entrée // Paste du menu) → 2 blocs restants + 3 collés = 5 await page.keyboard.press('Control+v'); await page.waitForTimeout(300); await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(5, { timeout: 5000 }); await expect(page.locator('#_blocksCt [data-bid]').nth(1)).toContainText('Premier bloc'); await expect(page.locator('#_blocksCt [data-bid]').nth(2)).toContainText('Titre deux'); }); test('clic droit avec une sélection → groupe Clipboard', async ({ page }) => { await login(page); await newPage(page, [ { id: 'b1', type: 'paragraph', content: 'Alpha bloc' }, { id: 'b2', type: 'paragraph', content: 'Beta bloc' }, ]); await dragAcross(page, 1); // la sélection multi-blocs doit SURVIVRE au clic droit (le navigateur // déplacerait sinon le curseur dans le bloc cliqué) expect((await clipState(page)).ei).toBe(1); await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } }); expect((await clipState(page)).ei).toBe(1); const menu = page.locator('#blockMenu'); await expect(menu).toBeVisible(); await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible(); await expect(menu.locator('[data-bm-act="clip-cut"]')).toBeVisible(); await expect(menu.locator('[data-bm-act="clip-paste"]')).toBeVisible(); await page.screenshot({ path: 'shots/v768-ctx-clipboard.png' }); await menu.locator('[data-bm-act="clip-copy"]').click(); const clip = await page.evaluate(() => navigator.clipboard.readText()); expect(clip).toContain('Alpha bloc'); expect(clip).toContain('Beta bloc'); });