Suspend the NextGraph suites, and smoke the ActivityPods stack
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@
|
||||
"src/modules/*/steps/**/*.ts"
|
||||
],
|
||||
"paths": ["src/modules/*/features/**/*.feature"],
|
||||
"tags": "not @wip and not @humain",
|
||||
"tags": "not @wip and not @humain and not @suspendu and not @e2e-activitypods",
|
||||
"format": [
|
||||
"progress-bar",
|
||||
"json:reports/cucumber-report.json",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"default": {
|
||||
"import": [
|
||||
"src/shared/support/**/*.ts",
|
||||
"src/shared/steps/**/*.ts",
|
||||
"src/modules/*/steps/**/*.ts"
|
||||
],
|
||||
"paths": ["src/modules/event/features/e2e-activitypods.feature"],
|
||||
"tags": "@e2e-activitypods and not @suspendu",
|
||||
"format": ["progress-bar"],
|
||||
"language": "fr",
|
||||
"formatOptions": {
|
||||
"snippetInterface": "async-await"
|
||||
},
|
||||
"strict": false
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"build": "bun run build.ts",
|
||||
"test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract",
|
||||
"cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json",
|
||||
"test:data": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json --tags @data",
|
||||
"test:data": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json --tags '@data and not @suspendu'",
|
||||
"test:auth-setup": "bun scripts/setup-test-auth.ts",
|
||||
"cucumber:report": "bun scripts/parse-test-results.ts",
|
||||
"features:parse": "bun scripts/parse-features.ts",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,153 +1 @@
|
||||
[
|
||||
{
|
||||
"description": " En tant que développeur\n Je veux valider, contre le vrai broker, que l'isolation est ACTIVE via le SDK :\n un compte ne lit PAS l'entité PROTÉGÉE d'un autre compte tant qu'ils ne sont\n pas connectés, la lit une fois qu'ils se connectent, et lit toujours l'entité\n PUBLIQUE de cet autre compte — le tout appliqué par le SDK (filtre ReadCap +\n déclaration de connexions), pas par un filtre applicatif.",
|
||||
"elements": [
|
||||
{
|
||||
"description": "",
|
||||
"id": "isolation-protégée-par-connexions-(ng-eventually);un-compte-non-connecté-ne-lit-pas-l'entité-protégée-d'un-autre,-puis-la-lit-après-connexion",
|
||||
"keyword": "Scénario",
|
||||
"line": 12,
|
||||
"name": "Un compte non connecté ne lit pas l'entité protégée d'un autre, puis la lit après connexion",
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Before",
|
||||
"hidden": true,
|
||||
"result": {
|
||||
"status": "passed",
|
||||
"duration": 5854976529
|
||||
}
|
||||
},
|
||||
{
|
||||
"arguments": [],
|
||||
"keyword": "Étant donné ",
|
||||
"line": 13,
|
||||
"name": "le wallet contient l'entité protégée du compte \"alice\"",
|
||||
"match": {
|
||||
"location": "src/modules/workshop/steps/data/protected-connections.steps.ts:29"
|
||||
},
|
||||
"result": {
|
||||
"status": "passed",
|
||||
"duration": 49100359938
|
||||
}
|
||||
},
|
||||
{
|
||||
"arguments": [],
|
||||
"keyword": "Et ",
|
||||
"line": 14,
|
||||
"name": "le compte \"bob\" est courant sans connexion à \"alice\"",
|
||||
"match": {
|
||||
"location": "src/modules/workshop/steps/data/protected-connections.steps.ts:54"
|
||||
},
|
||||
"result": {
|
||||
"status": "passed",
|
||||
"duration": 2859361
|
||||
}
|
||||
},
|
||||
{
|
||||
"arguments": [],
|
||||
"keyword": "Alors ",
|
||||
"line": 15,
|
||||
"name": "\"bob\" ne voit aucune entité protégée d'\"alice\"",
|
||||
"match": {
|
||||
"location": "src/modules/workshop/steps/data/protected-connections.steps.ts:67"
|
||||
},
|
||||
"result": {
|
||||
"status": "passed",
|
||||
"duration": 4697041
|
||||
}
|
||||
},
|
||||
{
|
||||
"arguments": [],
|
||||
"keyword": "Mais ",
|
||||
"line": 16,
|
||||
"name": "\"bob\" voit l'entité publique d'\"alice\"",
|
||||
"match": {
|
||||
"location": "src/modules/workshop/steps/data/protected-connections.steps.ts:72"
|
||||
},
|
||||
"result": {
|
||||
"status": "passed",
|
||||
"duration": 3629995
|
||||
}
|
||||
},
|
||||
{
|
||||
"arguments": [],
|
||||
"keyword": "Quand ",
|
||||
"line": 17,
|
||||
"name": "l'app déclare la connexion entre \"alice\" et \"bob\"",
|
||||
"match": {
|
||||
"location": "src/modules/workshop/steps/data/protected-connections.steps.ts:77"
|
||||
},
|
||||
"result": {
|
||||
"status": "passed",
|
||||
"duration": 3505684800
|
||||
}
|
||||
},
|
||||
{
|
||||
"arguments": [],
|
||||
"keyword": "Alors ",
|
||||
"line": 18,
|
||||
"name": "\"bob\" voit l'entité protégée d'\"alice\"",
|
||||
"match": {
|
||||
"location": "src/modules/workshop/steps/data/protected-connections.steps.ts:84"
|
||||
},
|
||||
"result": {
|
||||
"status": "passed",
|
||||
"duration": 6217858
|
||||
}
|
||||
},
|
||||
{
|
||||
"arguments": [],
|
||||
"keyword": "Et ",
|
||||
"line": 19,
|
||||
"name": "\"bob\" voit toujours l'entité publique d'\"alice\"",
|
||||
"match": {
|
||||
"location": "src/modules/workshop/steps/data/protected-connections.steps.ts:90"
|
||||
},
|
||||
"result": {
|
||||
"status": "passed",
|
||||
"duration": 4433940
|
||||
}
|
||||
},
|
||||
{
|
||||
"keyword": "After",
|
||||
"hidden": true,
|
||||
"result": {
|
||||
"status": "passed",
|
||||
"duration": 20596009
|
||||
}
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "@WORKSHOP",
|
||||
"line": 2
|
||||
},
|
||||
{
|
||||
"name": "@priority-1",
|
||||
"line": 2
|
||||
},
|
||||
{
|
||||
"name": "@data",
|
||||
"line": 11
|
||||
}
|
||||
],
|
||||
"type": "scenario"
|
||||
}
|
||||
],
|
||||
"id": "isolation-protégée-par-connexions-(ng-eventually)",
|
||||
"line": 3,
|
||||
"keyword": "Fonctionnalité",
|
||||
"name": "Isolation protégée par connexions (ng-eventually)",
|
||||
"tags": [
|
||||
{
|
||||
"name": "@WORKSHOP",
|
||||
"line": 2
|
||||
},
|
||||
{
|
||||
"name": "@priority-1",
|
||||
"line": 2
|
||||
}
|
||||
],
|
||||
"uri": "src/modules/workshop/features/protected-connections.feature"
|
||||
}
|
||||
]
|
||||
[]
|
||||
+96
-417
@@ -1,190 +1,101 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* validate.ts — Validation matrix (broker-level tests only, no @ui/@e2e).
|
||||
* validate.ts — the Festipod validation matrix.
|
||||
*
|
||||
* Default run (no flags) — key subset only, fast:
|
||||
* (a) Polyfill unit tests (@ng-eventually/client — bun test)
|
||||
* (b) Polyfill e2e real-broker (@ng-eventually/client — bun run e2e/run.ts)
|
||||
* (c) Festipod @data KEY SUBSET (cucumber --name regex covering terrain bugs)
|
||||
* (d) Festipod @multibrowser (cucumber --tags @multibrowser)
|
||||
* (e) Festipod @smoke (cucumber --tags @smoke — boot connecté rend)
|
||||
* (f) Festipod @wip [informational only, non-blocking]
|
||||
* SCOPE (2026-10-09, after the ActivityPods-first switch). Two layers are in
|
||||
* the gate:
|
||||
*
|
||||
* With --full flag:
|
||||
* (c) becomes full @data suite (cucumber --tags @data)
|
||||
* (a) Festipod @ui — the UI layer. The `@ui` tag does not exist in this repo;
|
||||
* the layer is exactly the complement of the polyfill/broker tags, and its
|
||||
* command is, verbatim:
|
||||
* node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js \
|
||||
* --config cucumber.json \
|
||||
* --tags 'not @wip and not @humain and not @data and not @e2e and not @multibrowser'
|
||||
* `cucumber.json` additionally excludes `@suspendu` and `@e2e-activitypods`
|
||||
* (Cucumber ANDs the config tags with the CLI ones), so this stays the
|
||||
* stack-free UI layer exactly as verified at 125 scenarios / 0 failed.
|
||||
*
|
||||
* Each step runs even if the previous one failed (--bail mode is OFF).
|
||||
* Exit code is non-zero if any non-informational step has failures.
|
||||
* (b) Festipod @e2e-activitypods — the smoke over the LOCAL ActivityPods stack
|
||||
* (pod provider + Festipod backend + the app served for real). Distinct
|
||||
* from the suspended `@e2e`: no wallet, no broker, so `cucumber.json`
|
||||
* excludes it. Its config is `cucumber.smoke.json`; its reproducible setup
|
||||
* is the concept note `data-layer`, `cookbook_local-activitypods-platform.md`.
|
||||
* Requires the stack to be UP (the app server and Chromium are started
|
||||
* here); an unreachable stack fails the layer rather than skipping it.
|
||||
*
|
||||
* Profile rotation: both Playwright profiles are rotated before @data and
|
||||
* @multibrowser when their size exceeds BLOAT_THRESHOLD_MB (default 50 MB),
|
||||
* to avoid the sparql_query hang described in caveat_wallet-bloat-hang.
|
||||
* SUSPENDED, NOT RUN HERE (documented skip, code preserved — nothing deleted):
|
||||
* - `@data`, `@e2e`, `@multibrowser`, `@shared-wallet`, `@smoke` scenarios are
|
||||
* polyfill/broker-bound: they sign the shared NextGraph wallet through the
|
||||
* harness (`src/shared/test-harness/harness-ng.tsx`). They are tagged
|
||||
* `@suspendu` in the feature files and excluded there, awaiting the NextGraph
|
||||
* workstream.
|
||||
* - the SDK's own tests (nextgraph/ng-eventually-js) are NOT Festipod's and are
|
||||
* no longer run from here (concept `bdd-testing`,
|
||||
* `rule_tests-validate-festipod-not-the-sdk`).
|
||||
*
|
||||
* A smoke layer that cannot reach its stack FAILS, loudly — it never silently
|
||||
* skips. Exit code is non-zero if any layer fails.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// ─── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
const FESTIPOD_DIR = "/home/sylvain/projects/festipod/festipod";
|
||||
const POLYFILL_DIR =
|
||||
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/client";
|
||||
const REPO_DIR = process.cwd();
|
||||
|
||||
const FESTIPOD_PROFILE = path.join(FESTIPOD_DIR, ".playwright-profile");
|
||||
const POLYFILL_PROFILE = path.join(
|
||||
POLYFILL_DIR,
|
||||
"e2e",
|
||||
".playwright-profile-lib",
|
||||
);
|
||||
/** The UI layer, verbatim — see the header. */
|
||||
const UI_TAGS = "not @wip and not @humain and not @data and not @e2e and not @multibrowser";
|
||||
|
||||
/** Rotate profile when it exceeds this many MB (caveat_wallet-bloat-hang). */
|
||||
const BLOAT_THRESHOLD_MB = 50;
|
||||
/** The ActivityPods smoke, minus its documented suspends. */
|
||||
const APODS_TAGS = "@e2e-activitypods and not @suspendu";
|
||||
|
||||
/**
|
||||
* Per-step timeouts:
|
||||
* - polyfill unit/e2e: short steps, keep 10 min
|
||||
* - @data key subset: generous — BeforeAll + 8 scenarios, ~15 min margin
|
||||
* - @data full: full suite, ~35 min margin
|
||||
* - @multibrowser: 7 scenarios, ~10 min margin
|
||||
* - @wip: informational, 10 min
|
||||
*/
|
||||
const TIMEOUT_POLYFILL_UNIT_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_POLYFILL_E2E_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_DATA_KEY_MS = 15 * 60 * 1000; // 15 min (key subset)
|
||||
const TIMEOUT_DATA_FULL_MS = 35 * 60 * 1000; // 35 min (--full)
|
||||
const TIMEOUT_MULTIBROWSER_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_SMOKE_MS = 10 * 60 * 1000; // 10 min (1 @e2e boot scenario)
|
||||
const TIMEOUT_WIP_MS = 10 * 60 * 1000; // 10 min
|
||||
|
||||
/**
|
||||
* Key-subset --name regex: matches exactly the 8 scenarios that cover the
|
||||
* known terrain bugs (inscription, désinscription, isolation, reconnexion,
|
||||
* compteur dérivé, créateur ne participe pas, auth vide, auth distinctes).
|
||||
*
|
||||
* Uses a single cucumber invocation so BeforeAll (broker login) runs once.
|
||||
*
|
||||
* French accent chars must be URL-safe in the regex — cucumber uses JS
|
||||
* RegExp, which handles unicode natively; we pass the literal string.
|
||||
*/
|
||||
const DATA_KEY_NAME_REGEX = [
|
||||
"S'inscrire à un événement",
|
||||
"Se désinscrire d'un événement$",
|
||||
"Une identité fraîche ne voit pas la participation d'une autre",
|
||||
"Une page fraîche pour la même identité relit ses propres données",
|
||||
"Le créateur ne participe pas automatiquement à son événement",
|
||||
"L'inscription fait converger le compteur dérivé du propriétaire",
|
||||
"Un portefeuille connecté est vide par défaut",
|
||||
"Les données du portefeuille sont distinctes des données par défaut",
|
||||
].join("|");
|
||||
const TIMEOUT_UI_MS = 15 * 60 * 1000; // 15 min
|
||||
const TIMEOUT_APODS_MS = 10 * 60 * 1000; // 10 min
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function dirSizeMB(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
try {
|
||||
const result = spawnSync("du", ["-sm", dir], { encoding: "utf-8" });
|
||||
const line = result.stdout.trim().split("\n")[0] ?? "";
|
||||
return parseInt(line.split("\t")[0] ?? "0", 10);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any stale Chromium singleton files from `profilePath`. Chromium refuses
|
||||
* to launch (ProcessSingleton error) if SingletonLock, SingletonCookie, or
|
||||
* SingletonSocket are left over from a previous crashed run. Idempotent — safe to
|
||||
* call even when the profile does not exist yet.
|
||||
*/
|
||||
function cleanSingletons(profilePath: string, label: string): void {
|
||||
if (!fs.existsSync(profilePath)) return;
|
||||
const singletons = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
|
||||
for (const name of singletons) {
|
||||
const p = path.join(profilePath, name);
|
||||
if (fs.existsSync(p)) {
|
||||
try {
|
||||
fs.rmSync(p, { force: true });
|
||||
console.log(`[rotate] ${label}: removed stale ${name}.`);
|
||||
} catch {
|
||||
// Non-fatal: if we can't remove it, launch will fail with a clear error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rotateProfile(profilePath: string, label: string): void {
|
||||
const sizeMB = dirSizeMB(profilePath);
|
||||
if (sizeMB > BLOAT_THRESHOLD_MB) {
|
||||
console.log(
|
||||
`[rotate] ${label}: ${sizeMB}MB > ${BLOAT_THRESHOLD_MB}MB — rotating profile...`,
|
||||
);
|
||||
try {
|
||||
fs.rmSync(profilePath, { recursive: true, force: true });
|
||||
console.log(`[rotate] ${label}: profile removed. Will be recreated.`);
|
||||
} catch (e) {
|
||||
console.warn(`[rotate] ${label}: failed to remove profile: ${e}`);
|
||||
}
|
||||
} else {
|
||||
// Even if we keep the profile, remove any stale Chromium singleton files left
|
||||
// by a previous crashed run — Chromium refuses to launch if they exist.
|
||||
cleanSingletons(profilePath, label);
|
||||
console.log(
|
||||
`[rotate] ${label}: ${sizeMB}MB — below threshold, keeping profile.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface StepResult {
|
||||
label: string;
|
||||
status: "passed" | "failed" | "error";
|
||||
/** Lines to show in the summary (failed scenario names, FAIL lines, etc.) */
|
||||
failures: string[];
|
||||
/** Raw exit code */
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command and capture its output. Returns the result with parsed
|
||||
* pass/fail summary. Never throws — all errors are captured in StepResult.
|
||||
*/
|
||||
function runStep(
|
||||
label: string,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
extraEnv: Record<string, string> = {},
|
||||
): StepResult {
|
||||
/** The Cucumber invocation every layer shares (same form as the UI gate command). */
|
||||
function cucumberArgs(config: string, tags: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/@cucumber/cucumber/bin/cucumber.js",
|
||||
"--config",
|
||||
config,
|
||||
"--tags",
|
||||
tags,
|
||||
];
|
||||
}
|
||||
|
||||
function runStep(label: string, args: string[], timeoutMs: number): StepResult {
|
||||
const t0 = Date.now();
|
||||
console.log(`\n${"═".repeat(60)}`);
|
||||
console.log(`\n${"═".repeat(64)}`);
|
||||
console.log(`▶ ${label}`);
|
||||
console.log(` ${cmd} ${args.join(" ")} (cwd: ${cwd})`);
|
||||
console.log(` timeout: ${Math.round(timeoutMs / 60000)}min`);
|
||||
console.log(`${"═".repeat(60)}`);
|
||||
console.log(` node ${args.join(" ")}`);
|
||||
console.log(`${"═".repeat(64)}`);
|
||||
|
||||
const env = { ...process.env, ...extraEnv };
|
||||
|
||||
const result = spawnSync(cmd, args, {
|
||||
cwd,
|
||||
env,
|
||||
const result = spawnSync("node", args, {
|
||||
cwd: REPO_DIR,
|
||||
env: process.env,
|
||||
encoding: "utf-8",
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 20 * 1024 * 1024, // 20MB
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - t0;
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
const combined = stdout + "\n" + stderr;
|
||||
|
||||
// Print output in real-time equivalent (post-hoc since spawnSync)
|
||||
if (stdout) process.stdout.write(stdout);
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
const combined = (result.stdout ?? "") + "\n" + (result.stderr ?? "");
|
||||
process.stdout.write(result.stdout ?? "");
|
||||
process.stderr.write(result.stderr ?? "");
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[${label}] process error:`, result.error.message);
|
||||
return {
|
||||
label,
|
||||
status: "error",
|
||||
@@ -193,298 +104,66 @@ function runStep(
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
const exitCode = result.status ?? 1;
|
||||
const failures = extractFailures(combined, label);
|
||||
const status = exitCode === 0 ? "passed" : "failed";
|
||||
|
||||
return { label, status, failures, exitCode, durationMs };
|
||||
return {
|
||||
label,
|
||||
status: (result.status ?? 1) === 0 ? "passed" : "failed",
|
||||
failures: extractFailures(combined),
|
||||
exitCode: result.status ?? 1,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful failure lines from combined stdout+stderr.
|
||||
* Heuristics per step type (cucumber scenario names, FAIL lines, etc.).
|
||||
*/
|
||||
function extractFailures(output: string, label: string): string[] {
|
||||
/** The failure lines worth surfacing: failed scenario names and the summary. */
|
||||
function extractFailures(output: string): string[] {
|
||||
const lines = output.split("\n");
|
||||
const failures: string[] = [];
|
||||
|
||||
if (label.includes("polyfill:unit")) {
|
||||
// bun test output: lines starting with "✗" or "FAIL" or "fail"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (/^(✗|✕|FAIL|fail)\s/.test(l) || l.includes("tests failed")) {
|
||||
failures.push(l);
|
||||
}
|
||||
}
|
||||
// Also capture summary line "N passed, M failed"
|
||||
const summary = lines.find(
|
||||
(l) => l.includes("passed") && l.includes("failed"),
|
||||
);
|
||||
if (summary) failures.push(summary.trim());
|
||||
} else if (label.includes("polyfill:e2e")) {
|
||||
// e2e/run.ts output: lines starting with " [FAIL]"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (l.startsWith("[FAIL]")) failures.push(l);
|
||||
}
|
||||
// Summary: "N passed / M failed" style
|
||||
const summary = lines.find(
|
||||
(l) => l.includes("passed") || l.includes("failed"),
|
||||
);
|
||||
if (summary && !failures.includes(summary.trim()))
|
||||
failures.push(summary.trim());
|
||||
} else {
|
||||
// Cucumber steps: look for "✗" scenario lines, "FAILED" scenario names,
|
||||
// or lines beginning with "✖" / "×" / "Scenario:" after a failure tag
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (
|
||||
/^(✗|✕|×|✖)\s/.test(l) ||
|
||||
l.startsWith("✘") ||
|
||||
l.includes("# Scénario:") ||
|
||||
l.includes("# Scenario:") ||
|
||||
(l.startsWith("F") && l.length === 1) // progress-bar failure tick
|
||||
) {
|
||||
if (l.length > 1) failures.push(l);
|
||||
}
|
||||
}
|
||||
// Cucumber "N scenarios (M failed)" summary
|
||||
const summary = lines.find((l) =>
|
||||
/\d+ sc[eé]narios?.*(failed|undefined)/.test(l),
|
||||
);
|
||||
if (summary) failures.push(summary.trim());
|
||||
// Individual scenario fail lines: "✗ Scenario name (features/...)"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (l.startsWith("✗") || l.startsWith("✕")) {
|
||||
if (!failures.includes(l)) failures.push(l);
|
||||
}
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (/^(✗|✕|×|✖|✘)\s/.test(l)) failures.push(l);
|
||||
if (/\d+ sc[eé]narios?.*(failed|undefined)/.test(l) && !failures.includes(l)) {
|
||||
failures.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
return failures.filter(Boolean);
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = ((ms % 60_000) / 1000).toFixed(0);
|
||||
return `${m}m${s}s`;
|
||||
return `${Math.floor(ms / 60_000)}m${((ms % 60_000) / 1000).toFixed(0)}s`;
|
||||
}
|
||||
|
||||
function printMatrix(
|
||||
steps: StepResult[],
|
||||
wipResult: StepResult | null,
|
||||
fullMode: boolean,
|
||||
): void {
|
||||
function printMatrix(steps: StepResult[]): void {
|
||||
console.log("\n");
|
||||
console.log("╔══════════════════════════════════════════════════════════╗");
|
||||
console.log("║ VALIDATION MATRIX ║");
|
||||
if (fullMode) {
|
||||
console.log("║ (mode: --full, @data complet) ║");
|
||||
} else {
|
||||
console.log("║ (mode: défaut, sous-ensemble clé) ║");
|
||||
}
|
||||
console.log("╚══════════════════════════════════════════════════════════╝");
|
||||
console.log("");
|
||||
|
||||
console.log("║ VALIDATION MATRIX ║");
|
||||
console.log("╚══════════════════════════════════════════════════════════╝\n");
|
||||
const maxLabel = Math.max(...steps.map((s) => s.label.length));
|
||||
|
||||
for (const step of steps) {
|
||||
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : "⚠️ ";
|
||||
const pad = step.label.padEnd(maxLabel + 2);
|
||||
console.log(` ${icon} ${pad} [${fmtDuration(step.durationMs)}]`);
|
||||
for (const f of step.failures) {
|
||||
console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (wipResult) {
|
||||
console.log("");
|
||||
console.log(" ── @wip (informational, non-blocking) ──────────────────");
|
||||
const icon =
|
||||
wipResult.status === "passed"
|
||||
? "✅"
|
||||
: wipResult.status === "failed"
|
||||
? "❌"
|
||||
: "⚠️ ";
|
||||
const pad = wipResult.label.padEnd(maxLabel + 2);
|
||||
console.log(` ${icon} ${pad} [${fmtDuration(wipResult.durationMs)}]`);
|
||||
for (const f of wipResult.failures) {
|
||||
console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
const allPassed = steps.every((s) => s.status === "passed");
|
||||
const totalMs = steps.reduce((sum, s) => sum + s.durationMs, 0) +
|
||||
(wipResult?.durationMs ?? 0);
|
||||
if (allPassed) {
|
||||
console.log(" 🟢 ALL STEPS PASSED");
|
||||
} else {
|
||||
const failed = steps.filter((s) => s.status !== "passed");
|
||||
console.log(` 🔴 ${failed.length} STEP(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
|
||||
}
|
||||
console.log(` ⏱ Total: ${fmtDuration(totalMs)}`);
|
||||
if (!fullMode) {
|
||||
console.log(" ℹ️ Pour @data complet : bun run validate -- --full");
|
||||
console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${fmtDuration(step.durationMs)}]`);
|
||||
for (const f of step.failures) console.log(` ↳ ${f}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ─── Cucumber command builder ───────────────────────────────────────────────
|
||||
|
||||
function cucumberArgsByTags(tags: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/.bin/cucumber-js",
|
||||
"--config",
|
||||
"cucumber.json",
|
||||
"--tags",
|
||||
tags,
|
||||
];
|
||||
}
|
||||
|
||||
function cucumberArgsByName(nameRegex: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/.bin/cucumber-js",
|
||||
"--config",
|
||||
"cucumber.json",
|
||||
"--tags",
|
||||
"@data",
|
||||
"--name",
|
||||
nameRegex,
|
||||
];
|
||||
console.log(" ℹ️ @data/@e2e/@multibrowser/@shared-wallet/@smoke : suspendus (workstream NextGraph).");
|
||||
const failed = steps.filter((s) => s.status !== "passed");
|
||||
console.log(failed.length === 0
|
||||
? " 🟢 ALL LAYERS PASSED"
|
||||
: ` 🔴 ${failed.length} LAYER(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
|
||||
console.log(` ⏱ Total: ${fmtDuration(steps.reduce((sum, s) => sum + s.durationMs, 0))}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const fullMode = args.includes("--full");
|
||||
|
||||
if (fullMode) {
|
||||
console.log("🔍 Festipod — Full Validation Run (--full : @data complet)");
|
||||
} else {
|
||||
console.log("🔍 Festipod — Validation Run (sous-ensemble clé)");
|
||||
console.log(" Pour @data complet : bun run validate -- --full");
|
||||
}
|
||||
console.log(` Festipod: ${FESTIPOD_DIR}`);
|
||||
console.log(` Polyfill: ${POLYFILL_DIR}`);
|
||||
console.log("");
|
||||
|
||||
// ── Profile rotation AVANT les étapes broker ──────────────────────────────
|
||||
console.log("── Profile rotation check (avant @data et @multibrowser) ────");
|
||||
rotateProfile(FESTIPOD_PROFILE, "festipod");
|
||||
rotateProfile(POLYFILL_PROFILE, "polyfill-lib");
|
||||
function main(): void {
|
||||
console.log(`🔍 Festipod — validation (repo: ${REPO_DIR})`);
|
||||
|
||||
const steps: StepResult[] = [];
|
||||
steps.push(runStep("festipod:@ui", cucumberArgs("cucumber.json", UI_TAGS), TIMEOUT_UI_MS));
|
||||
steps.push(runStep("festipod:@e2e-activitypods", cucumberArgs("cucumber.smoke.json", APODS_TAGS), TIMEOUT_APODS_MS));
|
||||
|
||||
// ── (a) Polyfill unit tests ───────────────────────────────────────────────
|
||||
steps.push(
|
||||
runStep(
|
||||
"polyfill:unit",
|
||||
"bun",
|
||||
["test"],
|
||||
POLYFILL_DIR,
|
||||
TIMEOUT_POLYFILL_UNIT_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (b) Polyfill e2e real broker ──────────────────────────────────────────
|
||||
// Clean singleton files immediately before launching Chromium — guards against
|
||||
// any file left by polyfill:unit (unlikely but defensive) or by a previous
|
||||
// interrupted run that the initial rotateProfile call ran before.
|
||||
cleanSingletons(POLYFILL_PROFILE, "polyfill-lib (pre-e2e)");
|
||||
steps.push(
|
||||
runStep(
|
||||
"polyfill:e2e",
|
||||
"bun",
|
||||
["run", "e2e/run.ts"],
|
||||
POLYFILL_DIR,
|
||||
TIMEOUT_POLYFILL_E2E_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (c) Festipod @data ────────────────────────────────────────────────────
|
||||
if (fullMode) {
|
||||
// --full : lance tout @data
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@data (complet)",
|
||||
"node",
|
||||
cucumberArgsByTags("@data"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_DATA_FULL_MS,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// défaut : sous-ensemble clé en UNE invocation (BeforeAll partagé)
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@data (clé)",
|
||||
"node",
|
||||
cucumberArgsByName(DATA_KEY_NAME_REGEX),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_DATA_KEY_MS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── (d) Festipod @multibrowser ────────────────────────────────────────────
|
||||
// Exclude @wip: a scenario tagged @wip @multibrowser (e.g. the reactive
|
||||
// cross-session scenario, blocked by the shared-wallet structural limit) must
|
||||
// not gate the baseline — it flows into the informational @wip pass below.
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@multibrowser",
|
||||
"node",
|
||||
cucumberArgsByTags("@multibrowser and not @wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_MULTIBROWSER_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (e) Festipod @smoke — boot connecté rend / page blanche ───────────────
|
||||
// Un seul scénario @e2e : boote le VRAI App, se connecte au broker, et vérifie
|
||||
// que l'accueil connecté rend du contenu d'app réel SANS erreur runtime. Garde
|
||||
// la CLASSE « crash de rendu une fois connecté » (page blanche). On ne lance
|
||||
// QUE @smoke (pas tout @e2e) pour garder le run par défaut rapide.
|
||||
// Nettoie les singletons Chromium juste avant, comme les autres étapes broker.
|
||||
cleanSingletons(FESTIPOD_PROFILE, "festipod (pre-@smoke)");
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@smoke",
|
||||
"node",
|
||||
cucumberArgsByTags("@smoke and not @wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_SMOKE_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (f) Festipod @wip [informational] ────────────────────────────────────
|
||||
console.log("\n── @wip informational pass (non-blocking) ──────────────────");
|
||||
const wipResult = runStep(
|
||||
"festipod:@wip",
|
||||
"node",
|
||||
cucumberArgsByTags("@wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_WIP_MS,
|
||||
);
|
||||
|
||||
// ── Matrix ────────────────────────────────────────────────────────────────
|
||||
printMatrix(steps, wipResult, fullMode);
|
||||
|
||||
// ── Exit code ─────────────────────────────────────────────────────────────
|
||||
const anyFailed = steps.some((s) => s.status !== "passed");
|
||||
process.exit(anyFailed ? 1 : 0);
|
||||
printMatrix(steps);
|
||||
process.exit(steps.some((s) => s.status !== "passed") ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("validate.ts: unhandled error:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
main();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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