Files
decision/web/e2e/reader.spec.ts
T

387 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});