diff --git a/.project/concepts/e2e-harness/_debt.md b/.project/concepts/e2e-harness/_debt.md new file mode 100644 index 0000000..2fd543b --- /dev/null +++ b/.project/concepts/e2e-harness/_debt.md @@ -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) diff --git a/packages/ng-e2e-helpers/bin/mint-wallet.ts b/packages/ng-e2e-helpers/bin/mint-wallet.ts new file mode 100755 index 0000000..7430196 --- /dev/null +++ b/packages/ng-e2e-helpers/bin/mint-wallet.ts @@ -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 [--out ] [--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 [--out ] [--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(); + 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 { + 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: , password: } })"); +} + +main().then( + () => process.exit(0), + (e: unknown) => { + console.error(`[mint-wallet] ${e instanceof Error ? e.message : String(e)}`); + process.exit(1); + }, +); diff --git a/packages/ng-e2e-helpers/package.json b/packages/ng-e2e-helpers/package.json index 0ad4b18..1bd07d3 100644 --- a/packages/ng-e2e-helpers/package.json +++ b/packages/ng-e2e-helpers/package.json @@ -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" } } diff --git a/packages/ng-e2e-helpers/src/index.ts b/packages/ng-e2e-helpers/src/index.ts index 9bd79b4..30bade2 100644 --- a/packages/ng-e2e-helpers/src/index.ts +++ b/packages/ng-e2e-helpers/src/index.ts @@ -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, diff --git a/packages/ng-e2e-helpers/src/wallet-export-page.ts b/packages/ng-e2e-helpers/src/wallet-export-page.ts index 5de3d3f..11c9938 100644 --- a/packages/ng-e2e-helpers/src/wallet-export-page.ts +++ b/packages/ng-e2e-helpers/src/wallet-export-page.ts @@ -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 diff --git a/packages/ng-e2e-helpers/src/wallet.ts b/packages/ng-e2e-helpers/src/wallet.ts index 534755b..5d5d16c 100644 --- a/packages/ng-e2e-helpers/src/wallet.ts +++ b/packages/ng-e2e-helpers/src/wallet.ts @@ -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 { +): Promise { const bundle = buildExportBundle(); const html = 'wallet export' + @@ -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 { + 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 { + 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(); + } +} diff --git a/packages/ng-e2e-helpers/tsconfig.json b/packages/ng-e2e-helpers/tsconfig.json index 49d4733..65602e0 100644 --- a/packages/ng-e2e-helpers/tsconfig.json +++ b/packages/ng-e2e-helpers/tsconfig.json @@ -4,5 +4,5 @@ "types": ["bun"], "noEmit": true }, - "include": ["src"] + "include": ["src", "bin"] } diff --git a/packages/polyfill/e2e/harness-page.ts b/packages/polyfill/e2e/harness-page.ts index fe4f451..736b1a9 100644 --- a/packages/polyfill/e2e/harness-page.ts +++ b/packages/polyfill/e2e/harness-page.ts @@ -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 { - 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 { + return mintWalletProfile(suite, credentials); } /** `bun build` is a local bundle; a minute is already ten times what it takes. */ diff --git a/packages/polyfill/e2e/notebook.ts b/packages/polyfill/e2e/notebook.ts index a4a965c..a51ab5a 100644 --- a/packages/polyfill/e2e/notebook.ts +++ b/packages/polyfill/e2e/notebook.ts @@ -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 = ''; @@ -690,11 +690,13 @@ async function main(): Promise { 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 { // 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