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
@@ -1,5 +1,5 @@
# language: fr
@AUTH @priority-1
@AUTH @priority-1 @suspendu
Fonctionnalité: Connexion NextGraph et chargement des données
En tant qu'utilisateur
Je peux me connecter à mon portefeuille NextGraph
@@ -1,5 +1,5 @@
# language: fr
@EVENT @priority-1
@EVENT @priority-1 @suspendu
Fonctionnalité: Cycle de vie d'un événement
En tant qu'utilisateur connecté
Je peux créer, consulter, modifier et participer à des événements
@@ -6,7 +6,7 @@
# ANNUAIRE Festipod — un document public dont l'app connaît le lien, alimenté par
# dépôt et matérialisé par son curateur. À ce moment-là ce scénario est réécrit
# sur l'annuaire et redevient actif.
@EVENT @priority-1 @wip
@EVENT @priority-1 @wip @suspendu
Fonctionnalité: Découverte publique via l'index global
En tant qu'utilisateur
Je veux découvrir les événements publics des autres comptes sans être connecté
@@ -0,0 +1,59 @@
# language: fr
@EVENT @priority-1 @e2e-activitypods
Fonctionnalité: Smoke e2e de la pile ActivityPods locale
En tant que développeur validant le branchement ActivityPods
Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)
Afin de prouver que les flux du modèle passent réellement par le Pod.
# PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`
# suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte
# aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi
# reproductible est le concept `data-layer`,
# `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod
# :3003, redis remappé :6390). Le chargement de l'app reprend le motif `@e2e` du
# repo (serveur réel + Playwright), pas le wallet partagé.
Scénario: La pile locale est joignable et le backend sert son application
Alors le pod provider local répond sur son endpoint OpenID
Et le backend Festipod sert la description de son application
@browser
Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod
Quand l'application Festipod est chargée depuis la pile locale
Alors l'entrée de connexion au Pod est proposée
Et aucune erreur runtime n'a été émise au chargement
Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod
Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test"
Quand je crée un événement "Concert smoke" et un point de rencontre "Rdv entrée" rattaché par as:context
Alors la lecture du point de rencontre expose son lien as:context vers l'événement
Et l'événement et le point de rencontre partagent le conteneur as:Event
# SUSPENDU — le flux d'inscription exige que l'app Festipod soit INSTALLÉE sur le
# Pod (consentement de l'agent d'autorisation + dispatcher d'activités). Sans cette
# installation, l'activité `as:Join` postée sur l'outbox de l'utilisateur n'est pas
# délivrée au backend et la collection `apods:attendees` reste inchangée — vérifié
# sur la pile locale (POST outbox 201, collection inchangée). Le tag `@suspendu`
# l'exclut de la porte ; les steps restent écrits pour la reprise.
# Bloqueur de reprise : brancher l'installation de l'app (flux OIDC de T04.d) sur la
# pile locale, sinon délivrer l'activité à l'inbox de l'app.
@suspendu
Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees
Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test"
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
Quand la session rejoint le point de rencontre
Alors la collection apods:attendees contient la session
Quand la session quitte le point de rencontre
Alors la collection apods:attendees ne contient plus la session
# SUSPENDU — dépend du scénario précédent : sans inscription sur le Pod, il n'y a ni
# compteur ni liste à lire. La règle de lecture qu'il vise (compteur = total de la
# collection, une référence non résolue restant comptée et rendue anonyme) est
# couverte au niveau unitaire par `src/shared/context/refineDerive.test.ts`.
@suspendu
Scénario: Le compteur et la liste des participants tiennent une référence non résolue
Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test"
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
Quand la session rejoint le point de rencontre
Alors le compteur du point de rencontre compte cette inscription
Et la liste des participants rend ce participant comme anonyme
@@ -1,5 +1,5 @@
# language: fr
@EVENT @priority-1 @multibrowser @shared-wallet
@EVENT @priority-1 @multibrowser @shared-wallet @suspendu
Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f)
En tant que développeur validant les fonctionnalités T02
Je pilote DEUX navigateurs isolés (contextes Playwright distincts, sessions
@@ -1,5 +1,5 @@
# language: fr
@EVENT @priority-1
@EVENT @priority-1 @suspendu
Fonctionnalité: Inscription réelle au point de rencontre via inbox (T02.c)
En tant qu'utilisateur qui s'inscrit à un point de rencontre
Mon inscription est persistée, l'hôte reçoit une notification par son inbox,
@@ -1,6 +1,6 @@
# language: fr
# SUSPENDU (@wip) — comportement Festipod bien réel, mais inexprimable aujourd'hui : il faut DEUX contextes navigateur réellement séparés, chacun se connectant pour lui-même via `ensureIdentity()`, ce que le support multi-navigateur n'offre pas encore.
@EVENT @priority-1 @data @wip
@EVENT @priority-1 @data @wip @suspendu
Fonctionnalité: Isolation entre deux identités sur le wallet partagé
En tant qu'utilisateur qui se connecte pour lui-même, sur le MÊME wallet
physique partagé qu'un autre utilisateur,
@@ -1,5 +1,5 @@
# language: fr
@EVENT @priority-1 @data
@EVENT @priority-1 @data @suspendu
Fonctionnalité: Reconnexion à froid SANS état local NextGraph (durabilité broker réelle)
En tant qu'utilisateur qui crée un événement puis se reconnecte sous la MÊME
identité depuis un appareil/onglet SANS aucune donnée NextGraph locale,
@@ -1,5 +1,5 @@
# language: fr
@EVENT @priority-1 @data
@EVENT @priority-1 @data @suspendu
Fonctionnalité: Reconnexion d'une même identité sur le wallet persistant
En tant qu'utilisateur qui, sur le MÊME wallet physique, ouvre une PAGE FRAÎCHE
(nouveau login broker, session fraîche) sous le MÊME identifiant qu'avant,
@@ -1,5 +1,5 @@
# language: fr
@EVENT @priority-1
@EVENT @priority-1 @suspendu
Fonctionnalité: Persistance d'un événement à la reconnexion (app réelle, @e2e)
En tant qu'utilisateur qui, dans la VRAIE app, crée un événement puis FERME
et ROUVRE l'app sous la MÊME identité (même wallet, session verifier fraîche),
@@ -34,7 +34,7 @@ Fonctionnalité: US-13 Relayer/Modifier/Supprimer un événement
# le créateur, qui n'est PAS obligé de participer. À la création, aucune
# participation n'est écrite et le compteur démarre à 0. Le créateur voit « J'y
# serai » et peut rejoindre/quitter son propre événement comme tout le monde.
@data
@data @suspendu
Scénario: Le créateur ne participe pas automatiquement à son événement
Étant donné que le créateur relaie l'événement "Signalé par le créateur"
Alors le créateur n'est pas participant de l'événement "Signalé par le créateur"
@@ -37,7 +37,7 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
# scénario @multibrowser réactif (e2e-multibrowser.feature « Un participant apparaît
# réactivement… »), avec un vrai propriétaire (A) et un vrai inscrit (B).
@data
@data @suspendu
Scénario: S'inscrire à un événement
Étant donné un événement "Formation CNV" existe
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
@@ -45,7 +45,7 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
Alors l'utilisateur est participant de l'événement "Formation CNV"
Et l'utilisateur apparaît dans la liste des participants de l'événement "Formation CNV"
@data
@data @suspendu
Scénario: Se désinscrire d'un événement
Étant donné un événement "Résidence Reconnexion" existe
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
@@ -53,14 +53,14 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
Alors l'utilisateur n'est plus participant de l'événement "Résidence Reconnexion"
Et l'utilisateur n'apparaît plus dans la liste des participants de l'événement "Résidence Reconnexion"
@data
@data @suspendu
Scénario: L'inscription est idempotente
Étant donné un événement "Résidence Reconnexion" existe
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
Quand l'utilisateur essaie de s'inscrire une seconde fois à l'événement "Résidence Reconnexion"
Alors l'inscription est idempotente pour l'événement "Résidence Reconnexion"
@data
@data @suspendu
Scénario: Se désinscrire d'un événement auquel on n'est pas inscrit
Étant donné un événement "Formation CNV" existe
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
@@ -80,7 +80,7 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
# non-borné au scénario). La convergence 1→2 réactive absolue reste prouvée par le
# scénario @multibrowser réactif (propriétaire A + inscrit B, e2e-multibrowser).
# Voir data-layer/knowledge_context-internals § participantCount + le caveat id-form.
@data
@data @suspendu
Scénario: L'inscription fait converger le compteur dérivé du propriétaire
Étant donné un événement "Formation CNV" existe
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
@@ -0,0 +1,187 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
import type { ApodsSession } from '../../../../shared/support/activitypodsStack';
import {
apodsStackConfig,
attendeesCollectionUri,
contextLink,
createResource,
linkId,
podLogin,
postOutboxActivity,
readAttendees,
readResource,
recordUri,
urlStatus,
} from '../../../../shared/support/activitypodsStack';
/**
* Steps for the `@e2e-activitypods` smoke layer: real HTTP against the local
* ActivityPods stack (pod provider + Festipod backend) and, for `@browser`
* scenarios, the real app in Playwright. Nothing here mocks the platform — the
* point of the layer is that the flows actually round-trip through a Pod.
*/
function session(world: FestipodWorld): ApodsSession {
if (!world.apods.session) {
throw new Error('No Pod session — a "session Pod locale" step must run first');
}
return world.apods.session;
}
function requires(world: FestipodWorld, value: string | undefined, what: string): string {
if (!value) throw new Error(`${what} is unset — a prior step must have created it`);
return value;
}
// ── The stack is up ────────────────────────────────────────────────────────
Then("le pod provider local répond sur son endpoint OpenID", async function (this: FestipodWorld) {
const { podProviderUrl } = apodsStackConfig();
const status = await urlStatus(`${podProviderUrl}/.well-known/openid-configuration`);
expect(status, `GET ${podProviderUrl}/.well-known/openid-configuration`).to.equal(200);
});
Then("le backend Festipod sert la description de son application", async function (this: FestipodWorld) {
const { backendUrl } = apodsStackConfig();
const description = await readResource(undefined, `${backendUrl}/app`);
// The description IS the OIDC client the app registers with: it must carry
// the interop access-need group the Pod grants, not merely answer 200.
expect(JSON.stringify(description.type)).to.contain('Application');
expect(linkId(description['interop:hasAccessNeedGroup']), 'access-need group').to.be.a('string');
});
// ── The app boots in the ActivityPods variant ──────────────────────────────
When("l'application Festipod est chargée depuis la pile locale", async function (this: FestipodWorld) {
const appUrl = requires(this, this.apods.appUrl, 'app URL (set by the @browser Before hook)');
expect(this.page, 'a Playwright page (set by the @browser Before hook)').to.not.be.null;
await this.page!.goto(appUrl, { waitUntil: 'domcontentloaded' });
// No session yet → AuthGate renders the Pod-provider picker.
await this.page!.waitForSelector('text=Connectez-vous avec votre Pod', { timeout: 30000 });
});
Then("l'entrée de connexion au Pod est proposée", async function (this: FestipodWorld) {
const { podProviderUrl } = apodsStackConfig();
const host = new URL(podProviderUrl).host;
await this.page!.waitForSelector(`text=Se connecter avec ${host}`, { timeout: 30000 });
});
Then("aucune erreur runtime n'a été émise au chargement", function (this: FestipodWorld) {
expect(
this.pageErrors,
`Des erreurs runtime ont été émises au chargement de l'app :\n` +
this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\n'),
).to.be.empty;
});
// ── Event + meeting point, the as:context graft ────────────────────────────
Given(
'une session Pod locale pour {string} avec le mot de passe {string}',
async function (this: FestipodWorld, username: string, password: string) {
this.apods.session = await podLogin(apodsStackConfig(), username, password);
},
);
When(
'je crée un événement {string} et un point de rencontre {string} rattaché par as:context',
async function (this: FestipodWorld, title: string, meetingPointTitle: string) {
const pod = session(this);
const anchor = await createResource(pod, {
type: 'Event',
name: title,
startTime: '2026-11-01T18:00:00Z',
endTime: '2026-11-01T22:00:00Z',
});
this.apods.anchorUri = anchor;
this.apods.meetingPointUri = await createResource(pod, {
type: 'Event',
name: meetingPointTitle,
'as:context': anchor,
organizer: pod.webId,
});
},
);
Then(
"la lecture du point de rencontre expose son lien as:context vers l'événement",
async function (this: FestipodWorld) {
const pod = session(this);
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
const record = await readResource(pod, mpUri);
expect(contextLink(record)).to.equal(this.apods.anchorUri);
},
);
Then(
"l'événement et le point de rencontre partagent le conteneur as:Event",
async function (this: FestipodWorld) {
const pod = session(this);
const anchor = await readResource(pod, requires(this, this.apods.anchorUri, 'anchor URI'));
const mp = await readResource(pod, requires(this, this.apods.meetingPointUri, 'meeting point URI'));
// Both are as/Event, both created under the session's storage — so a single
// container holds the two, and the ONLY distinction is the as:context link
// the reader's bindings test.
expect(JSON.stringify(anchor.type)).to.contain('Event');
expect(JSON.stringify(mp.type)).to.contain('Event');
expect(recordUri(anchor), 'anchor URI').to.match(new RegExp(`^${pod.storage}/`));
expect(recordUri(mp), 'meeting point URI').to.match(new RegExp(`^${pod.storage}/`));
expect(contextLink(anchor), 'an anchor event carries no as:context').to.be.undefined;
expect(contextLink(mp), 'the meeting point carries as:context').to.be.a('string');
},
);
// ── Join / leave through the outbox (suspended in the feature) ─────────────
Given(
'un point de rencontre {string} rattaché à un événement, avec sa collection apods:attendees',
async function (this: FestipodWorld, title: string) {
const pod = session(this);
const anchor = await createResource(pod, { type: 'Event', name: `${title} — ancre` });
const collection = await createResource(pod, { type: 'Collection' });
const mp = await createResource(pod, {
type: 'Event',
name: title,
'as:context': anchor,
'apods:attendees': collection,
organizer: pod.webId,
});
this.apods.anchorUri = anchor;
this.apods.collectionUri = collection;
this.apods.meetingPointUri = mp;
expect(attendeesCollectionUri(await readResource(pod, mp))).to.equal(collection);
},
);
When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {
const pod = session(this);
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
// The app's own path (data/outbox.ts): as:Join to the user's outbox, carrying
// the meeting point record so the backend finds its apods:attendees collection.
await postOutboxActivity(pod, 'Join', await readResource(pod, mpUri), pod.webId);
});
When('la session quitte le point de rencontre', async function (this: FestipodWorld) {
const pod = session(this);
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
await postOutboxActivity(pod, 'Leave', await readResource(pod, mpUri), pod.webId);
});
Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await readAttendees(pod, collectionUri);
expect(members.map(linkId)).to.include(pod.webId);
});
Then(
'la collection apods:attendees ne contient plus la session',
async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await readAttendees(pod, collectionUri);
expect(members.map(linkId)).to.not.include(pod.webId);
},
);
@@ -19,7 +19,7 @@
#
# Tout futur crash de rendu connecté = ce smoke passe au rouge.
@e2e @smoke
@e2e @smoke @suspendu
Fonctionnalité: L'accueil connecté rend du contenu réel
En tant qu'utilisateur qui vient de se connecter
Je veux que l'écran d'accueil rende réellement l'application
@@ -1,5 +1,5 @@
# language: fr
@data @multibrowser
@data @multibrowser @suspendu
Fonctionnalité: Harness multi-navigateur — modèle shared-wallet
Pour valider le provisioning du wallet partagé sur plusieurs navigateurs
En tant que développeur du stopgap puis de la cible NextGraph
+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();