@parity/product-lab 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +27 -0
- package/adapters/browser/playwright/README.md +2 -0
- package/adapters/browser/playwright/src/page-frame-capture.js +10 -2
- package/adapters/chain/papi/README.md +3 -1
- package/adapters/environment/engine/README.md +1 -1
- package/adapters/features/cucumber/README.md +4 -0
- package/adapters/features/cucumber/src/steps/register-feature-steps.js +15 -1
- package/adapters/system/node/README.md +3 -3
- package/adapters/video/ffmpeg/README.md +4 -0
- package/adapters/video/ffmpeg/src/ffmpeg-video-editor.d.ts +1 -1
- package/adapters/video/ffmpeg/src/ffmpeg-video-editor.js +19 -6
- package/core/application/README.md +30 -1
- package/core/application/src/features/direct-feature-steps.d.ts +23 -0
- package/core/application/src/features/direct-feature-steps.js +25 -0
- package/core/application/src/features/feature-recording.d.ts +6 -0
- package/core/application/src/features/feature-recording.js +3 -0
- package/core/application/src/features/feature-scenario.d.ts +10 -8
- package/core/application/src/features/feature-scenario.js +11 -15
- package/core/application/src/features/feature-step-times.d.ts +11 -1
- package/core/application/src/features/feature-step-times.js +20 -6
- package/core/application/src/features/lab-feature-session.d.ts +3 -1
- package/core/application/src/features/lab-feature-session.js +5 -2
- package/core/application/src/features/run-feature-steps.d.ts +3 -1
- package/core/application/src/features/run-feature-steps.js +4 -2
- package/core/application/src/lab-runtime-use-cases.js +5 -1
- package/core/application/src/recordings/demo-markup.d.ts +8 -0
- package/core/application/src/recordings/demo-markup.js +11 -1
- package/core/application/src/recordings/demo-recording.d.ts +0 -2
- package/core/application/src/recordings/demo-recording.js +40 -23
- package/core/application/src/recordings/frame-sources.d.ts +20 -1
- package/core/application/src/recordings/frame-sources.js +89 -0
- package/core/application/src/recordings/record-runs.d.ts +7 -1
- package/core/application/src/recordings/record-runs.js +66 -11
- package/core/application/src/recordings/recording-draft.d.ts +4 -0
- package/core/application/src/recordings/recording-draft.js +13 -4
- package/core/domain/README.md +14 -0
- package/core/domain/src/recordings/demo-cut.js +6 -4
- package/core/domain/src/recordings/demo-holds.d.ts +12 -0
- package/core/domain/src/recordings/demo-holds.js +31 -0
- package/core/domain/src/recordings/index.d.ts +1 -0
- package/core/domain/src/recordings/index.js +1 -0
- package/core/domain/src/recordings/recording-data.d.ts +9 -1
- package/core/domain/src/recordings/recording-settings.d.ts +5 -1
- package/core/domain/src/recordings/recording-settings.js +7 -0
- package/core/domain/src/recordings/run-recording.d.ts +2 -0
- package/core/domain/src/recordings/run-recording.js +8 -1
- package/core/ports/README.md +6 -0
- package/core/ports/src/driven/recording/demo-ports.d.ts +2 -0
- package/core/ports/src/driven/recording/frame-capture.d.ts +4 -0
- package/core/ports/src/driving/demo.d.ts +2 -0
- package/core/ports/src/driving/feature-session.d.ts +7 -0
- package/entrypoints/cli/README.md +6 -3
- package/entrypoints/cli/src/demo-command.d.ts +1 -1
- package/entrypoints/cli/src/demo-command.js +10 -5
- package/entrypoints/mcp/README.md +3 -0
- package/entrypoints/panel/README.md +6 -3
- package/entrypoints/panel/src/visible/detached-identities.js +5 -1
- package/entrypoints/panel/src/visible/window-frames.js +14 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @parity/product-lab
|
|
2
2
|
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d9721de: Keep direct and custom feature steps in recordings, with their timing and after-step images. Default frames-only runs keep capture non-blocking. Video runs wait for document load completion and exact screenshot stability over 200 ms before taking after-step images, and add a 2-second tail per scenario before cleanup. Stability checking defaults to a 3-second cap, configurable with `PRODUCT_LAB_RECORDINGS_SETTLE_MS`; the effective cap reserves 1000 ms for the final image (half the capture timeout below 2000 ms), so the default capture timeout clamps requested stability waits to at most 4000 ms. At the cap, a fresh image is kept with `settled: false` and `waitedMs`, without failing the step. The recording view and demo captions disclose when a frame was taken at the stability cap. The existing whole-capture timeout still applies. Recording metadata is additive and older recordings remain readable.
|
|
8
|
+
|
|
9
|
+
`product-lab demo` now produces 1280×960 held stills with captions below the app, instead of a moving 1280×720 cut. Each step is held for at least 2 seconds and exports a midpoint PNG. `--max-seconds` no longer shortens the cut. Older recordings without after-state evidence are refused, including with `--plan` and `--slide-only`, instead of substituting unrelated footage.
|
|
10
|
+
|
|
3
11
|
## 0.1.1
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -323,3 +323,30 @@ For Parity's security disclosure process, and Bug Bounty program, feel free to v
|
|
|
323
323
|
|
|
324
324
|
Apache-2.0: see LICENSE. The icons and fonts the lab window carries keep their own licences, in the files beside them
|
|
325
325
|
(`lucide.LICENSE`, `*.OFL.txt`); the packages npm installs with this one keep theirs.
|
|
326
|
+
|
|
327
|
+
## Settled feature demos
|
|
328
|
+
|
|
329
|
+
Feature recordings include direct/custom step timing. Default frames-only capture stays asynchronous. Video recording (`--video`) awaits bounded after-step captures and keeps a 2-second tail per scenario before cleanup.
|
|
330
|
+
|
|
331
|
+
`product-lab demo` produces 1280×960 held stills, with a 1280×720 app above a separate caption band and a decoded midpoint PNG per step. Each hold lasts at least two seconds; `--max-seconds` never shortens it. Older recordings without after-state evidence are refused, including with `--plan` and `--slide-only`. Setup context and older slide fallback imagery are labelled.
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
Video feature after-frames first check that the current document has completed loading, then compare exact PNGs
|
|
335
|
+
across a 200 ms window, sampling at least 100 ms apart. The stability wait is capped at 3000 ms by default
|
|
336
|
+
(`RecordingSettings.settleTimeoutMs`, or `PRODUCT_LAB_RECORDINGS_SETTLE_MS`). At the cap, the recorder takes a
|
|
337
|
+
fresh frame anyway and adds `settled: false` and `waitedMs` to its metadata. Successful checks add `settled: true`;
|
|
338
|
+
`loadComplete` records when the source verified the document. The existing `captureTimeoutMs` still bounds the
|
|
339
|
+
whole capture, including this wait and any final image. The wait never changes the step's own timestamps or outcome.
|
|
340
|
+
|
|
341
|
+
Only feature after-frames with video enabled wait for visual stability, including the final frame after the
|
|
342
|
+
2-second scenario tail. Tool-run captures, before-frames, checkpoints and default frames-only feature captures
|
|
343
|
+
retain their existing asynchronous policy. Scenario cleanup still settles captures already in flight. Custom
|
|
344
|
+
capture sources may ignore the optional load check; absent `loadComplete` means load completion is unknown.
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
The effective stability cap reserves time for the final screenshot: it is clamped to `captureTimeoutMs` minus
|
|
348
|
+
1000 ms, or minus half that timeout when it is shorter than 2000 ms. With the default 5000 ms capture timeout,
|
|
349
|
+
`PRODUCT_LAB_RECORDINGS_SETTLE_MS` accepts positive values but uses at most 4000 ms; the default stays 3000 ms.
|
|
350
|
+
A captured frame with `settled: false` is labelled "Taken at the stability cap" in recording-view notes and in
|
|
351
|
+
the demo image manifest and captions, including reused setup and failure context. Older frames without this
|
|
352
|
+
metadata retain their existing labels.
|
|
@@ -212,3 +212,5 @@ condition on this package's own files; a distribution of axe-core itself keeps i
|
|
|
212
212
|
`newContext({ permissions })` and `newPage({ permissions })` forward explicit permissions to the selected Playwright context. `onPageError(listener)` observes the original error message and returns a disposer; it does not change the app or its error handling. Consumers redact diagnostic strings before persistence. The Chromium controls exercise camera/microphone grants and listener disposal in `tests/playwright.test.ts`. An example's companion launchers receive `SessionContext.browsers` rather than constructing an independent browser.
|
|
213
213
|
|
|
214
214
|
The native RTC connection fixture also closes its own browser when Node cancels its test. The test runner deadline alone does not interrupt an outstanding browser evaluation; the cancellation cleanup keeps a failed connection attempt from leaving that browser and test process alive. The original connection, privacy and ownership assertions remain in place.
|
|
215
|
+
|
|
216
|
+
`PageFrameCapture.capture` accepts optional `loadComplete: true`: it waits until the current document has `readyState === "complete"`, within the supplied capture timeout, and reports `loadComplete: true`. Video feature after-frame checks use this before comparing PNGs; ordinary captures add no load wait. Tests in `tests/page-frame-capture.test.ts` cover ordering and existing capture behavior.
|
|
@@ -56,7 +56,15 @@ export class PageFrameCapture {
|
|
|
56
56
|
else
|
|
57
57
|
return { status: "unavailable", reason: `Identity ${actorId}'s app document cannot paint over elements (no locators)` };
|
|
58
58
|
}
|
|
59
|
-
|
|
59
|
+
let loadComplete;
|
|
60
|
+
const image = async (target) => {
|
|
61
|
+
if (options.loadComplete && "waitForFunction" in target && typeof target.waitForFunction === "function") {
|
|
62
|
+
await target.waitForFunction(() => document.readyState === "complete", undefined, { timeout: shot.timeout });
|
|
63
|
+
loadComplete = true;
|
|
64
|
+
options.signal?.throwIfAborted();
|
|
65
|
+
}
|
|
66
|
+
return screenshots(target) ? new Uint8Array(await target.screenshot(shot)) : framed(target) ? new Uint8Array(await (await target.frameElement()).screenshot(shot)) : undefined;
|
|
67
|
+
};
|
|
60
68
|
let bytes;
|
|
61
69
|
try {
|
|
62
70
|
bytes = await image(page);
|
|
@@ -69,7 +77,7 @@ export class PageFrameCapture {
|
|
|
69
77
|
bytes = await image(again);
|
|
70
78
|
}
|
|
71
79
|
if (bytes)
|
|
72
|
-
return { status: "captured", mediaType: "image/png", bytes, ...settled };
|
|
80
|
+
return { status: "captured", mediaType: "image/png", bytes, ...settled, ...(loadComplete !== undefined ? { loadComplete } : {}) };
|
|
73
81
|
return { status: "unavailable", reason: `Identity ${actorId}'s app document offers no image capture` };
|
|
74
82
|
}
|
|
75
83
|
async accessibility(actorId, options = {}) {
|
|
@@ -38,7 +38,9 @@ alive, when an endpoint stopped answering: Node's own `WebSocket.close()` waits
|
|
|
38
38
|
bound, and polkadot-api's ws-provider drops a socket whose connection timed out (5 s) without closing it. A Notes
|
|
39
39
|
fork run whose live freshness reads timed out held four public sockets for nine minutes.
|
|
40
40
|
`tests/close-unresponsive.test.ts` drives both cases against a loopback endpoint that never answers, and
|
|
41
|
-
`composition/tests/fork-live.test.ts` the live runtime read; with the cut disabled all three fail.
|
|
41
|
+
`composition/tests/fork-live.test.ts` the live runtime read; with the cut disabled all three fail. The endpoint counts
|
|
42
|
+
only the client's WebSocket upgrades, so a local port probe's `GET /` is not taken for a reconnect, and each test closes
|
|
43
|
+
its access even when it fails early.
|
|
42
44
|
|
|
43
45
|
|
|
44
46
|
The account watch's reads (`ChainHistory`, since 29 September 2026): `signedExtrinsics(block, accounts)` gives a block's extrinsics one of the accounts signed, with their hashes (the signer is read from each signed version-4 extrinsic's `MultiAddress::Id`, `src/extrinsic-signer.ts`; unsigned and general extrinsics have none), and `pendingExtrinsics(accounts)` the pool's (`author_pendingExtrinsics`; `undefined` when the node does not offer the method, `isMethodUnavailable` in `src/errors.ts`). `poolAwareNonce(account)` (2 October 2026, backlog item 17k) is the node's own next nonce for the account, `system_accountNextIndex` asked by SS58 address: its nonce at the best block plus its transactions in the pool, every sender's and every format the pool holds; `undefined` without the method. Live on the People fast chain (omni-node), three `//Alice` remarks in flight: 6 while the nonce at the best block was 3 and `author_pendingExtrinsics` held 3; a read before the node imported them answers the best block's nonce.
|
|
@@ -23,7 +23,7 @@ use to reach an Engine network. Imported from the original lab.
|
|
|
23
23
|
| `engine/network-template.ts` | engine.json `template: true`: `saveNetworkTemplate` keeps a clean copy of a new network right after its first boot (Engine stopped, before the first resume) in `<workspace>/template/` (its data folder, network.json, versions.json, the session, `template.json`); `restoreNetworkTemplate` (a reset) clones it back as a new generation: a new spawn stamp in `<data>/spawn.json` and the session (the session check and the network lock compare it) and a new run id; its chain state is the first boot's, so nothing created since exists on it. `cloneTree`: `cp -c -R` (APFS copy-on-write) on macOS, `cp --reflink=auto -a` elsewhere, else a plain copy with a warning. The template serves its own workspace's resets only: every saved path names `<workspace>/network` |
|
|
24
24
|
| `engine/network-snapshots.ts` | Fixtures: `networkSnapshots(control)`, the `EnvironmentSnapshots` of a persistent network: `save` records each chain's finalized head, stops the network, copies `<workspace>/network` through the `TreeCloner` port (an APFS clone on macOS) with network.json, versions.json and the session, and resumes; `restore` refuses another kind, another lineage (`networkLineage`: the template's first boot, else the session's run; a network forked again is another lineage) or another workspace (saved paths name it), moves the live network aside, copies the snapshot in and resumes it, putting the moved one back when that fails. Since 30 September 2026 (backlog 1a) `control.files` names more plain files of the workspace to save beside the network (a consumer's own manifests, for example a workspace, an identity pool and its actors) under `files/`; restore puts each back, and one absent when saved stays absent. `networkSnapshotsUnsupported(owned, trees)` says why an attached network offers none. Since 30 September 2026 `networkSnapshots({ upgrade })` offers `EnvironmentSnapshots.upgrade` (a lab that owns the fork passes `savedForkUpdates(...).upgradeToLive`: `fixture refresh` upgrades a restored fixture in place); without it the snapshots say `upgradeUnsupported`. An Engine topology run (`engineEnvironment`) is disposable and reports `snapshotsUnsupported` Since 3 October 2026 (backlog 17h) `save` records `detail.savedAt` once the network has stopped, and with `control.locks` (`{ manager, directory?, log? }`) a restore reclaims the restored network's stale lock that a killed run took after that moment (`reclaimLockPredatedByRestore`), logging `Reclaimed the network lock … the restored state predates it (saved …)` |
|
|
25
25
|
| `engine/network-adoption.ts` | Fixture sharing (30 September 2026): `planNetworkAdoption` reads an imported network snapshot and this workspace's running plan and says what adopting it moves: the saved workspace directory and prepared Engine tooling directory to this checkout's (another tooling hash, another Engine, is refused) and, when the port block differs by one offset, every port of the old block wherever a port is named (plan arguments and environment, session, `engine-ports.json`, isolation and zombienet records, chain spec boot nodes, IPFS addresses; `rewriteText`); it refuses, naming each, another port layout, content-addressed IPFS blocks naming an old loopback port, and any other small file that still names the old workspace or ports. `applyNetworkAdoption` rewrites the snapshot copy; `networkSnapshots` `restore(…, { adopt: true })` plans before anything stops, applies it to its own snapshot copy, keeps the brought lineage in `network/adopted-lineage.json` (`readAdoptedLineage`, read first by `networkLineage`; a template reset replaces the folder and forgets it) and returns what moved. `save` records Engine's bite (`fork-snapshot.json`) and ports as `detail.provenance`. Since backlog 242 the workspace's own files a snapshot carries under `files/` (the environment names them: `networkSnapshots({ files })`) are rewritten too, so an environment's records with their ports and directories move with it. `tests/network-adoption.test.ts`; a dry run on a real saved fork: `tools/verify/live/engine/fixture-adoption.ts` |
|
|
26
|
-
| `engine/finality-stall.ts` | A saved network whose finality never advances after a resume (backlog 76). Seen live: relay validators that restart together can each lose the approval check of a candidate in their first second (`Session params unavailable (no active leaf?)`, upstream behaviour), and when too few are left to approve it (four of six lost) the relay block is never approved and nothing finalizes until the validators restart. `persistentNetwork.start()` recovers within a bound: it reads the validators' logs every second (`lostApprovalChecks`, `unapprovable`: the verdict arrives about 12 s into the start) and also calls a stall from the symptom alone (every chain answers, no finalized head advanced for `stallAfterMs`, default 2 minutes); it keeps what every process logged in `<workspace>/stall-evidence/<time>-start-<n>/` with `stall.json` (`keepStallEvidence`; `history.jsonl` lists every stall and outcome), stops the saved processes and starts them again, at most `stallRecovery.restarts` times (default 2). A recovered stall is reported: the `network-ready` phase says `finality advancing after 1 restart (finality had stalled: …)`, the logger warns, and `network.recovery.stalls` lists each stall (`NetworkStartRecovery`). One that is not recovered throws `FinalityStalledError` (`attempts`, `stalls`, each with its evidence directory), with the processes stopped and the data kept. `tests/finality-stall.test.ts` (the control: a network that never finalizes fails after 3 starts); the live loop is `tools/verify/live/engine/resume-loop.ts` Backlog 143 (5 October 2026): chains that stand while the relay's finalized head advances (read from a relay validator's RPC, `relayEndpoint` in `engine/drain.ts`) are waiting for the validators to prepare the parachains' PVFs, so they get no standing verdict and no restart, said in the start's progress; the start's deadline still bounds them, and a relay that stands too, or cannot be read, gives the verdict as before (`tests/finality-stall.test.ts`, with a frozen relay as its control). |
|
|
26
|
+
| `engine/finality-stall.ts` | A saved network whose finality never advances after a resume (backlog 76). Seen live: relay validators that restart together can each lose the approval check of a candidate in their first second (`Session params unavailable (no active leaf?)`, upstream behaviour), and when too few are left to approve it (four of six lost) the relay block is never approved and nothing finalizes until the validators restart. `persistentNetwork.start()` recovers within a bound: it reads the validators' logs every second (`lostApprovalChecks`, `unapprovable`: the verdict arrives about 12 s into the start) and also calls a stall from the symptom alone (every chain answers, no finalized head advanced for `stallAfterMs`, default 2 minutes); it keeps what every process logged in `<workspace>/stall-evidence/<time>-start-<n>/` with `stall.json` (`keepStallEvidence`; `history.jsonl` lists every stall and outcome), stops the saved processes and starts them again, at most `stallRecovery.restarts` times (default 2). A recovered stall is reported: the `network-ready` phase says `finality advancing after 1 restart (finality had stalled: …)`, the logger warns, and `network.recovery.stalls` lists each stall (`NetworkStartRecovery`). One that is not recovered throws `FinalityStalledError` (`attempts`, `stalls`, each with its evidence directory), with the processes stopped and the data kept. `tests/finality-stall.test.ts` (the control: a network that never finalizes fails after 3 starts); the live loop is `tools/verify/live/engine/resume-loop.ts` Backlog 143 (5 October 2026): chains that stand while the relay's finalized head advances (read from a relay validator's RPC, `relayEndpoint` in `engine/drain.ts`) are waiting for the validators to prepare the parachains' PVFs, so they get no standing verdict and no restart, said in the start's progress; the start's deadline still bounds them, and a relay that stands too, or cannot be read, gives the verdict as before (`tests/finality-stall.test.ts`, with a frozen relay as its control; the test's fake relay answers only JSON-RPC POSTs, so a local port probe's `GET /` gets a 404). |
|
|
27
27
|
| `engine/chain-clock.ts` | A resumed network's chains reach the wall clock before it is ready (backlog 106). A saved chain keeps the time of its last block until it builds a new one, and readiness waits for finality only on the lab's required chains, so a chain that only has to answer (Notes: Asset Hub) could still be at the save's time. That time is signed with: the truAPI CLI host timestamps its dotNS registration with Asset Hub's `Timestamp.Now` at the best block, and the identity backend refuses one more than 600 s old (seen 5 October 2026: gate batch 21's resume of a fork saved hours earlier failed onboarding with `dotns.signedAt is older than the intake freshness bound (600s)`). `persistentNetwork.start()` reads every chain's `Timestamp.Now` and best block after readiness (`waitForChainClocks`, `chainTime`) and waits until each is within 2 minutes (`CHAIN_CLOCK_MAX_LAG_MS`) of the wall clock, the `network-ready` phase naming what it waits for; one still behind after `CHAIN_CLOCK_TIMEOUT_MS` (2 minutes) is not building blocks, and the start fails with `ChainClockBehindError` (`behind`: each chain, its lag and best block), naming why and the way out (restart the network, or a fresh fork). The logger gives each chain's time, its lag at the first read and how long the wait took (`onRead`). `chainClock: false` skips it. `tests/chain-clock.test.ts` (the fake dashboard's `chainLagMs` sets each chain's time) |
|
|
28
28
|
| `engine/approval-pin.ts` | Relay finality pinned at the approval threshold after a session change (backlog 93, 239). A start whose first block changes the session builds two gossip topologies before any peer connects; polkadot-sdk's `SessionGridTopologies::update_authority_ids` gives the peers to the first only, so some approval votes reach nobody until approval-distribution's level-1 aggression (`l1_threshold` 16), and every block then finalizes about 16 blocks late for the rest of the start. `ApprovalPinWatch` and the constants `APPROVAL_PIN_GAP` (14), `APPROVAL_PIN_HOLD_MS` (30 s), `APPROVAL_PIN_RESTARTS` (1): once the relay has finalized past the best head of the start's first read (so a backlog the start began with, as after the pin's own restart without a drain, is finalized first), a best head 14 or more ahead of the finalized one for 30 s restarts the network once (`stallRecovery.pinned`), recorded in `recovery.stalls` as `pinned` ("finality pinned at the approval threshold after a session change; restarted once", with the heads and the gap), its logs kept like a stall's; a second pin fails the start (`FinalityPinnedError`). Watched while the start waits for readiness and, if the relay is at the threshold when it is ready, until it falls below or holds |
|
|
29
29
|
| `engine/node-temp-dir.ts` | A relay validator's TMPDIR fits its PVF sockets (backlog 93). A validator binds its PVF workers' Unix sockets directly in its TMPDIR (`pvf-host-prepare-<10>`); a path of `sun_path`'s size or more is refused (`path must be shorter than SUN_LEN`: 104 bytes on macOS and the BSDs, 108 on Linux, `SUN_PATH_BYTES`), so no candidate is validated, no approval completes and the relay's finality stops at the last block before the first unapproved candidate. Seen 4 October 2026: a fork started with TMPDIR under a lane's `_artifacts` folder (107-byte socket paths) stalled at relay #1,310,664 on a fresh boot and on every resume. `nodeTempDir(network, inherited)`: when the inherited TMPDIR is too long (`pvfSocketsFit`), the network's processes get `TMPDIR=/tmp/pl-pvf-<12 hex of the network directory>` (0700), on the first boot (`OwnedEngineDependency`: Engine, zombie-cli and so every node) and on each resume (`persistentNetwork`: every saved process), with a warning naming the byte counts; it is removed on stop, and the next start of the same network removes one a crashed run left. A TMPDIR that fits changes nothing. `tests/node-temp-dir.test.ts` (both sides of the limit on macOS and Linux), `tests/finality-stall.test.ts` (a resume), `tools/verify/tests/engine-owned-start.test.ts` (a first boot); each fails with the override removed |
|
|
@@ -83,3 +83,7 @@ and the run report. `error` remains the original failure text, so explanation ru
|
|
|
83
83
|
text-judged controls cannot match diagnostic words. Status and failure reason remain those of the original result. A normal
|
|
84
84
|
settled step gets no extra attachment. `entrypoints/cli/tests/abort-diagnostics.test.ts` exercises
|
|
85
85
|
this through a timed-out generated step.
|
|
86
|
+
|
|
87
|
+
## Held feature demos
|
|
88
|
+
|
|
89
|
+
registerFeatureSteps calls the optional startStep/finishStep observers for direct/custom definitions. Generated steps time themselves. The recording AfterStep hook is registered after the pause hook because Cucumber runs AfterStep hooks in reverse order, so video recording's bounded after-state capture finishes before a pause. Default frames-only capture stays asynchronous. In-process tests cover this order and custom definitions.
|
|
@@ -156,8 +156,9 @@ export function registerFeatureSteps(session, options = {}) {
|
|
|
156
156
|
});
|
|
157
157
|
api.BeforeStep(function ({ pickleStep }) {
|
|
158
158
|
const state = scenarios.get(this);
|
|
159
|
-
if (state)
|
|
159
|
+
if (state) {
|
|
160
160
|
state.current = state.ids.indexOf(pickleStep.id);
|
|
161
|
+
}
|
|
161
162
|
});
|
|
162
163
|
if (options.beforeStep) {
|
|
163
164
|
const gate = options.beforeStep;
|
|
@@ -166,6 +167,10 @@ export function registerFeatureSteps(session, options = {}) {
|
|
|
166
167
|
await gated(gate({ scenario: pickle.name, index: state ? state.ids.indexOf(pickleStep.id) : -1, text: pickleStep.text, status: "pending" }));
|
|
167
168
|
});
|
|
168
169
|
}
|
|
170
|
+
api.BeforeStep(function () {
|
|
171
|
+
const state = scenarios.get(this);
|
|
172
|
+
state?.scenario.startStep?.(state.current);
|
|
173
|
+
});
|
|
169
174
|
for (const view of sentences) {
|
|
170
175
|
const body = async function () {
|
|
171
176
|
const state = scenarios.get(this);
|
|
@@ -199,6 +204,15 @@ export function registerFeatureSteps(session, options = {}) {
|
|
|
199
204
|
await gated(gate({ scenario: pickle.name, index: state ? state.ids.indexOf(pickleStep.id) : -1, text: pickleStep.text, status: String(result.status).toLowerCase() }));
|
|
200
205
|
});
|
|
201
206
|
}
|
|
207
|
+
api.AfterStep({ timeout: hookTimeout }, async function ({ result }) {
|
|
208
|
+
const state = scenarios.get(this);
|
|
209
|
+
if (!state)
|
|
210
|
+
return;
|
|
211
|
+
const status = String(result.status).toUpperCase();
|
|
212
|
+
if (status === "SKIPPED")
|
|
213
|
+
return;
|
|
214
|
+
await state.scenario.finishStep?.(state.current, { status: status === "PASSED" ? "passed" : "failed", ...(result.message ? { error: result.message } : {}) });
|
|
215
|
+
});
|
|
202
216
|
api.After({ name: "product-lab: end the scenario", timeout: hookTimeout }, async function ({ pickle, result }) {
|
|
203
217
|
const state = scenarios.get(this);
|
|
204
218
|
scenarios.delete(this);
|
|
@@ -9,7 +9,7 @@ Node implementations of the system ports in [`@product-lab/ports/system`](../../
|
|
|
9
9
|
| `GitChangeSource` | `ChangeSource` | Phase 7: files changed between the merge base of a base revision and HEAD, plus uncommitted and untracked ones, relative to the lab's directory (`../` outside it); read-only git, no fetch; an unknown base or an option-like revision is refused; every git command runs through a `CommandRunner` (default `NodeCommandRunner`) bounded by `timeoutMs` (default 30 000 ms), a non-zero exit rejects with git's first stderr line, and an uncommitted file that cannot be read rejects `revision` rather than counting as deleted (`tests/git-change-source.test.ts`, a real temporary repository). Backlog 37: `commits` (first-parent commits that changed the lab's directory between two, oldest first; refused when the first is not an ancestor), `checkout` (`git worktree add --detach` into a new directory, with the lockfile at its top and its sha256; the lab's checkout untouched) and `removeCheckout` (forced remove, the directory deleted, `git worktree prune`) |
|
|
10
10
|
| `NodeSourceWatcher` | `SourceWatcher` | Hot reload of app code (backlog item 5): `fs.watch`, recursive on a watched directory (FSEvents on macOS, inotify on Linux), a watched file or a path not created yet through its nearest existing parent directory; reports changed paths relative to the lab with `/` separators (a watch of `.`, the lab's directory, reports every change under it, an editor's safe write by rename included), not debounced (the application's `AppReloads` debounces). `digest`: sha256 over each regular file's relative path and content in path order, an ignored directory skipped whole, symbolic links not followed. `tests/node-source-watcher.test.ts` (real temporary directory) |
|
|
11
11
|
| `NodeConditionedLinks` | `ConditionedLinks` (backlog 31) | One identity's conditioned link to one loopback endpoint (`node-conditioned-links.ts`): a TCP proxy on a port the caller reserved, carrying every byte as sent through the domain's `ConditionedWire` (one per direction of each connection: in order, a burst at its speed, half the round trip each way while throttled); offline refuses new connections and cuts the open ones; half-closes travel through the wire, so an answer sent before its server closes still arrives. A plain `http:` or `ws:` endpoint is named as the request's `Host` in the first request head of each connection (`request-head.ts`: a local Substrate node answers "Provided Host header is not whitelisted" to any other), and a plain request is made its connection's only one (`Connection: close`); a WebSocket upgrade keeps its connection, and bytes that do not start a request pass as sent. A public endpoint is refused, never proxied. Backpressure is not modelled. `tests/node-conditioned-links.test.ts` (real loopback sockets) |
|
|
12
|
-
| `NodeChainGates` | `ChainGates` (backlog 92) | Origin gates in front of local chain nodes (`node-chain-gates.ts`): one loopback TCP relay per plain `ws:` loopback chain route that accepts connections (a closed port, TLS and a public node are kept as they are). It holds each connection's first request head, decides it once, then pipes bytes both ways (backpressure, no per-message parsing). A request with neither `Origin` nor `Sec-Fetch-Site` (a native host, Node's fetch and WebSocket) passes; a browser's passes only from an admitted origin (`admit`), otherwise the connection is reset unanswered (a 403 would still resolve a no-cors fetch) and recorded (`refused()`: chain, gate URL, kind, origin, time). The node sees its own address as `Host` and a plain request is its connection's only one (`request-head.ts`), so no later request on a kept-alive connection skips the gate. A half-close travels on for a grace (5 s, `halfCloseGraceMs`), then both sides are cut; either side closing or failing destroys both; `close()` cuts everything. Substrate's own `--rpc-cors` cannot do this: its RPC server adds CORS headers but refuses no WebSocket upgrade. `tests/node-chain-gates.test.ts` (real loopback sockets: a closed upstream, a half-closed client, keep-alive, close) |
|
|
12
|
+
| `NodeChainGates` | `ChainGates` (backlog 92) | Origin gates in front of local chain nodes (`node-chain-gates.ts`): one loopback TCP relay per plain `ws:` loopback chain route that accepts connections (a closed port, TLS and a public node are kept as they are). It holds each connection's first request head, decides it once, then pipes bytes both ways (backpressure, no per-message parsing). A request with neither `Origin` nor `Sec-Fetch-Site` (a native host, Node's fetch and WebSocket) passes; a browser's passes only from an admitted origin (`admit`), otherwise the connection is reset unanswered (a 403 would still resolve a no-cors fetch) and recorded (`refused()`: chain, gate URL, kind, origin, time). The node sees its own address as `Host` and a plain request is its connection's only one (`request-head.ts`), so no later request on a kept-alive connection skips the gate. A half-close travels on for a grace (5 s, `halfCloseGraceMs`), then both sides are cut; either side closing or failing destroys both; `close()` cuts everything. Substrate's own `--rpc-cors` cannot do this: its RPC server adds CORS headers but refuses no WebSocket upgrade. `tests/node-chain-gates.test.ts` (real loopback sockets: a closed upstream, a half-closed client, keep-alive, close; the fake node does not record a plain `GET` with no browser mark, which is what a local port probe sends, and the test sends one to both ports) |
|
|
13
13
|
| `NodeAppProxies` | `AppProxies` | The lab's proxy in front of an app's dev server (backlog items 64 to 66; `src/app-proxy/`): an HTTP reverse proxy on a loopback port that passes every request and answer as sent, except that an HTML document the browser loads gets the host's bridge script (the domain's `injectScriptTag`; then `no-store`, no validators), bodies are asked for uncompressed and documents unconditionally, a redirect to exactly the dev server's origin names the proxy, and the connection to the dev server is the proxy's own (one per request, since dev servers close idle kept-alive sockets). It reads the document's framing headers and never changes them (`framing()`). WebSockets are tunnelled byte for byte (`web-socket-tunnel.ts`); an update socket it understands (`VITE_UPDATES` in `vite-updates.ts`, Vite's `vite-hmr` subprotocol, checked against Vite 7.3.7) is read message by message: each announced update is put to the policy and forwarded or dropped, the same announcement on several pages' sockets is one update, and the page's side stays open when the dev server's closes (`lost`). A socket or an event stream it does not understand passes untouched and is reported once (`unknown-socket`), never for a declared dev server. `tests/node-app-proxy.test.ts` (real loopback servers, Node's WebSocket client; controls: an asset, a fragment, a conditional request, a dead dev server) |
|
|
14
14
|
| `RandomIdGenerator` | `IdGenerator` | random UUIDs |
|
|
15
15
|
| `ConsoleLogger` | `Logger` | level filter; details as JSON; with `redactor` (a `Redactor`'s `text`), every line scrubbed before it is written (backlog 89: the composition root passes the runtime's, so a provider logging a secret of the lab never prints it) |
|
|
@@ -70,7 +70,7 @@ The app-proxy fixture registers listener cleanup before proxy startup; an occupi
|
|
|
70
70
|
|
|
71
71
|
The process-tree readiness controls count the anchor, command and child separately; the gated child must appear before readiness passes.
|
|
72
72
|
|
|
73
|
-
`bunx nx run system-node:test`: real process trees for the runner (registry in memory; `tests/owned-group-anchor.test.ts`: a command exiting 0 or non-zero before any refresh with a descendant in its group, an escaped `setsid` descendant pinned as not covered, a stop during a start, a failed recording, a spawn error, a crashed controller (`tests/anchor-controller.ts`, registry on disk through `tests/disk-files.ts`) while the command runs and after it exits, reused pids, the anchor's own bounds, the command's default and named `cwd`, and a start never acknowledged: `tests/unacknowledging-anchor.mjs` as `anchor.path`, silent (stopped) or dying (refused, record kept)), real one-shot commands for `NodeCommandRunner` (exit code, cwd, env overlay and removal, stdin; timeout, abort, pre-aborted, a missing command and a zero timeout rejected; descendants: one holding the output after the command exits (settles within `drainMs`, group killed), a timeout with one in the group (both killed, on time), one that left the group (not killed, still settles by timeout + drain), and controls: a descendant with closed output neither delays nor fails a run, and 4 MiB of output is read in full with a 50 ms drain), fakes for the process table (EPERM, survivors, group capture), locks (liveness, clock), JSON-RPC transports and the connection probe (lsof output, process trees, exit 1 with a partial listing, a bounded `stop()`, and real `ps`/`lsof` with a watched process that exits), real loopback sockets for ports (a block found free at the start of the run from an OS-assigned port, or `SYSTEM_NODE_TEST_PORT_BASE`). `tests/node-process-sandbox.test.ts`: platform selection, construction and profile/plan-wrapping unit tests plus live smoke tests (gated to where `sandbox-exec` is supported) that spawn a wrapped process and assert a public connect gets `EPERM` while loopback still works, and record that the machine's own address counts as `localhost`. `tests/linux-namespace-sandbox.test.ts`: the probe and the wrap against fakes everywhere; on Linux where the probe passes, live smoke tests (this machine's own non-loopback address reachable unsandboxed and `ENETUNREACH` sandboxed, a public address refused at once, a declared loopback port reached through the forwarder and an undeclared one refused although it listens, a declared listen port reached from outside, exit codes and SIGTERM passed through) and a leaky control (a plan left unwrapped fails the same deny check, "reached … outside loopback"). `tools/linux` runs them on Linux in a container. On disk and across a crashed owner process: `tools/verify/tests/system-adapters.test.ts`.
|
|
73
|
+
`bunx nx run system-node:test`: real process trees for the runner (registry in memory; `tests/owned-group-anchor.test.ts`: a command exiting 0 or non-zero before any refresh with a descendant in its group, an escaped `setsid` descendant pinned as not covered, a stop during a start, a failed recording, a spawn error, a crashed controller (`tests/anchor-controller.ts`, registry on disk through `tests/disk-files.ts`) while the command runs and after it exits, reused pids, the anchor's own bounds, the command's default and named `cwd`, and a start never acknowledged: `tests/unacknowledging-anchor.mjs` as `anchor.path`, silent (stopped) or dying (refused, record kept)), real one-shot commands for `NodeCommandRunner` (exit code, cwd, env overlay and removal, stdin; timeout, abort, pre-aborted, a missing command and a zero timeout rejected; descendants: one holding the output after the command exits (settles within `drainMs`, group killed), a timeout with one in the group (both killed, on time), one that left the group (not killed, still settles by timeout + drain), and controls: a descendant with closed output neither delays nor fails a run, and 4 MiB of output is read in full with a 50 ms drain), fakes for the process table (EPERM, survivors, group capture), locks (liveness, clock), JSON-RPC transports and the connection probe (lsof output, process trees, exit 1 with a partial listing, a bounded `stop()`, and real `ps`/`lsof` with a watched process that exits), real loopback sockets for ports (a block found free at the start of the run from an OS-assigned port, or `SYSTEM_NODE_TEST_PORT_BASE`). `tests/node-process-sandbox.test.ts`: platform selection, construction and profile/plan-wrapping unit tests plus live smoke tests (gated to where `sandbox-exec` is supported) that spawn a wrapped process and assert a public connect gets `EPERM` while loopback still works, and record that the machine's own address counts as `localhost`. Their greeter cuts every connection still open when it closes, since it never reads one; the machine-address test sends a local port probe itself and is bounded (30 s), so a close that never ends fails it instead of hanging the file. `tests/linux-namespace-sandbox.test.ts`: the probe and the wrap against fakes everywhere; on Linux where the probe passes, live smoke tests (this machine's own non-loopback address reachable unsandboxed and `ENETUNREACH` sandboxed, a public address refused at once, a declared loopback port reached through the forwarder and an undeclared one refused although it listens, a declared listen port reached from outside, exit codes and SIGTERM passed through) and a leaky control (a plan left unwrapped fails the same deny check, "reached … outside loopback"). `tools/linux` runs them on Linux in a container. On disk and across a crashed owner process: `tools/verify/tests/system-adapters.test.ts`.
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
## Source revisions for compatibility
|
|
@@ -107,7 +107,7 @@ API-name correction: `bridge()` is a method on the `AppProxy` returned by `NodeA
|
|
|
107
107
|
|
|
108
108
|
One 3000 ms deadline covers the exact application-path HEAD collision probe and native GET. Native headers are capped at 16 KiB and the script at 2 MiB. Redirects are never followed; caller cookies and conditionals are never sent to the host. A successful non-HTML application asset at the generated path is refused with 409. HTML SPA fallback is accepted, but an intentional HTML route is indistinguishable from it. Unsupported HEAD replies refuse with explicit 502; this check does not atomically protect against later application route changes. The whole namespace is reserved, including missing/expired URLs; consumers must keep their routes outside it. The native socket remains at its original endpoint, so `script-src 'self'` still needs a suitable `connect-src` allowance. Nonce/hash-only scripts and strict-dynamic are unqualified.
|
|
109
109
|
|
|
110
|
-
Focused checks: `node --import tsx --test adapters/system/node/tests/node-app-bridge.test.ts adapters/system/node/tests/node-app-proxy.test.ts`. `APP_BRIDGE_TEST_PORT_BASE` selects the new fixture block (default 45300; one distinct ten-port slice per case); `APP_PROXY_TEST_PORT_BASE` optionally selects distinct ports for the older proxy tests, whose default remains independent OS allocation. All fixture listeners close with their tests.
|
|
110
|
+
Focused checks: `node --import tsx --test adapters/system/node/tests/node-app-bridge.test.ts adapters/system/node/tests/node-app-proxy.test.ts`. `APP_BRIDGE_TEST_PORT_BASE` selects the new fixture block (default 45300; one distinct ten-port slice per case); `APP_PROXY_TEST_PORT_BASE` optionally selects distinct ports for the older proxy tests, whose default remains independent OS allocation. All fixture listeners close with their tests. The bridge tests count, and wait for, only reads of a native server's bound path, so a local port probe's `GET /` is not taken for a read.
|
|
111
111
|
|
|
112
112
|
## Explicit Next App Router profile (1 October 2026)
|
|
113
113
|
|
|
@@ -37,3 +37,7 @@ generated video cut into an MP4 and a WebM of the planned length with an overlay
|
|
|
37
37
|
where ffmpeg is not installed).
|
|
38
38
|
|
|
39
39
|
Private, workspace-only package. Consumers will install the single umbrella package `@parity/product-lab` once it is published.
|
|
40
|
+
|
|
41
|
+
## Held feature demos
|
|
42
|
+
|
|
43
|
+
VideoEditRequest.stills supplies exactly one PNG per segment; the adapter loops each at 25 fps for its planned duration, concatenates them, and encodes the result. No source-video timestamp is substituted for a missing still. The existing trim/speed/overlay path is retained when stills are absent. An offline integration test checks encoded midpoint pixels, duration, caption placement and failure visibility.
|
|
@@ -6,14 +6,19 @@ import { join } from "node:path";
|
|
|
6
6
|
const FRAME_RATE = 25;
|
|
7
7
|
const INSTALL = "macOS: brew install ffmpeg; Debian/Ubuntu: apt install ffmpeg; or set PRODUCT_LAB_FFMPEG to its folder";
|
|
8
8
|
const seconds = (ms) => (ms / 1000).toFixed(3);
|
|
9
|
-
export function demoFilterGraph(plan, overlays, size) {
|
|
9
|
+
export function demoFilterGraph(plan, overlays, size, stills = false) {
|
|
10
10
|
if (!plan.segments.length)
|
|
11
11
|
throw new Error("The demo plan has no segment to cut");
|
|
12
12
|
const { width, height } = size;
|
|
13
13
|
const frame = 1000 / FRAME_RATE;
|
|
14
14
|
const parts = plan.segments.map((segment, index) => {
|
|
15
15
|
const shown = segment.outputEnd - segment.outputStart;
|
|
16
|
-
const filters = [
|
|
16
|
+
const filters = stills ? [
|
|
17
|
+
"setpts=PTS-STARTPTS", `fps=${FRAME_RATE}`,
|
|
18
|
+
`scale=${width}:${height}:force_original_aspect_ratio=decrease`,
|
|
19
|
+
`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=0xf6f6f4`, "setsar=1",
|
|
20
|
+
`trim=duration=${seconds(shown)}`,
|
|
21
|
+
] : [
|
|
17
22
|
`trim=start=${seconds(segment.sourceStart)}:end=${seconds(Math.max(segment.sourceEnd, segment.sourceStart + frame + 1))}`,
|
|
18
23
|
`setpts=(PTS-STARTPTS)/${segment.speed.toFixed(4)}`,
|
|
19
24
|
`fps=${FRAME_RATE}`,
|
|
@@ -24,10 +29,10 @@ export function demoFilterGraph(plan, overlays, size) {
|
|
|
24
29
|
`trim=duration=${seconds(shown)}`,
|
|
25
30
|
"setpts=PTS-STARTPTS",
|
|
26
31
|
];
|
|
27
|
-
return `[0:v]${filters.join(",")}[s${index}]`;
|
|
32
|
+
return `[${stills ? index : 0}:v]${filters.join(",")}[s${index}]`;
|
|
28
33
|
});
|
|
29
34
|
const concat = `${plan.segments.map((_, index) => `[s${index}]`).join("")}concat=n=${plan.segments.length}:v=1:a=0[cut0]`;
|
|
30
|
-
const layers = overlays.map((overlay, index) => `[cut${index}][${index + 1}:v]overlay=0:0:enable='between(t,${seconds(overlay.startMs)},${seconds(Math.max(overlay.endMs - 1, overlay.startMs))})'[cut${index + 1}]`);
|
|
35
|
+
const layers = overlays.map((overlay, index) => `[cut${index}][${index + (stills ? plan.segments.length : 1)}:v]overlay=0:0:enable='between(t,${seconds(overlay.startMs)},${seconds(Math.max(overlay.endMs - 1, overlay.startMs))})'[cut${index + 1}]`);
|
|
31
36
|
return { graph: [...parts, concat, ...layers].join(";"), output: `cut${overlays.length}` };
|
|
32
37
|
}
|
|
33
38
|
export class FfmpegVideoEditor {
|
|
@@ -86,15 +91,23 @@ export class FfmpegVideoEditor {
|
|
|
86
91
|
async render(request) {
|
|
87
92
|
const work = mkdtempSync(join(tmpdir(), "product-lab-demo-"));
|
|
88
93
|
try {
|
|
94
|
+
if (request.stills && request.stills.length !== request.plan.segments.length)
|
|
95
|
+
throw new Error("A demo needs exactly one still per segment");
|
|
96
|
+
const sources = request.stills ? request.stills.flatMap((png, index) => {
|
|
97
|
+
const file = join(work, `step-${index}.png`);
|
|
98
|
+
writeFileSync(file, png);
|
|
99
|
+
const segment = request.plan.segments[index];
|
|
100
|
+
return ["-loop", "1", "-framerate", String(FRAME_RATE), "-t", seconds(segment.outputEnd - segment.outputStart), "-i", file];
|
|
101
|
+
}) : ["-i", request.source];
|
|
89
102
|
const inputs = request.overlays.flatMap((overlay, index) => {
|
|
90
103
|
const file = join(work, `overlay-${index}.png`);
|
|
91
104
|
writeFileSync(file, overlay.png);
|
|
92
105
|
return ["-i", file];
|
|
93
106
|
});
|
|
94
|
-
const { graph, output } = demoFilterGraph(request.plan, request.overlays, request.size);
|
|
107
|
+
const { graph, output } = demoFilterGraph(request.plan, request.overlays, request.size, !!request.stills);
|
|
95
108
|
const encode = request.format === "mp4" ? ["-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", "-movflags", "+faststart"] : ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "34", "-row-mt", "1"];
|
|
96
109
|
const partial = `${request.out}.partial.${request.format}`;
|
|
97
|
-
await this.run(this.ffmpeg, ["-v", "error", "-y",
|
|
110
|
+
await this.run(this.ffmpeg, ["-v", "error", "-y", ...sources, ...inputs, "-filter_complex", graph, "-map", `[${output}]`, "-r", String(FRAME_RATE), ...encode, partial], { timeoutMs: this.timeoutMs, ...(request.signal ? { signal: request.signal } : {}) });
|
|
98
111
|
renameSync(partial, request.out);
|
|
99
112
|
return { file: request.out, bytes: statSync(request.out).size, durationMs: await this.durationMs(request.out) };
|
|
100
113
|
}
|
|
@@ -179,7 +179,7 @@ environment as unable to start when its sessions' default host runs here on a li
|
|
|
179
179
|
| `AdvanceEnvironmentTime` | `src/environment/advance-environment-time.ts`; the lab wrapper's check in `src/orchestration/lab/checked-environment-time.ts` | New (backlog 16, 30 September 2026): runs the generic time sentences (`the chain advances {int} blocks`, `time advances by {int} minutes`, kind `time`) through the running environment's `time` control, over every chain it lists, as setup work (a run the setup principal owns, credited to no participant); returns each chain's advance (`fromBlock`, `toBlock`, the clock at both, `checkedBy`) as the step's evidence. `unavailable` gives the environment's own reason (no control; a time sentence where it only builds blocks), which the feature session reports before the scenario runs; the plan also refuses a value outside `TimeAdvance`'s limits and an advance before the first actor. The lab wrapper reads each chain's finalized head and `Timestamp.Now` through its own connection before and after, and fails the step when either the environment's report or its own reading falls short (`tests/time-advance.test.ts`, with the negative control) |
|
|
180
180
|
| `CheckFixtures`, `RefreshFixture`, `FixtureMaintenanceService` | `src/fixtures/check-fixtures.ts`, `refresh-fixture.ts`, `fixture-maintenance-service.ts` | New (keeping fixtures current, 30 September 2026, backlog 1i, same record): `LabRuntime.fixtureMaintenance()`. **Check** starts nothing: it compares each declared fixture's saved key with this checkout's setup, lock and inputs. **Plan**, with the fixture's environment running, splits the key's differences into checkout parts, state (environment, lineage) and runtimes. When the runtimes alone differ and `EnvironmentFixtures.upgradable()` allows it, the plan is `upgrade`; otherwise it is `rebuild`, naming why. **Upgrade** needs the `restoreFixture` confirmation. With the session closed it restores the saved state as it is, then calls `EnvironmentSnapshots.upgrade`. It then saves again through `SaveFixture` with an `upgraded` history entry, keeping the earlier history (`SaveFixture.execute({ history })`). **Rebuild** runs in two steps. `prepareRebuild` needs no environment running: it sets the saved session directories aside (`FixtureStore.setAside`). After the caller has run the setup, `saveRebuilt` records `rebuilt` with the reasons and what the fixture replaced. Tests: `tests/fixture-maintenance.test.ts` |
|
|
181
181
|
| `RunSuite` | `src/suites/run-suite.ts` | New (Phase 7): `product-lab test --suite`. Plans the suite in the domain from the lab's `suites`, features, hosts, version policy and changes (`ChangeSource` for a base, or given paths); runs each runnable row as a feature run (`RunFeatures`: tags, environment, `PRODUCT_LAB_HOST`, candidates resolved once into a run manifest, the lock untouched), one after the other, stopping the row the budget runs out in (a stop that fails, on the budget or an interrupt, is named in the row's reason and fails a row that passed); judges with `judgeSuite`. Modes (`defineLab({ modes })`, `product-lab test --mode`, `SuiteRequest.mode`) plan and run here as suites of their own name: a row's device goes to its run as `PRODUCT_LAB_DEVICE` (checked against the browser's named devices), a row shared out among workers gets exactly its files, a mode's `workers` rows run in waves started together (`RunFeatures.executeTogether`, each with the lab's `workers.env` for its slot; refused without a declaration or above its `max`), and an old environment id names its environment through the lab's aliases (`tests/modes.test.ts`). A row plans exactly what its run runs: the feature runner's `selection` (its configuration loaded with the row's host) gives the files, unless the lab declares its own features (which the row's run then gets as its paths), and its tags and fail-fast always. After the run every planned scenario it did not report is named on the row (`unreported`, `unreportedScenarios`), with why when known (stopped by the budget or an interrupt; after the first failure under fail-fast); one without a reason fails a run that passed. A row that resolved a `host:<kind>` release from a version source asks its host adapter how to run it (`routeRelease`, backlog 27, `row-host-route.ts`): an unsupported route makes the row `unsupported` (never run, never passing, with the reason); otherwise the route goes to the run in `PRODUCT_LAB_HOST_ROUTE`, and the row records `hostRoute` (with the patch set), `timings.hostRouteMs` and how its host ran in the combination. Each record carries `counts` (`suite-metrics.ts`: scenarios planned, reported, executed, passed, failed, skipped, unsupported, not reported; checkpoints) and measured `timings` (version resolution, the run, each environment start the row's run recorded through `RunTimingsFile` in `PRODUCT_LAB_RUN_TIMINGS`, the reported step time, the row total; the suite's planning, critical path and total). Nothing is estimated: an absent value was not measured. `tests/suites.test.ts` |
|
|
182
|
-
| `RecordRuns` | `src/recordings/record-runs.ts` | New (Phase 9): follows `run-started`, `run-finished` and the environment events, and each run's step and status events; one recording per configured scenario run and one per feature scenario (its `feature:<scenario>#<n>` runs grouped under the in-process feature run, else while they keep coming); since 3 October 2026 (backlog 20) a feature scenario's recording names where it was written (`feature`: uri, line, tags, fixture) from what its runner gave `FeatureSession.begin` (`LabState.scenarioSources`), so a Cucumber process's recordings carry it too, and an in-process run's report replaces it; how the runner saw the scenario end (`FeatureScenarioHandle.end(outcome)`, `LabState.scenarioOutcomes`) makes a scenario that failed outside the lab's runs (a custom step, a hook) a failed recording with `runnerFailure`, never a passed one. Asks for a frame of the acting identity as a step starts and ends and of every identity at a checkpoint (a scenario run's pause; since 30 September 2026 also an in-process feature run's `feature-paused`, where every identity the scenario used and every running identity is taken, so a stepping feature's next "before" frame is settled: `tests/recording-feature-pause.test.ts`),
|
|
182
|
+
| `RecordRuns` | `src/recordings/record-runs.ts` | New (Phase 9): follows `run-started`, `run-finished` and the environment events, and each run's step and status events; one recording per configured scenario run and one per feature scenario (its `feature:<scenario>#<n>` runs grouped under the in-process feature run, else while they keep coming); since 3 October 2026 (backlog 20) a feature scenario's recording names where it was written (`feature`: uri, line, tags, fixture) from what its runner gave `FeatureSession.begin` (`LabState.scenarioSources`), so a Cucumber process's recordings carry it too, and an in-process run's report replaces it; how the runner saw the scenario end (`FeatureScenarioHandle.end(outcome)`, `LabState.scenarioOutcomes`) makes a scenario that failed outside the lab's runs (a custom step, a hook) a failed recording with `runnerFailure`, never a passed one. Asks for a frame of the acting identity as a step starts and ends and of every identity at a checkpoint (a scenario run's pause; since 30 September 2026 also an in-process feature run's `feature-paused`, where every identity the scenario used and every running identity is taken, so a stepping feature's next "before" frame is settled: `tests/recording-feature-pause.test.ts`), without waiting in default frames-only runs; video feature boundaries await bounded after-state captures and keep a 2-second tail before cleanup (each frame records its timing and the steps that overlapped it; no source, a throw or a timeout is `unavailable` or `failed` with the reason); records the chain heads observed at each step start and end, transactions and evidence blocks; redacts everything with the runtime's `Redactor` and writes through the `RecordingStore` with retention (`recording-saved`, `recording-failed` events). Helpers `RecordingDraft`, `RecordingStores`, `FrameSources` (the lab window's registered source first, else `LabEnvironment.frames`). Since backlog 18b (headless half; 52) a step's start also asks the source for its identity's page video (`FrameCapture.video`), named once per file in `capture.videos` with when its page opened, with a note that videos are not redacted; a video that cannot be named is a note, never a guess. `tests/recordings.test.ts` |. Since backlog 20 (3 October 2026) a feature recording also names its scenario (`feature`: file, line, tags, fixture), from the feature run's report when the run completes Since backlog 110 (5 October 2026) it keeps what each step did on screen (`uiCalls`): the frame source's own UI calls (`FrameCapture.uiCalls`, the lab window's) read as a step starts (what came between steps is left out) and as it ends, with a cursor per source; a source that keeps none gives no block (`tests/recordings.test.ts`). Since backlog 110 a feature scenario's recording also keeps its Gherkin steps with their times (`featureSteps`, kept by `FeatureScenario` through `timedFeatureStep` in `src/features/feature-step-times.ts` in `LabState.scenarioSteps`), the identity steps that run no lab run included, and starts where its first step did (`tests/recording-feature-steps.test.ts`).
|
|
183
183
|
| `BundleRecording`, `OpenRunBundle`, `ReproduceRun`, `controlSteps`, `RunBundlesService`, `RunBundleSources` | `src/runs/bundles/` | New (backlog 20, 3 October 2026): a recorded feature scenario written as one run bundle (the recording verbatim, the feature file, the lab lock, the run manifest, the fixture it started from as fixture export writes it, the lab's and each app's source revision through `ChangeSource.revision`); an identity whose key comes from a lab secret (`LabCatalog.preOnboardedActors`) is withheld and named, and a plain-text entry holding a lab secret's value refuses the bundle. `OpenRunBundle` reads the index first, extracts and checks every entry, shows each recording read-only (`recordingView`) and names every reason a reproduction here would be refused (`RunBundleReproduction`). `ReproduceRun` refuses with every reason first, then rebuilds the starting state (fixture imported; else the environment started, or the running one reset with its confirmation), reruns the scenario in process, judges the rerun's recording (`controlSteps`) against the bundled failure and writes a bundle holding every run; with `reruns` (backlog 26) it reruns from the same rebuilt state up to that many times, stopping at the first rerun that does not fail (verdict `flaky`, never passing). Ports: `RunBundlePorts` (`archives`, `recordingsAt`). Tests: `composition/tests/run-bundles.test.ts`, `tests/reproduce-run.test.ts` | Since backlog 110 `BundleRecording.reproducibility(id)` (`LabRunBundles.reproducibility`) says before anything is bundled or started whether a recording's run can be reproduced, from the facts a bundle records (feature file, public network, starting state, fixture, keys from lab secrets), in plain words with one way forward (`tests/reproducibility.test.ts`).
|
|
184
184
|
| `BisectRegression`, `ProbeSession` | `src/bisect/bisect-regression.ts`, `src/bisect/probe-session.ts` | New (backlog 37, 4 October 2026): `product-lab bisect`. Reads two suite records, compares them as `report compare` does and takes one change; the domain's `bisectPlan` gives its axes or refuses it. An app axis is the lab's commits (`ChangeSource.commits`), a runtime or host axis the published releases between the two versions (`VersionSource.publishedVersions`). Each probe runs the one scenario as its row ran it (`RunSuite.probe`, every version pinned), twice by default, and the domain judges it; the ends first. With several axes each is first moved alone to its bad value. A probe of the lab's source runs from a worktree under the lab's cache with its dependencies installed frozen from its own lockfile (the package cache under the lab's cache; which lockfile and whether the cache was warm are recorded), and every worktree is removed in a `finally` block. Nothing starts below 15 GB of free disk (`BISECT_MIN_FREE_DISK`); each probe's disk use is recorded. Writes `product-lab-bisect.json` and `summary.md`; `reproduce` is one shell command that reruns the first bad value (for the lab's source, from the repository's top: `git worktree add`, the frozen install, the run from the lab's directory). A probe's worktree report is read through `readReport` (the feature runner's reader of its Cucumber JSON). Test: `tests/bisect-regression.test.ts` |
|
|
185
185
|
| `ListRecordings`, `OpenRecording` | `src/recordings/` | New (Phase 9): the history and one recording with the domain's frame choice per step and identity, and a frame's image. Read the store only: no environment needed, nothing changes (`tests/recordings.test.ts` checks the runtime is unchanged, with a negative control) | Since backlog 25 `RecordingView.health` gives each step's health and findings (`RecordingHealth.DEFAULT`), so the lab window shows what `product-lab recordings` shows.
|
|
@@ -515,3 +515,32 @@ A step aborted by its limit, cancellation or interruption keeps the abort reason
|
|
|
515
515
|
pass through the configured error describer. `StepReport.reason` and scheduling decisions
|
|
516
516
|
still use the abort reason and original error text; reconciliation receives that original reason, and quarantine
|
|
517
517
|
keeps it. See `tests/scheduler-abort-diagnostics.test.ts`.
|
|
518
|
+
|
|
519
|
+
## Held feature demos
|
|
520
|
+
|
|
521
|
+
FeatureScenario observes direct definitions through startStep/finishStep as well as generated runs. RecordRuns opens at the first feature step. Frames-only capture remains asynchronous; video runs await bounded captures at step boundaries and keep a 2 s tail before closing scenario services. Frames name their 1-based featureStep, including steps with no tool run. DemoRecording prefers these frames, uses planDemoHolds, renders a separate caption band, and decodes step-001.png and later midpoint images from the encoded cut. Slide-only output without ffmpeg uses the last available recorded frame with an explicit label when it predates the final step. Tests cover coverage, reload tail, direct failures and the former planner as a negative control.
|
|
522
|
+
|
|
523
|
+
Scenario cleanup settles pending captures before finalizing its recording, then awaits its host-log read before
|
|
524
|
+
closing identities and scenario services. Recording writes can still await background transaction checks; cleanup
|
|
525
|
+
does not wait for those writes.
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
Video feature after-frames first check that the current document has completed loading, then compare exact PNGs
|
|
529
|
+
across a 200 ms window, sampling at least 100 ms apart. The stability wait is capped at 3000 ms by default
|
|
530
|
+
(`RecordingSettings.settleTimeoutMs`, or `PRODUCT_LAB_RECORDINGS_SETTLE_MS`). At the cap, the recorder takes a
|
|
531
|
+
fresh frame anyway and adds `settled: false` and `waitedMs` to its metadata. Successful checks add `settled: true`;
|
|
532
|
+
`loadComplete` records when the source verified the document. The existing `captureTimeoutMs` still bounds the
|
|
533
|
+
whole capture, including this wait and any final image. The wait never changes the step's own timestamps or outcome.
|
|
534
|
+
|
|
535
|
+
Only feature after-frames with video enabled wait for visual stability, including the final frame after the
|
|
536
|
+
2-second scenario tail. Tool-run captures, before-frames, checkpoints and default frames-only feature captures
|
|
537
|
+
retain their existing asynchronous policy. Scenario cleanup still settles captures already in flight. Custom
|
|
538
|
+
capture sources may ignore the optional load check; absent `loadComplete` means load completion is unknown.
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
The effective stability cap reserves time for the final screenshot: it is clamped to `captureTimeoutMs` minus
|
|
542
|
+
1000 ms, or minus half that timeout when it is shorter than 2000 ms. With the default 5000 ms capture timeout,
|
|
543
|
+
`PRODUCT_LAB_RECORDINGS_SETTLE_MS` accepts positive values but uses at most 4000 ms; the default stays 3000 ms.
|
|
544
|
+
A captured frame with `settled: false` is labelled "Taken at the stability cap" in recording-view notes and in
|
|
545
|
+
the demo image manifest and captions, including reused setup and failure context. Older frames without this
|
|
546
|
+
metadata retain their existing labels.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { LabState } from "../lab/lab-state.js";
|
|
2
|
+
import type { FeatureRecording } from "./feature-recording.js";
|
|
3
|
+
import type { ScenarioPlan } from "@parity/product-lab/domain";
|
|
4
|
+
/** Observes custom definitions without treating them as scheduler-owned work. */
|
|
5
|
+
export declare class DirectFeatureSteps {
|
|
6
|
+
private readonly options;
|
|
7
|
+
constructor(options: {
|
|
8
|
+
state: LabState;
|
|
9
|
+
scenario: string;
|
|
10
|
+
now: () => number;
|
|
11
|
+
redact: (text: string) => string;
|
|
12
|
+
plan: ScenarioPlan;
|
|
13
|
+
ended: () => boolean;
|
|
14
|
+
ran: Set<number>;
|
|
15
|
+
identities: () => string[];
|
|
16
|
+
recording?: FeatureRecording;
|
|
17
|
+
});
|
|
18
|
+
start(index: number): void;
|
|
19
|
+
finish(index: number, outcome: {
|
|
20
|
+
status: "passed" | "failed";
|
|
21
|
+
error?: string;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Copyright 2026 Parity Technologies (UK) Ltd.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { startFeatureStep } from "./feature-step-times.js";
|
|
4
|
+
export class DirectFeatureSteps {
|
|
5
|
+
options;
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.options = options;
|
|
8
|
+
}
|
|
9
|
+
start(index) {
|
|
10
|
+
const step = this.options.plan.steps[index];
|
|
11
|
+
if (!step?.custom || this.options.ended() || this.options.ran.has(index))
|
|
12
|
+
return;
|
|
13
|
+
this.options.ran.add(index);
|
|
14
|
+
const at = this.options.now();
|
|
15
|
+
startFeatureStep(this.options.state, this.options.scenario, index, step.text, at);
|
|
16
|
+
this.options.recording?.stepStarted(this.options.scenario, at);
|
|
17
|
+
}
|
|
18
|
+
async finish(index, outcome) {
|
|
19
|
+
const step = this.options.state.scenarioSteps.get(this.options.scenario)?.find((item) => item.featureStep === index + 1);
|
|
20
|
+
if (!step || step.finishedAt !== undefined)
|
|
21
|
+
return;
|
|
22
|
+
Object.assign(step, { status: outcome.status, finishedAt: this.options.now(), ...(outcome.error ? { error: this.options.redact(outcome.error) } : {}) });
|
|
23
|
+
await this.options.recording?.stepFinished(this.options.scenario, index + 1, this.options.identities());
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Recording boundaries include direct/custom feature steps that create no runtime run. */
|
|
2
|
+
export interface FeatureRecording {
|
|
3
|
+
stepStarted(scenario: string, at: number): void;
|
|
4
|
+
stepFinished(scenario: string, featureStep: number, identities: readonly string[]): Promise<void>;
|
|
5
|
+
scenarioEnded(scenario: string): Promise<void>;
|
|
6
|
+
}
|
|
@@ -8,7 +8,9 @@ import type { ManageActors } from "../actors/manage-actors.js";
|
|
|
8
8
|
import type { LabState } from "../lab/lab-state.js";
|
|
9
9
|
import type { RunScenario } from "../runs/run-scenario.js";
|
|
10
10
|
import type { ScenarioStepChecks } from "./scenario-step-checks.js";
|
|
11
|
+
import type { FeatureRecording } from "./feature-recording.js";
|
|
11
12
|
export interface ScenarioDependencies {
|
|
13
|
+
recording?: FeatureRecording;
|
|
12
14
|
id: string;
|
|
13
15
|
state: LabState;
|
|
14
16
|
runScenario: RunScenario;
|
|
@@ -61,6 +63,7 @@ export declare class FeatureScenario implements FeatureScenarioHandle {
|
|
|
61
63
|
private readonly ownedRuns;
|
|
62
64
|
/** New actors created ahead of their steps (scenario-actor-creation.ts). */
|
|
63
65
|
private readonly creation;
|
|
66
|
+
private readonly directSteps;
|
|
64
67
|
/** Backlog 31: the controls this scenario set a condition through, by identity: each is reset when it ends. */
|
|
65
68
|
private readonly conditioned;
|
|
66
69
|
readonly outputs: StepOutputs;
|
|
@@ -78,22 +81,21 @@ export declare class FeatureScenario implements FeatureScenarioHandle {
|
|
|
78
81
|
/** What a paused run shows about this scenario (backlog 17; scenario-inspection.ts). */
|
|
79
82
|
inspect(next: number): ScenarioInspection;
|
|
80
83
|
/** Every output recorded so far, the ones the scenario began with included (backlog 21). */
|
|
81
|
-
recorded()
|
|
82
|
-
trace()
|
|
84
|
+
recorded: () => readonly RecordedOutput[];
|
|
85
|
+
trace: () => readonly FeatureStepTrace[];
|
|
83
86
|
run(index: number): Promise<FeatureStepTrace>;
|
|
84
87
|
private runStep;
|
|
88
|
+
startStep: (index: number) => void;
|
|
89
|
+
finishStep: (index: number, outcome: {
|
|
90
|
+
status: "passed" | "failed";
|
|
91
|
+
error?: string;
|
|
92
|
+
}) => Promise<void>;
|
|
85
93
|
private stepBody;
|
|
86
94
|
/**
|
|
87
95
|
* Backlog 40: after a step passed, each actor that acted in it is checked (a screenshot against its baseline, an
|
|
88
96
|
* accessibility scan); what the checks found is kept in the step's trace, and a failure fails the step.
|
|
89
97
|
*/
|
|
90
98
|
private checkStep;
|
|
91
|
-
/**
|
|
92
|
-
* A run that shows each actor's app on a surface of its own (the lab window's tiles): an identity call ends once each
|
|
93
|
-
* of its actors' surfaces shows the app ready, so no later step acts on, or conditions, an app that has not loaded.
|
|
94
|
-
* The session readied its own page; a tile is another document of the same app.
|
|
95
|
-
*/
|
|
96
|
-
private surfacesReady;
|
|
97
99
|
/**
|
|
98
100
|
* `{actor} is a new actor` / `these actors exist:`: create an identity per new name (manual mode reuses a bound
|
|
99
101
|
* one). When the environment creates several identities at once (`actorCreation.concurrency`), the new names of
|
|
@@ -14,8 +14,9 @@ import { RunScopedValues } from "./run-scoped-values.js";
|
|
|
14
14
|
import { ConditionProfiles, ConditionStep, describeNetwork } from "@parity/product-lab/domain/conditions";
|
|
15
15
|
import { conditionControl } from "./scenario-conditions.js";
|
|
16
16
|
import { raceStepDelay } from "./scenario-race.js";
|
|
17
|
-
import { readIdentities, timedFeatureStep } from "./feature-step-times.js";
|
|
17
|
+
import { readIdentities, readyFeatureSurfaces, timedFeatureStep } from "./feature-step-times.js";
|
|
18
18
|
import { ScenarioActorCreation } from "./scenario-actor-creation.js";
|
|
19
|
+
import { DirectFeatureSteps } from "./direct-feature-steps.js";
|
|
19
20
|
const NAVIGATION_WAIT_MS = 30_000;
|
|
20
21
|
export class FeatureScenario {
|
|
21
22
|
name;
|
|
@@ -38,6 +39,7 @@ export class FeatureScenario {
|
|
|
38
39
|
inFlight = new Set();
|
|
39
40
|
ownedRuns = new Set();
|
|
40
41
|
creation;
|
|
42
|
+
directSteps;
|
|
41
43
|
conditioned = new Map();
|
|
42
44
|
outputs;
|
|
43
45
|
fixture;
|
|
@@ -59,6 +61,7 @@ export class FeatureScenario {
|
|
|
59
61
|
this.outputs = this.view(undefined);
|
|
60
62
|
this.creation = new ScenarioActorCreation({ steps: plan.steps, manageActors: deps.manageActors, ...(deps.clock ? { clock: deps.clock } : {}), signal: this.abort.signal, environment: () => this.active.environment, bound: (name) => this.bindings.has(name), conditions: () => this.conditionedNames(), created: (id) => { if (this.binding === "fresh")
|
|
61
63
|
this.created.push(id); }, chosenHost: (name) => deps.hosts?.[name], scenario: this.name });
|
|
64
|
+
this.directSteps = new DirectFeatureSteps({ state: deps.state, scenario: name, now: () => deps.runScenario.clock.date().getTime(), redact: (text) => deps.redactor.text(text), identities: () => this.identities(), ...(deps.recording ? { recording: deps.recording } : {}), plan, ended: () => this.ended, ran: this.ran });
|
|
62
65
|
}
|
|
63
66
|
current() {
|
|
64
67
|
const active = this.deps.state.active;
|
|
@@ -90,12 +93,8 @@ export class FeatureScenario {
|
|
|
90
93
|
inspect(next) {
|
|
91
94
|
return inspectScenario(this.plan, next, this.store, this.bindings, this.deps.redactor);
|
|
92
95
|
}
|
|
93
|
-
recorded()
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
trace() {
|
|
97
|
-
return this.deps.redactor.value(this.steps);
|
|
98
|
-
}
|
|
96
|
+
recorded = () => this.store.entries();
|
|
97
|
+
trace = () => this.deps.redactor.value(this.steps);
|
|
99
98
|
run(index) {
|
|
100
99
|
if (this.ended)
|
|
101
100
|
return Promise.reject(new Error(`Scenario "${this.name}" has ended`));
|
|
@@ -117,8 +116,10 @@ export class FeatureScenario {
|
|
|
117
116
|
this.ran.add(index);
|
|
118
117
|
this.current();
|
|
119
118
|
const now = () => this.deps.runScenario.clock.date().getTime();
|
|
120
|
-
return timedFeatureStep(this.deps.state, this.name, index, step.text, now, (text) => this.deps.redactor.text(text), () => this.stepBody(index, step));
|
|
119
|
+
return timedFeatureStep(this.deps.state, this.name, index, step.text, now, (text) => this.deps.redactor.text(text), () => this.stepBody(index, step), { recording: this.deps.recording, identities: () => this.identities() });
|
|
121
120
|
}
|
|
121
|
+
startStep = (index) => this.directSteps.start(index);
|
|
122
|
+
finishStep = (index, outcome) => this.directSteps.finish(index, outcome);
|
|
122
123
|
async stepBody(index, step) {
|
|
123
124
|
const calls = step.calls.map((call) => ({
|
|
124
125
|
owner: call.entry.owner,
|
|
@@ -158,7 +159,7 @@ export class FeatureScenario {
|
|
|
158
159
|
await flush();
|
|
159
160
|
try {
|
|
160
161
|
await this.identity(call, calls[position], index);
|
|
161
|
-
await this.
|
|
162
|
+
await readyFeatureSurfaces(this.deps.surface, introducedNames(call), this.bindings, this.abort.signal);
|
|
162
163
|
calls[position].status = "complete";
|
|
163
164
|
}
|
|
164
165
|
catch (error) {
|
|
@@ -187,12 +188,6 @@ export class FeatureScenario {
|
|
|
187
188
|
if (failures.length)
|
|
188
189
|
throw FeatureStepError.of(`Step ${index + 1} ("${step.text}"): ${failures.join("; ")}`, this.deps.redactor.value(trace), reasons?.[0]);
|
|
189
190
|
}
|
|
190
|
-
async surfacesReady(names) {
|
|
191
|
-
if (!this.deps.surface)
|
|
192
|
-
return;
|
|
193
|
-
const ids = [...new Set(names.map((name) => this.bindings.get(name)).filter((id) => id !== undefined))];
|
|
194
|
-
await Promise.all(ids.map((id) => this.deps.surface(id)?.ready?.({ signal: this.abort.signal })));
|
|
195
|
-
}
|
|
196
191
|
async identity(call, trace, index) {
|
|
197
192
|
const names = introducedNames(call);
|
|
198
193
|
const unfunded = call.entry.owner === NEW_UNFUNDED_ACTOR_OWNER;
|
|
@@ -572,6 +567,7 @@ export class FeatureScenario {
|
|
|
572
567
|
run.interrupt(reason);
|
|
573
568
|
await Promise.allSettled([...this.inFlight]);
|
|
574
569
|
await this.creation.settled();
|
|
570
|
+
await this.deps.recording?.scenarioEnded(this.name);
|
|
575
571
|
await this.deps.state.beforeClose.settle();
|
|
576
572
|
const errors = [];
|
|
577
573
|
for (const [actorId, control] of this.conditioned) {
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
import type { RecordedFeatureStep } from "@parity/product-lab/domain/recordings";
|
|
1
2
|
import type { LabEnvironment } from "@parity/product-lab/ports/lab";
|
|
2
3
|
import type { LabState } from "../lab/lab-state.js";
|
|
4
|
+
import type { FeatureRecording } from "./feature-recording.js";
|
|
5
|
+
import type { FeatureSessionOptions } from "@parity/product-lab/ports/driving";
|
|
3
6
|
/** Run step `index` (`work`) of scenario `name`, keeping its times and outcome; `redact` cleans a failure's message. */
|
|
4
|
-
export declare function timedFeatureStep<T>(state: LabState, name: string, index: number, text: string, now: () => number, redact: (text: string) => string, work: () => Promise<T
|
|
7
|
+
export declare function timedFeatureStep<T>(state: LabState, name: string, index: number, text: string, now: () => number, redact: (text: string) => string, work: () => Promise<T>, observe?: {
|
|
8
|
+
recording?: FeatureRecording;
|
|
9
|
+
identities: () => string[];
|
|
10
|
+
}): Promise<T>;
|
|
11
|
+
/** Direct/custom definitions use the same timing data as steps run through the scheduler. */
|
|
12
|
+
export declare function startFeatureStep(state: LabState, name: string, index: number, text: string, at: number): RecordedFeatureStep;
|
|
13
|
+
/** A visible tile is a separate document from the session's own page; ready it before later steps use it. */
|
|
14
|
+
export declare function readyFeatureSurfaces(surface: FeatureSessionOptions["surface"], names: readonly string[], bindings: ReadonlyMap<string, string>, signal: AbortSignal): Promise<void>;
|
|
5
15
|
/**
|
|
6
16
|
* Backlog 160: what the environment reads of the identities a step made, on chain and on their screens, kept as the
|
|
7
17
|
* step's checks (`LabState.featureStepReadings`). A reading the environment cannot take fails the step: a new identity
|