import { expect, type Browser, type 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 const EVE = { address: 'g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj', name: 'Eve' }; export async function signIn(page: Page, name: string, admin = false) { const r = await page.request.post('/api/auth/dev', { data: { name, admin }, headers: { 'x-requested-with': 'libredecision' }, }); if (!r.ok()) throw new Error(`dev login ${r.status()}`); } export async function person(browser: Browser, name: string, admin = false): Promise { const ctx = await browser.newContext({ locale: 'fr-FR' }); const page = await ctx.newPage(); 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; * `window.__ldHold = true` makes it wait, as a person who has not answered yet, * until `window.__ldAnswer(true | false)` (sign, or 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 & { __ldSigned: unknown[]; __ldRefuse?: boolean; __ldHold?: boolean; __ldAnswer?: (yes: boolean) => void; }; w.__ldSigned = []; w.injectedWeb3 = { 'duniter-connect': { version: 'e2e', enable: async () => ({ accounts: { get: async () => list, subscribe: () => () => undefined }, signer: { signPayload: async (payload: Record) => { if (w.__ldHold) { const yes = await new Promise((resolve) => (w.__ldAnswer = resolve)); if (!yes) throw new Error('Cancelled'); } 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))); }), ); } /** The line of an article in « Propositions en cours », by its title alone (an excerpt may name it). */ export function deepRow(page: Page, title: string) { return page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) }); } /** * « Propositions en cours » shows its sections folded (lot U): « Tout déplier » opens them * all, so that a line can be clicked. */ export async function unfold(page: Page) { const all = page.getByRole('button', { name: 'Tout déplier' }); await expect(all).toBeVisible(); await all.click(); await expect(page.getByRole('button', { name: 'Tout replier' })).toBeVisible(); } /** 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` }); } const CSRF = { 'x-requested-with': 'libredecision' }; /** The document of the test group, as the server gives it. */ export async function testDoc(page: Page): Promise<{ id: string; units: { id: string; code: string; inForce: { id: string; title: string } | null; proposals: { id: string; title: string; seedProposal?: boolean }[]; }[]; }> { const g = await (await page.request.get('/api/groups/essai')).json(); const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json(); return { id: d.doc.id, units: d.units }; } /** * The three seed proposals (ADR 0011: I1, E1 and E2, not in force at the start) brought into * force in the test group, as the vote does it: voices for until each enters, then those * voices withdrawn. A version in force stays so (a permanent vote: only a rival displaces * it), at zero votes, as the controls written before the seed proposals found it. Never in * Monnaie libre, whose vote goes through the chain; nothing done twice. E12 « Responsabilité * civique », the fourth (lot T4), stays a seed proposal: open, as C35 reads it. */ export async function seedsInForce(browser: Browser) { const voices = [await person(browser, 'Amorce A'), await person(browser, 'Amorce B')]; for (const code of ['I1', 'E1', 'E2']) { const unit = (await testDoc(voices[0]!)).units.find((u) => u.code === code); const seed = unit?.proposals.find((p) => p.seedProposal); if (!unit || unit.inForce || !seed) continue; const cast: Page[] = []; for (const page of voices) { const r = await page.request.post(`/api/proposals/${seed.id}/vote`, { data: { choice: 'for' }, headers: CSRF }); if (!r.ok()) throw new Error(`vote ${r.status()} ${await r.text()}`); cast.push(page); if ((await r.json()).entered?.length) break; } for (const page of cast) { const r = await page.request.post(`/api/proposals/${seed.id}/vote`, { data: { choice: null }, headers: CSRF }); if (!r.ok()) throw new Error(`withdraw ${r.status()} ${await r.text()}`); } const now = (await testDoc(voices[0]!)).units.find((u) => u.code === code); if (now?.inForce?.id !== seed.id) throw new Error(`${code} did not enter into force`); } for (const page of voices) await page.context().close(); } /** A new article proposed by the person of `page`, submitted to the vote; returns its id. */ export async function propose(page: Page, title: string, body: string): Promise { const doc = await testDoc(page); const r = await page.request.post(`/api/docs/${doc.id}/proposals`, { data: { kind: 'text', unitId: null, title, body, submit: true }, headers: CSRF, }); if (!r.ok()) throw new Error(`propose ${r.status()} ${await r.text()}`); return (await r.json()).id; } /** Moves the server's clock forward (dev login only) and passes the deadlines at once. */ export async function advanceClock(page: Page, seconds: number): Promise<{ lifted: number; applied: number; stale: number }> { const r = await page.request.post('/api/dev/clock', { data: { seconds }, headers: CSRF }); if (!r.ok()) throw new Error(`clock ${r.status()}`); return r.json(); } /** Screenshots on a desktop and on a phone, when `LD_SHOTS` names a directory (never in the loop). */ export async function shots(page: Page, name: string) { if (!process.env.LD_SHOTS) return; const size = page.viewportSize(); await page.waitForTimeout(400); await shot(page, `${name}-bureau`); await page.setViewportSize({ width: 412, height: 915 }); await page.waitForTimeout(200); await shot(page, `${name}-mobile`); if (size) await page.setViewportSize(size); await page.waitForTimeout(200); } /** 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' | 'node?down=1' | 'node?down=0') => (await page.request.post(`${base}/control/${what}`)).json(), }; } /** * The welcome (Yvv, 2026-11-02), in French: the keystone arch shelters the three points, the * toolbox first, each centred under the keystone in the opening; then the title, centred, on * two lines broken after « possible ». Nothing tilted, nothing past the edges of the screen. * Measured once the arch has settled (its entrance done). */ export async function expectWelcome(page: Page) { await expect(page.locator('.vault .leitmotiv li')).toHaveText(['boîte à outils', 'contexte et cadrage', 'processus et modalités']); const h1 = page.getByRole('heading', { level: 1 }); await expect(h1).toHaveText('Rendre possible la décision collective'); await expect(h1.locator('.line')).toHaveText(['Rendre possible', 'la décision collective']); const m = await page.evaluate(async () => { const intro = document.querySelector('.intro')!; await Promise.all(intro.getAnimations({ subtree: true }).map((a) => a.finished)); const text = (el: Element) => { const r = document.createRange(); r.selectNodeContents(el); return { box: r.getBoundingClientRect(), rects: r.getClientRects().length }; }; const v = document.querySelector('.vault')!.getBoundingClientRect(); // The arch in its own units: 92 wide (x from −46), the top of the keystone at y = −40. const u = v.width / 92; const at = (x: number, y: number) => ({ x: v.left + (x + 46) * u, y: v.top + (y + 40) * u }); const points = [...document.querySelectorAll('.leitmotiv li')].map((li) => { const { box, rects } = text(li); // Back into the arch's units, from its axis and its springing line (y = 0). return { rects, left: (box.left - at(0, 0).x) / u, right: (box.right - at(0, 0).x) / u, top: (box.top - at(0, 0).y) / u, bottom: (box.bottom - at(0, 0).y) / u }; }); const h = document.querySelector('h1')!.getBoundingClientRect(); const lines = [...document.querySelectorAll('h1 .line')].map((l) => ({ ...text(l), block: l.getBoundingClientRect() })); const tilted = [document.querySelector('.vault svg')!, ...document.querySelectorAll('.vault svg path')] .flatMap((n) => { const out: string[] = []; for (let e: Element | null = n; e && e !== document.body; e = e.parentElement) { const cs = getComputedStyle(e); const flat = cs.transform === 'none' || /^matrix\(1, 0, 0, 1, /.test(cs.transform); if (!flat || (cs.rotate && cs.rotate !== 'none')) out.push(`${e.tagName} ${cs.transform} ${cs.rotate}`); } return out; }); return { overflow: document.documentElement.scrollWidth - innerWidth, axis: v.left + v.width / 2, vaultBottom: v.bottom, points, title: { top: h.top, axis: h.left + h.width / 2 }, lines: lines.map(({ box, rects, block }) => ({ rects, top: block.top, bottom: block.bottom, axis: box.left + box.width / 2 })), tilted, }; }); expect(m.overflow, 'nothing past the edge of the screen').toBeLessThanOrEqual(0); expect(m.tilted, 'the arch is never tilted').toEqual([]); // The three points: one line each, top to bottom, centred on the axis, under the keystone (its // foot at y = −19), between the imposts (x = ±19) and above the slab (y = 15.6); a line that // rises above the springing line stays clear of the voussoirs, closer in there (x = ±17). for (const [i, p] of m.points.entries()) { expect(p.rects, `point ${i}: one line`).toBe(1); expect(Math.abs((p.left + p.right) / 2), `point ${i}: centred`).toBeLessThan(0.3); expect(p.top, `point ${i}: under the keystone`).toBeGreaterThan(-19); expect(p.bottom, `point ${i}: above the slab`).toBeLessThan(15.6); expect(p.right, `point ${i}: in the opening`).toBeLessThan(p.top < 0 ? 17 : 19); if (i > 0) expect(p.top, `point ${i}: under the one before`).toBeGreaterThan(m.points[i - 1]!.top); } // The title: under the arch, centred on its axis, two lines of one line each. expect(m.title.top).toBeGreaterThanOrEqual(m.vaultBottom); expect(Math.abs(m.title.axis - m.axis)).toBeLessThan(1); expect(m.lines.map((l) => l.rects)).toEqual([1, 1]); expect(m.lines[1]!.top).toBeGreaterThanOrEqual(m.lines[0]!.bottom - 1); for (const l of m.lines) expect(Math.abs(l.axis - m.axis)).toBeLessThan(1); }