import { expect, test } from '@playwright/test'; import { ALICE, chain, CHARLIE, deepRow, DOC, person, seedsInForce, shot, signed, signIn, TEST_DOC, VOTE_ACCOUNT, wallet } from './helpers'; // Lot B — the vote (Yvv, 2026-10-30): unfolded under the article, grouped in a // basket, and on the Ğ1 chain where the electorate is the web of trust. The chain // here is simulated (e2e/chain-mock.mjs): never a real transaction. In Monnaie libre // nothing is in force at the start (the seed proposals, ADR 0011): their vote unfolds in // the list of proposals; in the test group they were voted into force. test.beforeAll(async ({ browser }) => seedsInForce(browser)); test('C21 — le vote déplié sous l’article : ton vote, les totaux, le seuil', async ({ page, browser }) => { await signIn(page, 'Chiara'); await page.goto(TEST_DOC); const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' }); await e1.hover(); const toggle = e1.getByRole('button', { name: 'Le vote' }); await toggle.click(); const fold = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' }); await expect(fold).toBeVisible(); await expect(toggle).toHaveAttribute('aria-expanded', 'true'); await expect(toggle).toHaveAttribute('aria-controls', (await fold.getAttribute('id')) ?? ''); // How far the threshold is: the water, the totals, the votes asked and those missing. await expect(fold.getByRole('img', { name: /Niveau : \d+ % du seuil/ })).toBeVisible(); await expect(fold.getByText(/^Seuil : \d+ voix pour$/)).toBeVisible(); await expect(fold.getByText(/^Il manque \d+ voix pour\.$/)).toBeVisible(); await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible(); await expect(fold.getByText('0 pour · 0 contre')).toBeVisible(); // Her vote, at once. await fold.getByRole('button', { name: 'Pour', exact: true }).click(); await expect(fold.locator('.voted')).toHaveText('Ton vote : pour · enregistré'); await expect(fold.getByText('1 pour · 0 contre')).toBeVisible(); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveAttribute('aria-pressed', 'true'); await expect(fold.getByText('Ton vote ne se montre à personne : seuls les totaux sont visibles.')).toBeVisible(); await shot(page, '1-vote-deplie'); // Someone else sees the totals, never who voted what. const yvv = await person(browser, 'Yvv'); await yvv.goto(page.url()); const theirs = yvv.getByRole('region', { name: 'Le vote de « Unicité du compte »' }); await expect(theirs.getByText('1 pour · 0 contre')).toBeVisible(); await expect(theirs.getByText("Tu n'as pas voté sur cette version.")).toBeVisible(); await expect(theirs.getByText('Chiara')).toHaveCount(0); // Folded back, then her vote withdrawn: the group is left as found. await toggle.click(); await expect(fold).toHaveCount(0); await toggle.click(); await fold.getByRole('button', { name: 'Retirer mon vote' }).click(); await expect(fold.getByText('0 pour · 0 contre')).toBeVisible(); }); test('C22 — le vote groupé : une section en un geste, et l’article à deux versions se choisit', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto(TEST_DOC); // From the list of proposals: « Voter cette section », on the technical commitments. await page.locator('ul.deep li.section', { hasText: 'Engagements techniques' }).getByRole('button', { name: 'Voter cette section' }).click(); await expect(page).toHaveURL(/onglet=panier/); await expect(page).toHaveURL(/section=technique/); const panel = page.locator('aside.panel'); const tech = panel.getByRole('region', { name: 'Engagements techniques' }); await tech.getByRole('group', { name: 'Engagements techniques' }).getByRole('button', { name: 'Tout pour' }).click(); // E3 has two versions: the grouped gesture leaves it, and says so. const e3 = tech.locator('li.slot', { hasText: 'Connaissance suffisante' }); await expect(e3.getByText('Plusieurs versions : choisis la tienne.')).toBeVisible(); await expect(e3.getByRole('button', { name: 'Pour', pressed: true })).toHaveCount(0); await expect(panel.getByRole('status')).toContainText('plusieurs versions'); const filled = await tech.getByRole('button', { name: 'Pour', pressed: true }).count(); expect(filled).toBeGreaterThan(3); // Her version of E3, chosen by hand. await e3.getByRole('group', { name: /variante 1\.0\.0/ }).getByRole('button', { name: 'Pour' }).click(); const n = filled + 1; await expect(panel.getByRole('button', { name: `Voter (${n})` })).toBeEnabled(); await shot(page, '2-panier-bureau'); // The basket stays for the tab: a reload keeps it. await page.reload(); await expect(page.getByRole('button', { name: `Voter en lot · ${n}` })).toBeVisible(); // One gesture, one write. const writes: string[] = []; page.on('request', (r) => { if (r.method() === 'POST' && r.url().includes('/api/')) writes.push(new URL(r.url()).pathname); }); await panel.getByRole('button', { name: `Voter (${n})` }).click(); await expect(page.getByText(`${n} votes enregistrés.`)).toBeVisible(); expect(writes.filter((u) => u.endsWith('/votes'))).toHaveLength(1); expect(writes.filter((u) => u.endsWith('/vote'))).toHaveLength(0); await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible(); // Each counted: her version of E3 has its voice, the other none. Here every // dev sign-in joins the test group: from 16 members B^W vanishes and, C being 1, // her voice alone may put her version in force (ADR 0007). await expect(e3.getByRole('group', { name: /variante 1\.0\.0|en vigueur/ }).locator('..')).toContainText('1 pour · 0 contre · ton vote : pour'); await expect(e3.getByRole('group', { name: /variante 0\.4\.0-draft/ }).locator('..')).toContainText('0 pour · 0 contre'); }); test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur ; le dernier fait foi', async ({ page, browser }) => { // The chain is waited for: the server reads the indexer every 10 seconds. test.setTimeout(90_000); await signIn(page, 'Chiara'); await wallet(page, [ALICE]); await page.goto(DOC); const { control } = await chain(page); await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible(); await shot(page, '0-entete-voter-en-lot'); // The basket: two articles in force, for, in one transaction. await page.getByRole('button', { name: /^Voter en lot/ }).click(); const panel = page.locator('aside.panel'); for (const title of ['Unicité du compte', 'Réciprocité']) { await panel.getByRole('group', { name: `Ton choix pour « ${title} »` }).getByRole('button', { name: 'Pour' }).click(); } await expect(panel.getByText('Une seule transaction de 0,1 Ğ1, signée par ton portefeuille, pour tous tes choix.')).toBeVisible(); await panel.getByRole('button', { name: 'Signer et envoyer (2)' }).click(); const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' }); await expect(dialog.getByText('2 votes, en une transaction : 0,1 Ğ1 vers le compte des votes, avec ton bulletin scellé.')).toBeVisible(); await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 }); await expect(dialog.getByText("Compté pour l'identité Ğ1 « Alice ».")).toBeVisible(); // One signature: the amount to the vote account and the sealed ballot — nothing of the vote in clear. const tx = await signed(page); expect(tx).toHaveLength(1); expect(tx[0]).toMatchObject({ address: ALICE.address, dest: VOTE_ACCOUNT, amount: '10' }); expect(tx[0]?.remark).toMatch(/^ld1:[0-9a-f]{64}$/); await dialog.getByRole('button', { name: 'Fermer' }).click(); await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible(); await panel.getByRole('button', { name: 'Fermer le panneau' }).click(); // Under the seed proposal: her vote, from the chain, and the totals. const e1 = deepRow(page, 'Unicité du compte'); await e1.click(); const fold1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' }); await expect(fold1.locator('.voted')).toHaveText('Ton vote : pour · inscrit sur la chaîne Ğ1'); await expect(fold1.getByText('1 pour · 0 contre')).toBeVisible(); // The last one stands, in the order of the chain: a « contre » included first, but seen late // by the indexer, never overrides the withdrawal included after it. const e2 = deepRow(page, 'Réciprocité'); await e2.click(); const fold2 = page.getByRole('region', { name: 'Le vote de « Réciprocité »' }); await control('hide'); await fold2.getByRole('button', { name: 'Contre', exact: true }).click(); await expect(dialog.getByText('La chaîne Ğ1 confirme ton vote', { exact: false })).toBeVisible(); await shot(page, '4-chaine-attente'); await dialog.getByRole('button', { name: 'Fermer' }).click(); await expect(fold2.getByText('En attente de la chaîne Ğ1 : contre.')).toBeVisible(); await control('show'); await fold2.getByRole('button', { name: 'Retirer mon vote' }).click(); await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 }); await dialog.getByRole('button', { name: 'Fermer' }).click(); await expect(fold2.getByText('0 pour · 0 contre')).toBeVisible(); await control('release'); await expect(fold2.getByText('En attente de la chaîne Ğ1', { exact: false })).toHaveCount(0, { timeout: 30_000 }); await expect(fold2.getByText("Tu n'as pas voté sur cette version.")).toBeVisible(); await expect(fold2.getByText('0 pour · 0 contre')).toBeVisible(); // One vote unfolded at a time (an accordion): back to the first, untouched. await expect(fold1).toHaveCount(0); await e1.click(); await expect(fold1.getByText('1 pour · 0 contre')).toBeVisible(); await expect(fold2).toHaveCount(0); // Without a wallet in the browser: Ğecko, a link and a QR code; the page waits for the chain. const other = await person(browser, 'Maëlle'); await other.goto(DOC); await deepRow(other, "Raison d'être").click(); await other.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click(); const sheet = other.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' }); await expect(sheet.getByText("Aucun portefeuille n'est branché à ce navigateur", { exact: false })).toBeVisible(); await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click(); await expect(sheet.getByRole('img', { name: 'Code à scanner avec Ğecko' })).toBeVisible(); const link = (await sheet.getByRole('link', { name: 'Ouvrir dans Ğecko' }).getAttribute('href')) ?? ''; expect(link).toMatch(new RegExp(`^g1://${VOTE_ACCOUNT}\\?amount=0\\.1&comment=ld1:[0-9a-f]{64}$`)); await shot(other, '5-gecko-qr'); // Charlie pays it from Ğecko: the simulated node includes it, the page sees it counted. const { node } = await chain(other); const remark = link.split('comment=')[1] ?? ''; const fields = { address: CHARLIE.address, dest: VOTE_ACCOUNT, amount: '10', remark, signature: '0x01' }; await other.request.post(node, { data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [`0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`] }, }); await expect(sheet.getByText("Compté pour l'identité Ğ1 « Charlie ».")).toBeVisible({ timeout: 20_000 }); }); test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; le groupe d’essai vote', async ({ page }) => { await signIn(page, 'Chiara'); // The e2e server has its vote account: the page is shown a vote closed, as a server without // one answered before lot V. Since lot V (ADR 0012) a server without one votes in simulation // instead (the server: `without_a_vote_account_monnaie_libre_votes_in_simulation_…`; here, C34). await page.route(/\/api\/docs\/[^/?]+$/, async (route) => { const res = await route.fetch(); const body = await res.json(); if (body.vote?.way === 'chain') { body.vote = { ...body.vote, open: false, reason: 'chain_off', chain: null }; body.canVote = false; } await route.fulfill({ response: res, json: body }); }); await page.goto(DOC); await expect(page.getByRole('button', { name: /^Voter en lot/ })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Voter cette section' })).toHaveCount(0); await page.locator('ul.deep button.row').first().click(); const fold = page.locator('ul.deep section.fold'); await expect(fold.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer.", { exact: false })).toBeVisible(); await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); // The totals and the threshold stay in sight. // Raison d'être, level 2, the quorum 12 (ADR 0013). await expect(fold.getByText('Seuil : 9 voix pour')).toBeVisible(); await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded(); await shot(page, '6-vote-ferme'); // The basket, opened by its address, says the vote is closed — never « Tes votes comptent dès l'envoi ». await page.goto(`${DOC}?onglet=panier`); const basket = page.locator('aside.panel'); await expect(basket.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert", { exact: false })).toBeVisible(); await expect(basket.getByText("Tes votes comptent dès l'envoi", { exact: false })).toHaveCount(0); await expect(basket.getByRole('button', { name: /^Signer et envoyer|^Voter \(/ })).toBeDisabled(); // The test group still votes, directly. await page.goto(TEST_DOC); await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible(); const unit = page.locator('section.text article.unit').first(); await unit.hover(); await unit.getByRole('button', { name: 'Le vote' }).click(); await expect(unit.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); }); /** The version of an article of Monnaie libre, by its title, as the API tells it to `page`. */ async function version(page: import('@playwright/test').Page, title: string) { const g = await (await page.request.get('/api/groups/monnaie-libre')).json(); const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json(); type P = { title: string; tally: { against: number }; tallySimulated: { against: number }; myVoteState: string | null }; const all: P[] = d.units.flatMap((u: { proposals: P[] }) => u.proposals); const p = all.find((x) => x.title === title); if (!p) throw new Error(`no version « ${title} »`); return { vote: d.vote as { way: string; open: boolean; simulated: boolean }, p }; } test('C34 — la chaîne fermée, Monnaie libre vote quand même : le vote simulé compte aussitôt, marqué « simulé » dans tes bulletins', async ({ page }) => { // Lot V (ADR 0012, Yvv 2026-11-02: « Il compte, puis s'efface »). The e2e server has its // chain: it is closed for this control only, then opened again. The vote goes to a // proposal no other control reads, and only « contre »: nothing enters into force. await signIn(page, 'Sacha'); const chainOpen = async (open: boolean) => (await (await page.request.post('/api/dev/vote-chain', { data: { open }, headers: { 'x-requested-with': 'libredecision' } })).json()).open; const title = 'Logiciel auditable'; expect(await chainOpen(false)).toBe(false); try { const before = await version(page, title); expect(before.vote).toMatchObject({ way: 'server', open: true, simulated: true }); await page.goto(DOC); // The basket is there, without a wallet: the vote leaves by the server. await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible(); await deepRow(page, title).click(); const fold = page.getByRole('region', { name: `Le vote de « ${title} »` }); await fold.getByRole('button', { name: 'Contre', exact: true }).click(); await expect(page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' })).toHaveCount(0); await expect(fold.locator('.voted')).toHaveText(/^Ton vote : contre/); await expect(fold.getByText(`0 pour · ${before.p.tally.against + 1} contre`)).toBeVisible(); // Under the article: marked « simulé », never « enregistré »; the note, once, by the buttons. await expect(fold.locator('.voted')).toHaveText('Ton vote : contre · simulé'); await expect(fold.getByText('enregistré', { exact: false })).toHaveCount(0); await expect(fold.getByText("La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé.", { exact: false })).toHaveCount(1); const n = before.p.tallySimulated.against + 1; await expect(fold.getByText(n === 1 ? 'dont 1 simulé' : `dont ${n} simulés`, { exact: true })).toBeVisible(); // Counted at once, marked as simulated. const after = await version(page, title); expect(after.p.myVoteState).toBe('simulated'); expect(after.p.tallySimulated.against).toBe(before.p.tallySimulated.against + 1); // In her ballots: simulated, counted for her session, never a block of the chain. await page.goto('/bulletins'); const ballot = page.locator('li.ballot').first(); await expect(ballot.locator('.state')).toHaveText("Simulé : compté pour « Sacha » jusqu'à l'ouverture de la chaîne Ğ1"); await expect(ballot.getByText('bloc', { exact: false })).toHaveCount(0); } finally { // The next controls need the chain. expect(await chainOpen(true)).toBe(true); } });