Files
decision/web/e2e/helpers.ts
T
YvvandClaude Opus 5.5 83f8ca0981 v3 : interface des objections, des coquilles validées et de la messagerie ; contrôles C28–C31
Une version retenue par une objection le dit sous l'article, à chacun
selon son rôle : la personne qui vote voit qu'elle ne se vote plus (les
votes faits restent) ; l'auteur, quoi faire (« Modifier l'article visé »
pour une redite, « Reformuler » pour une contradiction) ; la personne qui
objecte, lever ou réitérer avant l'échéance. Le panier la montre sans
choix et ne l'envoie jamais.

« Détails » : faire une objection (redit / contredit), les objections et
où elles en sont, les coquilles proposées par d'autres avec leur diff
resserré, valider / refuser / revenir, l'historique dit qui a validé.
Une coquille se propose sur toute version ouverte ou en vigueur.

Boîte « Messages » dans le menu, avec le nombre de non-lus (pastille sur
le bouton du menu en mobile) : objet, texte, gestes, échéance ; un message
traité n'a plus de geste. Répondre à une objection lit son message.

Trilingue (fr, en, es). C6 suit la règle : la coquille d'un administrateur
sur un texte du seed passe tout de suite ; C28–C31 couvrent le reste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 04:21:20 +01:00

128 lines
5.7 KiB
TypeScript

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 },
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.
* 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` });
}
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 }[] }[];
}> {
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 };
}
/** 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') => (await page.request.post(`${base}/control/${what}`)).json(),
};
}