const { test, expect } = require('@playwright/test'); /** * Sidebar — navigation par onglets compressibles (Home / Chat / Meeting / Inbox). * * - ordre exact des onglets ; * - l'onglet actif est une pilule avec son libellé, les autres sont réduits à * l'icône (largeur mesurée) ; * - chaque sélection remplace le contenu de la barre latérale ; * - les panneaux Chat / Meeting / Inbox interrogent leurs vraies API. */ 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'; 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'); try { await page.waitForURL('**/workspaces', { timeout: 8000 }); } catch (e) { // Compte absent de l'instance (harnais de test documenté du repo, comme // smoke.spec.js) : on le crée puis on rejoue la connexion. const resp = await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' }, }); if (!resp.ok()) throw new Error(`register ${resp.status()}: ${await resp.text()}`); 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'); await page.waitForURL('**/workspaces', { timeout: 15000 }); } } const panelText = (page, tab) => page.evaluate( (t) => { const el = document.querySelector(`.sidebar-tab-panel[x-show*="'${t}'"]`); if (!el) return null; const hidden = el.offsetParent === null; return { hidden, text: (el.innerText || '').trim().slice(0, 200) }; }, tab ); test('les quatre onglets se succèdent et le contenu bascule', async ({ page }) => { await login(page); await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.sidebar-nav-row'); // ordre des onglets const order = await page.$$eval('.sidebar-nav-row [data-tab]', (els) => els.map((e) => e.getAttribute('data-tab')) ); expect(order).toEqual(['home', 'chat', 'meetings', 'inbox']); // Home actif par défaut : pilule (libellé visible), les autres réduite à l'icône let labels = await page.$$eval('.sidebar-nav-row [data-tab]', (els) => els.map((e) => ({ tab: e.getAttribute('data-tab'), active: e.classList.contains('active'), labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null, width: Math.round(e.getBoundingClientRect().width), })) ); expect(labels.find((l) => l.tab === 'home').labelVisible).toBe(true); labels.filter((l) => l.tab !== 'home').forEach((l) => { expect(l.active).toBe(false); expect(l.labelVisible).toBe(false); }); const homeWidth = labels.find((l) => l.tab === 'home').width; const chatWidth = labels.find((l) => l.tab === 'chat').width; expect(homeWidth).toBeGreaterThan(chatWidth + 15); // Chat : le panneau Home disparaît, celui de Chat est visible await page.click('.sidebar-nav-row [data-tab="chat"]'); await page.waitForTimeout(400); expect((await panelText(page, 'home')).hidden).toBe(true); const chat = await panelText(page, 'chat'); expect(chat.hidden).toBe(false); expect(chat.text).toMatch(/CHAT/i); labels = await page.$$eval('.sidebar-nav-row [data-tab]', (els) => els.map((e) => ({ tab: e.getAttribute('data-tab'), active: e.classList.contains('active'), labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null, })) ); expect(labels.find((l) => l.tab === 'chat')).toEqual({ tab: 'chat', active: true, labelVisible: true, }); // Meeting await page.click('.sidebar-nav-row [data-tab="meetings"]'); await page.waitForTimeout(400); const meetings = await panelText(page, 'meetings'); expect(meetings.hidden).toBe(false); expect(meetings.text).toMatch(/MEETING/i); expect(await page.locator('.sb-quick-btn').count()).toBe(2); // Inbox await page.click('.sidebar-nav-row [data-tab="inbox"]'); await page.waitForTimeout(400); const inbox = await panelText(page, 'inbox'); expect(inbox.hidden).toBe(false); expect(inbox.text).toMatch(/INBOX/i); // retour Home : l'arbre du workspace est de nouveau là, et UNIQUEMENT Home // porte son libellé (régression : clic Home → texte sur tous les boutons) await page.click('.sidebar-nav-row [data-tab="home"]'); await page.waitForTimeout(300); expect((await panelText(page, 'home')).hidden).toBe(false); await expect(page.locator('#sidebar-workspace-items, .sidebar-static-links').first()).toBeVisible(); labels = await page.$$eval('.sidebar-nav-row [data-tab]', (els) => els.map((e) => ({ tab: e.getAttribute('data-tab'), labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null, })) ); expect(labels.find((l) => l.tab === 'home').labelVisible).toBe(true); labels.filter((l) => l.tab !== 'home').forEach((l) => { expect(l.labelVisible).toBe(false); }); }); test('le libellé suit l’onglet actif même avec une CSS périmée (cache)', async ({ page }) => { // ?v={{ asset_version }} = fichier VERSION : une feuille de style peut donc // être servie depuis le cache du navigateur. On rejoue ce cas en neutralisant // les règles .nav-tab-label de la feuille courante. await page.route('**/static/css/app.css*', async (route) => { const res = await route.fetch(); let body = await res.text(); body = body.replace(/\.nav-tab-btn \.nav-tab-label \{[^}]*\}/g, ''); body = body.replace(/\.nav-tab-btn\.active \.nav-tab-label \{[^}]*\}/g, ''); body = body.replace(/\.nav-tab-btn\.active \.nav-tab-badge \{[^}]*\}/g, ''); await route.fulfill({ response: res, body }); }); await login(page); await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.sidebar-nav-row'); const visible = async () => page.$$eval('.sidebar-nav-row [data-tab]', (els) => els.map((e) => ({ tab: e.getAttribute('data-tab'), labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null, })) ); // CSS sans les règles de libellé → seul l'onglet actif garde son texte for (const active of ['home', 'chat', 'meetings', 'inbox']) { await page.click(`.sidebar-nav-row [data-tab="${active}"]`); await page.waitForTimeout(250); const state = await visible(); expect(state.filter((s) => s.labelVisible).map((s) => s.tab)).toEqual([active]); } }); test('le panneau Meeting appelle /api/v2/meetings/upcoming', async ({ page }) => { const calls = []; page.on('request', (r) => { if (r.url().includes('/api/v2/meetings/upcoming')) calls.push(r.url()); }); await login(page); await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); await page.click('.sidebar-nav-row [data-tab="meetings"]'); await page.waitForResponse((r) => r.url().includes('/api/v2/meetings/upcoming'), { timeout: 10000 }); expect(calls.length).toBeGreaterThan(0); const status = await page.evaluate(async () => { const r = await fetch('/api/v2/meetings/upcoming?days=30', { credentials: 'same-origin' }); return r.status; }); expect(status).toBe(200); });