forked from yvv/decision
284 lines
19 KiB
TypeScript
284 lines
19 KiB
TypeScript
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);
|
||
}
|
||
});
|