Files
flowdeck/e2e/v764_comments_reactions.spec.js
bruno d58c321c07
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
feat: réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection (v7.67.0)
Tiroir Comments
- Section « Reactions » au-dessus des fils : émoji, compte (si >1) et texte
  ancré lu dans le DOM (reactionList() / _anchorText(), offsets source hors
  pastilles) ; l'état vide ne s'affiche plus s'il reste des réactions.
- Suppression : ✕ sur la ligne du tiroir (mes réactions) ET croix ::after au
  survol de la pastille sur l'émoji que j'ai posé (aucun nœud ajouté → offsets
  intacts). Deux entrées → removeReaction() → POST toggle → loadReactions().

Menu de sélection (maquette ASCII)
- Ligne 4 = rangée scannée : 💬 Comment (libellé) à gauche, 🙂+ réaction à
  droite (.ft-row-split) ; la réaction sort de la ligne 3.
- Entête Skills avec ≡ à droite (::after), liste Skills scrollable.
- ✨ Ask AI retiré de la toolbar : doublon exact de « Edit with AI » du pied.
- Écart volontaire à la maquette : le barré (T) de la ligne 2 est conservé.

Fix racine (flake de gate)
- _is_public_host attrape OSError (le garde SSRF renvoie un booléen, il ne
  lève plus) : un socket.timeout DNS ne bascule plus fetch_og_metadata dans
  son except Exception → repli silencieux → AssertionError.
- fixture stub_dns sur TestOGParser (même motif que test_agent_web_tools) :
  zéro vrai DNS dans les tests OG.

Tests: pytest tests/ -q -n auto → 1390 passed / 0 failed (flake corrigé) ;
ruff check app tests → OK ; e2e v764 (réactions dans le tiroir + suppression
A/B), v762 (nouvelle ligne 4), v766, v7641, v763 → 6 passed sur :8081
(image flowdeck:v7.67.0 rebuild + redéployée).
2026-10-08 14:25:48 -04:00

202 lines
9.5 KiB
JavaScript

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 || '[email protected]';
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);
});