test(e2e): multibrowser feature scenarios + @data proofs for T02

- New @data scenarios: inscription-inbox (registration + inbox deposit + notif;
  persistent deregistration), decouverte-publique (cross-account public read),
  protected-store (probe: the native protected store opens for ORM+SPARQL).
- New @multibrowser e2e (e2e-multibrowser.feature): registration+host-notif,
  persistent deregistration, and public discovery across two browser contexts.
- cycle-de-vie: @wip lifted on "Se désinscrire" (fixed).
- harness-ng: bridge helpers for the above; domain sets + ReadCap probe doc
  retargeted to the protected store.
- hooks: defensive AfterAll teardown + Before self-heal on Chromium crash under
  full-suite load. cucumber.json excludes @humain (live nextgraph.eu import,
  non-deterministic; passes standalone). Full suite: 86 passed / 0 failed / 71 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-03 15:51:57 +02:00
parent aacc2ec3ee
commit 83604cbc16
14 changed files with 1462 additions and 129 deletions
+209 -12
View File
@@ -12,8 +12,9 @@ import { createRoot } from 'react-dom/client';
import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
// useShape routed through the lib (SDK-identical surface); caps from /polyfill.
import { useShape, docs } from '@ng-eventually/client';
import { useShape, docs, inbox as docsInbox } from '@ng-eventually/client';
import { getCaps, setCurrentUser, resetCaps } from '@ng-eventually/client/polyfill';
import { hostInboxNuri as regInboxNuri } from '../data/registration';
import type { DeepSignalSet } from '@ng-eventually/client';
// doc_create goes through the lib's `docs` primitive (T01.a): it calls the REAL
// injected `ng` directly (never the public proxy), so postMessage marshaling
@@ -65,11 +66,15 @@ function ConnectedHarness() {
const ngCtx = useNextGraph();
const appData = useFestipodData();
// Use private store NURI as scope (opens the repo for reads AND writes)
// Private store NURI — the inbox shim anchor + the ReadCap-governed document.
const privateNuri = ngCtx.session && `did:ng:${ngCtx.session.private_store_id}`;
const events = useShape(FpEventShapeType, privateNuri) as DeepSignalSet<FpEvent>;
const users = useShape(FpUserProfileShapeType, privateNuri) as DeepSignalSet<FpUserProfile>;
const participations = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet<FpParticipation>;
// Protected store NURI — T02.h (axe A): the shareable DOMAIN entities (events,
// users, participations) now live in the real protected native store, so the
// harness's raw ORM sets subscribe there too (matching FestipodDataContext).
const protectedNuri = ngCtx.session && `did:ng:${ngCtx.session.protected_store_id}`;
const events = useShape(FpEventShapeType, protectedNuri) as DeepSignalSet<FpEvent>;
const users = useShape(FpUserProfileShapeType, protectedNuri) as DeepSignalSet<FpUserProfile>;
const participations = useShape(FpParticipationShapeType, protectedNuri) as DeepSignalSet<FpParticipation>;
const [bridgeReady, setBridgeReady] = useState(false);
// Read-filter validation: once a ReadCap policy is active, <FilterProbe> mounts
@@ -80,6 +85,8 @@ function ConnectedHarness() {
const [smokeDoc, setSmokeDoc] = useState<string | null>(null);
// Per-entity fan-out validation: several entity docs read together.
const [fanoutGraphs, setFanoutGraphs] = useState<string[]>([]);
// T02.h gating: mount a useShape(protectedNuri) to open the protected repo.
const [protectedActive, setProtectedActive] = useState(false);
useEffect(() => {
// Small delay for useShape to populate
@@ -145,6 +152,92 @@ function ConnectedHarness() {
const ev = [...events].find(e => e['@id'] === eventId);
if (ev) ev.participantCount = Math.max(0, ev.participantCount - 1);
},
// --- Real app-path registration (T02.c) ----------------------------
// These go through the REAL FestipodDataContext mutations (appData), so
// the @data scenario faces the same inbox-deposit + notification +
// SPARQL-DELETE path as the running app — not the direct ngSet helpers
// above (kept for backward compatibility with existing @data steps).
/** Create an event through the REAL app path (appData.createEvent → NG),
* persisting an FpEvent into the shared protected store. Returns its id.
* Used by the T02.f multi-browser flow: browser A (host) creates, then a
* SECOND browser (independent NG session, same wallet) reads it back via
* the broker and registers to it. Resolves the id from the returned
* record (falls back to a title lookup in the reactive set). */
async createEventReal(title: string) {
const created: any = await appData.createEvent({
title,
date: '2026-08-01',
time: '18:00',
location: 'Kiosque du parc',
description: 'Point de rencontre e2e multi-navigateurs',
participantCount: 0,
} as any);
const id = created?.id || created?.['@id'] ||
[...events].find(e => e.title === title)?.['@id'] || '';
return { id, title };
},
async appJoinEvent(eventId: string, userId?: string) {
await appData.joinEvent(eventId, userId);
},
async appLeaveEvent(eventId: string, userId?: string) {
await appData.leaveEvent(eventId, userId);
},
/** A LIVE current user id, resolved from the users set AT CALL TIME (not
* frozen at bridge-build). Prefers the app context's principal; falls
* back to the first user in the set. Guaranteed non-empty once users
* have hydrated — the real principal a Participation.user must carry. */
liveUserId() {
return appData.currentUserId || [...users][0]?.['@id'] || '';
},
/** isParticipating for the LIVE current user id (call-time resolved). */
liveIsParticipating(eventId: string) {
const uid = appData.currentUserId || [...users][0]?.['@id'] || '';
return [...participations].some(p => p.event === eventId && p.user === uid);
},
/** The host inbox NURI for an event (domain glue, T02.c). */
async eventInboxNuri(eventId: string) {
return regInboxNuri(eventId);
},
/** Materialize the raw registration deposits for an event (curator). The
* polyfill inbox is shared, so filter deposits to the given event. */
async readInboxDeposits(eventId: string) {
const target = await regInboxNuri(eventId);
const deposits = await docsInbox.read(target);
return deposits.filter(
(d: any) => d?.payload?.kind === 'new-participant' && d?.payload?.eventId === eventId,
);
},
/** Host-facing notifications currently surfaced by the data context. */
appNotifications() {
return appData.notifications;
},
/**
* AUTHORITATIVE participation count for (event, user), re-queried straight
* from the broker via SPARQL (docs.sparqlQuery) — NOT the reactive set.
* Proves the désinscription is DURABLE at the data level: after a leave,
* the broker itself must report 0 (the reactive `liveIsParticipating`
* could lie if the delete no-op'd but the set was flipped anyway — this
* bypasses the set entirely). Matches ?event/?user by literal string value
* (read-only count, so tolerance is safe). */
async authParticipationCount(eventId: string, userId: string) {
const esc = (v: string) =>
v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
const query = `
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE {
GRAPH <${protectedNuri}> {
?s a <http://festipod.org/Participation> ;
<http://festipod.org/event> ?event ;
<http://festipod.org/user> ?user .
FILTER( STR(?event) = "${esc(eventId)}" && STR(?user) = "${esc(userId)}" )
}
}`;
const result: any = await docs.sparqlQuery(session.session_id, query, undefined, protectedNuri);
const rows = Array.isArray(result) ? result : result?.results?.bindings ?? [];
const n = parseInt(rows[0]?.n?.value ?? '0', 10);
return Number.isFinite(n) ? n : 0;
},
updateEvent(eventId: string, updates: Record<string, any>) {
const ev = [...events].find(e => e['@id'] === eventId);
if (!ev) return;
@@ -162,11 +255,14 @@ function ConnectedHarness() {
// --- ReadCap read-filter validation (see decision_2026-06-17_eventually-library) ---
/** The document (repo NURI) all wallet entities live in (mono-store). */
documentNuri: privateNuri,
/** The document (repo NURI) the shareable domain entities live in. After
* T02.h this is the PROTECTED native store (was private) — the ReadCap
* read-filter test governs the document that actually holds the
* participations, so it must track the domain scope. */
documentNuri: protectedNuri,
/**
* Put the wallet document under a ReadCap policy: grant its read cap to
* Put the domain document under a ReadCap policy: grant its read cap to
* `reader` only, and set the current user to `user`. The lib's read
* filter is per-DOCUMENT, so this is all-or-nothing on that document —
* the faithful NextGraph behavior in a mono-store layout. <FilterProbe>
@@ -174,7 +270,7 @@ function ConnectedHarness() {
*/
governDocument(reader: string, user: string) {
resetCaps();
getCaps().grantRead(privateNuri!, reader);
getCaps().grantRead(protectedNuri!, reader);
setCurrentUser(user);
setFilterActive(true);
},
@@ -231,6 +327,73 @@ function ConnectedHarness() {
setFanoutGraphs([docA, docB]);
return { docA, docB, listed };
},
// --- Public discovery cross-accounts (T02.e) -----------------------
// Product-level scenario: a PUBLISHER account creates its own PUBLIC
// event document (createEntityDoc → makePublic via caps.open); a
// separate, NON-connected DISCOVERER account then materializes the
// cross-account public source (allAccounts → listEntityDocs('public'))
// and reads the event via a real useShape({graphs}). No friendship/
// connection is ever declared between them — discovery is by the public
// fan-out alone. Returns the publisher's doc + the discovered index.
// <FanoutProbe> is reused to mount the multi-graph subscription; the
// event is written into the publisher doc before the reader lists it.
async publishPublicEventAs(publisher: string, title: string) {
const reg = await import('../utils/storeRegistry');
reg.resetRegistryCache();
await reg.ensureAccount(publisher);
const doc = await reg.createEntityDoc(publisher, 'public');
return { doc };
},
async discoverPublicEventsAs(discoverer: string) {
const reg = await import('../utils/storeRegistry');
// The discoverer account exists but is NOT connected to the publisher.
await reg.ensureAccount(discoverer);
reg.resetRegistryCache();
const listed = await reg.listEntityDocs('public'); // cross-account
setFanoutGraphs(listed);
return { listed };
},
// --- T02.h GATING: protected native store openability -----------------
// Does the REAL protected store (`did:ng:${protected_store_id}`) open for
// ORM reads AND writes the same way private does? Private was chosen
// (decision_2026-03-17) precisely because it opened without RepoNotFound.
// Before switching the domain scope to protected, prove empirically that
// a write scoped to protectedNuri is READABLE back (round-trip). Mounting
// <ProtectedProbe> subscribes a useShape(protectedNuri) — that
// orm_start_graph call is what opens the repo in the verifier.
protectedNuri,
mountProtectedProbe() {
setProtectedActive(true);
},
/** Authoritative round-trip: SPARQL INSERT a marker triple into the
* protected store graph, then SPARQL SELECT it back — bypassing the ORM
* set entirely, so a RepoNotFound surfaces as a thrown error here. */
async protectedSparqlRoundTrip() {
if (!protectedNuri) throw new Error('no protected_store_id in session');
const subj = `did:ng:o:probe${Date.now().toString(36)}`;
const g = protectedNuri.replace(/^did:ng:/, 'did:ng:');
const insert = `INSERT DATA { GRAPH <${protectedNuri}> { <urn:probe:s> <urn:probe:p> "hit" } }`;
let insertError: string | null = null;
try {
await docs.sparqlUpdate(session.session_id, insert, protectedNuri);
} catch (e: any) {
insertError = String(e?.message ?? e);
}
void subj; void g;
let count = 0;
let queryError: string | null = null;
try {
const q = `SELECT (COUNT(*) AS ?n) WHERE { GRAPH <${protectedNuri}> { <urn:probe:s> <urn:probe:p> ?o } }`;
const res: any = await docs.sparqlQuery(session.session_id, q, undefined, protectedNuri);
const rows = Array.isArray(res) ? res : res?.results?.bindings ?? [];
count = parseInt(rows[0]?.n?.value ?? '0', 10) || 0;
} catch (e: any) {
queryError = String(e?.message ?? e);
}
return { insertError, queryError, count, protectedNuri };
},
};
console.log('[HarnessNG] Ready — events:', events.size, 'users:', users.size,
@@ -245,13 +408,47 @@ function ConnectedHarness() {
return (
<>
<div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>
{filterActive && privateNuri && <FilterProbe privateNuri={privateNuri} />}
{filterActive && protectedNuri && <FilterProbe documentNuri={protectedNuri} />}
{smokeDoc && <SmokeProbe docNuri={smokeDoc} />}
{fanoutGraphs.length > 0 && <FanoutProbe graphs={fanoutGraphs} />}
{protectedActive && protectedNuri && <ProtectedProbe protectedNuri={protectedNuri} />}
</>
);
}
// ============================================================================
// ProtectedProbe (T02.h gating) — subscribes an ORM set scoped to the REAL
// protected native store, so `orm_start_graph` opens that repo in the verifier
// (the same mechanism that made private work — decision_2026-03-17). Exposes
// window.__protected: an ORM add() + read-back, to prove the protected store
// round-trips writes the way private does (or surfaces RepoNotFound if not).
// ============================================================================
function ProtectedProbe({ protectedNuri }: { protectedNuri: string }) {
const set = useShape(FpParticipationShapeType, protectedNuri) as DeepSignalSet<FpParticipation>;
useEffect(() => {
(window as any).__protected = {
ready: true,
protectedNuri,
add() {
set.add({
'@graph': protectedNuri,
'@type': 'http://festipod.org/Participation',
'@id': '',
event: 'urn:protected:event',
user: 'urn:protected:user',
isConfirmed: true,
} as FpParticipation);
},
count() { return set.size; },
items() {
return [...set].map(p => ({ '@id': p['@id'], event: p.event, user: p.user }));
},
};
}, [set, protectedNuri]);
return null;
}
// ============================================================================
// FilterProbe — subscribes participations AFTER a ReadCap policy is active, so
// useShape returns the read-filtered VIEW. Exposes window.__readFilter.snapshot()
@@ -259,8 +456,8 @@ function ConnectedHarness() {
// validates the per-document read filter on the real ORM set.
// ============================================================================
function FilterProbe({ privateNuri }: { privateNuri: string }) {
const set = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet<FpParticipation>;
function FilterProbe({ documentNuri }: { documentNuri: string }) {
const set = useShape(FpParticipationShapeType, documentNuri) as DeepSignalSet<FpParticipation>;
useEffect(() => {
(window as any).__readFilter = {
ready: true,