fix: la barrière s'affiche à chaque chargement en page de tête, comme chez Festipod
Notre version décidait d'afficher la barrière sur la présence de l'identifiant. Festipod décidait sur la session — jamais établie en page de tête, donc l'écran s'affichait toujours, l'identifiant servant seulement à pré-remplir le champ. La différence n'est pas ergonomique. L'identifiant est un état qu'on observe ; le portefeuille, lui, vit dans le stockage d'une autre origine et nous est illisible. Un écran conditionnel doit donc DEVINER cet état invisible — et quand il devine « déjà installé » alors que le portefeuille a disparu du navigateur, il cache les seuls contrôles qui répareraient la situation et précipite la personne dans une impasse. Impasse observée sur le site réel : sans portefeuille, la page du broker affiche un texte statique, zéro bouton, un seul lien vers nextgraph.eu qui NE TRANSPORTE AUCUN retour vers l'application. Le retour arrière du navigateur est la seule issue — et il ne sert à rien si la barrière ne reprend pas la personne à l'arrivée. Le discriminant devient le cadre, pas l'identifiant : page de tête → toujours, iframe → on s'efface. C'est le signal que @ng-org/web utilise lui-même et que Festipod utilisait un étage plus bas. On ne détecte rien et on ne demande rien. Les trois étapes s'affichent toujours ; qui possède déjà son portefeuille ignore les deux premières. Aucune case « je l'ai déjà » : savoir si l'on a importé un portefeuille dans ce navigateur est une question trop technique pour être posée. Garde-fou repris de Festipod, qu'on n'avait pas : après un aller-retour vers l'onglet NextGraph et un retour arrière, la barrière restait figée sur un bouton mort. Elle recharge désormais sur pageshow persisted — seul moyen de rejouer le init() qui porte la redirection. Vérifié sur navigateur : cliquer « Import a Wallet File » ouvre un sélecteur sur place, sans navigation ni changement d'onglet, et notre onglet ne reçoit AUCUN signal quand l'import réussit. Détecter le retour est donc impossible, pas seulement fragile.
This commit is contained in:
@@ -69,6 +69,11 @@ const SUITE_DEADLINE_MS = 15 * 60 * 1000;
|
||||
/**
|
||||
* One journey. The longest (the newcomer's) does a wallet download, an import into a cold
|
||||
* profile, a broker round-trip and a first render — measured under 2 min, bounded at 6.
|
||||
* The returning visitor's does that twice over and measures 36s, so it fits the same bound
|
||||
* with room to spare; a bound of its own was tried and dropped, because on a healthy host
|
||||
* nothing justified it and an 11-minute journey can outlast the SUITE's own deadline —
|
||||
* which prints no summary at all. A journey that overruns THIS is a hang or a sick host,
|
||||
* and both are worth hearing about rather than absorbing.
|
||||
*/
|
||||
const JOURNEY_MS = 6 * 60 * 1000;
|
||||
/** Signing an actor in: broker redirect, unlock, iframe, first render. Measured ~5-10s. */
|
||||
@@ -89,10 +94,10 @@ function check(name: string, ok: boolean, detail?: string): void {
|
||||
* right for a journey that fails, but a journey that never RETURNS is caught by nothing —
|
||||
* and that is what three killed runs looked like from the outside.
|
||||
*/
|
||||
async function journey(name: string, fn: () => Promise<void>): Promise<void> {
|
||||
async function journey(name: string, fn: () => Promise<void>, boundMs = JOURNEY_MS): Promise<void> {
|
||||
console.log(`\n── ${name} ──`);
|
||||
try {
|
||||
await within(`the journey "${name}"`, JOURNEY_MS, fn);
|
||||
await within(`the journey "${name}"`, boundMs, fn);
|
||||
} catch (e: any) {
|
||||
check(name, false, "threw: " + String(e?.message ?? e));
|
||||
}
|
||||
@@ -179,8 +184,14 @@ async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise<
|
||||
if (m.type() === "error") console.error(`[${id} console]`, m.text());
|
||||
});
|
||||
// `?ng-id=` is the ONE channel that survives the broker round-trip (the access gate's
|
||||
// resolution order, `shared-wallet/access-gate.ts`), so a returning user never sees
|
||||
// the barrier. Here it is also how the suite signs an actor in without typing.
|
||||
// resolution order, `shared-wallet/access-gate.ts`). Here it is also how the suite
|
||||
// signs an actor in without typing.
|
||||
//
|
||||
// No barrier is met on this path, and the reason is the FRAME, not the identifier:
|
||||
// `setupBrokerPage` goes straight to the broker's redirect, so the application only
|
||||
// ever loads inside the iframe — where the round-trip is already behind it. The two
|
||||
// journeys that load the application's own address top-level do meet the barrier, and
|
||||
// must: that is the side a person actually arrives on.
|
||||
const frame = await setupBrokerPage(page, `${appUrl}/?ng-id=${encodeURIComponent(id)}`);
|
||||
await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 60000 });
|
||||
return { id, frame, page };
|
||||
@@ -411,9 +422,10 @@ async function main(): Promise<void> {
|
||||
});
|
||||
|
||||
// 5. The path no journey walked: somebody who holds NOTHING. No wallet in the
|
||||
// profile, no identifier anywhere, and the application's own address — not the one
|
||||
// `signIn()` builds, which appends `?ng-id=` and so makes the barrier resolve from
|
||||
// the URL and never appear. Two defects shipped green behind that shortcut: the
|
||||
// profile, no identifier anywhere, and the application's own address — not the
|
||||
// broker redirect `signIn()` goes through, which loads the application already
|
||||
// inside the iframe and so never meets the barrier. Two defects shipped green
|
||||
// behind that shortcut: the
|
||||
// application handed the page to the broker BEFORE the barrier could show (a
|
||||
// first-time user landed on a login with no wallet and no way to get one), and the
|
||||
// file the barrier offers was served by nobody, so its link pointed at a 404.
|
||||
@@ -528,6 +540,151 @@ async function main(): Promise<void> {
|
||||
try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// 6. The visit AFTER the first one, on the application's own address. The barrier used
|
||||
// to skip itself here — it asked only when nobody was known — and skipping is
|
||||
// silent: the page goes straight to the broker, and someone whose browser no longer
|
||||
// holds the wallet lands on a static dead end with no return path. The wallet is
|
||||
// handed out at the barrier and nowhere else, so the barrier has to be there.
|
||||
//
|
||||
// Nothing is seeded. The identifier this journey expects to find prefilled is the
|
||||
// one it typed itself, one visit earlier, at the real barrier, on a device that
|
||||
// started with nothing — the only way a person obtains it, and the only way this
|
||||
// journey may (`rule_never-shortcut-the-sign-in`). The wallet is not planted either:
|
||||
// it comes off the barrier's own link and through a real import, as it does above.
|
||||
//
|
||||
// Its OWN browser profile, for the same reason the newcomer's journey has one: a
|
||||
// device that starts with nothing is the only one on which the wallet can be
|
||||
// OBTAINED rather than found already there. It also keeps this journey off the
|
||||
// actors' profile, which no journey should be adding broker pages to.
|
||||
await journey("a returning visitor meets the barrier again, prefilled, and keeps their space", async () => {
|
||||
const returning = `returning-${t}`;
|
||||
const downloaded = path.join(tmpDir, "downloaded-by-the-returning-visitor.ngw");
|
||||
const fresh = await launchCleanProfileContext();
|
||||
let first: Page | null = null;
|
||||
let again: Page | null = null;
|
||||
const startedAtJourney = Date.now();
|
||||
/**
|
||||
* Progress, not assertion. This journey is the longest in the suite, and when it
|
||||
* overran its bound it had reported NOTHING — so there was no way to tell a slow
|
||||
* broker from a genuine hang, or to know which step to look at.
|
||||
*/
|
||||
const at = (what: string): void =>
|
||||
console.log(` · ${what} (+${((Date.now() - startedAtJourney) / 1000).toFixed(0)}s)`);
|
||||
/** Open the application's OWN address, top-level, and wait for the barrier. */
|
||||
const arriveAtTheBarrier = async (label: string): Promise<Page> => {
|
||||
const p = await fresh.ctx.newPage();
|
||||
p.on("pageerror", (e) => console.error(`[${label} pageerror]`, e.message));
|
||||
p.on("console", (m) => {
|
||||
if (m.type() === "error") console.error(`[${label} console]`, m.text());
|
||||
});
|
||||
await p.goto(url, { waitUntil: "domcontentloaded" });
|
||||
await p.locator('[data-testid="ng-identity-input"]').waitFor({ state: "visible", timeout: 30000 });
|
||||
return p;
|
||||
};
|
||||
|
||||
try {
|
||||
// The FIRST visit — how the identifier and the wallet come to exist at all on this
|
||||
// device. Both are obtained here, neither is handed over by the test.
|
||||
first = await arriveAtTheBarrier("returning-first-visit");
|
||||
at("first visit: the barrier is up");
|
||||
const gate = first.locator('[data-ng-eventually="access-gate"]');
|
||||
const [download] = await Promise.all([
|
||||
first.waitForEvent("download", { timeout: 30000 }),
|
||||
gate.locator("a[download]").click(),
|
||||
]);
|
||||
await download.saveAs(downloaded);
|
||||
at("first visit: the wallet is downloaded");
|
||||
const password = ((await gate.locator("code").first().textContent()) ?? "").trim();
|
||||
const [walletPage] = await Promise.all([
|
||||
fresh.ctx.waitForEvent("page", { timeout: 30000 }),
|
||||
gate.locator('a[target="_blank"]').click(),
|
||||
]);
|
||||
await importWalletViaFile(walletPage, downloaded, password);
|
||||
await walletPage.close().catch(() => {});
|
||||
at("first visit: the wallet is imported on this device");
|
||||
await first.locator('[data-testid="ng-identity-input"]').fill(returning);
|
||||
await first.locator('[data-testid="ng-identity-enter"]').click();
|
||||
// The APPLICATION navigates, and it has to have DONE so before the broker login is
|
||||
// driven: until then the top-level frame is still the application's own, and
|
||||
// `completeBrokerLogin` would hand back that frame — which then navigates away, so
|
||||
// everything waited for on it waits forever. Cost two runs to see.
|
||||
await first.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {});
|
||||
at("first visit: handed over to the broker");
|
||||
const firstFrame = await completeBrokerLogin(first, url);
|
||||
await firstFrame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 });
|
||||
// A note, so the second visit can be shown to land in the SAME space rather than
|
||||
// merely displaying the same name.
|
||||
at("first visit: the application is up");
|
||||
await writeNote({ id: returning, frame: firstFrame, page: first }, "protected", "Carnet", "de la première visite");
|
||||
at("first visit: a note is written");
|
||||
// Closed here, not merely at the end: the second visit has to be a fresh page that
|
||||
// finds the identifier where the FIRST one left it, not a tab still holding it —
|
||||
// and one broker page at a time in this profile.
|
||||
await closeQuietly("the first visit's page", () => first!.close());
|
||||
first = null;
|
||||
|
||||
// The SECOND visit — the same address a bookmark would open, nothing appended. The
|
||||
// device now holds the wallet, and the barrier still neither knows nor asks: the
|
||||
// steps are offered again and this person walks past them to the field. Whether a
|
||||
// wallet was imported lives in another origin's storage and is unreadable, so the
|
||||
// alternative would be asking them — the question this design refuses.
|
||||
again = await arriveAtTheBarrier("returning-second-visit");
|
||||
at("return visit: the barrier is up again");
|
||||
check(
|
||||
"the barrier still hands out the wallet without asking whether they have it",
|
||||
(await again.locator('[data-ng-eventually="access-gate"]').locator("a[download]").count()) === 1,
|
||||
);
|
||||
check(
|
||||
"the barrier appears again, on a visit where the identifier is already known",
|
||||
again.url().startsWith(url),
|
||||
again.url(),
|
||||
);
|
||||
const prefilled = await again.locator('[data-testid="ng-identity-input"]').inputValue();
|
||||
check(
|
||||
"and it arrives prefilled — one click, nothing to retype",
|
||||
prefilled === returning,
|
||||
`field=${prefilled || "(vide)"}`,
|
||||
);
|
||||
|
||||
// Confirmed, not retyped: what settles the identity here is the value the barrier
|
||||
// itself put in the field.
|
||||
await again.locator('[data-testid="ng-identity-enter"]').click();
|
||||
await again.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {});
|
||||
check(
|
||||
"confirming the prefilled field is what hands the page over",
|
||||
/nextgraph\./.test(again.url()) && again.url().includes(`ng-id%3D${returning}`),
|
||||
again.url(),
|
||||
);
|
||||
const backFrame = await completeBrokerLogin(again, url);
|
||||
at("return visit: back inside the broker iframe");
|
||||
await backFrame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 });
|
||||
const who = ((await backFrame.locator('[data-testid="who"]').textContent()) ?? "").trim();
|
||||
check("the round-trip brings them back as the same identity", who.includes(returning), who);
|
||||
|
||||
// The identity is one space, not two — the failure that skipping the barrier used
|
||||
// to hide was precisely a SECOND virtual space that looked like a working
|
||||
// application. A note written before the round-trip is what tells them apart.
|
||||
const arrived: Actor = { id: returning, frame: backFrame, page: again };
|
||||
await showScope(arrived, "protected", "Carnet");
|
||||
// `showScope` settles on an EMPTY list too, and a page this fresh can render one
|
||||
// before its repos have synchronised — so the marker gets its own bounded wait.
|
||||
// Not swallowed: if it never arrives, the check below reads the list and fails on
|
||||
// what is actually there.
|
||||
await backFrame.locator('li:has-text("Carnet")').waitFor({ timeout: 60000 }).catch(() => {});
|
||||
const list = (await backFrame.locator('[data-testid="notes"]').textContent()) ?? "";
|
||||
check(
|
||||
"and into the same space — the note from the first visit is still theirs",
|
||||
list.includes("Carnet") && list.includes("de la première visite"),
|
||||
list.replace(/\s+/g, " ").slice(0, 70),
|
||||
);
|
||||
} finally {
|
||||
if (first) await closeQuietly("the first visit's page", () => first!.close());
|
||||
if (again) await closeQuietly("the return visit's page", () => again!.close());
|
||||
await closeContext("returning-visitor", fresh.ctx);
|
||||
try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
// Bounded, and it has to be: `BrowserContext.close()` on a browser that has already
|
||||
// gone never resolves, and this `finally` is where that hang swallowed the summary.
|
||||
|
||||
Reference in New Issue
Block a user