import { readFileSync } from 'node:fs'; import { expect, test, type Page } from '@playwright/test'; import { ALICE, chain, DOC, EVE, person, shot, signIn, VOTE_ACCOUNT, wallet } from './helpers'; // Lot B2 — the vote on the Ğ1 chain, after its review: a ballot nothing left for // is let go; signed, or shown as a Ğecko code, it is kept and the server confirms // it without the page; the receipt goes to the one who asked, who checks it here. // The chain is simulated (e2e/chain-mock.mjs): never a real transaction. The votes go // to open proposals no other control reads, and only « contre »: nothing enters into // force, nothing becomes well rated, the other controls find the text as they expect. type Entry = { id: string; status: string; comment: string }; const mine = async (page: Page): Promise => (await (await page.request.get('/api/ballots')).json()).ballots; /** Unfolds the vote of an open proposal, from « Propositions en cours » (its sections folded: lot U). */ async function unfold(page: Page, title: string) { const all = page.getByRole('button', { name: 'Tout déplier' }); if (await all.isVisible()) await all.click(); // By its title only: the excerpt of another proposal may name it. await page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) }).click(); return page.getByRole('region', { name: `Le vote de « ${title} »` }); } const REVOCATION = 'Document de révocation'; const NEVER_ALONE = 'Ne jamais certifier seul'; const answer = (page: Page, yes: boolean) => page.evaluate((y) => (window as unknown as { __ldAnswer: (yes: boolean) => void }).__ldAnswer(y), yes); test('C32 — rien n’est parti : le bulletin est lâché ; signé ou par Ğecko, il est gardé et confirmé sans la page', async ({ page, browser }) => { // The server reads the indexer every 10 seconds. test.setTimeout(120_000); await signIn(page, 'Rosa'); await wallet(page, [EVE]); await page.goto(DOC); const { control } = await chain(page); const asked: string[] = []; page.on('request', (r) => { if (r.method() !== 'GET' && /\/api\/(docs\/[^/]+\/ballots|ballots\/)/.test(r.url())) { asked.push(`${r.method()} ${new URL(r.url()).pathname.replace(/[0-9a-f]{32}/g, ':id')}`); } }); const deleted = () => asked.filter((a) => a.startsWith('DELETE')).length; const fold = await unfold(page, REVOCATION); const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' }); // The node does not answer before anything is signed: nothing left, the ballot is let go. await control('node?down=1'); try { await fold.getByRole('button', { name: 'Contre', exact: true }).click(); await expect(dialog.getByText('La chaîne Ğ1 ne répond pas. Réessaie dans un moment.')).toBeVisible(); await expect.poll(() => asked).toEqual(['POST /api/docs/:id/ballots', 'DELETE /api/ballots/:id']); await dialog.getByRole('button', { name: 'Fermer' }).click(); await expect(fold.getByText('En attente de la chaîne Ğ1', { exact: false })).toHaveCount(0); await expect.poll(() => mine(page)).toEqual([]); } finally { // The next controls need the node. await control('node?down=0'); } // The window closed while the wallet still asks: kept until it answers; refused, let go. await page.evaluate(() => ((window as unknown as { __ldHold: boolean }).__ldHold = true)); await fold.getByRole('button', { name: 'Contre', exact: true }).click(); await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible(); await dialog.getByRole('button', { name: 'Fermer' }).click(); expect(deleted()).toBe(1); await answer(page, false); await expect.poll(deleted).toBe(2); await expect.poll(() => mine(page)).toEqual([]); // Signed once the window is closed: it may land, so it is kept; the server confirms it. await fold.getByRole('button', { name: 'Contre', exact: true }).click(); await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible(); await dialog.getByRole('button', { name: 'Fermer' }).click(); await answer(page, true); await expect.poll(async () => (await mine(page)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']); expect(deleted()).toBe(2); await page.goto(DOC); await expect((await unfold(page, REVOCATION)).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1'); // Ğecko: the code shown, the window closed — the ballot is kept; paid from a phone, confirmed by the server. const ugo = await person(browser, 'Ugo'); await ugo.goto(DOC); const theirs = await unfold(ugo, REVOCATION); await theirs.getByRole('button', { name: 'Contre', exact: true }).click(); const sheet = ugo.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' }); await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click(); const link = (await sheet.getByRole('link', { name: 'Ouvrir dans Ğecko' }).getAttribute('href')) ?? ''; await sheet.getByRole('button', { name: 'Fermer' }).click(); await expect(theirs.getByText('En attente de la chaîne Ğ1 : contre.')).toBeVisible(); const { node } = await chain(ugo); const fields = { address: ALICE.address, dest: VOTE_ACCOUNT, amount: '10', remark: link.split('comment=')[1] ?? '', signature: '0x01' }; await ugo.request.post(node, { data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [`0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`] }, }); await expect.poll(async () => (await mine(ugo)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']); await ugo.goto(DOC); await expect((await unfold(ugo, REVOCATION)).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1'); }); test('C33 — le reçu de ton vote : téléchargé, retrouvé dans tes bulletins, vérifié ici ; jamais chez les autres', async ({ page, browser }) => { test.setTimeout(90_000); await signIn(page, 'Ines'); await wallet(page, [ALICE]); await page.goto(DOC); const fold = await unfold(page, NEVER_ALONE); await fold.getByRole('button', { name: 'Contre', exact: true }).click(); const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' }); await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 }); const [download] = await Promise.all([page.waitForEvent('download'), dialog.getByRole('link', { name: 'Télécharger ton reçu' }).click()]); expect(download.suggestedFilename()).toMatch(/^recu-vote-\d{4}-\d{2}-\d{2}-[0-9a-f]{8}\.json$/); const file = await download.path(); const receipt = JSON.parse(readFileSync(file, 'utf8')); expect(receipt).toMatchObject({ format: 'ld1', status: 'confirmed', voter: 'Alice', author: ALICE.address }); expect(receipt.comment).toMatch(/^ld1:[0-9a-f]{64}$/); expect(receipt.salt).toMatch(/^[0-9a-f]{32}$/); expect(receipt.lines).toEqual([expect.objectContaining({ choice: 'against', title: NEVER_ALONE })]); // The same ballot, in her ballots. await dialog.getByRole('link', { name: 'Tes bulletins' }).click(); await expect(page).toHaveURL(/\/bulletins$/); await expect(page.getByRole('heading', { name: 'Tes bulletins de vote' })).toBeVisible(); const entry = page.locator('li.ballot'); await expect(entry).toHaveCount(1); await expect(entry).toContainText("Compté pour l'identité Ğ1 « Alice »"); await expect(entry).toContainText(receipt.comment); // Checked here, in the browser, without the server. await page.getByLabel('Fichier du reçu').setInputFiles(file); await expect(page.getByText('Le reçu tient : ses votes et son sel donnent bien ce commentaire.')).toBeVisible(); // Only what the seal covers is said verified: the version, its revision and the choice. await expect(page.locator('ul.lines li').first()).toContainText(`Version ${receipt.lines[0].proposalId}, révision ${receipt.lines[0].revision} : contre`); // The article and its title are written in the receipt, outside the seal (relecture of 7ffa4fd). await expect(page.getByText(`${NEVER_ALONE} — noté dans le reçu, hors du sceau`, { exact: false })).toBeVisible(); // One choice changed: it no longer holds. const changed = { ...receipt, lines: receipt.lines.map((l: object) => ({ ...l, choice: 'for' })) }; await page.getByLabel('Ou colle le reçu').fill(JSON.stringify(changed)); await page.getByRole('button', { name: 'Vérifier', exact: true }).click(); await expect(page.getByText('Le reçu ne tient pas : ses votes et son sel ne donnent pas son commentaire.')).toBeVisible(); await shot(page, '7-bulletins-recu'); // Nobody else gets it: neither in their ballots, nor by its address. const other = await person(browser, 'Lou'); await other.goto('/bulletins'); await expect(other.getByText("Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1.")).toBeVisible(); expect((await other.request.get(`/api/ballots/${receipt.id}/receipt`)).status()).toBe(404); });