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:
Sylvain Duchesne
2026-08-13 09:49:24 +02:00
parent f77317c4d1
commit 55714d0a23
10 changed files with 458 additions and 76 deletions
+81
View File
@@ -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