docs: des contrats à deux voix, et une politique de version qui dit la vérité
Le régime des contrats est devenu bidirectionnel : chaque partie rédige sa position dans son dépôt et tire celle de l'autre. Le fournisseur écrit son engagement, chaque consommateur écrit sa déclaration — ce qu'il appelle vraiment, sous quelles contraintes, et avec quelles frictions. Ce dernier point change nos usages : la section Frictions est le chemin par lequel un consommateur expose un problème, le canal hors-bande ne portant que le signal. Les huit points que Festipod nous a remontés auraient dû arriver par là, et notre réponse appartient à l'engagement plutôt qu'à un échange qui s'évapore. Trois interfaces existaient, une seule était documentée. Elles ont maintenant leur dossier et leurs deux voix : la surface du polyfill, ng-e2e-helpers, et la couche d'indexation. On a écrit les engagements dont nous sommes l'auteur et les déclarations des consommateurs qui sont à nous — délibérément AUCUN usage_festipod : ce n'est pas notre dépôt, et décider à leur place ce qu'ils consomment viderait le mécanisme de son sens. Et la Change policy s'inverse. Elle disait « ce paquet n'offre pas de stabilité sémantique ». Refuser un schéma ne ralentit pas le mouvement — ça retire au consommateur le seul outil pour le gérer, alors qu'on faisait déjà le travail d'une publication versionnée sans lui en donner l'étiquette. Semver, donc, et les majeurs sont le cas NORMAL : une surface qui converge vers une cible qu'elle n'atteint pas encore casse souvent, et c'est le signal vrai. Le texte dit les déclencheurs plutôt que le nom du schéma — retirer un symbole publié ou resserrer un appel est un majeur, ajouter est un mineur. Version pleine sur main, pré-version sur une branche : Festipod peut épingler aujourd'hui sans qu'on lui promette du stable, et sans qu'on ait à lui retirer son adresse à la fusion. Au passage, la signature publiée de watchShape était fausse — elle annonçait un argument unique là où l'appel en prend deux. Rapporté par Festipod, confirmé indépendamment.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
---
|
||||
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.
|
||||
|
||||
`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.
|
||||
Reference in New Issue
Block a user