Files
ng-eventually/packages/polyfill/test/lifecycle.test.ts
T
Sylvain Duchesne 3547de202c fix: régler l'identité ne demande pas de session, se connecter oui
init() de @ng-org/web redirige vers le broker en première instruction, dès qu'on
est en tête. L'application appelait donc init() au chargement du module, la page
partait, et ensureIdentity() ne s'exécutait jamais : la barrière n'apparaissait
pas, ?ng-id= restait absent de l'URL remise au broker, et un primo-arrivant se
retrouvait devant la page de connexion sans portefeuille et sans moyen d'en
obtenir un — sans la moindre erreur.

Appeler ensureIdentity() avant init() ne marchait pas non plus : il attend la
session, que seul le callback d'init() résout. Cycle vérifié empiriquement.

La cause n'était ni l'ordre ni la redirection, mais une confusion dans
ensureIdentity() entre deux actes de nature différente — régler qui est
l'utilisateur (barrière, URL, stockage : aucune session) et se connecter
(session requise). settleIdentity() porte le premier ; le wrapper init() du
polyfill l'attend avant de déléguer. L'invariant d'ordre est ainsi porté par la
composition, pas par une consigne d'ordre d'appel que personne ne lit.

Piège trouvé et épinglé en écrivant les tests : init() et ensureIdentity() dans
le même tick montaient deux barrières, l'utilisateur répondait à l'une et
l'autre ne se résolvait jamais. Le règlement en vol est désormais partagé.
2026-08-11 12:52:49 +02:00

251 lines
10 KiB
TypeScript

/**
* `init()` — the hand-over to the broker, and the one thing that must happen before it.
*
* The real `@ng-org/web` `init()` navigates a top-level page to NextGraph's login as its
* FIRST statement, carrying `window.location.href` AS IT FINDS IT. Everything the
* application would have done afterwards runs in a document that no longer exists. So an
* application that signed in on the next line never signed in at all: no barrier, no
* identifier in the URL that crossed, and a first-time user parked on a login with no
* wallet and no error anywhere.
*
* The library's `init()` therefore settles the identity BEFORE it delegates. That order is
* carried by composition, not by an instruction to a caller — so what these tests pin is
* what the injected `init` OBSERVES when it is called, never the sequence of calls: a
* regression that delegated first would still call things in the right order, and would
* still hand over a bare URL.
*/
import { test, expect, afterEach } from "bun:test";
import { configure, ensureIdentity } from "../src/index";
import { init } from "../src/surface/lifecycle";
import { resetConfig, resetStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
const KEY = "ng-eventually:identity";
const APP = "https://app.example/";
/** A localStorage double — the real one is absent in `bun test`. */
function fakeStorage(initial: Record<string, string> = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
};
}
/**
* A browser page, faithful in the one way these assertions depend on: `location.search`
* tracks `href`, and `history.replaceState` MOVES `href` — as a real browser does, and as
* the real `init()` relies on when it reads the address bar. A double whose `replaceState`
* did nothing would let a broken `init()` pass.
*/
function inBrowser(url: string, storage: ReturnType<typeof fakeStorage>) {
let href = url;
const location = {
get href(): string { return href; },
get search(): string { return new URL(href).search; },
};
Object.assign(globalThis, {
location,
history: { replaceState: (_s: unknown, _t: string, next: string): void => void (href = next) },
localStorage: storage,
});
return { location };
}
/** One element of the barrier, holding the listeners the gate attaches to it. */
function fakeElement() {
const handlers = new Map<string, ((e: unknown) => void)[]>();
return {
value: "",
disabled: false,
addEventListener(type: string, fn: (e: unknown) => void): void {
handlers.set(type, [...(handlers.get(type) ?? []), fn]);
},
focus(): void {},
fire(type: string): void {
for (const fn of handlers.get(type) ?? []) fn({});
},
};
}
/**
* A DOM that COUNTS the barriers mounted on it — the one thing this file needs from a
* document. It does not parse markup, so `querySelector` hands back the elements it was
* built with; what the markup contains is `access-gate.test.ts`'s subject and the e2e's.
*/
function fakeDom() {
const input = fakeElement();
const go = fakeElement();
let mounted = 0;
const root = {
innerHTML: "",
querySelector: (sel: string) => (sel === "input" ? input : sel === "button.go" ? go : null),
};
const host = {
setAttribute: (): void => {},
attachShadow: () => root,
remove: (): void => void (mounted -= 1),
};
return {
document: { createElement: () => host, body: { appendChild: (): void => void (mounted += 1) } },
/** How many barriers are on screen right now. */
get mounted(): number {
return mounted;
},
/** What a user does at the barrier: type an identifier, then press Entrer. */
submit(id: string): void {
input.value = id;
input.fire("input");
go.fire("click");
},
};
}
const PAGE_GLOBALS = ["location", "localStorage", "history", "document"] as const;
afterEach(() => {
setCurrentUser(null);
resetConfig();
resetStoreRegistry();
for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name);
});
/**
* The consumer's real wiring, reduced to the cycle it creates.
*
* An application resolves its session FROM `init()`'s callback and hands the library a
* thunk that waits for it (`examples/notebook/app.ts`). So before `init()` runs the session
* does not exist and cannot: nothing else resolves it. That is why the injected `init`
* here resolves it — a `getSession` that answered straight away would be a state the real
* system never reaches, and it is precisely the state under which the deadlock below is
* invisible.
*
* The spy records what the real `init()` reads at the moment it is called — the address
* bar — and returns a promise, as the real one does.
*/
function consumerWiring() {
let arrived!: (s: RegistrySession) => void;
const sessionReady = new Promise<RegistrySession>((resolve) => { arrived = resolve; });
const calls: { href: string; args: unknown[] }[] = [];
const returned = { itsOwnReturnValue: true };
const injectedInit = (...args: unknown[]): Promise<unknown> => {
calls.push({ href: String((globalThis as { location?: { href: string } }).location?.href), args });
arrived({ sessionId: "s", privateStoreId: "did:ng:o:p" });
return Promise.resolve(returned);
};
return { sessionReady, calls, injectedInit, returned, getSession: () => sessionReady };
}
function configured(wiring: ReturnType<typeof consumerWiring>, opts: { sharedWallet?: boolean } = {}) {
configure({
ng: {} as never,
useShape: (() => {}) as never,
init: wiring.injectedInit,
getSession: wiring.getSession,
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
...(opts.sharedWallet === false ? {} : { sharedWallet: { fileUrl: "/w.ngw", password: "pw" } }),
});
}
/**
* Turn a hang into a readable failure. NOT part of the mechanism — nothing in the library
* races or times out; this only says WHY a test stopped, instead of leaving the runner to
* report a bare timeout on a test whose whole subject is a deadlock.
*/
function within<T>(p: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("init() never resolved — it is waiting on the session it is itself supposed to open")),
1000,
);
});
return Promise.race([p, deadline]).finally(() => clearTimeout(timer));
}
test("the injected `init` finds the identifier ALREADY in the address bar", async () => {
// The assertion is on what the delegate OBSERVES, not on call order: the real `init()`
// reads `window.location.href` at its first statement and carries it to the broker, so a
// wrapper that delegated first and settled after would satisfy any ordering check and
// still send the round-trip off bare. Identity from storage is the case that bites —
// storage does not cross the broker's partition, the URL does.
const wiring = consumerWiring();
configured(wiring);
inBrowser(APP, fakeStorage({ [KEY]: "hana" }));
await within(init(() => {}, true, []));
expect(wiring.calls.length).toBe(1);
expect(wiring.calls[0]!.href).toBe(APP + "?ng-id=hana");
});
test("it resolves though the session exists only AFTER it delegates — the cycle, pinned", async () => {
// The deadlock this split exists to prevent, reproduced exactly. Signing in fully
// (`ensureIdentity`) awaits the connection work → `resolveAccount` → `lookupAccount` →
// `session()` → the consumer's thunk → a promise only `init()`'s callback resolves. Await
// that here and nothing ever runs: `init` is never called, so the session never arrives,
// so the wait never ends. `init()` therefore awaits the SESSION-FREE half only.
const wiring = consumerWiring();
configured(wiring);
inBrowser(APP + "?ng-id=iris", fakeStorage());
await within(init(() => {}, true, []));
expect(wiring.calls.length).toBe(1);
// And the direction the cycle was broken in: delegating is what made the session exist.
await expect(within(wiring.sessionReady)).resolves.toMatchObject({ sessionId: "s" });
});
test("a settling failure rejects — the page is not handed to the broker without an identity", async () => {
// Nothing known, and no shared wallet to hand the user one. Delegating anyway IS the
// defect: the page navigates to a login the user cannot complete and cannot come back
// from, silently. Failing at the call the application made is the only place the cause
// is still visible.
const wiring = consumerWiring();
configured(wiring, { sharedWallet: false });
inBrowser(APP, fakeStorage());
await expect(init(() => {}, true, [])).rejects.toThrow(/no shared wallet configured/i);
expect(wiring.calls.length).toBe(0);
});
test("`init` and `ensureIdentity` in the same tick raise ONE barrier, not two", async () => {
// Exactly the reference application's bootstrap: `init()` at module load, then `signIn()`
// awaiting `ensureIdentity()` (`examples/notebook/app.ts`). Both settle the identity now,
// so both reach the barrier in the same tick. Two barriers is not a cosmetic fault: the
// user answers whichever is on top, the other never resolves, and the `init()` waiting on
// it never hands the page to the broker — the application hangs before it ever loads.
const wiring = consumerWiring();
configured(wiring);
inBrowser(APP, fakeStorage());
const dom = fakeDom();
Object.assign(globalThis, { document: dom.document });
const delegated = init(() => {}, true, []);
const signedIn = ensureIdentity();
await Promise.resolve();
expect(dom.mounted).toBe(1);
dom.submit("kira");
await within(delegated);
expect(await within(signedIn)).toBe("kira");
expect(wiring.calls[0]!.href).toBe(APP + "?ng-id=kira");
});
test("arguments and return value pass through untouched — it is still a forwarder", async () => {
// Settling is added BEFORE the delegate, never around it: `init` takes a callback and
// upstream returns a promise, so anything this wrapper altered on the way in or out
// would be a difference the application has to unlearn at migration.
const wiring = consumerWiring();
configured(wiring);
inBrowser(APP, fakeStorage());
setCurrentUser("juno");
const callback = (): void => {};
const result = await within(init(callback, true, ["a-broker"]));
expect(wiring.calls[0]!.args).toEqual([callback, true, ["a-broker"]]);
expect(result).toBe(wiring.returned);
});