fix: trois coûts qui revenaient à l'appelant reviennent au paquet
Le contrat faisait porter à l'application trois choses qui sont des artefacts de notre implémentation, pas de la cible. Le rechargement de page. Au retour depuis le cache du navigateur, la barrière se rechargeait pour rejouer init() — et détruisait au passage l'état de l'application, qui ne pouvait ni s'y opposer ni nettoyer avant. Le paquet détenait pourtant ce qu'il fallait : la fonction init injectée et le callback de l'appelant. Il enregistre désormais sa délégation, ranime sa barrière au retour — champ conservé, bouton réactivé — et redélègue à la confirmation. Rien hors de la barrière n'est touché. Vérifié dans le bundle amont : en page de tête, init navigue à chaque appel, sa garde « une seule fois » ne portant que sur la branche iframe. L'ordre d'appel silencieux. ensureIdentity() attendu avant init() ne se résolvait jamais, sans erreur. Le paquet possédant la session, il distingue maintenant les deux cas sans délai ni heuristique : session pas encore arrivée → il attend ; init jamais appelé → elle n'arrivera pas, il lève en nommant l'appel à faire d'abord. Et la clause qui annonçait la barrière était rangée dans les exigences de déploiement, alors qu'une application n'y peut rien. Elle passe dans les garanties, avec ce qui la remplace : la page n'est jamais rechargée. Il reste deux lignes d'exigences : servir le fichier de portefeuille, et appeler init avant d'attendre l'identité — ce qui échoue désormais bruyamment.
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
import { getCurrentUser } from "../src/shared-wallet/bootstrap";
|
||||
import { test, expect, afterEach } from "bun:test";
|
||||
import { configure } from "../src/index";
|
||||
import { init } from "../src/surface/lifecycle";
|
||||
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
||||
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { ensureIdentity } from "../src/shared-wallet/access-gate";
|
||||
@@ -70,11 +71,14 @@ afterEach(() => {
|
||||
for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name);
|
||||
});
|
||||
|
||||
function configured() {
|
||||
function configured(injectedInit?: (...args: unknown[]) => unknown) {
|
||||
configure({
|
||||
ng: {} as never,
|
||||
useShape: (() => {}) as never,
|
||||
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
|
||||
// Only where a test needs a hand-over to come back FROM: the delegation is what the
|
||||
// polyfill's `init()` registers, and a page that never delegated was never handed over.
|
||||
...(injectedInit ? { init: injectedInit } : {}),
|
||||
});
|
||||
// AFTER `configure`, which wires the registry onto the package's own session — a session
|
||||
// that only `init()` can open, and no page here calls it. The substitution is what lets
|
||||
@@ -283,10 +287,17 @@ function inBrowser(opts: { url: string; storage?: ReturnType<typeof fakeStorage>
|
||||
location,
|
||||
storage,
|
||||
dom,
|
||||
/** How many times the page asked the browser to reload it. */
|
||||
/**
|
||||
* How many times the page asked the browser to reload it — which must stay at zero.
|
||||
* A reload throws away everything the application holds in memory, and it cannot opt
|
||||
* out, observe it, or clean up first; the barrier is this package's screen, so putting
|
||||
* it back is this package's business and nothing else on the page may be disturbed.
|
||||
*/
|
||||
get reloads(): number { return reloads; },
|
||||
/** Is anything listening for the browser restoring this page from its cache? */
|
||||
get armed(): boolean { return (listeners.get("pageshow") ?? []).length > 0; },
|
||||
/** How many such listeners — one hand-over or ten, the page needs exactly one. */
|
||||
get armings(): number { return (listeners.get("pageshow") ?? []).length; },
|
||||
/**
|
||||
* The browser restoring this page — from its back/forward cache (`persisted`) or
|
||||
* loading it afresh. Only the first is a return from a hand-over that did not complete.
|
||||
@@ -410,84 +421,156 @@ test("inside the iframe, a page that knows NOBODY still asks — the safe failur
|
||||
// their only way out, and it restores this document from the browser's cache exactly as
|
||||
// it left — barrier frozen, button dead, `init()` already delegated and never redirecting
|
||||
// again. Without a reset there is nothing on that page left to press.
|
||||
//
|
||||
// The reset used to be `location.reload()`, and it was this package charging its own
|
||||
// scaffolding to the application: a reload destroys whatever the application held in
|
||||
// memory, and the application cannot opt out of it, observe it, or clean up first. So the
|
||||
// barrier — which is entirely ours — is what comes back, and the page is left alone.
|
||||
//
|
||||
// Every test below therefore goes through the polyfill's `init()`: the hand-over IS that
|
||||
// delegation, so a page that never delegated has nothing to come back from, and a fixture
|
||||
// that played `pageshow` at it would be replaying a state no browser produces.
|
||||
|
||||
/**
|
||||
* A page wired the way an application wires one, with the injected `init` recording what
|
||||
* the address bar said each time it was handed over — which is what the broker would carry.
|
||||
*
|
||||
* It records rather than navigates: a delegation that really navigated would end the test
|
||||
* (and the document), and what is being judged here is that the SECOND one happens at all.
|
||||
*/
|
||||
function handingOverFrom(opts: { url: string; storage?: ReturnType<typeof fakeStorage>; side?: Side }) {
|
||||
const handOvers: string[] = [];
|
||||
configured((..._args: unknown[]) => {
|
||||
handOvers.push(String((globalThis as { location?: { href: string } }).location?.href));
|
||||
return Promise.resolve();
|
||||
});
|
||||
const page = inBrowser(opts);
|
||||
return { page, handOvers };
|
||||
}
|
||||
|
||||
test("a top-level page that has been handed over holds its barrier, frozen", async () => {
|
||||
configured();
|
||||
const page = inBrowser({ url: APP });
|
||||
const settled = ensureIdentity();
|
||||
const { page, handOvers } = handingOverFrom({ url: APP });
|
||||
const delegated = init(() => {}, true, []);
|
||||
page.dom.submit("Gina");
|
||||
await settled;
|
||||
await delegated;
|
||||
|
||||
expect(handOvers.length).toBe(1);
|
||||
// Not removed: the hand-over is a navigation, and a bare page is what the person would
|
||||
// come back to. This is also the state the reset below un-sticks.
|
||||
// come back to. This is also the state the revival below un-sticks.
|
||||
expect(page.dom.shown).toBe(true);
|
||||
expect(page.dom.button).toEqual({ label: "Accès en cours…", disabled: true });
|
||||
expect(page.armed).toBe(true);
|
||||
});
|
||||
|
||||
test("restored from the browser's cache, it reloads — the button comes back to life", async () => {
|
||||
configured();
|
||||
const page = inBrowser({ url: APP });
|
||||
const settled = ensureIdentity();
|
||||
test("restored from the browser's cache, the BARRIER comes back to life — the page is not reloaded", async () => {
|
||||
const { page, handOvers } = handingOverFrom({ url: APP });
|
||||
const delegated = init(() => {}, true, []);
|
||||
page.dom.submit("Gina");
|
||||
await settled;
|
||||
await delegated;
|
||||
expect(handOvers.length).toBe(1);
|
||||
|
||||
page.pageshow(true);
|
||||
// A reload re-runs `init()`, which settles again and puts the barrier back up prefilled
|
||||
// from the address bar — the only reset available, since the redirect belongs to the
|
||||
// level below and has already happened.
|
||||
expect(page.reloads).toBe(1);
|
||||
|
||||
// Nothing outside the barrier is touched: no reload, so whatever the application held in
|
||||
// memory is still there, and it never had to be told to hold nothing.
|
||||
expect(page.reloads).toBe(0);
|
||||
// And the barrier is usable again — the same screen, the field as they left it, the
|
||||
// button live and labelled to be pressed rather than frozen on what already failed.
|
||||
expect(page.dom.shown).toBe(true);
|
||||
expect(page.dom.prefilled).toBe("Gina");
|
||||
expect(page.dom.button).toEqual({ label: "Entrer", disabled: false });
|
||||
});
|
||||
|
||||
test("a barrier already answered ignores a second Enter — one return listener, one reload", async () => {
|
||||
// The button is dead by then, and a dead button dispatches no click — but the field
|
||||
// still takes the key. Twice armed would be twice reloaded on the way back.
|
||||
configured();
|
||||
const page = inBrowser({ url: APP });
|
||||
const settled = ensureIdentity();
|
||||
test("confirming the revived barrier hands the page over AGAIN — which is the way out", async () => {
|
||||
// The point of reviving it. A live button that led nowhere would be the same dead end
|
||||
// with a friendlier face: what the person needs is the hand-over to happen again, and
|
||||
// `init()` cannot do it — it delegated once and its promise is long answered.
|
||||
const { page, handOvers } = handingOverFrom({ url: APP });
|
||||
const delegated = init(() => {}, true, []);
|
||||
page.dom.submit("Gina");
|
||||
await settled;
|
||||
await delegated;
|
||||
|
||||
page.pageshow(true);
|
||||
page.dom.confirm();
|
||||
|
||||
expect(handOvers).toEqual([APP + "?ng-id=gina", APP + "?ng-id=gina"]);
|
||||
expect(page.reloads).toBe(0);
|
||||
// Handing over again freezes it again — a second round-trip is in flight, and the same
|
||||
// listener is still there for a second return.
|
||||
expect(page.dom.button).toEqual({ label: "Accès en cours…", disabled: true });
|
||||
});
|
||||
|
||||
test("someone who comes back to change their identifier leaves with the NEW one", async () => {
|
||||
// Not a hypothetical: the dead end they came back from is exactly where a person
|
||||
// discovers they entered the wrong identifier. So the second confirmation is a full
|
||||
// settling — normalized, in the address bar, and the identity the page now acts as —
|
||||
// and not a replay of the value that left.
|
||||
const { page, handOvers } = handingOverFrom({ url: APP });
|
||||
const delegated = init(() => {}, true, []);
|
||||
page.dom.submit("Gina");
|
||||
await delegated;
|
||||
|
||||
page.pageshow(true);
|
||||
page.dom.submit("Hank");
|
||||
|
||||
expect(page.location.href).toBe(APP + "?ng-id=hank");
|
||||
expect(page.storage.getItem(KEY)).toBe("hank");
|
||||
expect(getCurrentUser()).toBe("hank");
|
||||
expect(handOvers[1]).toBe(APP + "?ng-id=hank");
|
||||
expect(page.reloads).toBe(0);
|
||||
});
|
||||
|
||||
test("a barrier handing over ignores a second Enter — one listener, one hand-over", async () => {
|
||||
// The button is dead by then, and a dead button dispatches no click — but the field
|
||||
// still takes the key. Twice acted on would be twice handed over, and twice armed would
|
||||
// leave a listener behind on every round-trip.
|
||||
const { page, handOvers } = handingOverFrom({ url: APP });
|
||||
const delegated = init(() => {}, true, []);
|
||||
page.dom.submit("Gina");
|
||||
await delegated;
|
||||
|
||||
page.dom.pressEnter();
|
||||
page.pageshow(true);
|
||||
expect(page.reloads).toBe(1);
|
||||
|
||||
expect(handOvers.length).toBe(1);
|
||||
expect(page.armings).toBe(1);
|
||||
});
|
||||
|
||||
test("an ordinary load is not a return — it must not reload", async () => {
|
||||
// `pageshow` fires on every load, cached or not. Reloading on the plain one would loop
|
||||
// the page forever, which is a worse failure than the one being fixed.
|
||||
configured();
|
||||
const page = inBrowser({ url: APP });
|
||||
const settled = ensureIdentity();
|
||||
test("an ordinary load is not a return — the barrier is left frozen", async () => {
|
||||
// `pageshow` fires on every load, cached or not. Reviving on the plain one would arm the
|
||||
// button of a page whose hand-over is still on its way out, and offer a second one.
|
||||
const { page, handOvers } = handingOverFrom({ url: APP });
|
||||
const delegated = init(() => {}, true, []);
|
||||
page.dom.submit("Gina");
|
||||
await settled;
|
||||
await delegated;
|
||||
|
||||
page.pageshow(false);
|
||||
|
||||
expect(page.reloads).toBe(0);
|
||||
expect(page.dom.button).toEqual({ label: "Accès en cours…", disabled: true });
|
||||
page.dom.confirm();
|
||||
expect(handOvers.length).toBe(1);
|
||||
});
|
||||
|
||||
test("someone still AT the barrier is not reset — what they typed survives", async () => {
|
||||
// Armed on the way OUT, not at load: a person who wandered off before confirming comes
|
||||
// back to the page they left, not to a reloaded one that lost their identifier. So the
|
||||
// arming has to straddle the confirmation, and this pins both sides of it.
|
||||
configured();
|
||||
const page = inBrowser({ url: APP });
|
||||
const settled = ensureIdentity();
|
||||
// back to the page they left, with the field as they left it and the button still live.
|
||||
// So the arming has to straddle the confirmation, and this pins both sides of it.
|
||||
const { page } = handingOverFrom({ url: APP });
|
||||
const delegated = init(() => {}, true, []);
|
||||
expect(page.armed).toBe(false);
|
||||
|
||||
// A barrier left unanswered would hold the settling in flight and every later test would
|
||||
// JOIN it instead of raising its own — so answer it, always.
|
||||
page.dom.submit("Gina");
|
||||
await settled;
|
||||
await delegated;
|
||||
expect(page.armed).toBe(true);
|
||||
});
|
||||
|
||||
test("inside the iframe nothing is armed — there was no hand-over to come back from", async () => {
|
||||
configured();
|
||||
const page = inBrowser({ url: APP, side: "in the broker iframe" });
|
||||
const settled = ensureIdentity();
|
||||
const { page } = handingOverFrom({ url: APP, side: "in the broker iframe" });
|
||||
const delegated = init(() => {}, true, []);
|
||||
page.dom.submit("Hana");
|
||||
await settled;
|
||||
await delegated;
|
||||
|
||||
// And the barrier IS removed here: the application renders behind it.
|
||||
expect(page.dom.shown).toBe(false);
|
||||
|
||||
@@ -323,6 +323,87 @@ test("the session id is RELAYED, not rebuilt — what the broker sent is what is
|
||||
expect(relayed).toBe(1);
|
||||
});
|
||||
|
||||
// ── Awaiting `ensureIdentity()` before `init()` ────────────────────────────
|
||||
//
|
||||
// A session arrives through `init()`'s callback and through nothing else, so an application
|
||||
// that awaits signing in FIRST is waiting for something that cannot happen. It used to wait
|
||||
// forever — no error, no timeout, the application simply stopped where it awaited — which is
|
||||
// the worst failure to hand someone integrating. The package owns the session now, so it can
|
||||
// tell "not yet" from "never" outright, with no timeout, no race and no heuristic delay.
|
||||
//
|
||||
// The two below wire the registry the way an APPLICATION does — through `configure` alone,
|
||||
// which points it at the package's own holder. The route the rest of this file substitutes
|
||||
// holds a session no `init()` of ours opened, and there a wait legitimately ends.
|
||||
|
||||
/** Let pending microtasks and timers run, so "has it settled yet" is a fair question. */
|
||||
function flush(): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* An application's bootstrap, with the injected `init` kept on a leash.
|
||||
*
|
||||
* The real one answers when the BROKER does, which is not the moment it is called — so the
|
||||
* callback is held here rather than fired, which is what makes "`init()` has been called and
|
||||
* the session has not arrived yet" a state these tests can be IN rather than assume.
|
||||
*/
|
||||
function anApplicationThatConfigured() {
|
||||
let deliver: ((event: unknown) => void) | null = null;
|
||||
configure({
|
||||
ng: {} as never,
|
||||
useShape: (() => {}) as never,
|
||||
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
|
||||
init: (...args: unknown[]): Promise<void> => {
|
||||
const callback = args[0];
|
||||
if (typeof callback === "function") deliver = callback as (e: unknown) => void;
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
return {
|
||||
/** The broker answering, at last — `{ status: "loggedin", session }` (`ngweb.js:124`). */
|
||||
theBrokerAnswers(): void {
|
||||
if (deliver === null) throw new Error("the injected `init` was never called");
|
||||
deliver(loggedIn());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("awaited BEFORE `init()`, signing in fails loudly — it does not wait on a session nobody will open", async () => {
|
||||
// Inside the iframe, where the barrier stands aside: what is judged here is the wait that
|
||||
// follows settling, and a barrier nobody answered would hold the call up for its own
|
||||
// unrelated reason. The error has to name the call to make — a rejection saying only that
|
||||
// something went wrong would leave the integrator exactly as stuck, just faster.
|
||||
anApplicationThatConfigured();
|
||||
inBrowser(APP + "?ng-id=iris", fakeStorage(), "in the broker iframe");
|
||||
|
||||
await expect(within(ensureIdentity())).rejects.toThrow(/awaited before init\(\)/i);
|
||||
});
|
||||
|
||||
test("with `init()` called, signing in WAITS for the session — the normal case", async () => {
|
||||
// The other half, and the reason the refusal cannot be a blanket one: an application calls
|
||||
// `init()` and then awaits `ensureIdentity()`, and between those two the session genuinely
|
||||
// has not arrived yet. Waiting there is right, and a refusal that fired here would break
|
||||
// every application it was meant to help.
|
||||
// Its OWN identifier: the connection work keys what it has in flight by identity, module
|
||||
// -wide, so two tests sharing one would let a run left pending by the other be JOINED here
|
||||
// instead of started — and this one would then be measuring that run, not its own.
|
||||
const app = anApplicationThatConfigured();
|
||||
inBrowser(APP + "?ng-id=nora", fakeStorage(), "in the broker iframe");
|
||||
|
||||
void init(() => {}, true, []);
|
||||
const signedIn = ensureIdentity();
|
||||
let outcome: string | null = null;
|
||||
void signedIn.then(
|
||||
(id) => { outcome = `resolved: ${id}`; },
|
||||
(failure) => { outcome = `rejected: ${String(failure)}`; },
|
||||
);
|
||||
await flush();
|
||||
expect(outcome).toBe(null);
|
||||
|
||||
app.theBrokerAnswers();
|
||||
expect(await within(signedIn)).toBe("nora");
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user