f5a3adc385
Notre version décidait d'afficher la barrière sur la présence de l'identifiant. Festipod décidait sur la session — jamais établie en page de tête, donc l'écran s'affichait toujours, l'identifiant servant seulement à pré-remplir le champ. La différence n'est pas ergonomique. L'identifiant est un état qu'on observe ; le portefeuille, lui, vit dans le stockage d'une autre origine et nous est illisible. Un écran conditionnel doit donc DEVINER cet état invisible — et quand il devine « déjà installé » alors que le portefeuille a disparu du navigateur, il cache les seuls contrôles qui répareraient la situation et précipite la personne dans une impasse. Impasse observée sur le site réel : sans portefeuille, la page du broker affiche un texte statique, zéro bouton, un seul lien vers nextgraph.eu qui NE TRANSPORTE AUCUN retour vers l'application. Le retour arrière du navigateur est la seule issue — et il ne sert à rien si la barrière ne reprend pas la personne à l'arrivée. Le discriminant devient le cadre, pas l'identifiant : page de tête → toujours, iframe → on s'efface. C'est le signal que @ng-org/web utilise lui-même et que Festipod utilisait un étage plus bas. On ne détecte rien et on ne demande rien. Les trois étapes s'affichent toujours ; qui possède déjà son portefeuille ignore les deux premières. Aucune case « je l'ai déjà » : savoir si l'on a importé un portefeuille dans ce navigateur est une question trop technique pour être posée. Garde-fou repris de Festipod, qu'on n'avait pas : après un aller-retour vers l'onglet NextGraph et un retour arrière, la barrière restait figée sur un bouton mort. Elle recharge désormais sur pageshow persisted — seul moyen de rejouer le init() qui porte la redirection. Vérifié sur navigateur : cliquer « Import a Wallet File » ouvre un sélecteur sur place, sans navigation ni changement d'onglet, et notre onglet ne reçoit AUCUN signal quand l'import réussit. Détecter le retour est donc impossible, pas seulement fragile.
711 lines
39 KiB
TypeScript
711 lines
39 KiB
TypeScript
/**
|
|
* The APPLICATIVE e2e suite — the same broker, driven through the example application.
|
|
*
|
|
* ── Why this exists beside `run.ts` ───────────────────────────────────────
|
|
* `run.ts` drives a bag of methods on `window.__sdk`. That proves the functions RUN; it
|
|
* cannot prove an application can be written with them, and the difference has already
|
|
* cost a shipped defect: a document's inbox was green there and unusable in practice,
|
|
* because the harness handed an address across an identity boundary through a JS
|
|
* variable — a channel no application has.
|
|
*
|
|
* This suite has no such channel. It drives `examples/notebook` through the DOM, one
|
|
* browser page per identity, and the only things that cross between them are the ones
|
|
* that cross in reality: a note's REFERENCE (copied from Alice's screen, as a human
|
|
* would copy it into a message) and an identifier typed into a field. Everything else
|
|
* each actor must OBTAIN through the application.
|
|
*
|
|
* The division of labour with `run.ts`: platform contracts, primitive characterisation
|
|
* and cold-start regressions stay there — they need privileged access, fresh profiles
|
|
* and raw SPARQL, and they are about the broker, not about an application. What lives
|
|
* here is the journeys, and they read as journeys.
|
|
*
|
|
* ── Why a bare reference is allowed to cross ──────────────────────────────
|
|
* Because the model says it circulates: it names a note and grants nothing, and if the
|
|
* note sits in a public store its cap is served to whoever asks
|
|
* (`emulated-verifier/public-store.ts`). A test that had to pass a KEY between actors
|
|
* would be describing something no application can do — that is the line, and it is the
|
|
* reason the application displays each note's reference: what no screen shows, no user
|
|
* can circulate.
|
|
*/
|
|
|
|
import { type BrowserContext, type Frame, type Page } from "playwright";
|
|
import { execSync } from "node:child_process";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
PROFILE_DIR,
|
|
WALLET_PASSWORD,
|
|
closeContext,
|
|
completeBrokerLogin,
|
|
ensureWallet,
|
|
exportWalletNgw,
|
|
importWalletViaFile,
|
|
launchCleanProfileContext,
|
|
launchWalletContext,
|
|
newPage,
|
|
serveOnEphemeralPort,
|
|
setupBrokerPage,
|
|
} from "./broker";
|
|
import { armSuiteDeadline, closeQuietly, within } from "./deadline";
|
|
import { acquireRunLock } from "./run-lock";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const APP_DIR = path.resolve(__dirname, "..", "..", "..", "examples", "notebook");
|
|
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "notebook.js");
|
|
/** Where the application configures its shared wallet (`examples/notebook/app.ts`). */
|
|
const WALLET_PATH = "/shared-wallet.ngw";
|
|
|
|
// ── bounds ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The batch's wall clock. A healthy run measures ~1.5 min; the longest single wait in it is
|
|
* the newcomer's cold first render, bounded at 3 min. Fifteen is an order of magnitude of
|
|
* room and still an answer inside a coffee break — which is the point, since what this
|
|
* replaces is a run that was killed by hand at 68 minutes having printed nothing.
|
|
*/
|
|
const SUITE_DEADLINE_MS = 15 * 60 * 1000;
|
|
/**
|
|
* One journey. The longest (the newcomer's) does a wallet download, an import into a cold
|
|
* profile, a broker round-trip and a first render — measured under 2 min, bounded at 6.
|
|
* The returning visitor's does that twice over and measures 36s, so it fits the same bound
|
|
* with room to spare; a bound of its own was tried and dropped, because on a healthy host
|
|
* nothing justified it and an 11-minute journey can outlast the SUITE's own deadline —
|
|
* which prints no summary at all. A journey that overruns THIS is a hang or a sick host,
|
|
* and both are worth hearing about rather than absorbing.
|
|
*/
|
|
const JOURNEY_MS = 6 * 60 * 1000;
|
|
/** Signing an actor in: broker redirect, unlock, iframe, first render. Measured ~5-10s. */
|
|
const SIGN_IN_MS = 3 * 60 * 1000;
|
|
|
|
// ── reporting ───────────────────────────────────────────────────────────────
|
|
|
|
type Check = { name: string; ok: boolean; detail?: string };
|
|
const results: Check[] = [];
|
|
function check(name: string, ok: boolean, detail?: string): void {
|
|
results.push({ name, ok, detail });
|
|
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
|
|
}
|
|
/**
|
|
* A journey, under its own deadline.
|
|
*
|
|
* The bound is what makes the catch honest: catching everything and recording a FAIL is
|
|
* right for a journey that fails, but a journey that never RETURNS is caught by nothing —
|
|
* and that is what three killed runs looked like from the outside.
|
|
*/
|
|
async function journey(name: string, fn: () => Promise<void>, boundMs = JOURNEY_MS): Promise<void> {
|
|
console.log(`\n── ${name} ──`);
|
|
try {
|
|
await within(`the journey "${name}"`, boundMs, fn);
|
|
} catch (e: any) {
|
|
check(name, false, "threw: " + String(e?.message ?? e));
|
|
}
|
|
}
|
|
|
|
// ── build + serve the application, exactly as a deployment would ────────────
|
|
|
|
function buildApp(): void {
|
|
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
|
|
execSync(`bun build ${path.join(APP_DIR, "app.ts")} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
|
|
stdio: "pipe",
|
|
cwd: APP_DIR,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Serve the application the way a deployment would — including the two things the access
|
|
* gate hands a first-time user, which nothing served before.
|
|
*
|
|
* The **wallet file**: `app.ts` configures `fileUrl: "/shared-wallet.ngw"`, and no such
|
|
* file exists in the repository (nor should one — a wallet is never committed). So the
|
|
* deployment supplies it, and here that is this server, from bytes exported at test time.
|
|
*
|
|
* The **password**: `app.ts` reads it from `__NOTEBOOK_WALLET_PASSWORD__`, resolved "at
|
|
* its own build" as `SharedWalletConfig` requires — the library reads no environment. The
|
|
* inline script below is that resolution; without it the barrier displays an empty
|
|
* password and no import can succeed.
|
|
*
|
|
* And an unknown path now 404s instead of returning the page. That is not tidiness: the
|
|
* catch-all made `/shared-wallet.ngw` answer 200 with the application's own HTML, so a
|
|
* download of a file NOBODY served looked like a perfectly good download. The check that
|
|
* the link resolves could not have failed.
|
|
*/
|
|
function serveApp(walletFile: Buffer, walletPassword: string): Promise<{ url: string; close: () => void }> {
|
|
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
|
|
const page = fs.readFileSync(path.join(APP_DIR, "index.html"), "utf-8");
|
|
const moduleTag = '<script type="module" src="/app.js"></script>';
|
|
if (!page.includes(moduleTag)) {
|
|
throw new Error(`[e2e/app] cannot inject the wallet password: ${moduleTag} not found in index.html`);
|
|
}
|
|
const html = page.replace(
|
|
moduleTag,
|
|
`<script>globalThis.__NOTEBOOK_WALLET_PASSWORD__ = ${JSON.stringify(walletPassword)};</script>\n ${moduleTag}`,
|
|
);
|
|
return serveOnEphemeralPort((req, res) => {
|
|
const route = (req.url ?? "/").split("?")[0];
|
|
if (route === "/app.js") {
|
|
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
|
res.end(bundle);
|
|
} else if (route === WALLET_PATH) {
|
|
res.writeHead(200, {
|
|
"Content-Type": "application/octet-stream",
|
|
"Content-Disposition": 'attachment; filename="shared-wallet.ngw"',
|
|
});
|
|
res.end(walletFile);
|
|
} else if (route === "/" || route === "/index.html") {
|
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
res.end(html);
|
|
} else {
|
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
res.end("not found");
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── one actor = one page, signed in as one identity ─────────────────────────
|
|
|
|
/**
|
|
* An actor is a browser page carrying its own identity. Nothing is shared between two
|
|
* actors but the broker and the application's URL — which is what makes a value crossing
|
|
* from one to the other visible in this file, instead of hidden in a closure.
|
|
*/
|
|
interface Actor {
|
|
id: string;
|
|
frame: Frame;
|
|
page: Page;
|
|
}
|
|
|
|
async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise<Actor> {
|
|
return within(`${id} to sign in`, SIGN_IN_MS, async () => {
|
|
const page = await newPage(id, ctx);
|
|
page.on("pageerror", (e) => console.error(`[${id} pageerror]`, e.message));
|
|
page.on("console", (m) => {
|
|
if (m.type() === "error") console.error(`[${id} console]`, m.text());
|
|
});
|
|
// `?ng-id=` is the ONE channel that survives the broker round-trip (the access gate's
|
|
// resolution order, `shared-wallet/access-gate.ts`). Here it is also how the suite
|
|
// signs an actor in without typing.
|
|
//
|
|
// No barrier is met on this path, and the reason is the FRAME, not the identifier:
|
|
// `setupBrokerPage` goes straight to the broker's redirect, so the application only
|
|
// ever loads inside the iframe — where the round-trip is already behind it. The two
|
|
// journeys that load the application's own address top-level do meet the barrier, and
|
|
// must: that is the side a person actually arrives on.
|
|
const frame = await setupBrokerPage(page, `${appUrl}/?ng-id=${encodeURIComponent(id)}`);
|
|
await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 60000 });
|
|
return { id, frame, page };
|
|
});
|
|
}
|
|
|
|
// ── the acts, expressed as the application expresses them ───────────────────
|
|
|
|
/**
|
|
* Show the notes of `scope` — the list is per-scope, so acting on a note means looking at
|
|
* the right shelf first.
|
|
*
|
|
* The wait is not decoration: the application's `change` handler runs `void refresh()`,
|
|
* un-awaited, so reading `textContent` straight after `selectOption` reads the PREVIOUS
|
|
* shelf. A suite that asserts "Bob's list does not contain Alice's note" against a list
|
|
* that has not re-rendered is green whether isolation holds or not — found adversarially,
|
|
* 2026-08-10.
|
|
*/
|
|
async function showScope(a: Actor, scope: string, settle: string): Promise<void> {
|
|
await a.frame.locator('[data-testid="scope"]').selectOption(scope);
|
|
// The list is rebuilt wholesale; waiting for the marker the caller expects (or for the
|
|
// list to be empty) is the only signal the application offers.
|
|
//
|
|
// This wait is NOT a synchronisation point when the marker is ALREADY on screen — it
|
|
// matches on the first poll and returns before the in-flight `refresh()` has done its
|
|
// broker round-trips. A check reading the list right after is then reading the previous
|
|
// render. Where a journey needs a FRESH list, it must create its own synchronisation
|
|
// point (a write it awaits), not lean on this. Found adversarially, 2026-08-10.
|
|
//
|
|
// No `.catch` swallowing the timeout either: a list that never settles is a failure to
|
|
// see, not a degradation to absorb — swallowing it reinstated the very bug this wait
|
|
// was added to fix.
|
|
await a.frame
|
|
.locator(`[data-testid="notes"]:has-text("${settle}"), [data-testid="notes"]:empty`)
|
|
.first()
|
|
.waitFor({ timeout: 60000 });
|
|
}
|
|
|
|
async function writeNote(a: Actor, scope: string, title: string, body: string): Promise<void> {
|
|
await a.frame.locator('[data-testid="title"]').fill(title);
|
|
await a.frame.locator('[data-testid="body"]').fill(body);
|
|
// No settle marker to wait for here: the write below is its own synchronisation point,
|
|
// and the shelf we are switching to may legitimately be empty or hold anything.
|
|
await a.frame.locator('[data-testid="scope"]').selectOption(scope);
|
|
await a.frame.locator('[data-testid="write"]').click();
|
|
await a.frame.locator(`li:has-text("${title}")`).waitFor({ timeout: 60000 });
|
|
}
|
|
|
|
/** The reference the application SHOWS for a note — what a human would copy out. */
|
|
async function referenceOnScreen(a: Actor, title: string): Promise<string> {
|
|
return (await a.frame.locator(`li:has-text("${title}") code.ref`).textContent())?.trim() ?? "";
|
|
}
|
|
|
|
/**
|
|
* Paste a reference and open it. The application blanks its answer before reading, so
|
|
* waiting for a NON-EMPTY answer here cannot be satisfied by the previous one — a trap
|
|
* this suite fell into on its first run, where a stale "readable" made an unreadable
|
|
* note look readable.
|
|
*/
|
|
async function openReceivedNote(a: Actor, reference: string): Promise<string> {
|
|
await a.frame.locator('[data-testid="reference"]').fill(reference);
|
|
await a.frame.locator('[data-testid="open-reference"]').click();
|
|
const out = a.frame.locator('[data-testid="shared"]');
|
|
await out.filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }).catch(() => {});
|
|
return (await out.textContent())?.trim() ?? "";
|
|
}
|
|
|
|
async function shareNoteWith(a: Actor, title: string, withId: string): Promise<void> {
|
|
await a.frame.locator('[data-testid="share-with"]').fill(withId);
|
|
await a.frame.locator(`li:has-text("${title}") button.share`).click();
|
|
await a.frame.locator('[data-testid="share-result"]').filter({ hasText: "partagé" }).waitFor({ timeout: 60000 });
|
|
}
|
|
|
|
async function openForMessages(a: Actor, title: string): Promise<void> {
|
|
await a.frame.locator(`li:has-text("${title}") button.open`).click();
|
|
await a.frame
|
|
.locator('[data-testid="share-result"]')
|
|
.filter({ hasText: "ouverte aux messages" })
|
|
.waitFor({ timeout: 60000 });
|
|
}
|
|
|
|
async function leaveMessage(a: Actor, reference: string, text: string): Promise<void> {
|
|
await a.frame.locator('[data-testid="on-note"]').fill(reference);
|
|
await a.frame.locator('[data-testid="message"]').fill(text);
|
|
await a.frame.locator('[data-testid="leave"]').click();
|
|
await a.frame.locator('[data-testid="left"]').filter({ hasText: "déposé" }).waitFor({ timeout: 60000 });
|
|
}
|
|
|
|
async function readMessages(a: Actor, title: string): Promise<string> {
|
|
await a.frame.locator(`li:has-text("${title}") button.msgs`).click();
|
|
const out = a.frame.locator('[data-testid="messages"]');
|
|
await out.filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }).catch(() => {});
|
|
return (await out.textContent())?.trim() ?? "";
|
|
}
|
|
|
|
/** Reload the page: what a user does, and what makes a durable fact distinguishable
|
|
* from one that only lived in this tab's memory. */
|
|
async function reopen(ctx: BrowserContext, appUrl: string, a: Actor): Promise<Actor> {
|
|
await closeQuietly(`${a.id}'s previous page`, () => a.page.close());
|
|
return signIn(ctx, appUrl, a.id);
|
|
}
|
|
|
|
// ── the journeys ────────────────────────────────────────────────────────────
|
|
|
|
async function main(): Promise<void> {
|
|
// Before anything touches the shared profile: this batch is about to DELETE it (see
|
|
// `ensureWallet`), so a second run alive right now would be destroyed by this one.
|
|
acquireRunLock("the applicative suite (e2e/notebook.ts)", PROFILE_DIR);
|
|
armSuiteDeadline("the applicative suite", SUITE_DEADLINE_MS);
|
|
console.log("[e2e/app] building the example application...");
|
|
buildApp();
|
|
console.log("[e2e/app] ensuring the batch wallet...");
|
|
await ensureWallet();
|
|
|
|
const t = Date.now().toString(36);
|
|
const ALICE = `alice-${t}`;
|
|
const BOB = `bob-${t}`;
|
|
// The wallet the barrier hands out, and the newcomer's download of it. Both under a
|
|
// temp dir, removed at the end: a wallet file is an identity, and one must never be
|
|
// committed — `*.ngw` is gitignored besides, which is the belt to this brace.
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-notebook-"));
|
|
const sharedWalletFile = path.join(tmpDir, "shared-wallet.ngw");
|
|
|
|
let ctx: BrowserContext | null = null;
|
|
let closeServer: (() => void) | null = null;
|
|
const startedAt = Date.now();
|
|
|
|
try {
|
|
// THIS batch's wallet, as bytes. It has to be the very one the other actors live in —
|
|
// one shared wallet hosts every identity — and its bytes exist only inside the broker
|
|
// iframe, so a harness page is what fetches them.
|
|
//
|
|
// In a context of its OWN, opened and closed before the actors'. When it shared
|
|
// theirs, one run had all four existing journeys collapse together — Alice and Bob
|
|
// signed in, then every `[data-testid=…]` vanished from their frames, which is what a
|
|
// RELOADED iframe looks like from here. Observed once and not reproduced, so the
|
|
// mechanism is a suspicion, not a finding: closing a broker page may end the session
|
|
// its profile-mates are using. Cheap to avoid, so avoided — the actors inherit
|
|
// nothing. Sequential contexts over the one profile dir; never two at once.
|
|
console.log("[e2e/app] exporting the wallet the barrier hands out...");
|
|
const exportCtx = await launchWalletContext("wallet-export");
|
|
let walletSize = 0;
|
|
try {
|
|
walletSize = await exportWalletNgw(exportCtx, sharedWalletFile);
|
|
} finally {
|
|
await closeContext("wallet-export", exportCtx);
|
|
}
|
|
|
|
ctx = await launchWalletContext("actors");
|
|
const served = await serveApp(fs.readFileSync(sharedWalletFile), WALLET_PASSWORD);
|
|
closeServer = served.close;
|
|
const url = served.url;
|
|
console.log(`[e2e/app] application served at ${url} (shared wallet: ${walletSize} bytes)`);
|
|
|
|
const alice = await signIn(ctx, url, ALICE);
|
|
check("Alice signs in and the application knows who she is", true, `who=${ALICE}`);
|
|
const bob = await signIn(ctx, url, BOB);
|
|
check("Bob signs in, in his own space", true, `who=${BOB}`);
|
|
|
|
// 1. A public note travels on its reference alone — the property the public-store
|
|
// emulation exists for. Nothing but the reference crosses, and no key does.
|
|
let publicRef = "";
|
|
await journey("Bob reads Alice's public note from its reference alone", async () => {
|
|
await writeNote(alice, "public", "Courses", "pain, café");
|
|
publicRef = await referenceOnScreen(alice, "Courses");
|
|
check("the application SHOWS the reference, so a human can circulate it", /^did:ng:/.test(publicRef), publicRef);
|
|
// The one value that crosses, and it crosses the way it would in life: copied off
|
|
// one screen, pasted into another. It carries no key.
|
|
const read = await openReceivedNote(bob, publicRef);
|
|
check("Bob reads it holding nothing but that reference", read.includes("Courses") && read.includes("pain, café"), read);
|
|
check("the reference carried no key", !publicRef.includes(":r:"), publicRef);
|
|
});
|
|
|
|
// 2. A protected note does NOT travel on its reference — until its owner shares it.
|
|
// Same gesture on Bob's side, opposite outcome, decided by where the note sits.
|
|
let secretRef = "";
|
|
await journey("Alice's protected note stays shut until she gives Bob the key", async () => {
|
|
await writeNote(alice, "protected", "Anniversaire", "surprise pour Bob");
|
|
secretRef = await referenceOnScreen(alice, "Anniversaire");
|
|
const before = await openReceivedNote(bob, secretRef);
|
|
check("Bob can NAME it and reads nothing of it", !before.includes("surprise"), before || "(illisible)");
|
|
|
|
await shareNoteWith(alice, "Anniversaire", BOB);
|
|
// Bob reopens the application: connecting is what applies what was deposited for
|
|
// him. He calls nothing — there is no "receive" in this model.
|
|
const bob2 = await reopen(ctx!, url, bob);
|
|
const after = await openReceivedNote(bob2, secretRef);
|
|
check("after Alice shares it, the same reference opens it", after.includes("surprise pour Bob"), after);
|
|
bob.frame = bob2.frame;
|
|
bob.page = bob2.page;
|
|
});
|
|
|
|
// 3. A note opened for messages: anyone deposits, only its owner reads. Bob addresses
|
|
// the NOTE — he never names an inbox, and no application should have to.
|
|
await journey("Bob leaves a message on Alice's note, and only Alice reads it", async () => {
|
|
await showScope(alice, "public", "Courses"); // her public shelf
|
|
await openForMessages(alice, "Courses");
|
|
// Bob has to REOPEN so the address published on the note is visible to his session.
|
|
const bob2 = await reopen(ctx!, url, bob);
|
|
await leaveMessage(bob2, publicRef, "j'apporte le café");
|
|
const mine = await readMessages(alice, "Courses");
|
|
check("Alice reads the message left on her note", mine.includes("j'apporte le café"), mine);
|
|
bob.frame = bob2.frame;
|
|
bob.page = bob2.page;
|
|
});
|
|
|
|
// 4. Each actor lists their OWN notes and nothing else — the boundary, seen from
|
|
// the only place that matters: what the screen shows.
|
|
await journey("each actor's list holds their own notes, and no one else's", async () => {
|
|
// POSITIVE CONTROL. Bob writes a public note of his own first — without it his list
|
|
// is empty whatever the boundary does, and "it does not contain Alice's note" is
|
|
// true for the wrong reason. The assertion has to be able to fail.
|
|
await writeNote(bob, "public", "Vélo", "réviser les freins");
|
|
await showScope(bob, "public", "Vélo");
|
|
const bobList = (await bob.frame.locator('[data-testid="notes"]').textContent()) ?? "";
|
|
|
|
// Alice's list has to be re-rendered AFTER Bob's note exists, or "she does not see
|
|
// it" is read off a stale snapshot and holds whatever the boundary does. Writing a
|
|
// note is the synchronisation point the application offers: `writeNote` awaits the
|
|
// new entry appearing, so what follows is a render that post-dates Bob's.
|
|
await writeNote(alice, "public", "Timbres", "en acheter un carnet");
|
|
const aliceList = (await alice.frame.locator('[data-testid="notes"]').textContent()) ?? "";
|
|
|
|
check("Alice sees her own notes", aliceList.includes("Courses") && aliceList.includes("Timbres"), aliceList.slice(0, 60));
|
|
check("Bob sees HIS own note — the control that lets the next check fail", bobList.includes("Vélo"), bobList.slice(0, 60));
|
|
check("Bob's list does not contain Alice's note", !bobList.includes("Courses"), bobList.slice(0, 60));
|
|
check("Alice's list does not contain Bob's note", !aliceList.includes("Vélo"), aliceList.slice(0, 60));
|
|
});
|
|
|
|
// 5. The path no journey walked: somebody who holds NOTHING. No wallet in the
|
|
// profile, no identifier anywhere, and the application's own address — not the
|
|
// broker redirect `signIn()` goes through, which loads the application already
|
|
// inside the iframe and so never meets the barrier. Two defects shipped green
|
|
// behind that shortcut: the
|
|
// application handed the page to the broker BEFORE the barrier could show (a
|
|
// first-time user landed on a login with no wallet and no way to get one), and the
|
|
// file the barrier offers was served by nobody, so its link pointed at a 404.
|
|
//
|
|
// Its own browser profile, deliberately: a wallet already in the profile is the
|
|
// other half of the same shortcut, and it is exactly what a first-time device
|
|
// does not have.
|
|
await journey("a first-time user, holding nothing, gets in through the barrier", async () => {
|
|
const newcomer = `newcomer-${t}`;
|
|
const downloaded = path.join(tmpDir, "downloaded-at-the-barrier.ngw");
|
|
const fresh = await launchCleanProfileContext();
|
|
let page: Page | null = null;
|
|
try {
|
|
page = await fresh.ctx.newPage();
|
|
page.on("pageerror", (e) => console.error("[newcomer pageerror]", e.message));
|
|
page.on("console", (m) => {
|
|
if (m.type() === "error") console.error("[newcomer console]", m.text());
|
|
});
|
|
|
|
// The address a link in an email gives: the application, nothing appended.
|
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
const gate = page.locator('[data-ng-eventually="access-gate"]');
|
|
const identityField = page.locator('[data-testid="ng-identity-input"]');
|
|
await identityField.waitFor({ state: "visible", timeout: 30000 });
|
|
// Still on the application's own page — the hand-over has NOT happened. That is
|
|
// the whole of the first defect: `init()` navigated first, so everything the
|
|
// application did next ran in a document that no longer existed.
|
|
check(
|
|
"the barrier appears, and the page has not been handed to the broker yet",
|
|
page.url().startsWith(url),
|
|
page.url(),
|
|
);
|
|
|
|
// Step 1 of the barrier: the wallet file. Captured through the browser's own
|
|
// download, which is the only thing that can say whether the link RESOLVES.
|
|
const [download] = await Promise.all([
|
|
page.waitForEvent("download", { timeout: 30000 }),
|
|
gate.locator("a[download]").click(),
|
|
]);
|
|
const failure = await download.failure();
|
|
if (failure === null) await download.saveAs(downloaded);
|
|
const bytes = fs.existsSync(downloaded) ? fs.statSync(downloaded).size : 0;
|
|
check(
|
|
"the barrier's download link serves a wallet file, not a 404",
|
|
failure === null && bytes > 0,
|
|
`failure=${failure ?? "none"} bytes=${bytes}`,
|
|
);
|
|
|
|
// Step 2: the password, read off the screen the way a user reads it. An injected
|
|
// value nobody looks at is a value that can be silently empty.
|
|
const password = ((await gate.locator("code").first().textContent()) ?? "").trim();
|
|
check("the barrier shows the password for the import", password.length > 0, password);
|
|
|
|
// Step 3: the wallet application, opened by the barrier's own link — a second tab,
|
|
// which is what `target="_blank"` gives. Nothing proves the downloaded bytes ARE a
|
|
// wallet except this import working, which is why the journey imports what it
|
|
// downloaded and nothing else.
|
|
const [walletPage] = await Promise.all([
|
|
fresh.ctx.waitForEvent("page", { timeout: 30000 }),
|
|
gate.locator('a[target="_blank"]').click(),
|
|
]);
|
|
await importWalletViaFile(walletPage, downloaded, password);
|
|
await walletPage.close().catch(() => {});
|
|
|
|
// Step 4: the identifier, typed. That is what settles the identity — and what
|
|
// `init()` then puts in the address bar before it hands the page over.
|
|
await identityField.fill(newcomer);
|
|
await page.locator('[data-testid="ng-identity-enter"]').click();
|
|
|
|
// The APPLICATION navigates, not the test; then the broker loads it back inside
|
|
// its iframe. `setupBrokerPage` is deliberately not used here — it would
|
|
// re-navigate and throw away the URL the application had just built.
|
|
await page.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {});
|
|
check("the application hands the page to the broker itself", /nextgraph\./.test(page.url()), page.url());
|
|
const frame = await completeBrokerLogin(page, url);
|
|
check(
|
|
"the application comes back inside the broker iframe",
|
|
frame !== page.mainFrame() && /nextgraph\./.test(page.mainFrame().url()),
|
|
`top=${page.mainFrame().url().slice(0, 40)}… app=${frame.url().slice(0, 40)}…`,
|
|
);
|
|
|
|
// THE assertion this journey exists for. The identifier crosses the round-trip in
|
|
// the URL and nowhere else — storage is partitioned by top-level site. Lose it and
|
|
// nothing throws: the iframe reads an empty identity, provisions a SECOND virtual
|
|
// space, and the user lands somewhere empty that looks like a working application.
|
|
check("the identifier survived the round-trip in the URL", frame.url().includes(`ng-id=${newcomer}`), frame.url());
|
|
const arrived: Actor = { id: newcomer, frame, page };
|
|
// A longer bound than `signIn`'s, and for a reason the other suite already
|
|
// measured: this profile is COLD — a wallet just imported, no local copy of any
|
|
// repo — so the first render waits on provisioning that round-trips the broker
|
|
// per repo (`run.ts` bounds the same cold-start generously for the same reason).
|
|
// A bound, not a sleep: it fails if the application never comes up.
|
|
await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 });
|
|
const who = ((await frame.locator('[data-testid="who"]').textContent()) ?? "").trim();
|
|
check("the application knows the newcomer as the identity he typed", who.includes(newcomer), who);
|
|
check(
|
|
"the barrier does not ask again inside the broker iframe",
|
|
(await frame.locator('[data-testid="ng-identity-input"]').count()) === 0,
|
|
);
|
|
|
|
// And it is a usable session, not merely a name on a screen.
|
|
await writeNote(arrived, "protected", "Première note", "écrite juste après la barrière");
|
|
const list = (await frame.locator('[data-testid="notes"]').textContent()) ?? "";
|
|
check(
|
|
"the newcomer writes a note and reads it back, as himself",
|
|
list.includes("Première note") && list.includes("écrite juste après la barrière"),
|
|
list.replace(/\s+/g, " ").slice(0, 70),
|
|
);
|
|
} finally {
|
|
if (page) await closeQuietly("the newcomer's page", () => page!.close());
|
|
await closeContext("clean-profile", fresh.ctx);
|
|
try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
// 6. The visit AFTER the first one, on the application's own address. The barrier used
|
|
// to skip itself here — it asked only when nobody was known — and skipping is
|
|
// silent: the page goes straight to the broker, and someone whose browser no longer
|
|
// holds the wallet lands on a static dead end with no return path. The wallet is
|
|
// handed out at the barrier and nowhere else, so the barrier has to be there.
|
|
//
|
|
// Nothing is seeded. The identifier this journey expects to find prefilled is the
|
|
// one it typed itself, one visit earlier, at the real barrier, on a device that
|
|
// started with nothing — the only way a person obtains it, and the only way this
|
|
// journey may (`rule_never-shortcut-the-sign-in`). The wallet is not planted either:
|
|
// it comes off the barrier's own link and through a real import, as it does above.
|
|
//
|
|
// Its OWN browser profile, for the same reason the newcomer's journey has one: a
|
|
// device that starts with nothing is the only one on which the wallet can be
|
|
// OBTAINED rather than found already there. It also keeps this journey off the
|
|
// actors' profile, which no journey should be adding broker pages to.
|
|
await journey("a returning visitor meets the barrier again, prefilled, and keeps their space", async () => {
|
|
const returning = `returning-${t}`;
|
|
const downloaded = path.join(tmpDir, "downloaded-by-the-returning-visitor.ngw");
|
|
const fresh = await launchCleanProfileContext();
|
|
let first: Page | null = null;
|
|
let again: Page | null = null;
|
|
const startedAtJourney = Date.now();
|
|
/**
|
|
* Progress, not assertion. This journey is the longest in the suite, and when it
|
|
* overran its bound it had reported NOTHING — so there was no way to tell a slow
|
|
* broker from a genuine hang, or to know which step to look at.
|
|
*/
|
|
const at = (what: string): void =>
|
|
console.log(` · ${what} (+${((Date.now() - startedAtJourney) / 1000).toFixed(0)}s)`);
|
|
/** Open the application's OWN address, top-level, and wait for the barrier. */
|
|
const arriveAtTheBarrier = async (label: string): Promise<Page> => {
|
|
const p = await fresh.ctx.newPage();
|
|
p.on("pageerror", (e) => console.error(`[${label} pageerror]`, e.message));
|
|
p.on("console", (m) => {
|
|
if (m.type() === "error") console.error(`[${label} console]`, m.text());
|
|
});
|
|
await p.goto(url, { waitUntil: "domcontentloaded" });
|
|
await p.locator('[data-testid="ng-identity-input"]').waitFor({ state: "visible", timeout: 30000 });
|
|
return p;
|
|
};
|
|
|
|
try {
|
|
// The FIRST visit — how the identifier and the wallet come to exist at all on this
|
|
// device. Both are obtained here, neither is handed over by the test.
|
|
first = await arriveAtTheBarrier("returning-first-visit");
|
|
at("first visit: the barrier is up");
|
|
const gate = first.locator('[data-ng-eventually="access-gate"]');
|
|
const [download] = await Promise.all([
|
|
first.waitForEvent("download", { timeout: 30000 }),
|
|
gate.locator("a[download]").click(),
|
|
]);
|
|
await download.saveAs(downloaded);
|
|
at("first visit: the wallet is downloaded");
|
|
const password = ((await gate.locator("code").first().textContent()) ?? "").trim();
|
|
const [walletPage] = await Promise.all([
|
|
fresh.ctx.waitForEvent("page", { timeout: 30000 }),
|
|
gate.locator('a[target="_blank"]').click(),
|
|
]);
|
|
await importWalletViaFile(walletPage, downloaded, password);
|
|
await walletPage.close().catch(() => {});
|
|
at("first visit: the wallet is imported on this device");
|
|
await first.locator('[data-testid="ng-identity-input"]').fill(returning);
|
|
await first.locator('[data-testid="ng-identity-enter"]').click();
|
|
// The APPLICATION navigates, and it has to have DONE so before the broker login is
|
|
// driven: until then the top-level frame is still the application's own, and
|
|
// `completeBrokerLogin` would hand back that frame — which then navigates away, so
|
|
// everything waited for on it waits forever. Cost two runs to see.
|
|
await first.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {});
|
|
at("first visit: handed over to the broker");
|
|
const firstFrame = await completeBrokerLogin(first, url);
|
|
await firstFrame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 });
|
|
// A note, so the second visit can be shown to land in the SAME space rather than
|
|
// merely displaying the same name.
|
|
at("first visit: the application is up");
|
|
await writeNote({ id: returning, frame: firstFrame, page: first }, "protected", "Carnet", "de la première visite");
|
|
at("first visit: a note is written");
|
|
// Closed here, not merely at the end: the second visit has to be a fresh page that
|
|
// finds the identifier where the FIRST one left it, not a tab still holding it —
|
|
// and one broker page at a time in this profile.
|
|
await closeQuietly("the first visit's page", () => first!.close());
|
|
first = null;
|
|
|
|
// The SECOND visit — the same address a bookmark would open, nothing appended. The
|
|
// device now holds the wallet, and the barrier still neither knows nor asks: the
|
|
// steps are offered again and this person walks past them to the field. Whether a
|
|
// wallet was imported lives in another origin's storage and is unreadable, so the
|
|
// alternative would be asking them — the question this design refuses.
|
|
again = await arriveAtTheBarrier("returning-second-visit");
|
|
at("return visit: the barrier is up again");
|
|
check(
|
|
"the barrier still hands out the wallet without asking whether they have it",
|
|
(await again.locator('[data-ng-eventually="access-gate"]').locator("a[download]").count()) === 1,
|
|
);
|
|
check(
|
|
"the barrier appears again, on a visit where the identifier is already known",
|
|
again.url().startsWith(url),
|
|
again.url(),
|
|
);
|
|
const prefilled = await again.locator('[data-testid="ng-identity-input"]').inputValue();
|
|
check(
|
|
"and it arrives prefilled — one click, nothing to retype",
|
|
prefilled === returning,
|
|
`field=${prefilled || "(vide)"}`,
|
|
);
|
|
|
|
// Confirmed, not retyped: what settles the identity here is the value the barrier
|
|
// itself put in the field.
|
|
await again.locator('[data-testid="ng-identity-enter"]').click();
|
|
await again.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {});
|
|
check(
|
|
"confirming the prefilled field is what hands the page over",
|
|
/nextgraph\./.test(again.url()) && again.url().includes(`ng-id%3D${returning}`),
|
|
again.url(),
|
|
);
|
|
const backFrame = await completeBrokerLogin(again, url);
|
|
at("return visit: back inside the broker iframe");
|
|
await backFrame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 });
|
|
const who = ((await backFrame.locator('[data-testid="who"]').textContent()) ?? "").trim();
|
|
check("the round-trip brings them back as the same identity", who.includes(returning), who);
|
|
|
|
// The identity is one space, not two — the failure that skipping the barrier used
|
|
// to hide was precisely a SECOND virtual space that looked like a working
|
|
// application. A note written before the round-trip is what tells them apart.
|
|
const arrived: Actor = { id: returning, frame: backFrame, page: again };
|
|
await showScope(arrived, "protected", "Carnet");
|
|
// `showScope` settles on an EMPTY list too, and a page this fresh can render one
|
|
// before its repos have synchronised — so the marker gets its own bounded wait.
|
|
// Not swallowed: if it never arrives, the check below reads the list and fails on
|
|
// what is actually there.
|
|
await backFrame.locator('li:has-text("Carnet")').waitFor({ timeout: 60000 }).catch(() => {});
|
|
const list = (await backFrame.locator('[data-testid="notes"]').textContent()) ?? "";
|
|
check(
|
|
"and into the same space — the note from the first visit is still theirs",
|
|
list.includes("Carnet") && list.includes("de la première visite"),
|
|
list.replace(/\s+/g, " ").slice(0, 70),
|
|
);
|
|
} finally {
|
|
if (first) await closeQuietly("the first visit's page", () => first!.close());
|
|
if (again) await closeQuietly("the return visit's page", () => again!.close());
|
|
await closeContext("returning-visitor", fresh.ctx);
|
|
try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
} finally {
|
|
// Bounded, and it has to be: `BrowserContext.close()` on a browser that has already
|
|
// gone never resolves, and this `finally` is where that hang swallowed the summary.
|
|
if (ctx) await closeContext("actors", ctx);
|
|
closeServer?.();
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
|
|
const failed = results.filter((r) => !r.ok).length;
|
|
const minutes = ((Date.now() - startedAt) / 60000).toFixed(1);
|
|
console.log(
|
|
`\n══ Application e2e summary: ${results.length - failed} passed, ${failed} failed, ` +
|
|
`${results.length} total — ${minutes} min ══`,
|
|
);
|
|
process.exit(failed === 0 ? 0 : 1);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
// Anything the journeys did not catch — a refused run lock, a browser lost during setup.
|
|
// Reported and exited, never left to become an unhandled rejection nobody sees.
|
|
console.error("[e2e/app] fatal:", e);
|
|
process.exit(1);
|
|
});
|