Files
decision/web/e2e/helpers.ts
T

263 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
*/
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);
}