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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user