forked from yvv/decision
- rien n'est parti (nœud muet avant la signature, refus, fenêtre fermée avant) : la page lâche le bulletin ; signé, ou montré en code Ğecko, il est gardé et le serveur le confirme sans la page ; le refus de la chaîne se dit (« chain ») ; - le reçu : téléchargeable depuis la fenêtre de vote, et la page « Tes bulletins » (/bulletins) qui garde l'historique et vérifie un reçu dans le navigateur, sans le serveur (lib/receipt.ts, mêmes vecteurs que le moteur) ; - le panier ne dit plus « Tes votes comptent dès l'envoi » quand le vote est fermé ; - @noble/hashes déclaré (utilisé par e2e/chain-mock.mjs et le reçu) ; - chaîne simulée : finalité, pages, filtres, nœud qui tombe ; contrôles C32, C33. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
14 KiB
TypeScript
217 lines
14 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
||
import { ALICE, chain, CHARLIE, DOC, person, 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.
|
||
|
||
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.
|
||
await expect(e3.getByRole('group', { name: /variante 1\.0\.0/ }).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 article: her vote, from the chain, and the totals.
|
||
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
|
||
await e1.hover();
|
||
await e1.getByRole('button', { name: 'Le vote' }).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 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
|
||
await e2.hover();
|
||
await e2.getByRole('button', { name: 'Le vote' }).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.hover();
|
||
await e1.getByRole('button', { name: 'Le vote' }).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);
|
||
const raison = other.locator('section.text article.unit').first();
|
||
await raison.hover();
|
||
await raison.getByRole('button', { name: 'Le vote' }).click();
|
||
await raison.locator('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 what a server without one answers
|
||
// (the server itself: `without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_votes`).
|
||
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);
|
||
const first = page.locator('section.text article.unit').first();
|
||
await first.hover();
|
||
await first.getByRole('button', { name: 'Le vote' }).click();
|
||
const fold = first.locator('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.
|
||
await expect(fold.getByText('Seuil : 50 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();
|
||
});
|