feat(e2e-helpers): fabriquer un portefeuille, pour un test comme pour un déploiement

Trois appelants veulent la même chose — un portefeuille neuf, ses octets et son
mot de passe : la suite du polyfill à son démarrage, la suite d'une application
au sien, et un humain une fois pour provisionner un déploiement. Rien ne
produisait ce fichier ; il fallait aller le chercher à la main sur nextgraph.eu.

Mêmes entrées, mêmes sorties, mais des appelants de nature différente : une
suite ne va pas lancer un sous-processus et analyser sa sortie, et un humain ne
va pas écrire un fichier jetable pour appeler une fonction. Donc une fonction,
et un script mince par-dessus.

mintWalletBytes(password, name) rend les OCTETS, pas un chemin. C'est une
correction de ce qui existait : exportWalletFile imposait le disque à tout le
monde, et la suite applicative écrivait un fichier temporaire pour le relire
aussitôt en mémoire, puis devait le nettoyer. Elle ne le fait plus — serveApp
prend les octets. Qui veut un fichier l'écrit ; personne n'y est forcé.

Le script exige --password et refuse d'en inventer un, et refuse d'écraser un
.ngw existant sans --force, vérifié AVANT de fabriquer quoi que ce soit.
Exécuté pour de vrai : 800 octets, et le mot de passe imprimé est celui passé en
entrée. C'est le seul des trois cas qu'aucune suite n'exerce, donc le seul qui
pouvait être livré cassé sans que rien ne le dise.

Ce qui ne change pas : les suites fabriquent un portefeuille par exécution et
n'en héritent jamais ; le provisionnement veut l'inverse, durable et conservé.
Ils partagent la fabrication et l'export, ils divergent sur la durée de vie.
Les identifiants en dur deviennent un paramètre — les suites passent toujours
les leurs, le script prend ceux qu'on lui donne, et il n'existe aucun mot de
passe par défaut.
This commit is contained in:
Sylvain Duchesne
2026-08-16 15:44:37 +02:00
parent 07dfe68473
commit b50591f5bd
9 changed files with 239 additions and 27 deletions
+8
View File
@@ -0,0 +1,8 @@
# Doc-debt — e2e-harness
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED packages/polyfill/e2e/harness-page.ts @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/e2e/notebook.ts @2026-08-16 (session f93872b5-293a-4916-a353-181409a96d42)
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bun
/**
* Mint a NextGraph wallet and write it as a `.ngw` — the one-off a person runs to provision a
* deployment.
*
* ── Why this exists next to the library function ─────────────────────────────
* An application that hands a wallet out serves a `.ngw` at the URL it passes to
* `configure({ sharedWallet: { fileUrl, password } })`, and nothing produces that file: minting
* one means driving the wallet application in a browser, which is exactly what
* `mintWalletBytes` already does for the suites. So this is not a second implementation — it is
* that call, a `writeFileSync`, and the two lines a human needs to fill the configuration in.
*
* ── What it does NOT do ──────────────────────────────────────────────────────
* It does not invent a password. The password is what opens the wallet for everyone the
* deployment lets in; one chosen here would be a secret the tool knows and the operator does
* not, printed to a terminal and never chosen by anybody. It is a required argument.
*
* It also refuses to overwrite an existing file unless told to. A `.ngw` is an identity, and
* the identities it holds exist nowhere else — a clobbered one is not recoverable from the
* broker or anywhere else.
*
* Usage:
* bun run packages/ng-e2e-helpers/bin/mint-wallet.ts --password <password> [--out <path.ngw>] [--name <wallet name>] [--force]
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { DEFAULT_WALLET_NAME, mintWalletBytes } from "../src/wallet";
const USAGE =
"usage: mint-wallet --password <password> [--out <path.ngw>] [--name <wallet name>] [--force]";
interface Options {
readonly password: string;
readonly name: string;
readonly out: string;
readonly force: boolean;
}
/** `--k v` and `--k=v` both, because a person types whichever one they learnt first. */
function parseArguments(argv: readonly string[]): Options {
const values = new Map<string, string>();
let force = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--force") {
force = true;
continue;
}
if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}\n${USAGE}`);
const equals = arg.indexOf("=");
const key = equals === -1 ? arg.slice(2) : arg.slice(2, equals);
let value: string | undefined;
if (equals !== -1) {
value = arg.slice(equals + 1);
} else {
value = argv[++i];
}
if (value === undefined) throw new Error(`--${key} needs a value\n${USAGE}`);
if (!["password", "out", "name"].includes(key)) {
throw new Error(`unknown option --${key}\n${USAGE}`);
}
values.set(key, value);
}
const password = values.get("password");
if (password === undefined || password === "") {
throw new Error(`--password is required — this tool does not invent one\n${USAGE}`);
}
const name = values.get("name") ?? DEFAULT_WALLET_NAME;
// Relative to where the person is standing, which is the only path they can predict. Any
// default landing inside a checkout is covered by the repository-wide `*.ngw` ignore.
const out = path.resolve(process.cwd(), values.get("out") ?? `${name}.ngw`);
return { password, name, out, force };
}
async function main(): Promise<void> {
const options = parseArguments(process.argv.slice(2));
if (!options.force && fs.existsSync(options.out)) {
throw new Error(
`${options.out} already exists — a .ngw is an identity, so this refuses to overwrite one.\n` +
"Pass --force if that file is genuinely disposable.",
);
}
console.log(`[mint-wallet] minting the wallet "${options.name}" (this drives a real browser)...`);
const bytes = await mintWalletBytes(options.password, options.name);
fs.mkdirSync(path.dirname(options.out), { recursive: true });
fs.writeFileSync(options.out, bytes);
console.log("");
console.log(`[mint-wallet] wrote ${bytes.length} bytes`);
console.log(` file: ${options.out}`);
console.log(` password: ${options.password}`);
console.log("");
console.log("Serve that file, and give the application its URL and this password:");
console.log(" configure({ sharedWallet: { fileUrl: <where you serve it>, password: <above> } })");
}
main().then(
() => process.exit(0),
(e: unknown) => {
console.error(`[mint-wallet] ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
},
);
+5 -1
View File
@@ -8,6 +8,9 @@
"exports": {
".": "./src/index.ts"
},
"bin": {
"ng-mint-wallet": "./bin/mint-wallet.ts"
},
"peerDependencies": {
"playwright": ">=1.40.0",
"@ng-org/web": ">=0.1.2-alpha.13"
@@ -17,6 +20,7 @@
"playwright": "^1.61.1"
},
"scripts": {
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
"typecheck": "bunx tsc --noEmit -p tsconfig.json",
"mint-wallet": "bun run bin/mint-wallet.ts"
}
}
+5 -1
View File
@@ -14,7 +14,8 @@
*
* ── The five things it gives you ─────────────────────────────────────────────
* - a WALLET: minted for this run, exported as bytes an application can serve, imported into
* a profile (`wallet.ts`);
* a profile (`wallet.ts`) — and, for the one-off that provisions a deployment rather than a
* run, the same minting behind an executable (`bin/mint-wallet.ts`);
* - the BROKER CROSSING, which dispatches on the screen it can see and identifies the
* application by ORIGIN (`broker.ts`, `nextgraph-ui.ts`);
* - PROFILES that belong to one run and are cleaned up after it (`profiles.ts`, `browser.ts`);
@@ -53,10 +54,13 @@ export { serveOnEphemeralPort } from "./serve";
export { BROKER_LOGIN_MS, BROKER_ROUND_TRIP_MS, completeBrokerLogin, setupBrokerPage } from "./broker";
export {
DEFAULT_WALLET_NAME,
createWalletInContext,
emptyProfileContext,
exportWalletBytes,
exportWalletFile,
importWalletFile,
mintWalletBytes,
mintWalletProfile,
mintWalletProfileKeepingContext,
type WalletCredentials,
@@ -1,5 +1,5 @@
/**
* The page that fetches a wallet's bytes — bundled and served by `exportWalletFile`.
* The page that fetches a wallet's bytes — bundled and served by `exportWalletBytes`.
*
* ── Why a page has to do this at all ─────────────────────────────────────────
* A wallet's bytes exist only inside the broker iframe: `wallet_get_file()` is an RPC to the
+79 -9
View File
@@ -1,6 +1,6 @@
/**
* The wallet lifecycle: mint one by driving the wallet application's real interface, get its
* bytes out as a `.ngw` file, and put a `.ngw` file into a browser profile.
* bytes out (`.ngw` contents, file optional), and put a `.ngw` file into a browser profile.
*
* ── Why the real interface and not a shortcut ────────────────────────────────
* A wallet obtained any other way is not the one a person has. The wallet application is an
@@ -26,7 +26,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { closeQuietly, within } from "./deadline";
import { launchWatchedContext, newPage } from "./browser";
import { closeContext, launchWatchedContext, newPage } from "./browser";
import { newRunProfile, type RunProfile } from "./profiles";
import { serveOnEphemeralPort } from "./serve";
import { setupBrokerPage } from "./broker";
@@ -55,6 +55,16 @@ export interface WalletCredentials {
readonly password: string;
}
/**
* The name a wallet gets when the caller has no opinion about it.
*
* A wallet's name is what a person types to pick one among several, and nothing in NextGraph
* keys off it — so a caller minting the only wallet it will ever hold has nothing to decide
* here. The PASSWORD never gets a default: it is the only thing between the file and whoever
* finds it, and a defaulted one would be a published secret.
*/
export const DEFAULT_WALLET_NAME = "ng-wallet";
/**
* Create a wallet in `ctx`'s profile by walking the wallet application, then unlock it once.
*
@@ -201,19 +211,26 @@ function buildExportBundle(): string {
}
/**
* Materialize the wallet held by `ctx`'s profile as a `.ngw` file at `ngwPath`, and return its
* size in bytes.
* The bytes of the wallet held by `ctx`'s profile — the contents of a `.ngw` file, without a
* `.ngw` file.
*
* Why an application's own suite needs this: a deployment that hands a wallet out — an access
* barrier with a download link, say — must be tested against a REAL wallet. Serving a
* placeholder there makes the download step a decoration: importing it cannot let anybody in,
* so the check that the link works cannot fail for the right reason.
*
* ── Why BYTES are the primitive and the file the convenience ─────────────────
* Every caller here already has the bytes in hand — they arrive from the broker iframe — and
* only some of them want a file. This used to write one unconditionally, so a suite that
* serves a wallet from memory had to name a temp path, write it, read it straight back and
* remember to remove it: three steps and a cleanup to get back what the function had. A caller
* that genuinely wants a file writes these bytes (or calls {@link exportWalletFile}), which is
* one step in the direction nobody has to undo.
*/
export async function exportWalletFile(
export async function exportWalletBytes(
ctx: BrowserContext,
ngwPath: string,
walletPassword: string,
): Promise<number> {
): Promise<Uint8Array> {
const bundle = buildExportBundle();
const html =
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>wallet export</title></head>' +
@@ -249,10 +266,63 @@ export async function exportWalletFile(
).__ngWalletExport.file(),
),
);
fs.writeFileSync(ngwPath, Buffer.from(exported.b64, "base64"));
return exported.len;
return new Uint8Array(Buffer.from(exported.b64, "base64"));
} finally {
await closeQuietly("the wallet export page", () => page.close());
close();
}
}
/**
* The same wallet, written to `ngwPath`, returning its size in bytes.
*
* For the callers that want a FILE — a browser's file input takes a path, and a person
* provisioning a deployment has to put the wallet somewhere. Everyone else takes the bytes.
*/
export async function exportWalletFile(
ctx: BrowserContext,
ngwPath: string,
walletPassword: string,
): Promise<number> {
const bytes = await exportWalletBytes(ctx, walletPassword);
fs.writeFileSync(ngwPath, bytes);
return bytes.length;
}
/**
* A wallet that did not exist a moment ago, as the bytes of a `.ngw`: mint one by walking the
* wallet application, take its bytes out, and drop everything else.
*
* The one call behind all three callers who need a fresh wallet — this package's own suite, a
* consuming application's suite, and a person provisioning a deployment (see `bin/mint-wallet.ts`,
* which is this function plus a `writeFileSync` and two `console.log`s).
*
* ── What it deliberately does NOT keep ───────────────────────────────────────
* The browser profile the wallet was minted in is discarded here. That is not a shortcut: the
* wallet's repos live on the broker and the bytes returned are what opens them, so the profile
* is scaffolding in every one of the three cases — including the durable one, where what is
* kept is the FILE the caller writes, never a browser directory.
*
* A suite that needs the profile to survive — because its actors sign in THROUGH it rather than
* importing the file — composes the two halves itself: {@link mintWalletProfile} for a profile
* that lasts the run, then {@link exportWalletBytes} over it. Same minting, same export,
* different lifetime, which is the only thing the two paths disagree about.
*/
export async function mintWalletBytes(
password: string,
name: string = DEFAULT_WALLET_NAME,
): Promise<Uint8Array> {
const profile = await mintWalletProfile(`minting the wallet "${name}"`, { name, password });
try {
// A context of its own over the profile, opened after the creation one closed — the order
// `mintWalletProfile` already imposes, and the one every export in this repository uses.
const ctx = await launchWatchedContext("wallet-export", profile.dir);
try {
return await exportWalletBytes(ctx, password);
} finally {
await closeContext("wallet-export", ctx);
}
} finally {
profile.discard();
}
}
+1 -1
View File
@@ -4,5 +4,5 @@
"types": ["bun"],
"noEmit": true
},
"include": ["src"]
"include": ["src", "bin"]
}
+19 -4
View File
@@ -25,21 +25,36 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* The wallet every suite in this package mints for its own run.
* The throwaway credentials every suite in this package mints its own run's wallet with.
*
* A NAME, not an identity that survives: each run gets a profile of its own and mints this
* wallet into it, so two runs sharing the name share nothing else. See `ng-e2e-helpers`'
* `profiles.ts` for why one physical user per run is the rule, and why it is now a property of
* the directory rather than something a lock had to enforce.
*
* Which is also why the password can sit in a source file in the clear: it opens a wallet that
* exists for the length of one run and is deleted with the profile that holds it. A wallet
* meant to LAST — the one a deployment serves — must never be minted with these; it gets its
* own, chosen by whoever provisions it (`ng-e2e-helpers`' `bin/mint-wallet.ts`).
*/
export const WALLET: WalletCredentials = {
name: "ng-eventually-e2e",
password: "ng-eventually-e2e",
};
/** This run's physical user, in a profile of its own. */
export function mintBatchWallet(suite: string): Promise<RunProfile> {
return mintWalletProfile(suite, WALLET);
/**
* This run's physical user, in a profile of its own.
*
* The credentials are a PARAMETER defaulting to this package's throwaway pair, not a constant
* baked into the call: minting is the same work whoever wants it, and a caller that needs its
* own — anyone provisioning a wallet that outlives a run — must not have to reach for a
* different function to get it.
*/
export function mintBatchWallet(
suite: string,
credentials: WalletCredentials = WALLET,
): Promise<RunProfile> {
return mintWalletProfile(suite, credentials);
}
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
+15 -10
View File
@@ -45,7 +45,7 @@ import {
declareSuite,
emptyProfileContext,
enclosingBound,
exportWalletFile,
exportWalletBytes,
firstLine,
frameTrouble,
importWalletFile,
@@ -330,7 +330,7 @@ function buildApp(): void {
* download of a file NOBODY served looked like a perfectly good download. The check that
* the link resolves could not have failed.
*/
function serveApp(walletFile: Buffer, walletPassword: string): Promise<{ url: string; close: () => void }> {
function serveApp(walletFile: Uint8Array, walletPassword: string): Promise<{ url: string; close: () => void }> {
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
const page = fs.readFileSync(path.join(APP_DIR, "index.html"), "utf-8");
const moduleTag = '<script type="module" src="/app.js"></script>';
@@ -690,11 +690,13 @@ async function main(): Promise<void> {
const t = Date.now().toString(36);
const ALICE = `alice-${t}`;
const BOB = `bob-${t}`;
// The wallet the barrier hands out, and the newcomer's download of it. Both under a
// temp dir, removed at the end: a wallet file is an identity, and one must never be
// committed — `*.ngw` is gitignored besides, which is the belt to this brace.
// Where the visitors' DOWNLOADS land the `.ngw` each one pulls off the barrier and hands
// to the wallet application, which takes a path and nothing else. Under a temp dir, removed
// at the end: a wallet file is an identity, and one must never be committed — `*.ngw` is
// gitignored besides, which is the belt to this brace.
//
// The wallet this suite SERVES is not here: it never becomes a file at all (see below).
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-notebook-"));
const sharedWalletFile = path.join(tmpDir, "shared-wallet.ngw");
let ctx: BrowserContext | null = null;
let closeServer: (() => void) | null = null;
@@ -710,21 +712,24 @@ async function main(): Promise<void> {
// mechanism is a suspicion, not a finding: closing a broker page may end the session
// its profile-mates are using. Cheap to avoid, so avoided — the actors inherit
// nothing. Sequential contexts over the one profile dir; never two at once.
// Bytes straight into the server that hands them out. They used to go through a temp
// `.ngw` that was written, read straight back and deleted at the end — a file nobody
// wanted, three steps to get back what the export already had in hand.
console.log("[e2e/app] exporting the wallet the barrier hands out...");
const exportCtx = await launchWatchedContext("wallet-export", wallet.dir);
let walletSize = 0;
let walletBytes: Uint8Array = new Uint8Array(0);
try {
walletSize = await exportWalletFile(exportCtx, sharedWalletFile, WALLET.password);
walletBytes = await exportWalletBytes(exportCtx, WALLET.password);
} finally {
await closeContext("wallet-export", exportCtx);
}
ctx = await launchWatchedContext("actors", wallet.dir);
actorsBrowser = ctx;
const served = await serveApp(fs.readFileSync(sharedWalletFile), WALLET.password);
const served = await serveApp(walletBytes, WALLET.password);
closeServer = served.close;
const url = served.url;
console.log(`[e2e/app] application served at ${url} (shared wallet: ${walletSize} bytes)`);
console.log(`[e2e/app] application served at ${url} (shared wallet: ${walletBytes.length} bytes)`);
// The actors sign in inside a JOURNEY, not at the suite's top level. Up here a failed
// sign-in threw past every journey into `main`'s own catch, which prints "fatal" and