164 lines
16 KiB
Markdown
164 lines
16 KiB
Markdown
---
|
|
type: contract
|
|
summary: 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. The load-bearing signatures:
|
|
|
|
```ts
|
|
// ── 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;
|
|
```
|
|
|
|
## 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 `SuiteReport` accepts (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 on `SuiteOptions`.
|
|
- **patch** — a fix that changes neither the exported surface nor anything above under `## Guarantees` — including a re-sized `*_MS` constant, 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.1` 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.1`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: ng-e2e-helpers@1.0.0-dev.1`, 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.
|