Files
festipod/scripts/e2e-rights-matrix-support.ts
T
Sylvain Duchesne 908f7ea973 Keep sign-ups unlinkable on ActivityPods, and land the pending coherence-pass work
Participation privacy (decision_2026-10-29_participation-privacy-mechanism):
a sign-up is a private Join naming the meeting point, addressed to nobody,
handled by the backend on emission. Attendee lists hold member URNs hashed
from (WebID, event, a secret kept in the participant's profile), so counts
stay public and only contacts recognise anyone; the creator has no read
right and is notified anonymously. Each person's participations collection,
created by the backend at the first sign-up, is readable by their /contacts
WAC group only, never world-readable. The member URN is sticky per
(meeting point, participant) and every collection write is verified by
reading back, so a refused write leaves the act unrecorded and retryable.
The EventRef mechanism and its browser-side provisioning are gone.

The backend now runs with WebACL on, as the reference apps do, on its own
secure datasets festipod / settings-festipod instead of the pod provider's
settings dataset; pod-notifications needs it. The compose header and the
deployment leaf say how to reset a stack whose datasets predate this.

The rights matrix covers the new mechanism live (148 hard lines), including
a forged contact Accept between throwaway accounts: the stranger enters the
contacts list (platform defect, reported upstream) but is refused the profile
and the participations. Tests may attack as long as they never break the dev
environment (rule_tests-never-break-the-dev-environment).

Also lands the coherence-pass work that was still uncommitted: contacts as
the Pod's social graph and invite links, image upload to the Pod, the
meeting-point form, profile completion gate, and the doctrine for them.
Superseded briefs and decisions are deleted rather than annotated.
2026-10-30 17:06:38 +01:00

525 lines
27 KiB
TypeScript

/**
* Support for `e2e-rights-matrix.ts`: the per-line report, raw HTTP with a
* user's token (or none), account bootstrap with programmatic app install, and
* the browser drives (pod login + consent, event and meeting-point creation).
*
* WHY NOT IMPORT THE BANKED SCRIPTS. `e2e-login-flow.ts` and `e2e-revocation.ts`
* run their whole probe at import time (top-level IIFE) and their drives read
* module-level user constants, so their helpers cannot be imported. The drives
* below are the same sequences, parameterised by the user who drives. The other
* direction works: `e2e-login-flow.ts` and `e2e-revocation.ts` import `driveLogin`
* (the login-flow probe also the install check) from here, so the sign-in drive
* exists once.
*
* NEVER A SILENT PASS. A line's check is a function returning `{ ok, actual }`;
* a throw, a timeout or an unreachable host is recorded as FAIL with the error
* as the actual value. A refusal is matched on an explicit status only
* (`refused`), so a 404, a 500 or a network error (status 0) never counts as one.
*/
import type { Frame, Page } from 'playwright';
import {
linkId,
podLogin,
readResource,
recordUri,
type ApodsSession,
type ApodsStackConfig,
type JsonRecord,
} from '../src/shared/support/activitypodsStack';
// ── Report ────────────────────────────────────────────────────────────────
/** How a line weighs on the exit code: only `hard` lines can fail the run. */
export type LineKind = 'hard' | 'known-deviation' | 'known-defect' | 'manual' | 'info';
export type LineStatus = 'PASS' | 'FAIL' | 'KNOWN-DEVIATION' | 'KNOWN-DEFECT' | 'MANUAL-CHECK' | 'INFO';
export interface CheckOutcome {
ok: boolean;
actual: string;
/** Raw evidence (status codes, bodies, ids) printed under the line. */
evidence?: string;
}
export interface LineResult {
id: string;
group: string;
name: string;
kind: LineKind;
expected: string;
actual: string;
status: LineStatus;
evidence?: string;
/** A tracked classification printed with a FAIL (it never turns it into a pass). */
finding?: string;
/** A loud notice: a known line whose observed behaviour changed. */
notice?: string;
}
const errorText = (e: unknown): string => (e instanceof Error ? `${e.name}: ${e.message}` : String(e)).slice(0, 300);
export class Matrix {
readonly lines: LineResult[] = [];
/** A hard assertion: PASS only when the check returns ok; anything else is FAIL. */
async hard(
id: string, group: string, name: string, expected: string,
check: () => Promise<CheckOutcome>,
opts: { finding?: string } = {},
): Promise<boolean> {
let outcome: CheckOutcome;
try {
outcome = await check();
} catch (e) {
outcome = { ok: false, actual: `ERROR ${errorText(e)}` };
}
const status: LineStatus = outcome.ok ? 'PASS' : 'FAIL';
this.push({
id, group, name, kind: 'hard', expected, actual: outcome.actual, status, evidence: outcome.evidence,
finding: status === 'FAIL' ? opts.finding : undefined,
});
return outcome.ok;
}
/**
* A tracked, non-fatal line (known deviation or known defect). `classify`
* receives the observation and says whether the tracked behaviour still holds;
* when it does not, the line carries a loud notice (promote it).
*/
async known(
kind: 'known-deviation' | 'known-defect', id: string, group: string, name: string, expected: string,
observe: () => Promise<CheckOutcome & { notice?: string }>,
): Promise<void> {
let outcome: CheckOutcome & { notice?: string };
try {
outcome = await observe();
} catch (e) {
outcome = { ok: false, actual: `ERROR ${errorText(e)}`, notice: 'the observation itself failed — the tracked behaviour is unverified this run' };
}
this.push({
id, group, name, kind, expected, actual: outcome.actual,
status: kind === 'known-defect' ? 'KNOWN-DEFECT' : 'KNOWN-DEVIATION',
evidence: outcome.evidence, notice: outcome.notice,
});
}
/**
* A MANUAL-CHECK line: an assertion this script does not exercise live yet.
* Printed with where it is covered; never affects the exit code.
*/
manual(id: string, group: string, name: string, coverage: string): void {
this.push({ id, group, name, kind: 'manual', expected: '(not exercised live)', actual: coverage, status: 'MANUAL-CHECK' });
}
/** An informational line: never affects the exit code; `notice` flags an observation that broke its stated premise. */
info(id: string, group: string, name: string, actual: string, notice?: string): void {
this.push({ id, group, name, kind: 'info', expected: '(informational)', actual, status: 'INFO', notice });
}
private push(line: LineResult): void {
this.lines.push(line);
const mark = line.status === 'FAIL' ? 'FAIL' : line.status;
console.error(`[matrix] ${mark.padEnd(15)} ${line.id} — ${line.name} :: ${line.actual}`);
}
get hardFailures(): LineResult[] {
return this.lines.filter(l => l.kind === 'hard' && l.status === 'FAIL');
}
print(header: string): void {
const out: string[] = [];
out.push('', '═'.repeat(100), `RIGHTS MATRIX — ${header}`, '═'.repeat(100));
let group = '';
for (const l of this.lines) {
if (l.group !== group) { group = l.group; out.push('', `── ${group} ${'─'.repeat(Math.max(0, 95 - group.length))}`); }
out.push(`${l.status.padEnd(15)} ${l.id} ${l.name}`);
out.push(`${' '.repeat(16)}expected: ${l.expected}`);
out.push(`${' '.repeat(16)}actual: ${l.actual}`);
if (l.evidence) out.push(`${' '.repeat(16)}evidence: ${l.evidence}`);
if (l.finding) out.push(`${' '.repeat(16)}finding: ${l.finding}`);
if (l.notice) out.push(`${' '.repeat(16)}!!! NOTICE: ${l.notice}`);
}
const count = (s: LineStatus): number => this.lines.filter(l => l.status === s).length;
const notices = this.lines.filter(l => l.notice);
out.push('', '═'.repeat(100));
out.push(`hard lines: ${this.lines.filter(l => l.kind === 'hard').length} — PASS ${count('PASS')}, FAIL ${count('FAIL')}; `
+ `KNOWN-DEVIATION ${count('KNOWN-DEVIATION')}, KNOWN-DEFECT ${count('KNOWN-DEFECT')}, MANUAL-CHECK ${count('MANUAL-CHECK')}, INFO ${count('INFO')}`);
for (const l of this.hardFailures) out.push(`FAILED: ${l.id} — ${l.name}${l.finding ? ` [${l.finding.split(':')[0]}]` : ''}`);
for (const l of notices) {
out.push('', '!'.repeat(100), `!!! ${l.id} (${l.status}): ${l.notice}`, '!'.repeat(100));
}
out.push(`VERDICT: ${this.hardFailures.length === 0 ? 'PASS — every hard line holds' : `FAIL — ${this.hardFailures.length} hard line(s) failed`}`);
out.push('═'.repeat(100));
console.log(out.join('\n'));
}
}
/** Throw a named prerequisite failure (the line then FAILs, it is never skipped). */
export function need<T>(value: T | undefined | null | false, what: string): T {
if (value === undefined || value === null || value === false) throw new Error(`prerequisite missing: ${what}`);
return value;
}
/** Is `status` one of the explicit refusals `allowed`? 0 (unreachable), 404, 5xx never are. */
export const refused = (status: number, allowed: number[]): boolean => allowed.includes(status);
// ── HTTP ──────────────────────────────────────────────────────────────────
export interface HttpResult { status: number; text: string; headers: Headers; error?: string }
/**
* One request, bounded. A network failure or timeout comes back as status 0
* with the error text — never thrown, never a status a refusal could match.
*/
export async function http(
method: string,
url: string,
opts: { token?: string; body?: string; contentType?: string; accept?: string; timeoutMs?: number } = {},
): Promise<HttpResult> {
const headers = new Headers({ Accept: opts.accept ?? 'application/ld+json' });
if (opts.token) headers.set('Authorization', `Bearer ${opts.token}`);
if (opts.contentType) headers.set('Content-Type', opts.contentType);
try {
const r = await fetch(url, { method, headers, body: opts.body, signal: AbortSignal.timeout(opts.timeoutMs ?? 15000) });
return { status: r.status, text: await r.text(), headers: r.headers };
} catch (e) {
return { status: 0, text: '', headers: new Headers(), error: errorText(e) };
}
}
export const brief = (r: HttpResult): string =>
r.status === 0 ? `unreachable (${r.error})` : `HTTP ${r.status}${r.text ? ` ${r.text.replace(/\s+/g, ' ').slice(0, 160)}` : ''}`;
export function parseJson(text: string): JsonRecord | undefined {
try {
const v: unknown = JSON.parse(text);
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as JsonRecord) : undefined;
} catch {
return undefined;
}
}
export const arrayOf = (value: unknown): unknown[] =>
value === undefined || value === null ? [] : Array.isArray(value) ? value : [value];
/** The member URIs of an attendees collection body (`items`, no `totalItems` on this platform). */
export function itemUris(record: JsonRecord | undefined): string[] {
if (!record) return [];
return arrayOf(record['items'] ?? record['as:items']).map(linkId).filter((u): u is string => !!u);
}
export const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms));
/** Poll `probe` until `done` holds or the bound elapses; returns the last value either way. */
export async function poll<T>(probe: () => Promise<T>, done: (v: T) => boolean, ms: number, every = 1000): Promise<T> {
const end = Date.now() + ms;
let v = await probe();
while (!done(v) && Date.now() < end) {
await sleep(every);
v = await probe();
}
return v;
}
// ── Accounts ──────────────────────────────────────────────────────────────
/**
* `podLogin` with retries: a fresh pod signup can outlive the helper's 15 s
* timeout while still succeeding server-side, so a retry logs in.
*/
export async function loginWithRetry(config: ApodsStackConfig, username: string, password: string): Promise<ApodsSession> {
let last: unknown;
for (let attempt = 0; attempt < 4; attempt++) {
try {
return await podLogin(config, username, password);
} catch (e) {
last = e;
await sleep(5000);
}
}
throw new Error(`podLogin(${username}) failed after retries: ${errorText(last)}`);
}
/**
* Programmatic app install (the smoke's install step): register the app ACTOR
* URI with every access need of its group, then wait for the registered Event
* container to exist.
*/
export async function installApp(config: ApodsStackConfig, session: ApodsSession): Promise<{ ok: boolean; actual: string }> {
const description = await readResource(undefined, `${config.backendUrl}/app`);
const appUri = need(recordUri(description), 'app actor URI (id of GET /app)');
const groupUri = need(linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI');
const group = await readResource(undefined, groupUri);
const body = JSON.stringify({
appUri,
acceptedAccessNeeds: arrayOf(group['interop:hasAccessNeed']),
acceptedSpecialRights: arrayOf(group['apods:hasSpecialRights']),
});
const reg = await http('POST', `${config.podProviderUrl}/.auth-agent/app-registrations/register`, {
token: session.token, body, contentType: 'application/json', accept: 'application/json', timeoutMs: 30000,
});
if (reg.status < 200 || reg.status >= 300) return { ok: false, actual: `register → ${brief(reg)}` };
const container = await poll(
() => http('GET', `${session.storage}/as/event`, { token: session.token }),
r => r.status === 200, 90000, 500,
);
return { ok: container.status === 200, actual: `register → HTTP ${reg.status}; ${session.storage}/as/event → ${brief(container).slice(0, 40)}` };
}
/** The profile document an actor links (`url`), read from the actor document. */
export async function profileUriOf(webId: string): Promise<string | undefined> {
const r = await http('GET', webId);
return linkId(parseJson(r.text)?.['url']);
}
// ── Browser drives ────────────────────────────────────────────────────────
export interface UiHttpError { who: string; url: string; status: number; method: string; body: string }
export interface UiPageError { who: string; url: string; message: string; stack: string }
export interface DriveContext {
app: string;
podFront: string;
hops: string[];
errors: UiHttpError[];
consoleErrors: string[];
/** Uncaught exceptions thrown in a page, with the page URL at the time. */
pageErrors: UiPageError[];
deadline: number;
}
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
export const bodyText = (page: Page): Promise<string> =>
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
/** Record every ≥400 response, console error and uncaught page error of a page, tagged by user. */
export function wire(page: Page, who: string, ctx: DriveContext): void {
page.on('response', r => {
if (r.status() >= 400) {
const method = r.request()?.method?.() ?? '?';
r.text()
.then(b => ctx.errors.push({ who, url: r.url(), status: r.status(), method, body: b.slice(0, 300) }))
.catch(() => ctx.errors.push({ who, url: r.url(), status: r.status(), method, body: '(unreadable)' }));
}
});
page.on('console', m => { if (m.type() === 'error') ctx.consoleErrors.push(`[${who}] ${m.text().slice(0, 300)}`); });
page.on('pageerror', e => ctx.pageErrors.push({ who, url: page.url(), message: String(e.message).slice(0, 300), stack: String(e.stack ?? '').slice(0, 600) }));
}
/** What a UI drive is EXPECTED to be refused, per user — every entry an exact URL. */
export interface ExpectedRefusals {
/** This run's profile documents: a profile answers only whom it grants (the Alice/Bob rule). */
profiles: ReadonlySet<string>;
/** Each user's OWN `pim:preferencesFile`: read by the app with its app token, which the pod does not grant (VERIFIED 2026-10-13). */
ownPreferences: ReadonlyMap<string, string>;
}
/**
* The expected ≥400 responses of a UI drive, each an exact request: the pod's
* push channel creation (POST …/.notifications/WebSocketChannel2023 → 403),
* a GET 403 on one of this run's profile documents, and a GET 403 on the
* signed-in user's OWN preferences file. Anything else — another 403 under
* `/data/` included — is unexpected.
*/
export function benignUiError(e: UiHttpError, expected: ExpectedRefusals): string | null {
if (e.method === 'POST' && e.status === 403 && e.url.endsWith('/.notifications/WebSocketChannel2023')) return 'push-channel creation 403';
if (e.method === 'GET' && e.status === 403 && expected.profiles.has(e.url)) return 'profile-document read 403 (WAC)';
if (e.method === 'GET' && e.status === 403 && expected.ownPreferences.get(e.who) === e.url) return 'own preferences-file read 403 (app token not granted)';
return null;
}
/**
* The uncaught page errors explained by an expected refusal: the live
* provider's `subscribe` rejects with "Forbidden" when the pod refuses its
* push channel — exactly one per refused channel POST of the same user
* (VERIFIED 2026-10-13: Bob 2/2, Alice 4/4, Carol 4/4). The throw site is
* identified by its two top stack frames, learnt from errors whose async stack
* still reaches `subscribe` (half of them are cut before that frame, from the
* very same site). Every other page error, and any surplus, is unexpected.
*/
export function splitPageErrors(errors: UiPageError[], http: UiHttpError[]): { expected: UiPageError[]; unexpected: UiPageError[] } {
const channelRefusals = new Map<string, number>();
for (const e of http) {
if (e.method === 'POST' && e.status === 403 && e.url.endsWith('/.notifications/WebSocketChannel2023')) {
channelRefusals.set(e.who, (channelRefusals.get(e.who) ?? 0) + 1);
}
}
const site = (e: UiPageError): string =>
e.stack.split('\n').slice(1, 3).map(l => l.trim().replace(/^at async /, 'at ')).join(' < ');
const subscribeSites = new Set(errors.filter(e => e.message === 'Forbidden' && /\bsubscribe\b/.test(e.stack)).map(site));
const expected: UiPageError[] = [];
const unexpected: UiPageError[] = [];
for (const e of errors) {
const budget = channelRefusals.get(e.who) ?? 0;
if (e.message === 'Forbidden' && subscribeSites.has(site(e)) && budget > 0) {
channelRefusals.set(e.who, budget - 1);
expected.push(e);
} else {
unexpected.push(e);
}
}
return { expected, unexpected };
}
/** A user's own preferences file (`pim:preferencesFile` of the actor document, read with her token). */
export async function preferencesFileOf(session: ApodsSession): Promise<string | undefined> {
const r = await http('GET', session.webId, { token: session.token });
return linkId(parseJson(r.text)?.['pim:preferencesFile']);
}
export interface LoginOutcome { ok: boolean; detail: string }
/**
* App picker → pod login → consent → back in the app shell, as a sequence of
* OBSERVED facts — never a fixed delay, never the mere absence of the picker.
*
* The real hop sequence (observed 2026-10-13): the sign-in click leaves the
* app for the pod's login form (:5000/login) — unless the pod's session is
* still open (a sign-out of the app keeps it: the revocation probe's second
* sign-in), in which case no form is shown and the hops below start at once;
* after the credentials the
* browser comes BACK to the app's OIDC callback (:4000/login?code…), and only
* then — when the app is not yet registered on this pod — does the app send it
* to the pod's consent screen (:5000/authorize), which navigates a second time
* right after it opens. Accept leads back to the app, then to the shell.
*
* H1 (false "shell reached"): while the app is on its own origin between those
* hops it renders neither the picker nor the shell. The shell therefore counts
* only once the browser has been to the pod, the URL is past /login, and the
* bottom navigation is visible with no picker.
* H2 (accept never landing): Accept is clicked only once it has been attached,
* visible and enabled for a full second (the page's second navigation is over),
* and the click is then VERIFIED by the page leaving the consent screen; if it
* does not, the drive fails with what the page showed — it never re-clicks.
*/
export async function driveLogin(page: Page, who: string, username: string, password: string, ctx: DriveContext): Promise<LoginOutcome> {
const t0 = Date.now();
const at = (): string => `+${((Date.now() - t0) / 1000).toFixed(1)}s`;
const hop = (h: string): void => { ctx.hops.push(`[${who}] ${at()} ${h}`); };
// The hop log covers this drive only: the listener is removed when the drive ends, so a page
// that signs in twice (the revocation probe) does not log its navigations twice.
const onNavigated = (f: Frame): void => { if (f === page.mainFrame()) hop(`→ ${clean(f.url())}`); };
page.on('framenavigated', onNavigated);
try {
const fail = async (why: string): Promise<LoginOutcome> => {
const text = (await bodyText(page)).slice(0, 240);
hop(`FAIL ${why} | at ${clean(page.url())} | page: ${text}`);
return { ok: false, detail: `${why} (at ${clean(page.url())}; page: ${text.slice(0, 120)})` };
};
const onApp = (): boolean => page.url().startsWith(ctx.app);
const accept = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
const consentReady = async (): Promise<boolean> =>
page.url().startsWith(`${ctx.podFront}/authorize`)
&& (await accept.first().isVisible().catch(() => false))
&& (await accept.first().isEnabled().catch(() => false));
const shellReady = async (): Promise<boolean> =>
onApp() && !new URL(page.url()).pathname.startsWith('/login')
&& (await page.getByText('Découvrir', { exact: true }).first().isVisible().catch(() => false))
&& (await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 1)) === 0;
await page.goto(`${ctx.app}/`, { waitUntil: 'domcontentloaded' });
const signIn = page.getByRole('button', { name: /Se connecter avec localhost:3000/i });
if (!(await signIn.first().waitFor({ state: 'visible', timeout: 30000 }).then(() => true, () => false))) return fail('the sign-in button never appeared');
await signIn.first().click();
if (!(await page.waitForURL(u => !u.href.startsWith(ctx.app), { timeout: 30000 }).then(() => true, () => false))) {
return fail('the sign-in click never left the app for the pod');
}
// At the pod: its login form when no pod session is open — or, when one still is (signing out
// of the APP keeps the pod's session), no form at all: straight on to consent or to the shell.
const userField = page.locator('input[name="username"]');
const atPod = await poll(
async () => ((await userField.isVisible().catch(() => false)) ? 'login' : (await consentReady()) ? 'consent' : (await shellReady()) ? 'shell' : 'pending'),
v => v !== 'pending', 30000, 250,
);
if (atPod === 'pending') return fail('after the sign-in click, neither the pod login form, the consent screen nor the app shell appeared');
if (atPod === 'login') {
await userField.fill(username);
await page.locator('input[name="password"]').fill(password);
const submit = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
await ((await submit.count()) ? submit.first() : page.locator('button[type=submit]').first()).click();
hop('credentials submitted');
} else {
hop(`no pod login asked (the pod session is still open) → ${atPod}`);
}
// After login: the consent screen (app not yet registered here) or straight to the shell (already registered).
const next = await poll(
async () => ((await consentReady()) ? 'consent' : (await shellReady()) ? 'shell' : 'pending'),
v => v !== 'pending', 60000, 250,
);
if (next === 'pending') return fail('after login, neither the consent screen nor the app shell appeared');
if (next === 'consent') {
// Stable for a full second (4 consecutive observations): the second navigation of the page is over.
let stable = 0;
const settleEnd = Date.now() + 15000;
while (stable < 4 && Date.now() < settleEnd) {
stable = (await consentReady()) ? stable + 1 : 0;
await page.waitForTimeout(250);
}
if (stable < 4) return fail('the consent Accept button never stayed visible and enabled');
hop('consent ready (Accept attached, visible, enabled for 1 s)');
await accept.first().click();
hop('accept clicked');
if (!(await page.waitForURL(u => !u.href.startsWith(`${ctx.podFront}/authorize`), { timeout: 30000 }).then(() => true, () => false))) {
return fail('Accept was clicked but the page never left the consent screen');
}
hop('left the consent screen');
if ((await poll(shellReady, v => v, 45000, 250)) !== true) return fail('back from consent, the app shell never appeared');
}
hop(`shell reached (bottom navigation, no picker) via ${next === 'consent' ? 'consent' : 'an existing registration'}`);
return { ok: true, detail: `shell reached in ${at()} via ${next === 'consent' ? 'the consent screen' : 'an existing registration (no consent asked)'}` };
} finally {
page.off('framenavigated', onNavigated);
}
}
/**
* Is the app installed on this user's pod? The registered Event container
* exists only once the app registration went through (consent or programmatic
* install). Polled: the registration is confirmed asynchronously.
*/
export async function installedContainer(session: ApodsSession, timeoutMs = 30000): Promise<CheckOutcome> {
const uri = `${session.storage}/as/event`;
const r = await poll(() => http('GET', uri, { token: session.token }), v => v.status === 200, timeoutMs, 1000);
return r.status === 200
? { ok: true, actual: `${clean(uri)} → HTTP 200` }
: { ok: false, actual: `${clean(uri)} → ${r.status === 0 ? `unreachable (${r.error})` : `HTTP ${r.status}`} after ${timeoutMs / 1000} s — the app is NOT installed on this pod (consent never completed / registration missing)` };
}
/** The banked event-creation drive; the event URI is read off the detail route it lands on. */
export async function createEventUi(page: Page, app: string, title: string): Promise<{ eventUri?: string; finalUrl: string }> {
await page.goto(`${app}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {});
await page.waitForTimeout(1500);
await page.locator('input[placeholder="Donnez un nom à votre événement"]').fill(title, { timeout: 10000 }).catch(() => {});
const dates = page.locator('input[type="date"]');
if (await dates.count().catch(() => 0)) await dates.first().fill('2026-12-20', { timeout: 3000 }).catch(() => {});
await page.getByRole('button', { name: /Suivant/ }).click({ timeout: 5000 }).catch(() => {});
await page.waitForTimeout(1200);
const next = page.getByRole('button', { name: /Suivant/ });
if (await next.count().catch(() => 0)) await next.click({ timeout: 5000 }).catch(() => {});
await page.waitForTimeout(1200);
await page.getByRole('button', { name: /Relayer l'événement/ }).click({ timeout: 5000 }).catch(() => {});
const landed = await poll(async () => page.url(), u => /\/events\/[^/]+$/.test(new URL(u).pathname) && !u.includes('/events/new'), 15000, 500);
const m = landed.startsWith(`${app}/events/`) ? /\/events\/([^/?#]+)/.exec(landed.slice(app.length)) : null;
const segment = m?.[1];
return { eventUri: segment && segment !== 'new' ? decodeURIComponent(segment) : undefined, finalUrl: landed };
}
/** The banked meeting-point drive, from the event detail page it starts on. */
export async function createMeetingPointUi(page: Page, title: string): Promise<boolean> {
const propose = page.getByRole('button', { name: /Proposer un point de rencontre/i });
await propose.first().waitFor({ timeout: 15000 }).catch(() => {});
if (!(await propose.count().catch(() => 0))) return false;
await propose.first().click().catch(() => {});
await page.waitForTimeout(1500);
await page.locator('input[placeholder="Ex : Gouvernance coopérative — retours SCIC"]').fill(title, { timeout: 10000 }).catch(() => {});
// The form REQUIRES a place since the title became optional (the pod has no
// slot for a free-text "when"; the place is the one mandatory fact).
await page.locator('input[placeholder="Café en face du tiers-lieu"]').fill('Devant la mairie', { timeout: 10000 }).catch(() => {});
await page.getByRole('button', { name: /Créer le point de rencontre/ }).click({ timeout: 5000 }).catch(() => {});
const txt = await poll(() => bodyText(page), t => t.includes(title), 15000, 500);
return txt.includes(title) && !/pas pu/i.test(txt);
}