Files
festipod/scripts/e2e-rights-matrix-support.ts
T
Sylvain Duchesne 9381c206df One sign-in drive for every e2e, the public surface and visibility in the rights matrix, and the stack gates in validate
e2e-login-flow and e2e-revocation now share the matrix's driveLogin, which counts a sign-in only once the app is really reached and the app install is asserted; their own loops could report "shell reached" while still on the picker page. The rights matrix gains a public-surface group (allow-listed endpoints answer 200, non-public paths 404, status codes only) and a visibility group (an event with a meeting point and a participant switched public and private by its owner: event, meeting point, attendees collection and catalog listing follow). validate.ts probes the stack and runs test:e2e-rights and test:e2e-revocation as layers, reported SKIPPED when the stack is down and never counted as passed.
2026-10-13 18:11:34 +02:00

523 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 deliberately does NOT exercise
* live (it would send a forged or spoofed activity — user decision 2026-10-13,
* defensive only). 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, 30000, 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(() => {});
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);
}