forked from yvv/decision
ADR 0014 « Les informations de fonctionnement ne se votent pas » : ce qui sort
(F1, F1.1, F1.2, F1.3, N1, N1.1, O1 et l'ordre de leurs sections), comment (rien
d'effacé, units.retired_at), la garde et ses refus, aucun vote futur (404 / 400),
ce que dira l'interface (U3), la file de publication, le retour arrière (prouvé
avec l'image de de1b7eb), les limites. textes-amorce.md : chaque texte avant /
après en fr, en, es, E12, ce qui sort, les choix à confirmer par Yvv.
e2e (aucun changement de web/src) : C3, C9 (27 articles, cinq sections, quatre
Propositions seed), C17 (33 modules à voter), C18 et C31 (le texte d'I1 de
T4), C25 (cinq lignes d'ordre, E12 dans l'ordre de « fondamental »), C35 (E12
entre E1 et E2 ; le glisser d'une place), C38 (4 Propositions seed).
CONTROLES.md suit ; CLAUDE.md : article_amend.rs, retire.rs, amorce.rs,
fixtures/seed-m8, le seed de 27 articles, l'invariant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
13 KiB
TypeScript
264 lines
13 KiB
TypeScript
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<Page> {
|
||
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<string, unknown> & {
|
||
__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<string, unknown>) => {
|
||
if (w.__ldHold) {
|
||
const yes = await new Promise<boolean>((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<string> {
|
||
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);
|
||
}
|