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);
|
||||
|
||||
Reference in New Issue
Block a user