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

284 lines
19 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 } from '@playwright/test';
import { ALICE, chain, CHARLIE, deepRow, DOC, person, seedsInForce, shot, signed, signIn, TEST_DOC, unfold, 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(/^\d+ oui sur \d+ votants? pour passer$/)).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, at the head of the text: her vote, from the chain, and the totals.
const seed = (title: string) => page.locator('section.text article.unit', { hasText: title }).getByRole('button', { name: 'Le vote' });
const e1 = seed('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 = seed('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 other.locator('section.text article.unit', { hasText: "Raison d'être" }).getByRole('button', { name: 'Le vote' }).click();
await other.locator('section.text 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('section.text article.unit').first().getByRole('button', { name: 'Le vote' }).click();
const fold = page.locator('section.text 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 how far it is stay in sight: Raison d'être, level 2, the quorum 12 (ADR 0013).
await expect(fold.getByText('9 oui sur 12 votants pour passer', { exact: true })).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, marked « vote simulé ».
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
await expect(page.locator('header.head .tag', { hasText: 'vote simulé' })).toBeVisible();
await unfold(page);
await deepRow(page, title).click();
const fold = page.getByRole('region', { name: `Le vote de « ${title} »` });
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
// Said simulated at once, never « enregistré » (review of M6).
await expect(page.getByText('Vote simulé.', { exact: true })).toBeVisible();
await expect(page.getByText('Vote enregistré.')).toHaveCount(0);
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);
// The basket too says « simulé », never « enregistrés » (review of M6): « contre » again, one line.
await page.getByRole('button', { name: /^Voter en lot/ }).click();
const basket = page.locator('aside.panel');
await basket.getByRole('group', { name: `Ton choix pour « ${title} »` }).getByRole('button', { name: 'Contre' }).click();
await basket.getByRole('button', { name: 'Voter (1)' }).click();
await expect(page.getByText('1 vote simulé.', { exact: true })).toBeVisible();
await expect(page.getByText('enregistré', { exact: false })).toHaveCount(0);
await basket.getByRole('button', { name: 'Fermer le panneau' }).click();
// 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();
// Until a human act erases the simulated votes — never « until the chain opens » (review of M6).
await expect(ballot.locator('.state')).toHaveText('Simulé : compté pour « Sacha » tant que les votes simulés ne sont pas effacés');
await expect(ballot.getByText('bloc', { exact: false })).toHaveCount(0);
} finally {
// The next controls need the chain.
expect(await chainOpen(true)).toBe(true);
}
});