16 KiB
type, summary
| type | summary |
|---|---|
| contract | The machinery ng-e2e-helpers offers a NextGraph application's end-to-end suite — wallets, the broker crossing, per-run profiles, bounds, and a bounded report |
contract_ng-e2e-helpers — ng-e2e-helpers
Scope
This package is the end-to-end testing machinery a NextGraph application needs to get a real person into itself: minting a wallet by driving the wallet application, crossing the broker, and coming back inside the iframe the application runs in — plus per-run browser profiles, bounds that turn a hang into a named failure, and a run report whose size does not depend on what failed.
It covers only what is generic to NextGraph. It knows nothing about any one application, and nothing about any compatibility layer: an application calling the NextGraph SDK directly is its intended consumer. What is specific to your repository — the page that carries your application, how you build your bundle, which journeys you run — is yours to write, and this package deliberately offers no place to put it.
It is not a test runner, not an assertion library, and not a fixture system. You keep your own runner and your own main().
Deployment requirements
playwright and @ng-org/web are peer dependencies, and you own both versions: browser binaries have to match the driver, and the SDK the export page opens a session with must be the one your application and your broker agree on. Import the browser types from the helpers that return them rather than from playwright directly — a second resolution of the driver produces a structurally different BrowserContext, and a context you opened then cannot be handed back to the helper that opens contexts.
A machine running this needs a real Chromium, network reach to the wallet application and to the broker, and a writable temporary directory for the per-run profiles.
Surface
Full typed shape: the package's types entry, ng-e2e-helpers. It also ships one executable, ng-mint-wallet, for provisioning a deployment's wallet file outside any run. BrowserContext, Frame, and Page — every Playwright type this surface mentions, no more — are re-exported as types from the same entry point, so a consumer can type its own helper functions against these signatures without a second, independently-versioned import of playwright. The load-bearing signatures:
// ── bounds: a hang becomes a named failure ───────────────────────────────────
export function within<T>(what: string, ms: number, task: () => Promise<T>): Promise<T>;
export class DeadlineExceeded extends Error {} // thrown by `within` — names `what`
export class BrowserGone extends Error {} // the browser died; not the task's fault
export function enclosingBound(steps: readonly number[], margin: number): number;
export function closeQuietly(what: string, close: () => Promise<unknown>): Promise<void>;
export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void;
export function browserLost(reason: string): void; // declare it, once
export function lossDeclared(): string | null; // has it been declared?
export function firstLine(e: unknown): string;
export const CLOSE_MS: number, CONTEXT_ACTION_MS: number, CONTEXT_NAVIGATION_MS: number;
// ── measurement: how a bound gets its number ─────────────────────────────────
export function measured<T>(what: string, bound: number, task: (ms: number) => Promise<T>): Promise<T>;
export function record(what: string, ms: number, ok: boolean, bound: number): void;
export function timingsWanted(): boolean; // true under E2E_TIMINGS=1
export function printTimings(): void;
// ── browser and profiles: one run owns its own ───────────────────────────────
export interface RunProfile { readonly dir: string; readonly purpose: string; discard(): void }
export function newRunProfile(purpose: string): RunProfile;
export function isAlive(pid: number): boolean;
export function launchWatchedContext(label: string, dir: string): Promise<BrowserContext>;
export function closeContext(label: string, ctx: BrowserContext): Promise<void>;
export function newPage(label: string, ctx: BrowserContext): Promise<Page>;
export const LAUNCH_MS: number, NEW_PAGE_MS: number;
// ── the wallet: minted, carried, imported ────────────────────────────────────
export interface WalletCredentials { readonly name: string; readonly password: string }
export const DEFAULT_WALLET_NAME: string; // the password never has a default
export function mintWalletProfile(purpose: string, c: WalletCredentials): Promise<RunProfile>;
export function mintWalletProfileKeepingContext(
purpose: string, c: WalletCredentials): Promise<{ ctx: BrowserContext; profile: RunProfile }>;
export function mintWalletBytes(password: string, name?: string): Promise<Uint8Array>;
export function createWalletInContext(ctx: BrowserContext, c: WalletCredentials): Promise<void>;
export function emptyProfileContext(
purpose: string): Promise<{ ctx: BrowserContext; profile: RunProfile }>;
export function exportWalletBytes(ctx: BrowserContext, walletPassword: string): Promise<Uint8Array>;
export function exportWalletFile(ctx: BrowserContext, ngwPath: string, pw: string): Promise<number>;
export function importWalletFile(page: Page, ngwPath: string, password: string): Promise<void>;
// ── the broker crossing ──────────────────────────────────────────────────────
export function setupBrokerPage(page: Page, appUrl: string, walletPassword: string): Promise<Frame>;
export function completeBrokerLogin(page: Page, appUrl: string, pw: string): Promise<Frame>;
export const BROKER_LOGIN_MS: number, BROKER_ROUND_TRIP_MS: number;
export function brokerRedirectFor(appUrl: string): string;
export const BROKER_SCREENS: readonly BrokerScreenSpec[]; // the screens, in test order
export const WALLET_APP, WALLET_CREATION, WALLET_IMPORT; // the wallet application's own pages
export type BrokerScreen = "choose-broker" | "login-offered" | "wallet-list"
| "password" | "working" | "error";
export type { BrokerScreenSpec, ScreenSignature, ScreenResponse, TextPattern };
// ── serving your application to the browser ──────────────────────────────────
export function serveOnEphemeralPort(
handler: (req: IncomingMessage, res: ServerResponse) => void,
): Promise<{ url: string; close: () => void }>;
// ── failures that are not the application's ──────────────────────────────────
export function browserTrouble(label: string, ctx: BrowserContext): Promise<string | null>;
export function frameTrouble(id: string, page: Page, frame: Frame, marker: string): Promise<string | null>;
export const BROWSER_PROBE_MS: number, FRAME_PROBE_MS: number;
// ── the report ───────────────────────────────────────────────────────────────
export interface JourneyDeclaration { readonly name: string; readonly checks: readonly string[] }
export type Prerequisite = () => Promise<string | null> | (string | null);
export interface JourneySpec {
readonly name: string; // must be a declared journey
readonly needs?: readonly Prerequisite[]; // each answers null, or why it cannot start
readonly run: () => Promise<void>;
}
export interface SuiteOptions {
readonly label: string;
readonly journeys: readonly JourneyDeclaration[];
readonly journeyBound: number;
readonly diagnose?: () => Promise<string | null>;
}
export interface SuiteReport {
check(name: string, ok: boolean, detail?: string): void; // throws on an undeclared name
journey(spec: JourneySpec): Promise<void>;
finish(fatal: string | null): never; // prints, then leaves
}
export function declareSuite(options: SuiteOptions): SuiteReport;
// ── playwright types this surface mentions, re-exported so a second import isn't needed ─────
export type { BrowserContext, Frame, Page };
Guarantees
A bound names what it was waiting for. within(what, ms, task) either settles the task or throws a DeadlineExceeded carrying what verbatim — never a bare timeout with no subject. armSuiteDeadline does the same for a whole run, and reports before leaving. enclosingBound(steps, margin) returns a number no smaller than the sum of the bounds it encloses, so an enclosure can never fire before the step that actually hung.
A run owns its profile and discards it. newRunProfile returns a directory belonging to this run alone; discard() kills whatever still holds it and removes it, is idempotent, and also runs when the process leaves — including when the run is killed. Two runs never share local state, so nothing a previous run left can make this one pass or fail.
A minted wallet is new. mintWalletProfile/mintWalletBytes walk the wallet application to create a wallet that did not exist a moment ago. mintWalletBytes keeps only the bytes and discards the profile it minted in; mintWalletProfile keeps the profile for the length of the run. The password is always a parameter and never has a default; the name defaults to DEFAULT_WALLET_NAME because nothing in NextGraph keys off it.
The crossing dispatches on the screen it can see, not on a fixed sequence: setupBrokerPage reads the page against BROKER_SCREENS in order, answers it, and returns the application's Frame once the crossing has completed. It identifies the application by ORIGIN. A terminal screen ends the crossing as a failure rather than an action.
An empty-profile context starts with nothing local. emptyProfileContext gives a context whose profile holds no wallet and no repo cache, which is the reconnection cold start: the wallet's repos are on the broker and not in this profile. importWalletFile then puts the wallet in — but not the repos' cache — so the next session over it still hits that cold start.
A closed server is closed. serveOnEphemeralPort tracks its open sockets and destroys them on close(), so a connection nobody hung up cannot be blamed on whatever goes wrong next.
A known failure mode is named instead of the innocent operation. browserTrouble/frameTrouble answer a string when the browser or the application frame is the actual cause, and null when they are not. SuiteOptions.diagnose puts that answer in FRONT of a journey's own reason, never in place of it. A frame that is attached, on the right URL, and empty is reported as trouble — that is the state a naive check misses.
The report's size does not depend on what failed. Journeys and their checks are declared up front to declareSuite; check throws on a name the journey did not declare, journey runs one journey bounded and isolated so it cannot change the report's shape, and finish reports every declared check the run did not get to before printing and exiting. A journey whose prerequisite is provably dead is reported as such instead of being driven.
Errors are values where a caller can act on them. browserTrouble/frameTrouble/Prerequisite answer string | null, never throw for the condition they detect. DeadlineExceeded and BrowserGone are exported classes, so instanceof is a supported way to tell a hang from a dead browser.
Non-guarantees
No assertion library, no runner, no fixtures. SuiteReport.check records a boolean you computed; nothing here decides what is true.
finish never returns — it exits the process. Do not put cleanup after it; put it in the discard()/closeQuietly path.
No message text is stable. The strings from browserTrouble, frameTrouble, DeadlineExceeded and the report are for a human reading a run. Do not parse them, match on them, or branch on them.
No timing promise. Every exported *_MS constant is a bound sized from a measurement on one machine, not a service level: they change when the measurements change, and a run slower than one of them is a failed run, not a broken guarantee. E2E_TIMINGS=1 reprints the measurements so you can resize your own.
Nothing survives a run. Profiles are discarded, and no artifact, cache, or wallet is carried from one run to the next. The one exception is a file you write yourself with exportWalletFile or ng-mint-wallet.
No concurrency contract. The suite machinery assumes one run per process. Two suites sharing a process share armSuiteDeadline, the timings table and the loss declaration.
No wallet-application version pinning. The crossing is driven against the wallet application as deployed at the broker you point it at. When that application changes its screens, BROKER_SCREENS is what has to be updated — a crossing that fails there is not necessarily your application's fault, and is not this package's promise to hold across upstream redesigns.
Change policy
Semver, and majors are the normal case. This machinery tracks a NextGraph and a wallet application that are both still moving, so a screen inventory, a bound, or a call shape changes whenever they do — the major number will move often, and that frequency is the honest signal about this package, not an apology. Refusing to version would not slow the churn down; it would only take away the one tool you have for managing it. Pin a version, upgrade deliberately, and re-pull this contract each time.
What each level means here, in this package's own terms:
- major — an exported symbol is removed or renamed, or an existing call narrows: it now throws where it returned, requires an argument that was optional, or returns something a caller must newly handle. A change to what a
SuiteReportaccepts (a check name, a journey shape) is a major, because your suite is written against it. A signature change a caller must react to counts; one that only accepts more than before does not. - minor — a symbol is added and nothing existing moves: a new helper, a new screen in
BROKER_SCREENS, a new optional field onSuiteOptions. - patch — a fix that changes neither the exported surface nor anything above under
## Guarantees— including a re-sized*_MSconstant, which is explicitly disclaimed above.
A tag says where it comes from. A release cut on main carries a full version (1.0.0), and the three rules above govern what changes between two full versions. Work still on a branch carries a pre-release of the version it is heading for (1.0.0-dev.3), which sorts below that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
Tags carry the package name, because this repository publishes more than one engagement and their versions move independently: ng-e2e-helpers/v1.0.0-dev.2 is this package, polyfill/v… is the other one. A bare v… tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
1.0.0 is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it. What exists today is 1.0.0-dev.2, on a branch: pin that string exactly, and anchor your usage_ leaf's against: on it — against: ng-e2e-helpers@1.0.0-dev.2, the string you pinned, never the version it is heading for.
There is no changelog file and no deprecation window: the sections above are the release note. A removal or a narrowing lands in ## Surface and ## Guarantees in the same version that ships it. Diff this leaf between two pulls — ## Guarantees and ## Non-guarantees before ## Surface, because that is where a narrowing shows up first.