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.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
type: usage
|
||||
summary: What the polyfill's own end-to-end suites call from ng-e2e-helpers, under which constraints, and where the fit still costs them code
|
||||
against: ng-e2e-helpers@1.0.0-dev.1
|
||||
---
|
||||
|
||||
# usage_polyfill-e2e — the polyfill's end-to-end suites
|
||||
|
||||
The consumer is the end-to-end suite of `@ng-eventually/polyfill`: four entry points under that package's `e2e/` — a batch of SDK checks against the broker, an applicative suite driving the example application, a reactivity probe, and a cold-start reproduction — plus the page module they share.
|
||||
|
||||
This consumer lives in the same repository as the provider. That changes nothing about what is declared here: the engagement is what these suites are written against, and anything they call that is NOT listed below is theirs to fix if it moves.
|
||||
|
||||
## Consumed surface
|
||||
|
||||
**Bounds** — `within`, `armSuiteDeadline`, `closeQuietly`, `enclosingBound`, `firstLine`. `within` is the most-called entry of the whole package here: every bridge call into the application iframe goes through it.
|
||||
|
||||
**Measurement** — `measured`. Nothing reads `record`, `timingsWanted` or `printTimings` directly; the `E2E_TIMINGS=1` output is consumed as printed output, not as a call.
|
||||
|
||||
**Browser and profiles** — `launchWatchedContext`, `closeContext`, `newPage`, and the type `RunProfile`. `newRunProfile` and `isAlive` are never called: profiles are always obtained through a wallet call.
|
||||
|
||||
**Wallet** — `mintWalletProfile` (the batch wallet, minted once per run), `mintWalletProfileKeepingContext` (the cold-start reproduction, which needs the very first application session over a never-used wallet), `emptyProfileContext` + `importWalletFile` (the reconnection journey), `exportWalletBytes` (serving the wallet from the application's own bundle), and the type `WalletCredentials`.
|
||||
|
||||
**Broker crossing** — `setupBrokerPage` in all four suites, `completeBrokerLogin` in the applicative one.
|
||||
|
||||
**Serving** — `serveOnEphemeralPort`, for both the SDK page and the example application's bundle.
|
||||
|
||||
**Known failure modes** — `browserTrouble`, `frameTrouble`.
|
||||
|
||||
**Report** — `declareSuite` and the types `JourneyDeclaration`, `Prerequisite`. Used by the applicative suite only.
|
||||
|
||||
**Constants** — `BROKER_ROUND_TRIP_MS`, `NEW_PAGE_MS`, both as inputs to `enclosingBound` rather than as waits of their own.
|
||||
|
||||
Everything else the package exports is offered and NOT consumed here — the screen inventory (`BROKER_SCREENS`, `WALLET_APP`, `WALLET_CREATION`, `WALLET_IMPORT`, `brokerRedirectFor`, and the screen types), `createWalletInContext`, `exportWalletFile`, `mintWalletBytes`, `DEFAULT_WALLET_NAME`, `browserLost`, `lossDeclared`, the exported error classes, and the remaining `*_MS` constants. It is safely evolvable as far as these suites are concerned.
|
||||
|
||||
## Constraints
|
||||
|
||||
**One Playwright, and the browser types come from the helpers.** These suites import `Frame`, `Page` and `BrowserContext` from `playwright` directly today, which holds only because the package resolves the same copy. The safer form — deriving them from the helpers that return them — is what a consumer in another repository must do, and what this one should adopt.
|
||||
|
||||
**Every suite mints its own physical wallet, per run.** Nothing is carried between runs and no wallet is reused, so a run's result never depends on a previous one. The batch wallet's NAME is stable and its identity is not: two runs sharing the name share nothing else.
|
||||
|
||||
**The password is supplied, never defaulted.** These suites also read the barrier's displayed password back off its own screen and pass it to `importWalletFile`, which is the only way to prove that what the barrier shows is what opens the file. A defaulted password would make that check vacuous.
|
||||
|
||||
**An enclosing bound is at least the sum of what it encloses.** These suites compute every enclosure with `enclosingBound` rather than picking a round number, because an enclosure that fires first reports "the journey timed out" and never names the step that hung.
|
||||
|
||||
**A wait must not be entered unbounded.** Playwright's `frame.evaluate` waits forever, so no bridge call is made outside `within`.
|
||||
|
||||
## Frictions
|
||||
|
||||
**`frame.evaluate` has no bound, and three suites each wrote the same wrapper.** A `sdk(frame, method, …args)` helper — `within(…, BRIDGE_MS, () => frame.evaluate(…))` — is duplicated verbatim in three of the four entry points. The bound is the single most important one in each of them, and the package offers nothing to obtain it from. A bounded `evaluate` helper here would delete the duplication and stop each new suite re-deriving it.
|
||||
|
||||
**"Measured and bounded" is one intent and two calls.** Sizing a bound from its own measurement is the discipline this package prescribes, yet the applicative suite composes `measured(what, ms, (bound) => within(what, bound, task))` by hand — and the indexing layer's suite, in another repository, wrote the same line byte for byte. A helper that pairs them belongs on the engagement.
|
||||
|
||||
**`declareSuite` fits a journey suite and not a check batch.** Only the applicative suite uses it; the other three declare a local `record`/`check` pair and print their own summary, because their shape is a flat list of checks discovered as the run goes rather than named journeys declared up front. The up-front declaration is exactly what makes the report bounded, so this is not a request to relax it — but three of four suites falling outside it suggests the package covers one of two real shapes.
|
||||
|
||||
**The screen inventory is exported and unreachable in practice.** `BROKER_SCREENS` and the `WALLET_*` page descriptions are published, but a suite that crosses the broker with `setupBrokerPage` never needs them, and one that wanted to extend the crossing has no supported way to hand a modified inventory back in. As it stands they read as internals that happen to be exported.
|
||||
Reference in New Issue
Block a user