/** * `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 { configureStoreRegistry, resetConfig, resetStoreRegistry, setCurrentUser, } from "../src/shared-wallet/bootstrap"; import { sharedWalletSession } from "../src/shared-wallet/session"; 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 = {}) { 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), }; } /** * Which side of the broker frontier the page is on. It decides whether the barrier shows * at all — top-level it always does, past the round-trip it never does — so no page here * may leave it unsaid: `window.self !== window.top` is what the gate reads, and a double * without a `window` would silently be top-level. */ type Side = "top-level" | "in the broker iframe"; /** * 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, side: Side) { let href = url; const location = { get href(): string { return href; }, get search(): string { return new URL(href).search; }, }; const self = {}; Object.assign(globalThis, { location, history: { replaceState: (_s: unknown, _t: string, next: string): void => void (href = next) }, localStorage: storage, window: { self, top: side === "top-level" ? self : {}, addEventListener: (): void => {} }, }); return { location }; } /** One element of the barrier, holding the listeners the gate attaches to it. */ function fakeElement() { const handlers = new Map 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"); }, /** What a returning user does: press Entrer on the field as they found it, prefilled. */ confirm(): void { go.fire("click"); }, }; } const PAGE_GLOBALS = ["location", "localStorage", "history", "document", "window"] as const; afterEach(() => { setCurrentUser(null); resetConfig(); resetStoreRegistry(); for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name); }); /** The event the real `init` sends its callback once the broker answers — `ngweb.js:124`. */ const BROKER_SESSION = { session_id: "s", private_store_id: "did:ng:o:p" }; const loggedIn = (session: Record = BROKER_SESSION) => ({ status: "loggedin", session, }); /** * The consumer's real wiring, reduced to the cycle it creates. * * The session exists only from `init()`'s callback: nothing else in the system opens one, * so before `init()` runs there is none and there cannot be. That is why the injected * `init` here is what produces it — a session 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. * * It produces it the way the real one does: by calling the callback it was HANDED, once, * with `{ status: "loggedin", session }`. Since 2026-08-12 that callback is the library's * wrapper, so this also exercises the capture; `sessionReady` here is this fixture's own * view of the same instant, kept so the assertions can name it. * * 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(session: Record = BROKER_SESSION) { let arrived!: (s: RegistrySession) => void; const sessionReady = new Promise((resolve) => { arrived = resolve; }); const calls: { href: string; args: unknown[] }[] = []; const returned = { itsOwnReturnValue: true }; const injectedInit = (...args: unknown[]): Promise => { calls.push({ href: String((globalThis as { location?: { href: string } }).location?.href), args }); const callback = args[0]; if (typeof callback === "function") void (callback as (e: unknown) => unknown)(loggedIn(session)); arrived({ sessionId: "s", privateStoreId: "did:ng:o:p" }); return Promise.resolve(returned); }; return { sessionReady, calls, injectedInit, returned, getSession: () => sessionReady }; } function configured(wiring: ReturnType, opts: { sharedWallet?: boolean } = {}) { configure({ ng: {} as never, useShape: (() => {}) as never, init: wiring.injectedInit, ...(opts.sharedWallet === false ? {} : { sharedWallet: { fileUrl: "/w.ngw", password: "pw" } }), }); // The registry reaches the session through the internal wiring path, pointed at THIS // fixture's promise — which, like the package's own, only `init()` can resolve. Without // that the deadlock test below would be measuring a session that arrives by itself. configureStoreRegistry({ getSession: wiring.getSession, normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(), }); } /** * 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(p: Promise): Promise { let timer: ReturnType | undefined; const deadline = new Promise((_, 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. // // Top-level, which is the only side that hands over at all, so Hana meets the barrier // with her identifier already in the field and confirms it. What must be true when the // delegate is finally called is unchanged: the address bar already carries her. const wiring = consumerWiring(); configured(wiring); inBrowser(APP, fakeStorage({ [KEY]: "hana" }), "top-level"); const dom = fakeDom(); Object.assign(globalThis, { document: dom.document }); const delegated = init(() => {}, true, []); await Promise.resolve(); dom.confirm(); await within(delegated); 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. // // Inside the iframe, which is where the cycle actually closes: the identifier arrived in // the URL, the barrier stands aside, and delegating is what opens the session. const wiring = consumerWiring(); configured(wiring); inBrowser(APP + "?ng-id=iris", fakeStorage(), "in the broker iframe"); 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(), "top-level"); 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(), "top-level"); 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, and the return value comes back // as it left: anything this wrapper altered on the way in or out is a difference the // application has to unlearn at migration. // // The ONE exception is the callback, wrapped since 2026-08-12 so the package can keep the // session the SDK delivers through it. What must therefore hold is not that the same // function object arrives — it does not — but that the caller's callback still sees // exactly the event the SDK sent, unchanged and un-narrowed. That is the property an // application depends on, and the only one that survives migration. const wiring = consumerWiring(); configured(wiring); inBrowser(APP, fakeStorage(), "top-level"); setCurrentUser("juno"); const seen: unknown[] = []; const callback = (event: unknown): void => void seen.push(event); const result = await within(init(callback, true, ["a-broker"])); expect(wiring.calls[0]!.args.slice(1)).toEqual([true, ["a-broker"]]); expect(seen).toEqual([loggedIn()]); expect(result).toBe(wiring.returned); }); test("the session id is RELAYED, not rebuilt — what the broker sent is what is kept", async () => { // The real broker answers `session_id: 1` — a NUMBER (upstream types it `string | number`, // `index.d.ts:266`) — and the wasm binding deserializes it by that type. Normalizing it to // a string was written into the capture first, and the applicative e2e refused every call // in the batch: `Deserialization error of session_id JsValue("1")`. Nothing downstream // reads this value, it only travels; so the capture must relay it untouched. const wiring = consumerWiring({ session_id: 1, private_store_id: "did:ng:o:p" }); configured(wiring); inBrowser(APP + "?ng-id=otto", fakeStorage(), "in the broker iframe"); await within(init(() => {}, true, [])); const relayed: unknown = (await within(sharedWalletSession())).sessionId; expect(relayed).toBe(1); }); test("the package holds the session even when the caller passes NO callback", async () => { // Upstream the callback is optional (`callback: Function | null`), and an application // that wants nothing from the lifecycle channel legitimately passes none. The session // still has to reach the library, or every read that follows waits on a session that // was delivered to nobody — the same silence, from the opposite direction. // // Inside the iframe: the only side where a session is ever opened. const wiring = consumerWiring(); configured(wiring); inBrowser(APP + "?ng-id=nell", fakeStorage(), "in the broker iframe"); await within(init(undefined, true, [])); await expect(within(sharedWalletSession())).resolves.toEqual({ sessionId: "s", privateStoreId: "did:ng:o:p", }); });