const { test, expect } = require('@playwright/test'); /** * v7.64 — commentaires & réactions façon Notion : * - sélection → bouton 🙂+ (« React to selected text ») → sélecteur d'émoji avec * le texte sélectionné en en-tête → pastille + plage jaune, contenu inchangé * - sélection → 💬 → tiroir refondu (avatar, nom, ✓, ⋯, saisie avec @ et ↑) → * fil ancré + plage jaune, clic sur l'ancre rouvre le fil, ✓ résout * - reload : ancres et pastilles repeintes depuis la base * 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' }); 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=Comments ${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: 'Comments', 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; } // sélectionne une SOUS-chaîne du bloc (ancre = plage, pas le bloc entier) async function selectSub(page, sub) { await page.evaluate((sub) => { const el = document.querySelector('#_blocksCt [data-bid]'); const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); let n; while ((n = w.nextNode())) { if (n.parentElement && n.parentElement.closest('.fd-react-chip')) continue; const i = n.nodeValue.indexOf(sub); if (i < 0) continue; const r = document.createRange(); r.setStart(n, i); r.setEnd(n, i + sub.length); const s = getSelection(); s.removeAllRanges(); s.addRange(r); el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); return; } throw new Error('sous-chaîne introuvable : ' + sub); }, sub); await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 }); } // sélectionne tout le bloc et simule la fin du glisser-souris async function selectBlock(page) { await page.evaluate(() => { const el = document.querySelector('#_blocksCt [data-bid]'); const r = document.createRange(); r.selectNodeContents(el); const s = getSelection(); s.removeAllRanges(); s.addRange(r); el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); }); await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 }); } test('réaction emoji + commentaires ancrés', async ({ page }) => { await login(page); await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Prise de reaction a un texte' }]); await expect(page.locator('#_blocksCt [data-bid]')).toHaveText('Prise de reaction a un texte'); // ── 1. bouton réaction dans la toolbar ── await selectBlock(page); const react = page.locator('.format-toolbar .ft-react'); await expect(react).toHaveAttribute('data-tooltip', 'React to selected text'); await page.screenshot({ path: 'shots/v764-toolbar-react.png' }); await react.click(); // ── 2. sélecteur : en-tête = texte sélectionné, Filter…, Recent/People ── const picker = page.locator('.fd-icon-picker'); await expect(picker).toBeVisible(); await expect(picker.locator('.fdip-title-text')).toHaveText('Prise de reaction a un texte'); await expect(picker.locator('.fdip-search')).toHaveAttribute('placeholder', 'Filter...'); await expect(picker.locator('.fdip-tabs')).toBeHidden(); // mode réaction : emoji only await page.screenshot({ path: 'shots/v764-picker.png' }); await picker.locator('.fdip-emoji').first().click(); await expect(picker).toBeHidden(); // ── 3. pastille + plage jaune, contenu du bloc inchangé ── await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1); await expect(page.locator('#_blocksCt .fd-anchor-react')).toHaveCount(1); const content = await page.evaluate(() => { window.E.sync(); return window.E.blocks[0].content; }); expect(content).toBe('Prise de reaction a un texte'); // la pastille n'est pas du contenu await page.screenshot({ path: 'shots/v764-chip.png' }); // ── 4. commentaire sur une SOUS-sélection : toolbar 💬 → tiroir refondu → envoi ── await expect(page.locator('#_commentSelBtn')).toHaveCount(0); // bouton bleu retiré await selectSub(page, 'reaction a un'); await page.locator('.format-toolbar .ft-item[data-tooltip="Comment — Ctrl+⇧+M"]').click(); const drawer = page.locator('.comments-drawer'); await expect(drawer).toBeVisible(); await drawer.locator('textarea').fill('Ceci est mon premier commentaire'); await expect(drawer.locator('.ct-send')).toBeEnabled(); await drawer.locator('.ct-send').click(); const thread = page.locator('.comment-thread'); await expect(thread).toHaveCount(1); await expect(thread.locator('.ct-avatar')).toHaveCount(1); await expect(thread.locator('.ct-name')).not.toHaveText(''); await expect(thread.locator('.ct-act')).toHaveCount(2); // ✓ + ⋯ await expect(thread.locator('.ct-body')).toHaveText('Ceci est mon premier commentaire'); // l'ancre jaune = UNIQUEMENT la plage sélectionnée, pas le bloc await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1); await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un'); await page.screenshot({ path: 'shots/v764-drawer.png' }); // ── 5. menu ⋯ du fil (Copy link / Delete) ── await thread.locator('.ct-act').nth(1).click(); await expect(thread.locator('.ct-menu')).toBeVisible(); await expect(thread.locator('.ct-menu-item').first()).toContainText('Copy link'); await page.keyboard.press('Escape'); await page.locator('.comments-drawer-header .topbar-btn').click(); await expect(drawer).toBeHidden(); // ── 6. clic sur l'ancre jaune → le fil se rouvre ── await page.locator('#_blocksCt .fd-anchor[data-cid]').click(); await expect(drawer).toBeVisible(); await expect(page.locator('.comment-thread')).toHaveCount(1); // ── 7. ✓ résout le fil (ancre en gris) ── await page.locator('.comment-thread .ct-act').first().click(); await expect(page.locator('.comment-thread.is-resolved')).toHaveCount(1); await expect(page.locator('#_blocksCt .fd-anchor.fd-anchor-done')).toHaveCount(1); // ── 8. reload : ancres + pastilles rechargées depuis la base ── await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 }); await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 }); await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un'); await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 }); // ── 9. v7.67 : la réaction vit aussi dans le tiroir Comments ── await page.locator('.topbar-btn[title="Comments"]').click(); const re = page.locator('.ct-reactions'); await expect(re).toBeVisible(); await expect(re.locator('.ct-re-emoji')).toHaveCount(1); await expect(re.locator('.ct-re-text')).toHaveText('Prise de reaction a un texte'); await page.screenshot({ path: 'shots/v764-drawer-reactions.png' }); // ── 10. suppression A : ✕ de la section Réactions ── await re.locator('.ct-reaction .ct-act').click(); await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0); await expect(re).toBeHidden(); // ── 11. suppression B : la croix survolée de la pastille ── await page.locator('.topbar-btn[title="Comments"]').click(); // referme le tiroir await selectBlock(page); await page.locator('.format-toolbar .ft-react').click(); const picker2 = page.locator('.fd-icon-picker'); await expect(picker2).toBeVisible(); await picker2.locator('.fdip-emoji').first().click(); await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1); const emo = page.locator('#_blocksCt .fd-react-emo.is-mine'); await expect(emo).toHaveCount(1); // la croix n'apparaît qu'au survol (::after) await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('none'); await page.locator('#_blocksCt .fd-react-chip').hover(); await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('flex'); await emo.click(); await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0); });