Suspend the NextGraph suites, and smoke the ActivityPods stack

This commit is contained in:
Sylvain Duchesne
2026-10-09 21:18:51 +02:00
parent c195211629
commit f8c2e3b809
27 changed files with 977 additions and 649 deletions
+11 -59
View File
@@ -35,7 +35,7 @@
import React, { useState, useCallback, useEffect, useMemo, type ReactNode } from 'react';
import { useList, useOne, useCreate, useUpdate, useInvalidate } from '@refinedev/core';
import type { FpEventData, FpUserData, FpParticipationData, FpMeetingPointData } from '../data/types';
import type { FpEventData, FpUserData, FpMeetingPointData } from '../data/types';
import { fetchActorDocument, fetchActorProfileRecord } from '../data/actor';
import { postJoinActivity, postLeaveActivity } from '../data/outbox';
import { useCurrentSession } from '../utils/currentSession';
@@ -51,6 +51,11 @@ import {
type FestipodDataContextValue,
type EventOwnership,
} from './festipodDataBase';
import {
applyMembershipOverlay,
deriveContainer,
type MembershipOverlay,
} from './refineDerive';
export function RefineDataProvider({ children }: { children: ReactNode }) {
return (
@@ -98,9 +103,6 @@ function emptyBootstrap(): BootstrapResult {
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } };
}
/** A membership overlay entry: what this session just joined or left. */
type MembershipOverlay = Map<string, 'join' | 'leave'>;
function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const bindings = backend.bindings;
const session = useCurrentSession();
@@ -129,40 +131,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const containerFailed = containerQuery.query.isError;
// --- SPLIT + PARTICIPATION/COUNT DERIVATION (vocabulary lives in bindings) --
const derived = useMemo(() => {
const events: FpEventData[] = [];
const meetingPoints: FpMeetingPointData[] = [];
// participations keyed BY MEETING POINT id: joining targets the meeting
// point; the anchor event's participants are derived as the union across
// its meeting points (below).
const participations: FpParticipationData[] = [];
const totals = new Map<string, number>();
for (const record of rawRecords) {
if (bindings.meetingPoint.isInstance?.(record) ?? false) {
const mp = bindings.meetingPoint.toDomain(record);
meetingPoints.push(mp);
const attendees = bindings.participation.membersOf?.(record);
const members = attendees?.members ?? [];
for (const member of members) {
const mapped = bindings.participation.toDomain(member);
participations.push({ ...mapped, id: `${mp.id}|${mapped.userId}`, eventId: mp.id });
}
totals.set(mp.id, attendees?.total ?? members.length);
} else if (bindings.event.isInstance?.(record) ?? true) {
events.push(bindings.event.toDomain(record));
}
}
// The event's count is the SUM of its meeting points' collection totals —
// derived at read time, never a stored counter. A record whose collection
// could not be read contributes 0, which is the honest "unknown" reading
// of a total nobody could count for us.
for (const event of events) {
event.participantCount = meetingPoints
.filter(mp => mp.eventId === event.id)
.reduce((sum, mp) => sum + (totals.get(mp.id) ?? 0), 0);
}
return { events, meetingPoints, participations, totals };
}, [rawRecords, bindings]);
const derived = useMemo(() => deriveContainer(rawRecords, bindings), [rawRecords, bindings]);
// --- OPTIMISTIC MEMBERSHIP OVERLAY ------------------------------------------
// `watchShape`-style latency, Refine edition: the outbox post returns before
@@ -171,27 +140,10 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// reconciles away once the read carries the truth — no polling.
const [overlay, setOverlay] = useState<MembershipOverlay>(new Map());
const participations = useMemo(() => {
if (overlay.size === 0 || !webId) return derived.participations;
const byMp = new Map<string, FpParticipationData[]>();
for (const p of derived.participations) {
const list = byMp.get(p.eventId) ?? [];
list.push(p);
byMp.set(p.eventId, list);
}
for (const [mpId, op] of overlay) {
const current = byMp.get(mpId) ?? [];
if (op === 'join') {
if (!current.some(p => p.userId === webId)) {
const mp = derived.meetingPoints.find(m => m.id === mpId);
byMp.set(mpId, [...current, { id: `${mpId}|${webId}`, eventId: mpId, userId: webId, isConfirmed: true }]);
}
} else {
byMp.set(mpId, current.filter(p => p.userId !== webId));
}
}
return [...byMp.values()].flat();
}, [derived, overlay, webId]);
const participations = useMemo(
() => applyMembershipOverlay(derived.participations, overlay, webId),
[derived.participations, overlay, webId],
);
// RECONCILIATION — drop each overlay entry once the container read carries
// what it asserts (join: membership present; leave: membership absent).
+128
View File
@@ -0,0 +1,128 @@
import { expect, test } from 'bun:test';
import { deriveContainer, applyMembershipOverlay } from './refineDerive';
import { getBackend } from '../refine/providers';
import type { FpParticipationData } from '../data/types';
// The real ActivityPods bindings: these derivations are only meaningful against
// the vocabulary they map, so the tests drive them with the shipped backend.
const bindings = getBackend('activitypods').bindings;
const EVENT = 'http://localhost:3000/alice/data/event-1';
const MP = 'http://localhost:3000/alice/data/mp-1';
const COLLECTION = 'http://localhost:3000/alice/data/as/collection/att-1';
const ALICE = 'http://localhost:3000/alice';
const BOB = 'http://localhost:3000/bob';
test('an as:Event container splits into events and meeting points by as:context', () => {
const derived = deriveContainer(
[
{ id: EVENT, type: 'Event', name: 'Concert', startTime: '2026-11-01T18:00:00Z' },
{
id: MP,
type: 'Event',
name: 'Rdv entrée',
'as:context': EVENT,
organizer: ALICE,
'apods:attendees': { id: COLLECTION, totalItems: 0, items: [] },
},
],
bindings,
);
expect(derived.events.map(e => e.id)).toEqual([EVENT]);
expect(derived.meetingPoints.map(mp => mp.id)).toEqual([MP]);
expect(derived.meetingPoints[0]?.eventId).toBe(EVENT);
// A meeting point's empty collection contributes nothing to the count.
expect(derived.events[0]?.participantCount).toBe(0);
});
test('a meeting point member becomes a participation keyed by the meeting point', () => {
const derived = deriveContainer(
[
{
id: MP,
type: 'Event',
'as:context': EVENT,
'apods:attendees': { id: COLLECTION, totalItems: 2, items: [BOB, { id: ALICE }] },
},
],
bindings,
);
expect(derived.participations).toEqual([
{ id: `${MP}|${BOB}`, eventId: MP, userId: BOB, isConfirmed: true },
{ id: `${MP}|${ALICE}`, eventId: MP, userId: ALICE, isConfirmed: true },
]);
expect(derived.totals.get(MP)).toBe(2);
});
// The Alice/Bob rule, read side: a collection total survives even when no
// member resolves, so the COUNT stays total while the participants list shows
// the remainder as anonymous (the reader never sees a profile for them).
test('the count stays the collection total when the members resolve to nobody', () => {
const derived = deriveContainer(
[
{
id: MP,
type: 'Event',
'as:context': EVENT,
'apods:attendees': { id: COLLECTION, totalItems: 3 },
},
],
bindings,
);
expect(derived.participations).toEqual([]);
expect(derived.totals.get(MP)).toBe(3);
});
test("an event's count is the sum of its meeting points' collection totals", () => {
const mp2 = 'http://localhost:3000/alice/data/mp-2';
const derived = deriveContainer(
[
{ id: EVENT, type: 'Event', name: 'Concert' },
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 2, items: [BOB] } },
{ id: mp2, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [] } },
],
bindings,
);
expect(derived.events[0]?.participantCount).toBe(3);
});
test('an unreadable collection reads as an honest zero, never a guess', () => {
const derived = deriveContainer(
[
{ id: EVENT, type: 'Event', name: 'Concert' },
// The collection URI travels alone (no embed): nothing readable, no count.
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': COLLECTION },
],
bindings,
);
expect(derived.totals.get(MP)).toBe(0);
expect(derived.events[0]?.participantCount).toBe(0);
});
test('the overlay adds a join for the session and reconciles nothing by itself', () => {
const read: FpParticipationData[] = [
{ id: `${MP}|${BOB}`, eventId: MP, userId: BOB, isConfirmed: true },
];
const joined = applyMembershipOverlay(read, new Map([[MP, 'join']]), ALICE);
expect(joined.map(p => p.userId).sort()).toEqual([ALICE, BOB]);
// Joining twice is idempotent: the read already carries the membership.
const again = applyMembershipOverlay(joined, new Map([[MP, 'join']]), ALICE);
expect(again.filter(p => p.userId === ALICE)).toHaveLength(1);
});
test('the leave overlay removes only the session membership', () => {
const read: FpParticipationData[] = [
{ id: `${MP}|${BOB}`, eventId: MP, userId: BOB, isConfirmed: true },
{ id: `${MP}|${ALICE}`, eventId: MP, userId: ALICE, isConfirmed: true },
];
const left = applyMembershipOverlay(read, new Map([[MP, 'leave']]), ALICE);
expect(left.map(p => p.userId)).toEqual([BOB]);
});
test('an empty overlay or no session leaves the read untouched', () => {
const read: FpParticipationData[] = [
{ id: `${MP}|${BOB}`, eventId: MP, userId: BOB, isConfirmed: true },
];
expect(applyMembershipOverlay(read, new Map(), ALICE)).toEqual(read);
expect(applyMembershipOverlay(read, new Map([[MP, 'join']]), undefined)).toEqual(read);
});
+117
View File
@@ -0,0 +1,117 @@
/**
* The pure read-side derivations of the Refine data path, lifted out of the
* `useRefineData` hook so the mapping can be exercised without a React tree.
*
* Nothing here touches React, Refine or a backend: it is the vocabulary the
* bindings carry, applied to a container's raw records. Two derivations live
* here, and they are the two places the ActivityPods model meets the app's:
*
* 1. `deriveContainer` SPLITS one `as/Event` container into events and meeting
* points and reads each meeting point's `apods:attendees` collection. Events
* and meeting points share the container (the bindings' `isInstance` is the
* only distinction), so the split has to happen exactly once, at read time.
* The event's `participantCount` is the SUM of its meeting points' collection
* totals — never a stored, incremented counter, and never the number of
* RESOLVED profiles, so it stays total for a reader who may resolve nobody
* (the Alice/Bob rule).
*
* 2. `applyMembershipOverlay` reflects a join/leave made beside Refine (through
* the outbox) before the collection read carries it. It reconciles away on
* its own once the read shows the truth — no polling.
*/
import type { FpEventData, FpMeetingPointData, FpParticipationData } from '../data/types';
import type { RecordBindings } from '../refine/providers/types';
/** What one container read yields, before selection and overlays. */
export interface DerivedContainer {
events: FpEventData[];
meetingPoints: FpMeetingPointData[];
/**
* Participations keyed BY MEETING POINT: `eventId` carries the meeting point
* id and `userId` the attendee reference — which may be a URI no profile
* resolves to (an anonymous participant).
*/
participations: FpParticipationData[];
/** Per-meeting-point collection totals, keyed by meeting point id. */
totals: Map<string, number>;
}
/**
* Split a container's raw records with the backend's own bindings and derive
* the participations and the participant counts.
*
* A meeting point's attendees collection travels inside its record; each member
* is mapped to a participation stamped with the HOST meeting point's id (a
* member carries no link back to what it attends), and the collection's total is
* kept as-is — a member the reader cannot resolve still counts.
*/
export function deriveContainer(
records: readonly unknown[],
bindings: RecordBindings,
): DerivedContainer {
const events: FpEventData[] = [];
const meetingPoints: FpMeetingPointData[] = [];
const participations: FpParticipationData[] = [];
const totals = new Map<string, number>();
for (const record of records) {
if (bindings.meetingPoint.isInstance?.(record) ?? false) {
const mp = bindings.meetingPoint.toDomain(record);
meetingPoints.push(mp);
const attendees = bindings.participation.membersOf?.(record);
const members = attendees?.members ?? [];
for (const member of members) {
const mapped = bindings.participation.toDomain(member);
participations.push({ ...mapped, id: `${mp.id}|${mapped.userId}`, eventId: mp.id });
}
totals.set(mp.id, attendees?.total ?? members.length);
} else if (bindings.event.isInstance?.(record) ?? true) {
events.push(bindings.event.toDomain(record));
}
}
// The event's count is the SUM of its meeting points' collection totals —
// derived at read time, never a stored counter. A record whose collection
// could not be read contributes 0, which is the honest "unknown" reading of
// a total nobody could count for us.
for (const event of events) {
event.participantCount = meetingPoints
.filter(mp => mp.eventId === event.id)
.reduce((sum, mp) => sum + (totals.get(mp.id) ?? 0), 0);
}
return { events, meetingPoints, participations, totals };
}
/** A membership overlay entry: what this session just joined or left. */
export type MembershipOverlay = Map<string, 'join' | 'leave'>;
/**
* Apply the optimistic join/leave overlay on top of the read participations.
*
* A `join` adds a membership for `webId` on the meeting point unless the read
* already carries it; a `leave` removes it. With no overlay entry, or no
* `webId` to attribute the act to, the read is returned untouched.
*/
export function applyMembershipOverlay(
participations: readonly FpParticipationData[],
overlay: MembershipOverlay,
webId: string | undefined,
): FpParticipationData[] {
if (overlay.size === 0 || !webId) return [...participations];
const byMp = new Map<string, FpParticipationData[]>();
for (const p of participations) {
const list = byMp.get(p.eventId) ?? [];
list.push(p);
byMp.set(p.eventId, list);
}
for (const [mpId, op] of overlay) {
const current = byMp.get(mpId) ?? [];
if (op === 'join') {
if (!current.some(p => p.userId === webId)) {
byMp.set(mpId, [...current, { id: `${mpId}|${webId}`, eventId: mpId, userId: webId, isConfirmed: true }]);
}
} else {
byMp.set(mpId, current.filter(p => p.userId !== webId));
}
}
return [...byMp.values()].flat();
}
+197
View File
@@ -0,0 +1,197 @@
/**
* The local ActivityPods stack, driven over real HTTP — the instrument the
* `@e2e-activitypods` smoke layer uses.
*
* WHAT THIS IS FOR. The suspended NextGraph suites (@data/@e2e/@multibrowser)
* sign into a broker through a wallet; the ActivityPods stack is a different
* thing altogether: a Pod provider (signup/login + LDP), the Festipod backend
* (the on-behalf app), and the app served against them. This module is the
* thin client the smoke scenarios speak to — real `fetch` calls, no mock, no
* SDK. It validates FESTIPOD's side of the stock platform, never the platform's
* own code (concept `bdd-testing`, `rule_tests-validate-festipod-not-the-sdk`).
*
* THE LOCAL STACK. Its reproducible setup is the concept note
* `.project/concepts/data-layer/cookbook_local-activitypods-platform.md`
* (pod provider on :3000, Festipod backend on :3003, redis remapped to :6390).
* The values come from the same environment variables the app and the backend
* read, with the cookbook's defaults as fallback.
*
* This module is loaded by Cucumber (Node) and has NO import-time side effects:
* configuration is resolved lazily, at call time, once `.env` has been loaded.
*/
/** A JSON-LD record as returned by the Pod or the backend: predicates verbatim. */
export type JsonRecord = Record<string, unknown>;
/** Where the local stack lives. */
export interface ApodsStackConfig {
podProviderUrl: string;
backendUrl: string;
}
function stripTrailingSlash(url: string): string {
return url.replace(/\/+$/, '');
}
/**
* Resolve the stack's URLs from the environment, falling back to the cookbook's
* local defaults. Resolved per call so it sees the values `.env` sets.
*/
export function apodsStackConfig(): ApodsStackConfig {
return {
podProviderUrl: stripTrailingSlash(process.env.VITE_POD_PROVIDER_URL?.trim() || 'http://localhost:3000'),
backendUrl: stripTrailingSlash(process.env.VITE_BACKEND_URL?.trim() || 'http://localhost:3003'),
};
}
/** An authenticated Pod session, plus the actor references the flows need. */
export interface ApodsSession {
webId: string;
token: string;
/** The user's own outbox — where a join/leave activity is posted. */
outbox: string;
/** The user's storage root — where a resource is created. */
storage: string;
}
/** The platform context: defines the `as:`/`apods:` prefixes the Pod round-trips. */
const JSON_CONTEXT = 'https://activitypods.org/context.json';
const AS_CONTEXT = 'https://www.w3.org/ns/activitystreams';
function asRecord(value: unknown): JsonRecord | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as JsonRecord) : undefined;
}
function firstEntry(value: unknown): unknown {
return Array.isArray(value) ? value[0] : value;
}
/** A linked value in AS2 form: a bare IRI string, or an object carrying id/@id. */
export function linkId(value: unknown): string | undefined {
if (typeof value === 'string') return value;
const o = asRecord(value);
return o ? (typeof o['id'] === 'string' ? o['id'] : typeof o['@id'] === 'string' ? o['@id'] : undefined) : undefined;
}
/** The `as:context` link of a record — what makes an `as/Event` a MEETING point. */
export function contextLink(record: JsonRecord): string | undefined {
return linkId(record['as:context']);
}
/** The `apods:attendees` collection URI carried by a meeting point record. */
export function attendeesCollectionUri(record: JsonRecord): string | undefined {
return linkId(firstEntry(record['apods:attendees']));
}
/** A record's own identifier — `id` or `@id`, whichever is present. */
export function recordUri(record: JsonRecord): string | undefined {
return linkId(record['id'] ?? record['@id']);
}
async function request(
url: string,
init: RequestInit & { session?: ApodsSession } = {},
): Promise<{ status: number; headers: Headers; text: string }> {
const { session, ...rest } = init;
const headers = new Headers(rest.headers);
if (!headers.has('Accept')) headers.set('Accept', 'application/ld+json');
if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(url, { ...rest, headers, signal: AbortSignal.timeout(15000) });
return { status: response.status, headers: response.headers, text: await response.text() };
}
/** Is the URL answering? Returns the HTTP status, or 0 when unreachable. */
export async function urlStatus(url: string): Promise<number> {
try {
return (await request(url, { method: 'GET' })).status;
} catch {
return 0;
}
}
/** Sign in to the local Pod provider and read the actor's outbox/storage. */
export async function podLogin(
config: ApodsStackConfig,
username: string,
password: string,
): Promise<ApodsSession> {
const login = await request(`${config.podProviderUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (login.status !== 200) {
throw new Error(`Pod login failed: HTTP ${login.status} ${login.text.slice(0, 200)}`);
}
const parsed = JSON.parse(login.text) as { token: string; webId: string };
const partial: ApodsSession = { webId: parsed.webId, token: parsed.token, outbox: '', storage: '' };
const actor = await readResource(partial, parsed.webId);
const outbox = linkId(actor['outbox']);
const storage = linkId(actor['pim:storage']);
if (!outbox || !storage) {
throw new Error(`Actor ${parsed.webId} carries no outbox or storage`);
}
return { ...partial, outbox, storage };
}
/** GET a resource and parse it as a JSON-LD record (no session → anonymous read). */
export async function readResource(session: ApodsSession | undefined, uri: string): Promise<JsonRecord> {
const response = await request(uri, { session });
if (response.status !== 200) {
throw new Error(`Read ${uri} failed: HTTP ${response.status} ${response.text.slice(0, 200)}`);
}
return JSON.parse(response.text) as JsonRecord;
}
/**
* Create one LDP resource in the session's storage, with the platform context
* so a real Pod expands the `as:`/`apods:` CURIE types. Returns its Location.
*/
export async function createResource(
session: ApodsSession,
body: JsonRecord,
): Promise<string> {
const response = await request(session.storage, {
method: 'POST',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify({ '@context': JSON_CONTEXT, ...body }),
session,
});
const location = response.headers.get('Location');
if ((response.status !== 201 && response.status !== 200) || !location) {
throw new Error(`Create in ${session.storage} failed: HTTP ${response.status} ${response.text.slice(0, 200)}`);
}
return location;
}
/** Post an activity to the session's own outbox (the app's join/leave path). */
export async function postOutboxActivity(session: ApodsSession, type: string, object: unknown, to?: string): Promise<void> {
const activity: JsonRecord = { '@context': AS_CONTEXT, type, actor: session.webId, object };
if (to) activity['to'] = to;
const response = await request(session.outbox, {
method: 'POST',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify(activity),
session,
});
if (response.status !== 200 && response.status !== 201) {
throw new Error(`Outbox ${type} post failed: HTTP ${response.status} ${response.text.slice(0, 200)}`);
}
}
/**
* The members of an `apods:attendees` collection, read by `getOne` on the
* collection URI. Returns the raw member entries (bare URIs when the reader
* may not dereference them) — a member the reader cannot resolve is a state
* the app model needs, so it is reported, never dropped.
*/
export async function readAttendees(session: ApodsSession, collectionUri: string): Promise<unknown[]> {
const response = await request(collectionUri, { session });
if (response.status !== 200) {
throw new Error(`Read collection ${collectionUri} failed: HTTP ${response.status} ${response.text.slice(0, 200)}`);
}
const record = JSON.parse(response.text) as JsonRecord;
const raw = record['items'] ?? record['as:items'];
if (Array.isArray(raw)) return raw;
return raw === undefined || raw === null ? [] : [raw];
}
+122
View File
@@ -9,6 +9,7 @@ import { pool } from './browserPool';
// The path the app asks the SDK to fetch its wallet from — ONE constant, shared
// with the app, so the harness server and the bundle can never disagree on it.
import { SHARED_WALLET_FILE_URL } from '../utils/sharedWallet';
import { apodsStackConfig, urlStatus } from './activitypodsStack';
setDefaultTimeout(90000);
@@ -135,6 +136,74 @@ let useRealBroker = false;
let appServerProcess: ChildProcess | null = null;
let appPort = 0;
// ── ActivityPods local-stack smoke (@e2e-activitypods) ─────────────────────
// A DIFFERENT app server and browser from the NG `@e2e` path: the ActivityPods
// app variant needs no wallet and no broker — only the local stack's URLs, which
// come from `activitypodsStack.apodsStackConfig()` (env, with the cookbook's
// local defaults). Kept apart from the wallet-context launcher so the two layers
// never share a profile or a session.
let apodsAppServerProcess: ChildProcess | null = null;
let apodsAppPort = 0;
let apodsBrowser: Browser | null = null;
/** The app's own `/festipod-config.json`, used as the readiness probe. */
async function waitForAppReady(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if ((await urlStatus(`${url}/festipod-config.json`)) === 200) return;
if (Date.now() > deadline) {
throw new Error(`ActivityPods app server did not become ready at ${url} within ${timeoutMs}ms`);
}
await new Promise<void>((resolve) => { setTimeout(resolve, 500); });
}
}
/**
* Start the real Festipod app server for the ActivityPods smoke and return its
* URL — the same `bun src/index.ts` the NG `@e2e` path starts, on its own port
* and with its own process group (so teardown can signal the whole tree).
* Idempotent: the server and the Chromium instance are started once per run.
*/
async function ensureApodsApp(): Promise<string> {
if (!apodsAppPort) {
apodsAppPort = await new Promise<number>((resolve) => {
const s = http.createServer();
s.listen(0, '127.0.0.1', () => {
const port = (s.address() as { port: number }).port;
s.close(() => resolve(port));
});
});
apodsAppServerProcess = spawn('bun', ['src/index.ts'], {
env: {
...process.env,
PORT: String(apodsAppPort),
NODE_ENV: 'production',
// The app's runtime config comes from the server's env: point it at the
// local stack the smoke talks to (`.env` carries none here).
VITE_BACKEND_URL: apodsStackConfig().backendUrl,
VITE_POD_PROVIDER_URL: apodsStackConfig().podProviderUrl,
},
stdio: 'pipe',
cwd: process.cwd(),
detached: true,
});
// Drain the pipes: an unread 64 KB stdio buffer would block the server mid-run.
apodsAppServerProcess.stdout?.on('data', () => {});
apodsAppServerProcess.stderr?.on('data', () => {});
await waitForAppReady(`http://127.0.0.1:${apodsAppPort}`, 30000);
console.log(`[ApodsE2E] App server on http://127.0.0.1:${apodsAppPort}`);
}
if (!apodsBrowser) {
apodsBrowser = await chromium.launch({
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
});
console.log('[ApodsE2E] Chromium launched');
}
return `http://127.0.0.1:${apodsAppPort}`;
}
const WALLET_NAME = 'festipod-tests';
const WALLET_PASSWORD = 'festipod-tests';
@@ -519,6 +588,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
this.screenSourceContent = '';
this.currentScreen = null;
this.pageErrors = [];
this.apods = {};
// Multi-browser scenarios drive their own isolated browsers via steps
// (this.openBrowser). They must NOT get the legacy single shared page.
@@ -637,6 +707,51 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
}
});
/**
* ActivityPods local-stack smoke. Sits AFTER the generic Before (which reset
* the world), so it only has to gate on the stack and, for `@browser` scenarios,
* load the real app. The stack is REQUIRED — a smoke that silently skips when
* the stack is down would prove nothing, so an unreachable stack fails here with
* the cookbook pointer instead.
*/
Before({ timeout: 120000 }, async function (this: FestipodWorld, scenario) {
const tags = scenario.pickle.tags.map(t => t.name);
if (!tags.includes('@e2e-activitypods')) return;
const config = apodsStackConfig();
const podStatus = await urlStatus(`${config.podProviderUrl}/.well-known/openid-configuration`);
if (podStatus !== 200) {
throw new Error(
`Pod provider unreachable at ${config.podProviderUrl} (HTTP ${podStatus}). ` +
'Start the local stack — concept `data-layer`, `cookbook_local-activitypods-platform.md`.',
);
}
const backendStatus = await urlStatus(`${config.backendUrl}/app`);
if (backendStatus !== 200) {
throw new Error(
`Festipod backend unreachable at ${config.backendUrl}/app (HTTP ${backendStatus}). ` +
'Start the local stack — concept `data-layer`, `cookbook_local-activitypods-platform.md`.',
);
}
if (!tags.includes('@browser')) return;
const appUrl = await ensureApodsApp();
this.page = await apodsBrowser!.newPage();
const world = this;
this.page.on('pageerror', (err) => {
console.error('[Browser error]', err.message);
world.pageErrors.push(`pageerror: ${err.message}`);
});
this.page.on('console', (msg) => {
if (msg.type() === 'error') {
console.error('[Browser console]', msg.text());
world.pageErrors.push(`console.error: ${msg.text()}`);
}
});
this.apods.appUrl = appUrl;
});
After({ timeout: 10000 }, async function (this: FestipodWorld, scenario) {
if (scenario.result?.status === Status.FAILED) {
this.attach(`Current route: ${this.currentRoute}`, 'text/plain');
@@ -775,6 +890,13 @@ AfterAll(async function () {
appServerProcess = null;
await safe('appServer.stop', 10000, () => stopAppServer(child));
}
if (apodsAppServerProcess) {
const child = apodsAppServerProcess;
apodsAppServerProcess = null;
await safe('apodsAppServer.stop', 10000, () => stopAppServer(child));
}
await safe('apodsBrowser.close', 30000, () => apodsBrowser?.close());
apodsBrowser = null;
await safe('freshBrowser.close', 30000, () => freshBrowser?.close());
freshBrowser = null;
pool.freshBrowser = null;
+22
View File
@@ -5,6 +5,21 @@ import * as fs from 'fs';
import * as path from 'path';
import { renderScreen as renderUiScreen, unmountRender } from '../test-harness/renderHelper';
import { pool, spawnContext, type NamedBrowser, type WalletModel } from './browserPool';
import type { ApodsSession } from './activitypodsStack';
/**
* The live HTTP state one `@e2e-activitypods` scenario accumulates against the
* local Pod: the session it signed in with, and the URIs it created. Reset per
* scenario by the Before hook.
*/
export interface ApodsScenarioState {
session?: ApodsSession;
/** The app server URL the `@browser` scenario loads (set by the hook). */
appUrl?: string;
anchorUri?: string;
meetingPointUri?: string;
collectionUri?: string;
}
export interface FestipodWorld extends World {
currentRoute: string;
@@ -30,6 +45,10 @@ export interface FestipodWorld extends World {
// (guards the "page blanche once connected" class of bug). Reset per scenario.
pageErrors: string[];
// ActivityPods local-stack smoke (@e2e-activitypods): the live HTTP state a
// scenario accumulates. Reset per scenario.
apods: ApodsScenarioState;
// Multi-browser (named, isolated contexts) — for cross-browser wallet tests.
// The wallet model (own vs shared) is an axis orthogonal to browser count.
browsers: Map<string, NamedBrowser>;
@@ -248,6 +267,9 @@ class CustomWorld extends World implements FestipodWorld {
// Runtime errors emitted by the app page during the scenario (see interface).
pageErrors: string[] = [];
// ActivityPods local-stack smoke state (see interface).
apods: ApodsScenarioState = {};
// Multi-browser (named, isolated contexts)
browsers: Map<string, NamedBrowser> = new Map();