v3 : le vote déplié sous l'article, le panier, la signature par le portefeuille Ğ1

- Le vote se déplie sous l'article, façon accordéon, l'URL pour état (?vote=) :
  niveau d'eau, totaux, seuil et voix manquantes, ton vote et où il en est
  (enregistré, inscrit sur la chaîne, en attente, à reconfirmer, non compté),
  inertie et formule. Le reste d'une version (coquilles, traductions,
  signalements, historique) passe dans l'onglet « Détails » du panneau.
- Le panier : article par article, « tout pour / tout contre » par section ou
  pour tout le document, jamais pour un article à plusieurs versions ; un seul
  geste (une écriture, ou une transaction).
- La signature : le portefeuille de la page (injectedWeb3), polkadot-api chargé
  au moment de signer ; sans portefeuille, Ğecko par lien et QR ; la page
  attend la confirmation. Un bulletin que le portefeuille n'a pas signé est
  lâché aussitôt (DELETE /api/ballots/{id}).
- Pastilles d'ambiance : flèches dans le groupe, anneau de focus pointillé à
  la couleur d'accent de la page (3:1 au moins), distinct du choix.
- e2e : chaîne simulée (indexeur et nœud), faux portefeuille ; C21–C24, et
  C4, C5, C6, C11, C17, C19, C20, M1 mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-30 22:47:26 +01:00
co-authored by Claude Opus 5.5
parent 3541e1b1a6
commit b260596cfd
36 changed files with 4353 additions and 234 deletions
+120
View File
@@ -0,0 +1,120 @@
// A simulated Ğ1 chain for the end-to-end tests (ADR 0008): never a real node,
// never a real transaction. Two doors:
//
// - `/rpc`, the « node »: the page, in test mode, sends what the fake wallet
// signed (JSON as hex, see `testExtrinsic` in src/lib/wallet/injected.ts);
// each one is included in a new block, as the transfer and the remark of the
// real `utility.batchAll` would be;
// - `/v1/graphql`, the « indexer »: answers the server's query
// `txComments(condition: {hash})` in the shape of indexer.duniter.org.
//
// And `/control/*` for the tests: `hide` (what is included next stays unseen by
// the indexer, as an indexer late on the chain), `show` (stop hiding), `release`
// (the indexer catches up), `silent` (the indexer stops answering) and `state`.
//
// Listens on an ephemeral port on 127.0.0.1, written to the file given as argument.
import { createServer } from 'node:http';
import { writeFileSync } from 'node:fs';
import { blake2b } from '@noble/hashes/blake2.js';
// Substrate's well-known development keys as Ğ1 addresses, with the identity each
// one has on this simulated chain. Bob has an identity, but is not a member.
const IDENTITIES = {
g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu: { index: 9001, name: 'Alice', isMember: true },
g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g: { index: 9002, name: 'Bob', isMember: false },
g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi: { index: 9003, name: 'Charlie', isMember: true },
g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj: { index: 9005, name: 'Eve', isMember: true },
};
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, submitted: 0 };
const hex = (bytes) => Buffer.from(bytes).toString('hex');
const remarkHash = (remark) => `0x${hex(blake2b(Buffer.from(remark, 'utf8'), { dkLen: 32 }))}`;
function send(res, status, body, cors = false) {
const headers = { 'content-type': 'application/json' };
if (cors) Object.assign(headers, { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type' });
res.writeHead(status, headers);
res.end(body === undefined ? '' : JSON.stringify(body));
}
async function read(req) {
let raw = '';
for await (const chunk of req) raw += chunk;
try {
return JSON.parse(raw || '{}');
} catch {
return {};
}
}
/** One signed extrinsic of the test mode: a new block, its transfer and its remark. */
function include(fields) {
state.block += 1;
state.submitted += 1;
const comment = {
id: `${String(state.block).padStart(10, '0')}-${hex(Buffer.from(String(state.block))).slice(-5)}-000002`,
blockNumber: state.block,
remark: fields.remark,
author: { id: fields.address, identity: IDENTITIES[fields.address] ?? null },
transfersByCommentId: { nodes: [{ fromId: fields.address, toId: fields.dest, amount: String(fields.amount) }] },
hidden: state.hiding,
};
state.comments.push(comment);
return comment;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? '/', 'http://mock');
if (req.method === 'OPTIONS') return send(res, 204, undefined, true);
if (url.pathname === '/rpc' && req.method === 'POST') {
const body = await read(req);
if (body.method !== 'author_submitExtrinsic') return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: -32601, message: 'Method not found' } }, true);
let fields;
try {
fields = JSON.parse(Buffer.from(String(body.params?.[0] ?? '').slice(2), 'hex').toString('utf8'));
} catch {
fields = null;
}
if (!fields?.signature || !fields.remark || !fields.address) {
return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: 1010, message: 'Invalid Transaction' } }, true);
}
const c = include(fields);
return send(res, 200, { jsonrpc: '2.0', id: body.id, result: `0x${hex(blake2b(Buffer.from(c.id), { dkLen: 32 }))}` }, true);
}
if (url.pathname === '/v1/graphql' && req.method === 'POST') {
const body = await read(req);
const hash = body.variables?.hash;
// Only the query of the ballots; the others (size of the web of trust) get nothing, as from a dead indexer.
if (state.silent || !hash || !String(body.query ?? '').includes('txComments')) return send(res, 502, { error: 'unavailable' });
const nodes = state.comments
.filter((c) => !c.hidden && remarkHash(c.remark) === hash)
.sort((a, b) => a.blockNumber - b.blockNumber)
.map(({ hidden: _, ...c }) => c);
return send(res, 200, { data: { txComments: { nodes } } });
}
if (url.pathname.startsWith('/control/') && req.method === 'POST') {
const what = url.pathname.slice('/control/'.length);
if (what === 'hide') state.hiding = true;
else if (what === 'show') state.hiding = false;
else if (what === 'release') {
state.hiding = false;
for (const c of state.comments) c.hidden = false;
} else if (what === 'silent') state.silent = url.searchParams.get('on') !== '0';
else if (what !== 'state') return send(res, 404, { error: 'unknown' }, true);
return send(res, 200, { block: state.block, submitted: state.submitted, hidden: state.comments.filter((c) => c.hidden).length }, true);
}
send(res, 404, { error: 'not_found' }, true);
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
const file = process.argv[2];
if (file) writeFileSync(file, String(port));
console.log(`chain mock on 127.0.0.1:${port}`);
});
+15 -7
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from '@playwright/test';
import { DOC, person, signIn } from './helpers';
import { chain, CHARLIE, DOC, person, signIn } from './helpers';
const ML = '/g/monnaie-libre';
const path = (p: string) => (u: URL) => u.pathname === p;
@@ -145,15 +145,23 @@ test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans to
await expect(rows.first()).toContainText('33 modules à voter');
await expect(rows.first()).toContainText('dont 33 sans ton vote');
// A vote on one version of E3 (it has a variant): one module fewer without her vote. « Contre »
// counts as much, and leaves the previews of C9 unchanged (the server is shared).
const group = await (await page.request.get('/api/groups/monnaie-libre')).json();
const doc = await (await page.request.get(`/api/docs/${group.docs[0].id}`)).json();
const e3 = doc.units.find((u: { code: string }) => u.code === 'E3');
const r = await page.request.post(`/api/proposals/${e3.proposals[1].id}/vote`, {
data: { choice: 'against' },
// counts as much, and leaves the previews of C9 unchanged (the server is shared). In Monnaie
// libre the vote goes through the Ğ1 chain: the ballot, then what her wallet (Charlie's
// account) would send, on the simulated chain.
const { doc, node } = await chain(page);
const e3 = doc.units.find((u) => u.code === 'E3');
const r = await page.request.post(`/api/docs/${doc.doc.id}/ballots`, {
data: { lines: [{ proposalId: e3?.proposals[1]?.id, choice: 'against' }], author: CHARLIE.address },
headers: { 'x-requested-with': 'libredecision' },
});
expect(r.ok()).toBe(true);
const { ballot } = await r.json();
const fields = { address: CHARLIE.address, dest: ballot.account, amount: String(ballot.amount), remark: ballot.comment, signature: '0x01' };
const extrinsic = `0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`;
await page.request.post(node, { data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [extrinsic] } });
await expect
.poll(async () => (await (await page.request.get(`/api/ballots/${ballot.id}`)).json()).ballot.status, { timeout: 15_000 })
.toBe('confirmed');
await page.reload();
await expect(rows.first()).toContainText('33 modules à voter');
await expect(rows.first()).toContainText('dont 32 sans ton vote');
+65
View File
@@ -3,6 +3,13 @@ import type { Browser, Page } from '@playwright/test';
export const DOC = '/g/monnaie-libre/docs/acte-engagement-certification';
export const TEST_DOC = '/g/essai/docs/acte-engagement-certification';
/** The vote account of the e2e server (serve.sh): Dave's development key. */
export const VOTE_ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
/** Wallets of the simulated chain (chain-mock.mjs): Alice and Charlie are members, Bob is not. */
export const ALICE = { address: 'g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu', name: 'Alice' };
export const BOB = { address: 'g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g', name: 'Bob' };
export const CHARLIE = { address: 'g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi', name: 'Charlie' };
export async function signIn(page: Page, name: string, admin = false) {
const r = await page.request.post('/api/auth/dev', {
data: { name, admin },
@@ -17,3 +24,61 @@ export async function person(browser: Browser, name: string, admin = false): Pro
await signIn(page, name, admin);
return page;
}
/**
* A fake wallet extension, as duniter-connect offers itself to pages
* (`window.injectedWeb3`): it lists `accounts` and signs whatever it is given,
* recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse.
* It signs nothing real: the page, in test mode, sends to the simulated node.
*/
export async function wallet(page: Page, accounts: { address: string; name: string }[]) {
await page.addInitScript((list) => {
const w = window as unknown as Record<string, unknown> & { __ldSigned: unknown[]; __ldRefuse?: boolean };
w.__ldSigned = [];
w.injectedWeb3 = {
'duniter-connect': {
version: 'e2e',
enable: async () => ({
accounts: { get: async () => list, subscribe: () => () => undefined },
signer: {
signPayload: async (payload: Record<string, unknown>) => {
if (w.__ldRefuse) throw new Error('Cancelled');
w.__ldSigned.push(payload);
return { id: w.__ldSigned.length, signature: `0x${'01'.repeat(64)}` };
},
},
}),
},
};
}, accounts);
}
/** What the fake wallet signed, read back (the test extrinsic is JSON as hex). */
export async function signed(page: Page): Promise<{ address: string; dest: string; amount: string; remark: string }[]> {
return page.evaluate(() =>
((window as unknown as { __ldSigned: { method: string }[] }).__ldSigned ?? []).map((p) => {
const bytes = (p.method.slice(2).match(/../g) ?? []).map((b) => parseInt(b, 16));
return JSON.parse(new TextDecoder().decode(Uint8Array.from(bytes)));
}),
);
}
/** A screenshot for the visual check, when `LD_SHOTS` names a directory (never in the loop). */
export async function shot(page: Page, name: string) {
const dir = process.env.LD_SHOTS;
if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
}
/** The control door of the simulated chain, found where the page finds its node. */
export async function chain(page: Page, docPath = DOC) {
const [, , group] = docPath.split('/');
const g = await (await page.request.get(`/api/groups/${group}`)).json();
const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json();
const node: string = d.vote.chain.testNode;
const base = node.replace(/\/rpc$/, '');
return {
doc: d as { doc: { id: string }; units: { code: string; inForce: { id: string } | null; proposals: { id: string }[] }[] },
node,
control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent') => (await page.request.post(`${base}/control/${what}`)).json(),
};
}
+85 -9
View File
@@ -1,7 +1,31 @@
import { expect, test } from '@playwright/test';
import { DOC, signIn } from './helpers';
import { expect, test, type Locator } from '@playwright/test';
import { DOC, shot, signIn } from './helpers';
test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille, réglages lisibles', async ({ page }) => {
/** WCAG contrast ratio of two CSS colours `rgb(r, g, b)`. */
function contrast(a: string, b: string): number {
const lum = (c: string) => {
const [r, g, bl] = (c.match(/[\d.]+/g) ?? []).slice(0, 3).map((x) => {
const v = Number(x) / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (bl ?? 0);
};
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return ((hi ?? 0) + 0.05) / ((lo ?? 0) + 0.05);
}
/** The colour the ring of a swatch is drawn on: the first opaque background around it. */
async function backdrop(el: Locator): Promise<string> {
return el.evaluate((node) => {
for (let n: Element | null = node.parentElement; n; n = n.parentElement) {
const bg = getComputedStyle(n).backgroundColor;
if (bg && bg !== 'transparent' && !/rgba\(.*,\s*0\)$/.test(bg)) return bg;
}
return getComputedStyle(document.body).backgroundColor;
});
}
test('M1 — mobile : logo dans la barre, menu en tiroir, vote déplié, panier, réglages lisibles, pastilles au clavier', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto('/');
const brand = page.locator('header.topbar a.brand');
@@ -13,15 +37,67 @@ test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, pann
const drawer = page.getByRole('dialog', { name: 'Boîte à outils' });
await expect(drawer.locator('.tool', { hasText: 'Protocoles' })).toBeVisible();
await expect(drawer.locator('.tool', { hasText: 'Docs de référence' }).locator('.count')).toHaveText('1');
await expect(drawer.getByRole('radiogroup', { name: 'Ambiance' }).getByRole('radio')).toHaveCount(4);
const moods = drawer.getByRole('radiogroup', { name: 'Ambiance' });
await expect(moods.getByRole('radio')).toHaveCount(4);
// The swatches, by keyboard: one stop for Tab, the arrows move and choose; the focus ring
// reads at 3:1 or more on the page whatever the swatch, and is not the mark of the chosen one.
await expect(moods.locator('[tabindex="0"]')).toHaveCount(1);
const start = (await page.locator('html').getAttribute('data-mood')) ?? '';
await moods.getByRole('radio', { checked: true }).focus();
for (let i = 0; i < 4; i++) {
await page.keyboard.press('ArrowRight');
const now = moods.getByRole('radio', { checked: true });
await expect(now).toBeFocused();
await expect(moods.locator('[tabindex="0"]')).toHaveCount(1);
const ring = await now.evaluate((el) => {
const cs = getComputedStyle(el);
return { style: cs.outlineStyle, color: cs.outlineColor, width: parseFloat(cs.outlineWidth) };
});
expect(ring.style).toBe('dashed');
expect(ring.width).toBeGreaterThanOrEqual(2);
const mood = await page.locator('html').getAttribute('data-mood');
expect(contrast(ring.color, await backdrop(moods)), `${mood}: ${ring.color}`).toBeGreaterThanOrEqual(3);
if (i === 0) await shot(page, '7-pastilles-focus-mobile');
}
await expect(page.locator('html')).toHaveAttribute('data-mood', start);
await page.keyboard.press('ArrowLeft');
await expect(page.locator('html')).not.toHaveAttribute('data-mood', start);
await page.keyboard.press('ArrowRight');
await expect(page.locator('html')).toHaveAttribute('data-mood', start);
// The chosen one, without the focus: its own mark, a ring of ink, never the dashed one.
const chosen = moods.getByRole('radio', { checked: true });
await chosen.evaluate((el) => (el as HTMLElement).blur());
const mark = await chosen.evaluate((el) => ({ outline: getComputedStyle(el).outlineStyle, shadow: getComputedStyle(el).boxShadow }));
expect(mark.outline).toBe('none');
expect(mark.shadow).toContain('0px 0px 0px 2px');
await drawer.getByRole('button', { name: 'Fermer' }).first().click();
// The vote unfolds under the article, on a phone too.
const first = page.locator('section.text article.unit').first();
await first.getByRole('button', { name: 'Le vote' }).click();
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
const fold = first.locator('section.fold');
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
// The settings of the formula read on a phone: the table of the five levels holds in the sheet.
await page.locator('aside.panel').getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(page.locator('aside.panel').getByRole('tab', { name: 'Réglages' })).toBeInViewport();
await expect(page.locator('aside.panel table tbody tr')).toHaveCount(5);
const fits = await page.locator('aside.panel .scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
await fold.getByRole('button', { name: 'Réglages de la formule' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByRole('tab', { name: 'Réglages' })).toBeInViewport();
await expect(panel.locator('table tbody tr')).toHaveCount(5);
const fits = await panel.locator('.scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
expect(fits).toBe(true);
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
// The basket holds on a phone: its send button stays in sight, nothing overflows.
await page.getByRole('button', { name: /^Voter en lot/ }).click();
await expect(panel.getByRole('heading', { name: 'Panier de vote' })).toBeVisible();
await panel.getByRole('group', { name: 'Ton choix pour « Unicité du compte »' }).getByRole('button', { name: 'Pour' }).click();
await panel.getByRole('group', { name: 'Ton choix pour « Réciprocité »' }).getByRole('button', { name: 'Contre' }).click();
const send = panel.getByRole('button', { name: 'Signer et envoyer (2)' });
await expect(send).toBeInViewport();
const holds = await panel.locator('.body').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
expect(holds).toBe(true);
await shot(page, '3-panier-mobile');
// Taken back out of the basket: nothing was sent.
await panel.getByRole('group', { name: 'Tout le document' }).getByRole('button', { name: 'Vider' }).click();
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
});
+56 -33
View File
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { DOC, person, signIn, TEST_DOC } from './helpers';
import { BOB, DOC, person, signIn, TEST_DOC, wallet } from './helpers';
test('C1 — l’accueil sans session : le titre, le leitmotiv en trois lignes, la connexion, le menu complet', async ({ page }) => {
await page.goto('/');
@@ -49,24 +49,29 @@ test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a
await expect(page.locator('ul.deep button.row')).toHaveCount(30);
});
test('C4 — les actions sont contextuelles, le vote s’ouvre en panneau', async ({ page }) => {
test('C4 — les actions sont contextuelles, le vote se déplie sous l’article', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
const first = page.locator('section.text article.unit').first();
await first.hover();
await first.getByRole('button', { name: 'Le vote' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible();
await panel.getByText('La formule du seuil').click();
await expect(panel.getByText(/W — électorat/)).toBeVisible();
const toggle = first.getByRole('button', { name: 'Le vote' });
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
const fold = first.locator('section.fold');
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(fold.getByText('Il manque 50 voix pour.')).toBeVisible();
await fold.getByText('La formule du seuil').click();
await expect(fold.getByText(/W — électorat/)).toBeVisible();
// The level of the article sets M and G: the Raison d'être is at 3 / 5.
await expect(panel.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible();
await expect(panel.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
// The panel lives in the URL.
await expect(page).toHaveURL(/onglet=vote/);
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(fold.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible();
await expect(fold.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
// No window opens: the vote unfolds in place, lives in the URL, and folds back.
await expect(page.locator('aside.panel')).toHaveCount(0);
await expect(page).toHaveURL(/vote=/);
await toggle.click();
await expect(fold).toHaveCount(0);
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(page).not.toHaveURL(/vote=/);
});
test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (groupe d’essai)', async ({ browser }) => {
@@ -75,12 +80,13 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr
for (const p of [yvv, chiara]) {
await p.goto(TEST_DOC);
await p.locator('ul.deep button.row', { hasText: 'Les deux garanties réciproques' }).click();
await p.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true }).click();
await p.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
await expect(p.getByText('Vote enregistré.')).toBeVisible();
}
await expect(chiara.getByText('« Les deux garanties réciproques » est entré en vigueur.')).toBeVisible();
await chiara.keyboard.press('Escape');
await expect(chiara.locator('section.text article.unit')).toHaveCount(4);
// In force now: its vote stays unfolded, under the text.
await expect(chiara.locator('section.text section.fold')).toContainText('2 pour · 0 contre');
});
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
@@ -89,7 +95,10 @@ test('C6 — une coquille se corrige sans vote', async ({ page }) => {
const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await unicity.hover();
await unicity.getByRole('button', { name: 'Le vote' }).click();
// What is not the vote lives in the panel, tab « Détails ».
await unicity.getByRole('button', { name: 'Coquilles, traductions, signalements, historique' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Corriger une coquille').click();
const body = panel.getByLabel('Texte', { exact: true });
// Typographic apostrophes: the letters and the signs stay, only their style changes.
@@ -151,7 +160,7 @@ test('C10 — trustinWallet : la gestion des accès est réservée', async ({ br
await expect(yvv.locator('.link code')).toContainText('/invitation#');
});
test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de la toile votent', async ({ browser }) => {
test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de la toile votent, par la chaîne', async ({ browser }) => {
const yvv = await person(browser, 'Yvv', true);
const r = await yvv.request.post('/api/access/invitations', {
data: { label: 'Invitée', groups: ['monnaie-libre', 'essai'] },
@@ -160,17 +169,28 @@ test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de
const { link } = (await r.json()) as { link: string };
const ctx = await browser.newContext({ locale: 'fr-FR' });
const invited = await ctx.newPage();
// Her wallet holds Bob's account: an identity, not a member.
await wallet(invited, [BOB]);
await invited.goto(`/invitation#${link.split('#')[1]}`);
await expect(invited).toHaveURL((u) => u.pathname === '/');
await invited.goto(DOC);
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
const panel = invited.locator('aside.panel');
await expect(panel.getByText('Ici, seuls les membres de la toile de confiance Ğ1 votent', { exact: false })).toBeVisible();
await expect(panel.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
// In the test group, the same person votes.
const fold = invited.locator('section.fold');
// An invitation gives no voice: the voice is that of the Ğ1 identity of the wallet that signs.
await expect(fold.getByText("Ta voix compte pour l'identité Ğ1 du portefeuille qui signe", { exact: false })).toBeVisible();
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
const dialog = invited.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(
dialog.getByText("Ton vote n'a pas été compté : le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1."),
).toBeVisible({ timeout: 20_000 });
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(fold.getByText("Ton dernier vote n'a pas été compté", { exact: false })).toBeVisible();
await expect(fold.getByText('0 pour · 0 contre')).toBeVisible();
// In the test group, the same person votes, directly.
await invited.goto(TEST_DOC);
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
await expect(invited.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
await expect(invited.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
await expect(invited.locator('section.fold').getByText('Ta voix compte', { exact: false })).toHaveCount(0);
});
test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre identité', async ({ browser }) => {
@@ -237,10 +257,11 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page
await expect(versions).toHaveCount(2);
await expect(versions.nth(0)).toContainText('variante 1.0.0');
await expect(versions.nth(1)).toContainText('variante 0.4.0-draft');
// A version opens its vote, as any proposal.
// A version unfolds its vote below it, as any proposal.
await versions.nth(1).click();
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(page).toHaveURL(/version=/);
await expect(versions.nth(1)).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('section.fold').getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(page).toHaveURL(/vote=/);
await e3.click();
await expect(e3).toHaveAttribute('aria-expanded', 'false');
await expect(versions).toHaveCount(0);
@@ -273,8 +294,8 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
await expect(panel.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
await panel.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(e1.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
await e1.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(panel.locator('tr.current')).toHaveCount(1);
await expect(panel.locator('tr.current th')).toContainText('4');
await expect(panel.locator('tr.current th')).toContainText('cet article');
@@ -287,15 +308,17 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
await e2.hover();
await e2.getByRole('button', { name: 'Le vote' }).click();
await panel.getByText("Proposer un changement d'inertie").click();
const levels = panel.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
await e2.getByText("Proposer un changement d'inertie").click();
const levels = e2.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
await expect(levels).toHaveCount(5);
await expect(levels.nth(3)).toBeDisabled();
await expect(levels.nth(3)).toContainText('actuelle');
await expect(panel.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible();
await expect(e2.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible();
await levels.nth(4).click();
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
await panel.getByRole('button', { name: 'Soumettre au vote' }).click();
await expect(panel.getByRole('heading', { name: 'inertie vers 5 / 5 · très haute' })).toBeVisible();
await expect(panel.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
await e2.getByRole('button', { name: 'Soumettre au vote' }).click();
// Its own vote unfolds, in the list of proposals.
const change = page.getByRole('region', { name: 'Le vote de « inertie vers 5 / 5 · très haute »' });
await expect(change).toBeVisible();
await expect(change.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
});
+14 -2
View File
@@ -1,11 +1,23 @@
#!/bin/sh
# Throwaway server for the end-to-end tests: fresh database, dev login, built interface.
# Throwaway server for the end-to-end tests: fresh database, dev login, built interface,
# and a simulated Ğ1 chain (e2e/chain-mock.mjs, an ephemeral local port): the vote account
# is a well-known development key, the page sends to the fake node, the server reads the
# fake indexer. Never a real transaction.
set -eu
here=$(cd "$(dirname "$0")/../.." && pwd)
data=/tmp/ld-e2e
rm -rf "$data"
mkdir -p "$data"
cd "$here"
node web/e2e/chain-mock.mjs "$data/chain-mock.port" > "$data/chain-mock.log" 2>&1 &
i=0
while [ ! -s "$data/chain-mock.port" ]; do
i=$((i + 1))
[ "$i" -gt 100 ] && { echo "chain mock did not start" >&2; exit 1; }
sleep 0.1
done
chain="http://127.0.0.1:$(cat "$data/chain-mock.port")"
LD_DATA_DIR="$data" LD_SEED_DIR=seeds LD_WEB_DIR=web/build LD_BIND=127.0.0.1:8092 LD_DEV_LOGIN=1 \
LD_PUBLIC_URL=http://127.0.0.1:8092 LD_RPC_ENDPOINTS=http://127.0.0.1:9 LD_INDEXERS=http://127.0.0.1:9 \
LD_PUBLIC_URL=http://127.0.0.1:8092 LD_RPC_ENDPOINTS=http://127.0.0.1:9 LD_INDEXERS="$chain/v1/graphql" \
LD_VOTE_ACCOUNT=g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM LD_VOTE_TEST_NODE="$chain/rpc" \
exec ./target/debug/ld-server
+206
View File
@@ -0,0 +1,206 @@
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 }) => {
await signIn(page, 'Chiara');
await wallet(page, [ALICE]);
await page.goto(DOC);
const { control } = await chain(page);
// 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 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();
});