import { expect, test, type Page } from '@playwright/test'; import { BOB, DOC, expectWelcome, person, seedsInForce, signIn, TEST_DOC, testDoc, unfold, wallet } from './helpers'; // The seed proposals (ADR 0011) are not in force at the start: the controls that read a // text in force find I1, E1 and E2 brought in by the vote in the test group. test.beforeAll(async ({ browser }) => seedsInForce(browser)); test('C1 — l’accueil sans session : la voûte abrite les trois points, le titre centré en deux lignes, la connexion, le menu complet', async ({ page }) => { await page.goto('/'); await expectWelcome(page); const nav = page.getByRole('navigation', { name: 'Boîte à outils' }); for (const item of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) { await expect(nav.locator('.tool', { hasText: item })).toBeVisible(); } await expect(nav.locator('.tool.off')).toHaveCount(3); await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toBeVisible(); // The four pillars live on the page of a group, no longer here. await expect(page.locator('.pillars')).toHaveCount(0); await expect(page.getByText('Monnaie libre', { exact: true })).toHaveCount(0); }); test('C2 — l’accueil avec session : le choix du groupe, ceux de la même clé ; pas l’Atelier du canal', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto('/'); await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective'); await expect(page.locator('a.root')).toContainText('Monnaie libre'); for (const g of ['Monnaie libre', 'Axiom', 'Librodrome', 'Groupe local Vallée Drôme Crestois']) { await expect(page.getByText(g, { exact: true })).toBeVisible(); } await expect(page.getByText('Atelier du canal')).toHaveCount(0); await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toHaveCount(0); // The old address and « Mes groupes » lead here. await page.goto('/groupes'); await expect(page).toHaveURL((u) => u.pathname === '/'); await page.goto('/g/monnaie-libre'); await page.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Mes groupes' }).click(); await expect(page).toHaveURL((u) => u.pathname === '/'); }); test('C3 — au départ, aucun texte en vigueur : la raison d’être et les deux engagements, Propositions seed, ouvrent le texte', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(DOC); await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification"); // Nothing in force (Yvv, 2026-11-02): the page says where to begin, once — the seed proposals, // read whole at the head of the text (lot U), each marked: never the bare page of a text in force. await expect(page.getByText("Aucun article n'est encore en vigueur. Commence par les Propositions seed : elles y entrent par le vote, comme toute proposition.")).toBeVisible(); const seeds = page.locator('section.text article.unit'); await expect(seeds.locator('h2')).toHaveText(["Raison d'être", 'Unicité du compte', 'Réciprocité']); await expect(seeds.locator('.tag', { hasText: 'Proposition seed' })).toHaveCount(3); await expect(seeds.getByTitle("Texte de départ mis en avant pour lancer le vote. Il n'est pas en vigueur : il y entre par le vote, comme toute proposition.")).toHaveCount(3); // At the low level they start at, how far each is from passing. for (const seed of await seeds.all()) await expect(seed).toContainText('0 pour · 0 contre · 9 oui sur 12'); for (const seed of await seeds.all()) await expect(seed).toContainText('inertie 2 / 5 · basse'); // Below, the open proposals, folded by section; the seed proposals are not said twice. await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible(); await expect(page.locator('ul.deep li.section')).toHaveCount(8); await expect(page.locator('ul.deep button.row')).toHaveCount(0); await expect(page.locator('ul.deep li.section', { hasText: 'Engagements techniques' })).toContainText('11 articles'); await unfold(page); // One line per article: the 33 less the 3 at the head, E3 and E7 holding their two variants inside. await expect(page.locator('ul.deep button.row')).toHaveCount(30); await expect(page.locator('ul.deep .tag', { hasText: 'Proposition seed' })).toHaveCount(0); }); test('C4 — les actions sont contextuelles, le vote se déplie sous l’article', async ({ page }) => { // In the test group, where the Raison d'être was voted into force (beforeAll). await signIn(page, 'Chiara'); await page.goto(TEST_DOC); const first = page.locator('section.text article.unit').first(); await expect(first.locator('h2')).toHaveText("Raison d'être"); await first.hover(); const toggle = first.getByRole('button', { name: 'Le vote' }); await toggle.click(); await expect(toggle).toHaveAttribute('aria-expanded', 'true'); const fold = first.locator('section.fold'); // How many « oui » pass it, at its quorum (ADR 0013): in force at no vote, the quorum of the test group. await expect(fold.getByText(/^\d+ oui sur \d+ votants? pour passer$/)).toBeVisible(); await expect(fold.getByText(/^Il manque \d+ voix pour\.$/)).toBeVisible(); await fold.getByText('La formule du seuil').click(); await expect(fold.getByText(/W — électorat/)).toBeVisible(); // The level of the article sets M and G: the Raison d'être, a seed proposal, starts at 2 / 5. await expect(fold.getByText('M — majorité à pleine participation : 50 % — inertie 2 / 5 · basse')).toBeVisible(); await expect(fold.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible(); // No window opens: the vote unfolds in place, lives in the URL, and folds back. await expect(page.locator('aside.panel')).toHaveCount(0); await expect(page).toHaveURL(/vote=/); await toggle.click(); await expect(fold).toHaveCount(0); await expect(toggle).toHaveAttribute('aria-expanded', 'false'); await expect(page).not.toHaveURL(/vote=/); }); test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (groupe d’essai)', async ({ browser }) => { // Every dev sign-in joins the test group: from 16 members B^W vanishes and, C being 1, one // voice may suffice (ADR 0007); else the second one brings it in. const title = 'Les deux garanties réciproques'; const news = `« ${title} » est entré en vigueur.`; const inForce = async (page: Page) => (await testDoc(page)).units.some((u) => u.inForce?.title === title); let voices = 0; let last: Page | undefined; for (const name of ['Yvv', 'Chiara']) { last = await person(browser, name); await last.goto(TEST_DOC); await unfold(last); await last.locator('ul.deep button.row', { hasText: title }).click(); await last.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click(); await expect(last.getByText('Vote enregistré.')).toBeVisible(); voices += 1; if (await inForce(last)) break; } if (!last) throw new Error('nobody voted'); await expect(last.getByText(news)).toBeVisible(); // With the three seed proposals voted in (beforeAll). await expect(last.locator('section.text article.unit')).toHaveCount(4); // In force now: its vote stays unfolded, under the text. await expect(last.locator('section.text section.fold')).toContainText(`${voices} pour · 0 contre`); }); test('C6 — une coquille se corrige sans vote', async ({ page }) => { // A seed text has no author: an administrator's fix applies at once (someone // else's waits for an administrator, or for 3 days: C31). await signIn(page, 'Yvv', true); await page.goto(TEST_DOC); const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' }); await unicity.hover(); await unicity.getByRole('button', { name: 'Le vote' }).click(); // What is not the vote lives in the panel, tab « Détails ». await unicity.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click(); const panel = page.locator('aside.panel'); await expect(panel.getByRole('heading', { name: 'Détails', exact: true })).toBeVisible(); await panel.getByText('Corriger une coquille').click(); const body = panel.getByLabel('Texte', { exact: true }); // Typographic apostrophes: the letters and the signs stay, only their style changes. await body.fill((await body.inputValue()).replaceAll("'", '’')); await panel.getByRole('button', { name: 'Enregistrer la correction' }).click(); await expect(page.getByText('Coquille corrigée, sans toucher aux votes.')).toBeVisible(); await expect(unicity).toContainText('Je m’engage sur l’honneur'); // A change of meaning is turned away. await panel.getByText('Corriger une coquille').click(); await body.fill('Tout autre chose, qui change le sens de cet engagement.'); await panel.getByRole('button', { name: 'Enregistrer la correction' }).click(); await expect(panel.getByText('Ce changement touche au sens : propose-le comme modification.')).toBeVisible(); }); test('C7 — l’assistant propose de découper deux décisions', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(TEST_DOC); await page.getByRole('button', { name: 'Proposer un nouvel article' }).click(); const panel = page.locator('aside.panel'); await panel.getByLabel('Titre').fill('Joignabilité et vérification'); await panel.getByLabel('Texte').fill( "Je m'engage à rester joignable par mes certifieurs.\n\nJe m'engage à vérifier la clé publique avec la personne.", ); await expect(panel.getByText('Ce texte semble porter 2 décisions.', { exact: false })).toBeVisible(); await panel.getByRole('button', { name: 'Découper en 2 brouillons' }).click(); await expect(page.getByText('Découpé en 2 brouillons.')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Tes brouillons' })).toBeVisible(); }); test('C8 — multilingue : le texte et l’interface en anglais et en espagnol', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(DOC); await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click(); await expect(page.getByRole('heading', { level: 1 })).toHaveText('Certification Commitment Act'); await expect(page.getByText('Open proposals', { exact: false }).first()).toBeVisible(); await page.getByRole('radiogroup', { name: 'Text language' }).getByRole('radio', { name: 'ES' }).click(); await expect(page.getByRole('heading', { level: 1 })).toHaveText('Acta de compromiso de Certificación'); }); test('C9 — les aperçus : bien cotées, et tout', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(DOC); await page.getByRole('radio', { name: 'Si tout passe' }).click(); await expect(page.getByText("Aperçu : le texte tel qu'il serait si toutes les propositions", { exact: false })).toBeVisible(); await expect(page.locator('section.text article.unit')).toHaveCount(33); // Nothing in force in Monnaie libre, and nothing well rated yet (no « pour » there so far). await page.getByRole('radio', { name: 'Si les bien cotées passent' }).click(); await expect(page.locator('section.text article.unit')).toHaveCount(0); }); test('C10 — trustinWallet : la gestion des accès est réservée', async ({ browser }) => { const chiara = await person(browser, 'Chiara'); await chiara.goto('/acces'); await expect(chiara.getByText('La gestion des accès est réservée aux administrateurs de trustinWallet.')).toBeVisible(); const yvv = await person(browser, 'Yvv', true); await yvv.goto('/acces'); await expect(yvv.getByRole('heading', { name: 'trustinWallet — accès' })).toBeVisible(); await yvv.getByLabel('Pour qui').fill('Invitée'); await yvv.getByRole('button', { name: "Créer le lien d'invitation" }).click(); await expect(yvv.locator('.link code')).toContainText('/invitation#'); }); test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de la toile votent, par la chaîne', async ({ browser }) => { const yvv = await person(browser, 'Yvv', true); const r = await yvv.request.post('/api/access/invitations', { data: { label: 'Invitée', groups: ['monnaie-libre', 'essai'] }, headers: { 'x-requested-with': 'libredecision' }, }); const { link } = (await r.json()) as { link: string }; const ctx = await browser.newContext({ locale: 'fr-FR' }); const invited = await ctx.newPage(); // Her wallet holds Bob's account: an identity, not a member. await wallet(invited, [BOB]); await invited.goto(`/invitation#${link.split('#')[1]}`); await expect(invited).toHaveURL((u) => u.pathname === '/'); await invited.goto(DOC); await unfold(invited); await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click(); const fold = invited.locator('section.fold'); // An invitation gives no voice: the voice is that of the Ğ1 identity of the wallet that signs. await expect(fold.getByText("Ta voix compte pour l'identité Ğ1 du portefeuille qui signe", { exact: false })).toBeVisible(); await fold.getByRole('button', { name: 'Pour', exact: true }).click(); const dialog = invited.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' }); await expect( dialog.getByText("Ton vote n'a pas été compté : le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1."), ).toBeVisible({ timeout: 20_000 }); await dialog.getByRole('button', { name: 'Fermer' }).click(); await expect(fold.getByText("Ton dernier vote n'a pas été compté", { exact: false })).toBeVisible(); await expect(fold.getByText('0 pour · 0 contre')).toBeVisible(); // In the test group, the same person votes, directly. await invited.goto(TEST_DOC); await unfold(invited); await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click(); await expect(invited.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); await expect(invited.locator('section.fold').getByText('Ta voix compte', { exact: false })).toHaveCount(0); }); test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre identité', async ({ browser }) => { const yvv = await person(browser, 'Yvv', true); await yvv.goto('/acces'); const team = yvv.locator('section', { has: yvv.getByRole('heading', { name: "Liens d'équipe" }) }); await team.getByLabel('Pour quelle équipe').fill('Équipe de test'); await team.getByLabel("Groupe d'essai").check(); await team.getByRole('button', { name: "Créer le lien d'équipe" }).click(); const link = (await team.locator('.link code').textContent()) ?? ''; expect(link).toContain('/equipe#'); for (const name of ['Luna', 'Paul']) { const ctx = await browser.newContext({ locale: 'fr-FR' }); const page = await ctx.newPage(); await page.goto(`/equipe#${link.split('#')[1]}`); await expect(page.getByText('« Équipe de test » t’ouvre', { exact: false })).toBeVisible(); await page.getByLabel('Ton nom').fill(name); await page.getByRole('button', { name: 'Rejoindre' }).click(); await expect(page).toHaveURL((u) => u.pathname === '/'); await ctx.close(); } // A shared browser: the person signed in steps aside, the next one gives a name. const shared = await person(browser, 'Zoé'); await shared.goto(`/equipe#${link.split('#')[1]}`); await expect(shared.getByText('Ta session est ouverte sous le nom Zoé', { exact: false })).toBeVisible(); await shared.getByRole('button', { name: "Ce n'est pas moi : me déconnecter" }).click(); await shared.getByLabel('Ton nom').fill('luna'); await shared.getByRole('button', { name: 'Rejoindre' }).click(); await expect(shared.getByRole('alert')).toContainText('Ce nom est déjà pris'); await yvv.reload(); await expect(team.getByText('2 / 30 personnes', { exact: false })).toBeVisible(); await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2); }); test('C18 — la Raison d’être ouvre le texte, en trois langues', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(DOC); // A seed proposal, not in force: it opens the text « if everything passes ». await page.getByRole('radio', { name: 'Si tout passe' }).click(); const first = page.locator('section.text article.unit').first(); await expect(first.locator('h2')).toHaveText("Raison d'être"); // The first sentence in italics, then two paragraphs. const paragraphs = first.locator('.prose p'); await expect(paragraphs).toHaveCount(3); await expect(paragraphs.nth(0).locator('em')).toContainText("Derrière une clé il n'y a qu'un seul être humain vivant"); await expect(paragraphs.nth(1)).toContainText("C'est le seul et unique propos de la certification"); await expect(paragraphs.nth(2)).toContainText('la première monnaie libre'); await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click(); await expect(first.locator('h2')).toHaveText('Purpose'); await page.getByRole('radiogroup', { name: 'Text language' }).getByRole('radio', { name: 'ES' }).click(); await expect(first.locator('h2')).toHaveText('Razón de ser'); }); test('C19 — un article, une ligne : ses versions se déplient', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(DOC); await unfold(page); const e3 = page.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' }); await expect(e3).toHaveCount(1); await expect(e3).toContainText('2 versions'); await expect(e3).toHaveAttribute('aria-expanded', 'false'); // Unfolded with the keyboard: its two versions, each with its label. await e3.focus(); await page.keyboard.press('Enter'); await expect(e3).toHaveAttribute('aria-expanded', 'true'); const versions = page.locator(`#${await e3.getAttribute('aria-controls')}`).getByRole('button'); await expect(versions).toHaveCount(2); await expect(versions.nth(0)).toContainText('variante 1.0.0'); await expect(versions.nth(1)).toContainText('variante 0.4.0-draft'); // A version unfolds its vote below it, as any proposal. await versions.nth(1).click(); await expect(versions.nth(1)).toHaveAttribute('aria-expanded', 'true'); // Level 3, the quorum 50 (ADR 0013): 39 « pour » at no vote, the voters missing counted as « non ». await expect(page.locator('section.fold').getByText('39 oui sur 50 votants pour passer', { exact: true })).toBeVisible(); await expect(page).toHaveURL(/vote=/); await e3.click(); await expect(e3).toHaveAttribute('aria-expanded', 'false'); await expect(versions).toHaveCount(0); }); test('C20 — réglages de la formule et cinq crans', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(DOC); // From the head of the document: the scale of the group, at its usual turnout. await page.getByRole('button', { name: 'Réglages de la formule' }).click(); await expect(page).toHaveURL(/onglet=reglages/); const panel = page.locator('aside.panel'); await expect(panel.getByRole('heading', { name: 'Réglages de la formule' })).toBeVisible(); await expect(panel.getByText(/W — électorat : 5\s745/)).toBeVisible(); // No vote yet: the reference, twice the reference quorum (ADR 0013). await expect(panel.getByText(/100 votants sur 5\s745 \(1,74 %\), deux fois 50, le quorum de référence : la participation de référence/)).toBeVisible(); const rows = panel.locator('table tbody tr'); await expect(rows).toHaveCount(5); await expect(rows.locator('th')).toHaveText([/1\s*très basse/, /2\s*basse/, /3\s*moyenne/, /4\s*haute/, /5\s*très haute/]); await expect(rows.locator('td:nth-child(2)')).toHaveText(['50 %', '50 %', '55 %', '60 %', '66 %']); await expect(rows.locator('td:nth-child(4)')).toHaveText(['60 %', '67 %', '75 %', '83 %', '90 %']); // The quorum of each level (Yvv: « 12 · 12 · 50 · 50 · 50 »). const quorums = rows.locator('td:nth-child(5)'); await expect(quorums).toHaveText(['12', '12', '50', '50', '50']); // At 100 voters, above every quorum: each level's share of the 100. const required = panel.locator('table tbody td.required'); await expect(required).toHaveText(['60', '67', '75', '83', '90']); await expect(panel.getByText("Voix pour : à 100 votants, comme d'habitude.")).toBeVisible(); await expect(panel.getByText("Part exigée : la part de « pour » demandée, à 100 votants comme d'habitude.")).toBeVisible(); await expect(panel.getByText(/^Quorum : en dessous, les votants qui manquent comptent comme « non »\./)).toBeVisible(); await page.keyboard.press('Escape'); // From the vote of an article: Unicité du compte, a seed proposal at the head, is at 2 / 5, its row is marked. const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' }); await unicity.hover(); await unicity.getByRole('button', { name: 'Le vote' }).click(); const e1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' }); await expect(e1.getByRole('heading', { name: 'Inertie · 2 / 5 · basse' })).toBeVisible(); await e1.getByRole('button', { name: 'Réglages de la formule' }).click(); await expect(panel.locator('tr.current')).toHaveCount(1); await expect(panel.locator('tr.current th')).toContainText('2'); await expect(panel.locator('tr.current th')).toContainText('cet article'); // No vote: each level on its quorum, the voters missing counted as « non ». await expect(quorums).toHaveText(['12', '12', '50', '50', '50']); await expect(required).toHaveText(['8', '9', '39', '43', '46']); await expect(panel.getByText('Voix pour : à 0 votant, la participation de cette version.')).toBeVisible(); await page.keyboard.press('Escape'); // The five levels to choose from, in the test group (Réciprocité voted into force there, // beforeAll); the change is voted at the article's level. await page.goto(TEST_DOC); const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' }); await e2.hover(); await e2.getByRole('button', { name: 'Le vote' }).click(); await e2.getByText("Proposer un changement d'inertie").click(); const levels = e2.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio'); await expect(levels).toHaveCount(5); // The current level is reachable — one stop in the tab order — and never chosen. await expect(levels.nth(1)).toHaveAttribute('aria-disabled', 'true'); await expect(levels.nth(1)).toContainText('actuelle'); await expect(e2.locator('[role="radio"][tabindex="0"]')).toHaveCount(1); await expect(levels.nth(1)).toHaveAttribute('tabindex', '0'); await expect(e2.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 2 / 5 · basse.")).toBeVisible(); // A click on it (Playwright refuses an aria-disabled target: the event is sent as is) chooses nothing. await levels.nth(1).dispatchEvent('click'); await expect(e2.locator('[role="radio"][aria-checked="true"]')).toHaveCount(0); // With the keyboard: the arrows go from level to level and choose it, wrapping round. await levels.nth(1).focus(); await page.keyboard.press('ArrowLeft'); await expect(levels.nth(0)).toBeFocused(); await expect(levels.nth(0)).toHaveAttribute('aria-checked', 'true'); await page.keyboard.press('ArrowLeft'); await expect(levels.nth(4)).toBeFocused(); await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true'); await expect(e2.locator('[role="radio"][tabindex="0"]')).toHaveCount(1); await page.keyboard.press('ArrowRight'); await expect(levels.nth(0)).toHaveAttribute('aria-checked', 'true'); // Or with a click. await levels.nth(4).click(); await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true'); await e2.getByRole('button', { name: 'Soumettre au vote' }).click(); // Its own vote unfolds, in the list of proposals. const change = page.getByRole('region', { name: 'Le vote de « inertie vers 5 / 5 · très haute »' }); await expect(change).toBeVisible(); await expect(change.getByText('inertie 2 / 5 · basse', { exact: true })).toBeVisible(); });