/** * `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 = {}) { 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 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((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 }); 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, 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(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: `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(), "top-level"); 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); });