Suspend the NextGraph suites, and smoke the ActivityPods stack
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user