@parity/product-lab 0.1.1 → 0.2.0

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +27 -0
  3. package/adapters/browser/playwright/README.md +2 -0
  4. package/adapters/browser/playwright/src/page-frame-capture.js +10 -2
  5. package/adapters/chain/papi/README.md +3 -1
  6. package/adapters/environment/engine/README.md +22 -4
  7. package/adapters/environment/engine/src/engine/engine-config.d.ts +1 -0
  8. package/adapters/environment/engine/src/engine/fork-bundle.d.ts +1 -1
  9. package/adapters/environment/engine/src/engine/fork-bundle.js +2 -2
  10. package/adapters/environment/engine/src/engine/fork-refresh.d.ts +4 -7
  11. package/adapters/environment/engine/src/engine/fork-refresh.js +52 -23
  12. package/adapters/environment/engine/src/engine/published-bundle.d.ts +19 -15
  13. package/adapters/environment/engine/src/engine/published-bundle.js +44 -51
  14. package/adapters/environment/engine/src/saved-fork/saved-fork-tooling.d.ts +1 -1
  15. package/adapters/environment/engine/src/saved-fork/saved-fork-tooling.js +5 -2
  16. package/adapters/environment/engine/src/saved-fork/saved-fork.js +1 -1
  17. package/adapters/environment/engine/src/setup/engine-build.d.ts +4 -4
  18. package/adapters/environment/engine/src/setup/engine-build.js +14 -4
  19. package/adapters/environment/engine/src/setup/patch-catalogue.d.ts +6 -0
  20. package/adapters/environment/engine/src/setup/patch-catalogue.js +5 -0
  21. package/adapters/environment/engine/src/setup/prepare-engine.d.ts +6 -5
  22. package/adapters/environment/engine/src/setup/prepare-engine.js +27 -10
  23. package/adapters/features/cucumber/README.md +4 -0
  24. package/adapters/features/cucumber/src/steps/register-feature-steps.js +15 -1
  25. package/adapters/system/node/README.md +3 -3
  26. package/adapters/video/ffmpeg/README.md +4 -0
  27. package/adapters/video/ffmpeg/src/ffmpeg-video-editor.d.ts +1 -1
  28. package/adapters/video/ffmpeg/src/ffmpeg-video-editor.js +19 -6
  29. package/core/application/README.md +30 -1
  30. package/core/application/src/features/direct-feature-steps.d.ts +23 -0
  31. package/core/application/src/features/direct-feature-steps.js +25 -0
  32. package/core/application/src/features/feature-recording.d.ts +6 -0
  33. package/core/application/src/features/feature-recording.js +3 -0
  34. package/core/application/src/features/feature-scenario.d.ts +10 -8
  35. package/core/application/src/features/feature-scenario.js +11 -15
  36. package/core/application/src/features/feature-step-times.d.ts +11 -1
  37. package/core/application/src/features/feature-step-times.js +20 -6
  38. package/core/application/src/features/lab-feature-session.d.ts +3 -1
  39. package/core/application/src/features/lab-feature-session.js +5 -2
  40. package/core/application/src/features/run-feature-steps.d.ts +3 -1
  41. package/core/application/src/features/run-feature-steps.js +4 -2
  42. package/core/application/src/lab-runtime-use-cases.js +5 -1
  43. package/core/application/src/recordings/demo-markup.d.ts +8 -0
  44. package/core/application/src/recordings/demo-markup.js +11 -1
  45. package/core/application/src/recordings/demo-recording.d.ts +0 -2
  46. package/core/application/src/recordings/demo-recording.js +40 -23
  47. package/core/application/src/recordings/frame-sources.d.ts +20 -1
  48. package/core/application/src/recordings/frame-sources.js +89 -0
  49. package/core/application/src/recordings/record-runs.d.ts +7 -1
  50. package/core/application/src/recordings/record-runs.js +66 -11
  51. package/core/application/src/recordings/recording-draft.d.ts +4 -0
  52. package/core/application/src/recordings/recording-draft.js +13 -4
  53. package/core/domain/README.md +14 -0
  54. package/core/domain/src/recordings/demo-cut.js +6 -4
  55. package/core/domain/src/recordings/demo-holds.d.ts +12 -0
  56. package/core/domain/src/recordings/demo-holds.js +31 -0
  57. package/core/domain/src/recordings/index.d.ts +1 -0
  58. package/core/domain/src/recordings/index.js +1 -0
  59. package/core/domain/src/recordings/recording-data.d.ts +9 -1
  60. package/core/domain/src/recordings/recording-settings.d.ts +5 -1
  61. package/core/domain/src/recordings/recording-settings.js +7 -0
  62. package/core/domain/src/recordings/run-recording.d.ts +2 -0
  63. package/core/domain/src/recordings/run-recording.js +8 -1
  64. package/core/ports/README.md +6 -0
  65. package/core/ports/src/driven/recording/demo-ports.d.ts +2 -0
  66. package/core/ports/src/driven/recording/frame-capture.d.ts +4 -0
  67. package/core/ports/src/driving/demo.d.ts +2 -0
  68. package/core/ports/src/driving/feature-session.d.ts +7 -0
  69. package/entrypoints/cli/README.md +6 -3
  70. package/entrypoints/cli/src/demo-command.d.ts +1 -1
  71. package/entrypoints/cli/src/demo-command.js +10 -5
  72. package/entrypoints/mcp/README.md +3 -0
  73. package/entrypoints/panel/README.md +6 -3
  74. package/entrypoints/panel/src/visible/detached-identities.js +5 -1
  75. package/entrypoints/panel/src/visible/window-frames.js +14 -1
  76. package/package.json +1 -1
@@ -1,9 +1,10 @@
1
1
  // Copyright 2026 Parity Technologies (UK) Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- import { DemoRefusedError, demoSteps, demoVtt, planDemo } from "@parity/product-lab/domain/recordings";
3
+ import { DemoRefusedError, demoSteps, demoVtt, planDemoHolds } from "@parity/product-lab/domain/recordings";
4
4
  import { joinPath as join } from "../runs/bundles/bundle-paths.js";
5
- import { captionHtml, failureBannerHtml, footerHtml, gherkinPhases, slideHtml, waitLabelHtml } from "./demo-markup.js";
6
- const VIDEO = { width: 1280, height: 720 };
5
+ import { demoFrameHtml, gherkinPhases, slideHtml } from "./demo-markup.js";
6
+ import { demoFrameSources } from "./frame-sources.js";
7
+ const VIDEO = { width: 1280, height: 960 };
7
8
  const SLIDE = { width: 1600, height: 900 };
8
9
  const utc = (at) => `${new Date(at).toISOString().slice(0, 16).replace("T", " ")} UTC`;
9
10
  const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
@@ -64,7 +65,11 @@ export class DemoRecording {
64
65
  const probe = this.ports.video ? await this.ports.video.probe() : { available: false, reason: "This runtime has no video editor" };
65
66
  const durationMs = probe.available ? await this.ports.video.durationMs(source) : Math.max(1, (video.closedAt ?? data.finishedAt) - video.openedAt);
66
67
  const steps = demoSteps(data, { openedAt: video.openedAt, ...(video.actor ? { actor: video.actor } : {}) }, scenario);
67
- const plan = planDemo(steps, durationMs, { ...(request.maxSeconds !== undefined ? { maxSeconds: request.maxSeconds } : {}) });
68
+ const sources = demoFrameSources(recording, steps, video, durationMs);
69
+ const plan = planDemoHolds(steps.map((step, index) => ({ step, ...sources[index] })), durationMs, {
70
+ ...(request.maxSeconds !== undefined ? { maxSeconds: request.maxSeconds } : {}),
71
+ ...(request.holdSeconds !== undefined ? { holdSeconds: request.holdSeconds } : {}),
72
+ });
68
73
  const redacted = this.redactor.value(plan);
69
74
  notes.push(...redacted.notes);
70
75
  const banner = failed ? this.failure(data) : undefined;
@@ -86,6 +91,7 @@ export class DemoRecording {
86
91
  status: data.status,
87
92
  video: { file: video.file, source: video.source, ...(shown ? { actor: shown } : {}), openedAt: video.openedAt, ...(video.bytes !== undefined ? { bytes: video.bytes } : {}), durationMs },
88
93
  plan: redacted,
94
+ images: sources,
89
95
  ...(banner ? { banner } : {}),
90
96
  }, null, 2)}\n`);
91
97
  const report = (editor) => this.redactor.value({
@@ -103,8 +109,23 @@ export class DemoRecording {
103
109
  return report();
104
110
  if (!this.ports.slides)
105
111
  notes.push("No slide renderer: slide.html was written without its PNG");
106
- const lastResult = [...redacted.segments].reverse().find((segment) => segment.kind === "result");
107
- const image = probe.available && lastResult ? await this.ports.video.frame(source, Math.max(0, lastResult.sourceEnd - 100)) : await this.lastFrame(target.store, recording, video.actor);
112
+ const readImage = async (index) => {
113
+ const chosen = sources[index];
114
+ if (chosen.frame) {
115
+ const stored = await target.store.frame(data.id, chosen.frame);
116
+ if (stored)
117
+ return stored.bytes;
118
+ throw new DemoRefusedError(`Step ${steps[index].number}'s after-frame ${chosen.frame} is missing`, "refused");
119
+ }
120
+ if (probe.available && chosen.atMs !== undefined)
121
+ return this.ports.video.frame(source, chosen.atMs);
122
+ throw new DemoRefusedError(`Step ${steps[index].number} needs ffmpeg to read its after-step video frame`, "missing-tools");
123
+ };
124
+ const fallback = !probe.available ? [...data.frames].reverse().find((frame) => frame.status === "captured" && !frame.overlapping.length && (!video.actor || frame.actor === video.actor)) : undefined;
125
+ const image = fallback ? (await target.store.frame(data.id, fallback.id))?.bytes : probe.available ? await readImage(steps.length - 1) : undefined;
126
+ const imageNote = fallback && fallback.id !== sources.at(-1)?.frame ? "Last available recorded frame; the final step's screen is unavailable without ffmpeg." : undefined;
127
+ if (imageNote)
128
+ notes.push(imageNote);
108
129
  const phases = gherkinPhases(redacted.captions.map((caption) => caption.keyword));
109
130
  const stepsSlide = steps.map((step) => ({
110
131
  number: step.number,
@@ -127,6 +148,7 @@ export class DemoRecording {
127
148
  recording: data.id,
128
149
  recordedAt: utc(data.recordedAt),
129
150
  ...(image ? { image: dataUrl(image) } : {}),
151
+ ...(imageNote ? { imageNote } : {}),
130
152
  ...(banner ? { banner } : {}),
131
153
  }));
132
154
  write("slide.html", html);
@@ -138,18 +160,17 @@ export class DemoRecording {
138
160
  throw new DemoRefusedError(`${probe.reason}; --plan and --slide-only work without it (the plan, demo.vtt and the slide are in ${out})`, "missing-tools");
139
161
  if (!this.ports.slides)
140
162
  throw new DemoRefusedError("This runtime has no slide renderer for the captions", "missing-tools");
141
- const overlays = [];
142
- const render = async (overlayHtml) => this.ports.slides.render(this.redactor.text(overlayHtml), VIDEO, { transparent: true });
143
163
  const footer = [data.title, data.environment.label, failed ? "failed" : "passed", ...(shown ? [`${shown}'s app`] : [])].join(" · ");
144
- overlays.push({ png: await render(footerHtml(footer)), startMs: 0, endMs: redacted.outputMs });
145
- if (banner)
146
- overlays.push({ png: await render(failureBannerHtml(banner)), startMs: 0, endMs: redacted.outputMs });
147
- for (const [index, caption] of redacted.captions.entries())
148
- overlays.push({ png: await render(captionHtml(caption, phases[index])), startMs: caption.outputStart, endMs: caption.outputEnd });
149
- for (const segment of redacted.segments.filter((item) => item.kind === "wait" && item.label))
150
- overlays.push({ png: await render(waitLabelHtml(segment.label)), startMs: segment.outputStart, endMs: segment.outputEnd });
164
+ const stills = [];
165
+ for (const [index, caption] of redacted.captions.entries()) {
166
+ const html = this.redactor.text(demoFrameHtml(dataUrl(await readImage(index)), caption, phases[index], footer, banner));
167
+ const png = await this.ports.slides.render(html, VIDEO);
168
+ stills.push(png);
169
+ }
151
170
  const file = join(out, `demo.${request.format ?? "mp4"}`);
152
- const result = await this.ports.video.render({ source, plan: redacted, overlays, out: file, format: request.format ?? "mp4", size: VIDEO, ...(request.signal ? { signal: request.signal } : {}) });
171
+ const result = await this.ports.video.render({ source, plan: redacted, overlays: [], stills, out: file, format: request.format ?? "mp4", size: VIDEO, ...(request.signal ? { signal: request.signal } : {}) });
172
+ for (const caption of redacted.captions)
173
+ write(`step-${String(caption.number).padStart(3, "0")}.png`, await this.ports.video.frame(result.file, (caption.outputStart + caption.outputEnd) / 2));
153
174
  written.push(result.file);
154
175
  notes.push(`The cut is ${(result.durationMs / 1000).toFixed(1)} s of a ${(redacted.sourceMs / 1000).toFixed(1)} s scenario (${(result.bytes / 1024).toFixed(0)} KB); captions and labels are burned in, demo.vtt has them as text.`);
155
176
  return report(probe.version);
@@ -199,15 +220,11 @@ export class DemoRecording {
199
220
  const found = features.find((feature) => feature.path === uri)?.scenarios.find((item) => item.name === name);
200
221
  return found ? { name: found.name, steps: [...(found.background ?? []), ...found.steps].map((step) => ({ keyword: step.keyword, text: step.text })) } : undefined;
201
222
  }
202
- async lastFrame(store, recording, actor) {
203
- const frames = recording.data.frames.filter((frame) => frame.status === "captured" && frame.phase === "after" && (!actor || frame.actor === actor));
204
- const last = frames.at(-1);
205
- return last ? (await store.frame(recording.data.id, last.id))?.bytes : undefined;
206
- }
207
223
  failure(data) {
208
224
  const step = data.steps.find((item) => item.status === "failed");
209
- const reason = (step?.error ?? data.runnerFailure?.error ?? data.error ?? "no reason recorded").split("\n")[0];
210
- const at = step?.featureStep ?? data.runnerFailure?.featureStep;
225
+ const direct = data.featureSteps?.find((item) => item.status === "failed");
226
+ const reason = (step?.error ?? direct?.error ?? data.runnerFailure?.error ?? data.error ?? "no reason recorded").split("\n")[0];
227
+ const at = step?.featureStep ?? direct?.featureStep ?? data.runnerFailure?.featureStep;
211
228
  return this.redactor.text(`Failed${at ? ` at step ${at}` : ""}: ${reason}`);
212
229
  }
213
230
  }
@@ -1,5 +1,7 @@
1
+ import { type DemoStep, type RecordedVideo, type RunRecording } from "@parity/product-lab/domain/recordings";
1
2
  import type { LabEnvironment } from "@parity/product-lab/ports/lab";
2
- import type { FrameCapture } from "@parity/product-lab/ports/recording";
3
+ import type { Clock } from "@parity/product-lab/ports/system";
4
+ import type { CaptureOptions, CapturedFrame, UnavailableFrame, FrameCapture } from "@parity/product-lab/ports/recording";
3
5
  export declare class FrameSources {
4
6
  private live;
5
7
  /** Use `capture` until the returned function is called. */
@@ -7,3 +9,20 @@ export declare class FrameSources {
7
9
  /** The source a frame of `environment`'s identities is taken with, or undefined when nothing can take one. */
8
10
  current(environment: LabEnvironment | undefined): FrameCapture | undefined;
9
11
  }
12
+ /** Evidence for one held demo image. Context is explicitly labelled when a step predates the app. */
13
+ export interface DemoFrameSource {
14
+ frame?: string;
15
+ atMs?: number;
16
+ note?: string;
17
+ }
18
+ /** Prefer a step's last settled after-frame; never clamp a missing video span onto unrelated footage. */
19
+ export declare function demoFrameSources(recording: RunRecording, steps: readonly DemoStep[], video: RecordedVideo, durationMs: number): DemoFrameSource[];
20
+ /**
21
+ * Exact PNG equality across a 200 ms window (samples at least 100 ms apart). No pixel tolerance: even a small
22
+ * animation keeps waiting. Loading is checked by sources that support it on each sample. At the cap, take a
23
+ * fresh image without a load wait and label it unsettled. The caller still bounds all capture I/O separately.
24
+ */
25
+ export declare function captureStableFrame(source: FrameCapture, actor: string, clock: Clock, timeoutMs: number, options: CaptureOptions): Promise<(CapturedFrame | UnavailableFrame) & {
26
+ settled: boolean;
27
+ waitedMs: number;
28
+ }>;
@@ -1,5 +1,7 @@
1
1
  // Copyright 2026 Parity Technologies (UK) Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
+ import { DemoRefusedError } from "@parity/product-lab/domain/recordings";
4
+ import { Deadline } from "./deadline.js";
3
5
  export class FrameSources {
4
6
  live = [];
5
7
  register(capture) {
@@ -12,3 +14,90 @@ export class FrameSources {
12
14
  return this.live.at(-1) ?? environment?.frames?.();
13
15
  }
14
16
  }
17
+ export function demoFrameSources(recording, steps, video, durationMs) {
18
+ const data = recording.data;
19
+ const recorded = (number) => data.steps.filter((step) => (step.featureStep ?? step.index + 1) === number);
20
+ const ownFrame = (step) => {
21
+ const calls = recorded(step.number);
22
+ const actor = video.actor ?? [...calls].reverse().find((call) => call.actor)?.actor;
23
+ const direct = data.frames.filter((frame) => frame.featureStep === step.number && frame.phase === "after" && frame.status === "captured" && !frame.overlapping.length && (!actor || frame.actor === actor)).at(-1);
24
+ if (direct)
25
+ return direct;
26
+ if (data.frames.some((frame) => frame.featureStep === step.number && (!actor || frame.actor === actor)))
27
+ return undefined;
28
+ const last = calls.filter((call) => !actor || call.actor === actor).at(-1);
29
+ return last ? data.frames.filter((frame) => frame.step === last.index && frame.phase === "after" && frame.status === "captured" && !frame.overlapping.length && (!actor || frame.actor === actor)).at(-1) : undefined;
30
+ };
31
+ const directFrames = (step) => data.frames.filter((frame) => frame.featureStep === step.number && (!video.actor || frame.actor === video.actor));
32
+ const beforeApp = (step) => !recorded(step.number).length && !ownFrame(step) && (!step.span || step.span.end < 0) && !directFrames(step).some((frame) => frame.status === "failed");
33
+ const firstApp = steps.find((step) => ownFrame(step) || (!beforeApp(step) && step.span && step.span.end >= 0 && step.span.end < durationMs));
34
+ const choose = (step) => {
35
+ const frame = ownFrame(step);
36
+ if (frame)
37
+ return { frame: frame.id, ...(frame.settled === false ? { note: "Taken at the stability cap; visual stability was not confirmed" } : {}) };
38
+ if (directFrames(step).length)
39
+ throw new DemoRefusedError(`Step ${step.number}'s after-state was not captured: ${directFrames(step).map((frame) => frame.reason ?? "capture overlaps a running step").join("; ")}`, "refused");
40
+ const at = step.span?.end;
41
+ if (at !== undefined && at >= 0 && at < durationMs)
42
+ return { atMs: at };
43
+ throw new DemoRefusedError(`Step ${step.number} has no settled after-frame or matching video timestamp; cannot invent its app state`, "refused");
44
+ };
45
+ const context = (source, note) => ({ ...source, note: [source.note, note].filter(Boolean).join(". ") });
46
+ return steps.map((step) => {
47
+ if (firstApp && step.number < firstApp.number && beforeApp(step)) {
48
+ return context(choose(firstApp), "Setup before the app opened; showing the first recorded app state");
49
+ }
50
+ const failure = [...steps].reverse().find((other) => other.number < step.number && other.status === "failed");
51
+ if (failure && !step.span && !recorded(step.number).length && !directFrames(step).length)
52
+ return context(choose(failure), `No recorded execution; showing step ${failure.number}'s failure state`);
53
+ return choose(step);
54
+ });
55
+ }
56
+ export async function captureStableFrame(source, actor, clock, timeoutMs, options) {
57
+ const started = clock.date().getTime();
58
+ const deadline = new Deadline(timeoutMs, (ms, signal) => clock.sleep(ms, signal));
59
+ const stop = () => deadline.stop();
60
+ options.signal?.addEventListener("abort", stop, { once: true });
61
+ let previous;
62
+ let unchangedSince = started;
63
+ const check = async () => {
64
+ for (;;) {
65
+ deadline.signal.throwIfAborted();
66
+ options.signal?.throwIfAborted();
67
+ try {
68
+ const frame = await source.capture(actor, { ...options, signal: deadline.signal, loadComplete: true, timeoutMs: Math.max(1, Math.min(options.timeoutMs ?? timeoutMs, timeoutMs - (clock.date().getTime() - started))) });
69
+ const now = clock.date().getTime();
70
+ if (frame.status === "captured") {
71
+ const same = previous?.length === frame.bytes.length && previous.every((byte, index) => byte === frame.bytes[index]);
72
+ if (!same)
73
+ unchangedSince = now;
74
+ if (same && now - unchangedSince >= 200)
75
+ return frame;
76
+ previous = frame.bytes;
77
+ }
78
+ else {
79
+ previous = undefined;
80
+ unchangedSince = now;
81
+ }
82
+ }
83
+ catch {
84
+ previous = undefined;
85
+ unchangedSince = clock.date().getTime();
86
+ }
87
+ await clock.sleep(100, deadline.signal);
88
+ }
89
+ };
90
+ try {
91
+ const result = await deadline.race(check(), undefined);
92
+ const waitedMs = Math.min(timeoutMs, Math.max(0, clock.date().getTime() - started));
93
+ deadline.stop();
94
+ if (result)
95
+ return { ...result, settled: true, waitedMs };
96
+ options.signal?.throwIfAborted();
97
+ return { ...await source.capture(actor, { ...options, loadComplete: false }), settled: false, waitedMs };
98
+ }
99
+ finally {
100
+ deadline.stop();
101
+ options.signal?.removeEventListener("abort", stop);
102
+ }
103
+ }
@@ -4,7 +4,7 @@ import type { FeatureRun } from "../features/feature-run.js";
4
4
  import type { LabState } from "../lab/lab-state.js";
5
5
  import type { RuntimeEvents } from "../lab/runtime-events.js";
6
6
  import type { CollectEvidence } from "../runs/collect-evidence.js";
7
- import type { FrameSources } from "./frame-sources.js";
7
+ import { type FrameSources } from "./frame-sources.js";
8
8
  import type { RecordingStores } from "./recording-stores.js";
9
9
  export interface RecordRunsOptions {
10
10
  state: LabState;
@@ -72,6 +72,12 @@ export declare class RecordRuns {
72
72
  private remember;
73
73
  /** Write the open recording of feature scenario `title` now (a load reads it at once, backlog 43), then wait for writes. */
74
74
  closeScenario(title: string): Promise<void>;
75
+ /** Open on the first direct step, rather than only on the first tool run. */
76
+ stepStarted(scenario: string, at: number): void;
77
+ /** Only video runs await after-state captures; default frames-only runs keep capture asynchronous. */
78
+ stepFinished(scenario: string, featureStep: number, identities: readonly string[]): Promise<void>;
79
+ /** Called before feature services/identities close, including scenarios consisting only of direct steps. */
80
+ scenarioEnded(scenario: string): Promise<void>;
75
81
  private now;
76
82
  private onRuntimeEvent;
77
83
  private runStarted;
@@ -5,11 +5,13 @@ import { RecordingHealth, RunRecording, StepConsoleErrors } from "@parity/produc
5
5
  import { NO_HOST_LOGS, readHostLogs } from "./host-logs.js";
6
6
  import { SETTLE_TIMEOUT_MS, StepChainReader } from "./step-chain-reader.js";
7
7
  import { HealthSampler } from "./health-sampler.js";
8
+ import { captureStableFrame } from "./frame-sources.js";
8
9
  import { RecordingDraft } from "./recording-draft.js";
9
10
  import { Deadline } from "./deadline.js";
10
11
  const FEATURE_JOB = /^feature:(.*)#(\d+)$/;
11
12
  const NO_SOURCE = "No frame source: the environment offers no capture (LabEnvironment.frames) and no lab window registered one";
12
13
  const WRITTEN_KEPT = 500;
14
+ const FEATURE_TAIL_MS = 2000;
13
15
  export class RecordRuns {
14
16
  options;
15
17
  open = new Map();
@@ -103,6 +105,47 @@ export class RecordRuns {
103
105
  this.finalize(draft.key, "the load ended");
104
106
  await this.flush();
105
107
  }
108
+ stepStarted(scenario, at) {
109
+ if (this.options.stores.settings.mode === "off")
110
+ return;
111
+ const active = this.options.state.active;
112
+ if (active && !active.closed)
113
+ this.featureDraft(active, scenario, at);
114
+ }
115
+ async stepFinished(scenario, featureStep, identities) {
116
+ const draft = [...this.open.values()].find((item) => item.kind === "feature" && item.title === scenario);
117
+ if (!draft || draft.active.closed)
118
+ return;
119
+ draft.rememberIdentities(identities);
120
+ const last = draft.runs.filter((entry) => entry.featureStep === featureStep).at(-1);
121
+ const index = last && last.run.record.steps.length ? draft.index(last.run, last.run.record.steps.length - 1) : undefined;
122
+ const captures = Promise.all(identities.map((actor) => {
123
+ this.noteVideo(draft, actor);
124
+ return this.capture(draft, actor, "after", index, featureStep, this.options.stores.settings.video);
125
+ }));
126
+ if (this.options.stores.settings.video)
127
+ await captures;
128
+ else {
129
+ void captures;
130
+ }
131
+ }
132
+ async scenarioEnded(scenario) {
133
+ const draft = [...this.open.values()].find((item) => item.kind === "feature" && item.title === scenario);
134
+ if (!draft)
135
+ return;
136
+ if (!draft.active.closed && (this.options.stores.settings.video || draft.videos.size)) {
137
+ await this.options.clock.sleep(FEATURE_TAIL_MS);
138
+ draft.tailFinishedAt = this.now();
139
+ const last = this.options.state.scenarioSteps.get(scenario)?.at(-1);
140
+ if (last)
141
+ await this.stepFinished(scenario, last.featureStep, draft.everyIdentity());
142
+ }
143
+ if (!draft.featureRun) {
144
+ await this.settleCaptures();
145
+ this.finalize(draft.key, "the feature scenario ended");
146
+ await draft.hostLogs;
147
+ }
148
+ }
106
149
  now() {
107
150
  return this.options.clock.date().getTime();
108
151
  }
@@ -207,7 +250,7 @@ export class RecordRuns {
207
250
  if (actor)
208
251
  draft.hostRequests?.stepStarted(index, actor);
209
252
  this.readUiCalls(draft);
210
- this.capture(draft, actor, "before", index);
253
+ void this.capture(draft, actor, "before", index);
211
254
  this.noteVideo(draft, actor);
212
255
  }
213
256
  else if (event.status === "complete" || event.status === "failed") {
@@ -215,11 +258,11 @@ export class RecordRuns {
215
258
  if (!draft.running.size)
216
259
  draft.health?.idle();
217
260
  this.observe(draft, index, "end");
218
- this.capture(draft, actor, "after", index);
261
+ void this.capture(draft, actor, "after", index);
219
262
  if (event.status === "failed")
220
263
  for (const other of draft.everyIdentity())
221
264
  if (other !== actor)
222
- this.capture(draft, other, "after", index);
265
+ void this.capture(draft, other, "after", index);
223
266
  this.readChainChanges(draft, index, actor);
224
267
  draft.hostRequests?.stepEnded(index);
225
268
  this.readUiCalls(draft, index);
@@ -237,13 +280,13 @@ export class RecordRuns {
237
280
  const done = draft.runs.flatMap((entry) => entry.run.record.steps.flatMap((step, position) => (step.status === "complete" || step.status === "failed" ? [draft.index(entry.run, position)] : []))).filter((item) => item !== undefined);
238
281
  const after = done.length ? Math.max(...done) : undefined;
239
282
  for (const actor of new Set([...draft.runs.flatMap((entry) => RecordingDraft.identities(entry.run)), ...identities]))
240
- this.capture(draft, actor, "checkpoint", after);
283
+ void this.capture(draft, actor, "checkpoint", after);
241
284
  }
242
285
  checkpoint(draft, run) {
243
286
  const done = run.record.steps.flatMap((step, position) => (step.status === "complete" ? [draft.index(run, position)] : [])).filter((item) => item !== undefined);
244
287
  const after = done.length ? Math.max(...done) : undefined;
245
288
  for (const actor of RecordingDraft.identities(run))
246
- this.capture(draft, actor, "checkpoint", after);
289
+ void this.capture(draft, actor, "checkpoint", after);
247
290
  }
248
291
  readUiCalls(draft, index) {
249
292
  const source = this.options.sources.current(draft.active.closed ? undefined : draft.active.environment);
@@ -295,13 +338,14 @@ export class RecordRuns {
295
338
  }));
296
339
  draft.observations.push({ step, at, recordedAt: this.now(), chains });
297
340
  }
298
- capture(draft, actor, phase, step) {
341
+ capture(draft, actor, phase, step, featureStep, stable = false) {
299
342
  if (this.options.stores.settings.mode !== "frames" || !actor)
300
- return;
343
+ return Promise.resolve();
301
344
  const source = this.options.sources.current(draft.active.closed ? undefined : draft.active.environment);
302
345
  const frame = {
303
346
  id: `f${draft.frames.length + 1}`,
304
347
  ...(step !== undefined ? { step } : {}),
348
+ ...(featureStep !== undefined ? { featureStep } : {}),
305
349
  actor,
306
350
  phase,
307
351
  status: "unavailable",
@@ -313,19 +357,24 @@ export class RecordRuns {
313
357
  };
314
358
  draft.frames.push(frame);
315
359
  if (!source)
316
- return;
360
+ return Promise.resolve();
317
361
  Object.assign(frame, { status: "failed", reason: "The capture had not finished when the recording closed" });
318
362
  const limit = this.options.stores.settings.captureTimeoutMs;
319
363
  const deadline = new Deadline(limit, (ms, signal) => this.options.clock.sleep(ms, signal));
320
364
  const work = deadline
321
- .race(Promise.resolve().then(() => source.capture(actor, { signal: deadline.signal, timeoutMs: limit })), "late")
365
+ .race(Promise.resolve().then(() => {
366
+ const options = { signal: deadline.signal, timeoutMs: limit };
367
+ return stable ? captureStableFrame(source, actor, this.options.clock, this.options.stores.settings.settleTimeoutMs, options) : source.capture(actor, options);
368
+ }), "late")
322
369
  .then((result) => {
370
+ if (stable)
371
+ Object.assign(frame, { settled: result !== "late" && "settled" in result ? result.settled : false, waitedMs: result !== "late" && "waitedMs" in result ? result.waitedMs : Math.min(limit, this.options.stores.settings.settleTimeoutMs) });
323
372
  const done = { capturedAt: this.now(), overlapping: [...draft.running].sort((a, b) => a - b) };
324
373
  if (result === "late")
325
374
  Object.assign(frame, { reason: `The capture took longer than ${limit} ms` });
326
375
  else if (result.status === "captured") {
327
376
  delete frame.reason;
328
- Object.assign(frame, { ...done, status: "captured", file: `${frame.id}.png`, mediaType: result.mediaType, bytes: result.bytes.length });
377
+ Object.assign(frame, { ...done, status: "captured", file: `${frame.id}.png`, mediaType: result.mediaType, bytes: result.bytes.length, ...(result.loadComplete !== undefined ? { loadComplete: result.loadComplete } : {}) });
329
378
  draft.images.set(frame.id, result.bytes);
330
379
  }
331
380
  else
@@ -336,6 +385,7 @@ export class RecordRuns {
336
385
  .finally(() => deadline.stop());
337
386
  draft.pending.add(work);
338
387
  void work.finally(() => draft.pending.delete(work));
388
+ return work;
339
389
  }
340
390
  noteVideo(draft, actor) {
341
391
  if (!actor)
@@ -410,7 +460,12 @@ export class RecordRuns {
410
460
  if (featureSteps?.length) {
411
461
  this.options.state.scenarioSteps.delete(draft.title);
412
462
  const readings = this.options.state.featureStepReadings.get(draft.title);
413
- Object.assign(data, { featureSteps: featureSteps.map((step) => ({ ...step, ...(readings?.get(step.featureStep - 1)?.length ? { readings: [...readings.get(step.featureStep - 1)] } : {}) })), startedAt: Math.min(data.startedAt, ...featureSteps.map((step) => step.startedAt)) });
463
+ Object.assign(data, {
464
+ featureSteps: featureSteps.map((step) => ({ ...step, ...(readings?.get(step.featureStep - 1)?.length ? { readings: [...readings.get(step.featureStep - 1)] } : {}) })),
465
+ startedAt: Math.min(data.startedAt, ...featureSteps.map((step) => step.startedAt)),
466
+ finishedAt: Math.max(data.finishedAt, ...featureSteps.map((step) => step.finishedAt ?? step.startedAt)),
467
+ ...(!draft.featureStatus && featureSteps.some((step) => step.status === "failed") ? { status: "failed" } : {}),
468
+ });
414
469
  }
415
470
  const kept = (await draft.hostLogs) ?? NO_HOST_LOGS;
416
471
  const files = new Map([...kept.files].map(([file, bytes]) => [file, new TextEncoder().encode(this.options.redactor.text(new TextDecoder().decode(bytes)))]));
@@ -33,6 +33,9 @@ export declare class RecordingDraft {
33
33
  readonly observations: ChainObservation[];
34
34
  /** Captures still in progress. */
35
35
  readonly pending: Set<Promise<void>>;
36
+ /** Direct steps may create identities without any runtime run. */
37
+ readonly featureIdentities: Set<string>;
38
+ tailFinishedAt?: number;
36
39
  /** The lab's checks after this recording's failed runs: only its write waits for them, never a scenario's end. */
37
40
  readonly checks: Set<Promise<void>>;
38
41
  /** The session's people as each run began: a runner that closes them before the write still names them (backlog 110). */
@@ -91,6 +94,7 @@ export declare class RecordingDraft {
91
94
  static identities(run: ScenarioRun): string[];
92
95
  /** Every identity of the runs recorded so far (backlog 110: a failed step takes each one's frame). */
93
96
  everyIdentity(): string[];
97
+ rememberIdentities(identities: readonly string[]): void;
94
98
  /** Each identity's name as the recording gives it (seen while recording, or the environment's now), by id. */
95
99
  names(): Map<string, string>;
96
100
  /** Whether a recorded run's step failed. */
@@ -20,6 +20,8 @@ export class RecordingDraft {
20
20
  images = new Map();
21
21
  observations = [];
22
22
  pending = new Set();
23
+ featureIdentities = new Set();
24
+ tailFinishedAt;
23
25
  checks = new Set();
24
26
  seen = new Map();
25
27
  videos = new Map();
@@ -105,7 +107,14 @@ export class RecordingDraft {
105
107
  return [...new Set([...record.steps.flatMap((step) => (step.actor ? [step.actor] : [])), ...(record.bindings ?? []).map((binding) => binding.identity), record.actorId].filter(Boolean))];
106
108
  }
107
109
  everyIdentity() {
108
- return [...new Set(this.runs.flatMap((entry) => RecordingDraft.identities(entry.run)))];
110
+ return [...new Set([...this.featureIdentities, ...this.runs.flatMap((entry) => RecordingDraft.identities(entry.run))])];
111
+ }
112
+ rememberIdentities(identities) {
113
+ for (const id of identities)
114
+ this.featureIdentities.add(id);
115
+ if (!this.active.closed)
116
+ for (const actor of this.active.environment.actors())
117
+ this.seen.set(actor.id, { name: actor.name, ...(actor.account ? { account: actor.account } : {}) });
109
118
  }
110
119
  names() {
111
120
  const now = this.active.closed ? [] : this.active.environment.actors();
@@ -136,7 +145,7 @@ export class RecordingDraft {
136
145
  const hostBypass = observed ? mergeHostBypassEvidence(this.runs.map((entry) => entry.run.record.hostBypass ? this.recordingEvidence(entry.run.record.hostBypass, entry.base ?? 0) : { scope: "unknown", apps: [], ledger: [], gaps: [`No host-bypass evidence recorded for run ${entry.run.id}`] })) : undefined;
137
146
  const known = new Map([...this.seen, ...this.active.environment.actors().map((actor) => [actor.id, actor])]);
138
147
  const roles = new Map(records.flatMap((record) => (record.bindings ?? []).map((binding) => [binding.identity, binding.actor])));
139
- const ids = [...new Set(this.runs.flatMap((entry) => RecordingDraft.identities(entry.run)))];
148
+ const ids = this.everyIdentity();
140
149
  const actors = ids.map((id) => {
141
150
  const actor = known.get(id);
142
151
  const role = roles.get(id);
@@ -169,7 +178,7 @@ export class RecordingDraft {
169
178
  if (unavailable || failed)
170
179
  notes.push(`${unavailable} frame${unavailable === 1 ? "" : "s"} unavailable and ${failed} failed: each says why.`);
171
180
  if (captured.some((frame) => frame.overlapping.length))
172
- notes.push("Captures never wait for the run: a frame taken while a step ran names that step.");
181
+ notes.push("Asynchronous captures name any overlapping step; video feature after-state captures finish before the next feature step starts.");
173
182
  if (media.has("synthetic"))
174
183
  notes.push("Some frames come from a browser with a synthetic camera and microphone.");
175
184
  if (this.videos.size)
@@ -190,7 +199,7 @@ export class RecordingDraft {
190
199
  ...(hostBypass ? { hostBypass } : {}),
191
200
  ...(error !== undefined ? { error } : this.runnerFailure ? { error: this.runnerFailure.error } : {}),
192
201
  startedAt: this.startedAt,
193
- finishedAt: Math.max(this.startedAt, ...finished),
202
+ finishedAt: Math.max(this.startedAt, this.tailFinishedAt ?? 0, ...finished),
194
203
  recordedAt: options.now,
195
204
  runs: records.map((record) => record.id),
196
205
  ...(this.featureRun !== undefined ? { featureRun: this.featureRun } : {}),
@@ -195,3 +195,17 @@ is the page's build). `AppDevServer.parse` accepts `"next-turbopack"`, `"webpack
195
195
  arguments name (`configArgument`) or the CLI would find (`DEFAULT_CONFIG_FILES`), the arguments that add the dev
196
196
  servers' own proxy setting (`proxySocketArgs`), the content of the lab's files (`webpackSocketOverlay`,
197
197
  `rspackSocketWrapper`) and the notice when the lab cannot tell (`proxySocketNotice`). Tests: `tests/dev-server-socket.test.ts`.
198
+
199
+ ## Held feature demos
200
+
201
+ planDemoHolds makes one frozen segment per step, with a default and minimum 2 s hold rounded upward to whole 25 fps frames. maxSeconds is advisory when the holds exceed it. demoSteps includes direct featureSteps even when no tool run exists. RecordedFrame.featureStep optionally names that direct Gherkin step; the format remains additive at schema version 1. planDemo remains available for existing moving-video callers and the negative control.
202
+
203
+ `RecordingSettings.settleTimeoutMs` optionally caps video feature after-frame stability (default 3000 ms; `PRODUCT_LAB_RECORDINGS_SETTLE_MS`). `RecordedFrame` adds optional `settled`, `waitedMs` and `loadComplete` fields. Older recordings remain readable; no field is renamed.
204
+
205
+
206
+ The effective stability cap reserves time for the final screenshot: it is clamped to `captureTimeoutMs` minus
207
+ 1000 ms, or minus half that timeout when it is shorter than 2000 ms. With the default 5000 ms capture timeout,
208
+ `PRODUCT_LAB_RECORDINGS_SETTLE_MS` accepts positive values but uses at most 4000 ms; the default stays 3000 ms.
209
+ A captured frame with `settled: false` is labelled "Taken at the stability cap" in recording-view notes and in
210
+ the demo image manifest and captions, including reused setup and failure context. Older frames without this
211
+ metadata retain their existing labels.
@@ -15,11 +15,13 @@ export const DEMO_DEFAULTS = {
15
15
  export function demoSteps(data, video, scenario) {
16
16
  const roleOf = (actor) => (actor ? (data.steps.find((step) => step.actor === actor && step.role)?.role ?? data.actors.find((item) => item.id === actor)?.name ?? actor) : undefined);
17
17
  const at = (wall) => wall - video.openedAt;
18
- const numbers = scenario ? scenario.steps.map((_, index) => index + 1) : [...new Set(data.steps.map((step) => step.featureStep ?? step.index + 1))];
18
+ const numbers = scenario ? scenario.steps.map((_, index) => index + 1) : [...new Set([...data.steps.map((step) => step.featureStep ?? step.index + 1), ...(data.featureSteps ?? []).map((step) => step.featureStep)])].sort((a, b) => a - b);
19
19
  return numbers.map((number) => {
20
20
  const recorded = data.steps.filter((step) => (step.featureStep ?? step.index + 1) === number);
21
21
  const timed = recorded.filter((step) => step.startedAt !== undefined);
22
22
  const gherkin = scenario?.steps[number - 1];
23
+ const feature = data.featureSteps?.find((step) => step.featureStep === number);
24
+ const runnerFailure = data.runnerFailure?.featureStep === number ? data.runnerFailure : undefined;
23
25
  const actorId = recorded.find((step) => step.actor)?.actor;
24
26
  const failed = recorded.find((step) => step.status === "failed");
25
27
  const waits = data.transactions
@@ -30,10 +32,10 @@ export function demoSteps(data, video, scenario) {
30
32
  return {
31
33
  number,
32
34
  keyword: gherkin?.keyword.trim() ?? "",
33
- text: gherkin?.text ?? recorded[0]?.title.replace(/ \[[^\]]+\]$/, "") ?? `step ${number}`,
35
+ text: gherkin?.text ?? feature?.text ?? recorded[0]?.title.replace(/ \[[^\]]+\]$/, "") ?? `step ${number}`,
34
36
  ...(roleOf(actorId) ? { actor: roleOf(actorId) } : {}),
35
- ...(timed.length ? { span: { start: at(Math.min(...timed.map((step) => step.startedAt))), end: at(end) } } : {}),
36
- ...(failed ? { status: "failed", ...(failed.error ? { error: failed.error.split("\n")[0] } : {}) } : recorded.length ? { status: "complete" } : {}),
37
+ ...(feature?.finishedAt !== undefined ? { span: { start: at(feature.startedAt), end: at(feature.finishedAt) } } : timed.length ? { span: { start: at(Math.min(...timed.map((step) => step.startedAt))), end: at(end) } } : {}),
38
+ ...((failed || runnerFailure || feature?.status === "failed") ? { status: "failed", ...((failed?.error ?? runnerFailure?.error) ? { error: (failed?.error ?? runnerFailure.error).split("\n")[0] } : {}) } : recorded.length || feature?.status === "passed" ? { status: "complete" } : {}),
37
39
  chainWaits: waits,
38
40
  ...(video.actor && actorId && actorId !== video.actor ? { offVideo: true } : {}),
39
41
  };
@@ -0,0 +1,12 @@
1
+ import type { DemoPlan, DemoStep } from "./demo-cut.js";
2
+ /** A step with evidence selected by the caller. A still has no video timestamp. */
3
+ export interface DemoHold {
4
+ step: DemoStep;
5
+ atMs?: number;
6
+ note?: string;
7
+ }
8
+ /** One frozen after-step image per step, in order. The requested maximum never shortens a hold. */
9
+ export declare function planDemoHolds(holds: readonly DemoHold[], sourceMs: number, options?: {
10
+ holdSeconds?: number;
11
+ maxSeconds?: number;
12
+ }): DemoPlan;
@@ -0,0 +1,31 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ export function planDemoHolds(holds, sourceMs, options = {}) {
4
+ const holdSeconds = options.holdSeconds ?? 2;
5
+ const maxSeconds = options.maxSeconds ?? 60;
6
+ if (!Number.isFinite(holdSeconds) || holdSeconds < 2)
7
+ throw new Error("A demo's hold must be at least 2 seconds");
8
+ if (!Number.isFinite(maxSeconds) || maxSeconds <= 0)
9
+ throw new Error("A demo's length must be above 0 seconds");
10
+ if (!holds.length)
11
+ throw new Error("The recording holds no step to show");
12
+ const holdMs = Math.ceil(holdSeconds * 25) * 40;
13
+ const captions = holds.map(({ step, note }, index) => ({
14
+ number: step.number, keyword: step.keyword, text: step.text,
15
+ ...(step.actor ? { actor: step.actor } : {}),
16
+ ...(step.status ? { status: step.status } : {}),
17
+ ...((note || step.status === "failed" || step.offVideo) ? { note: [
18
+ step.status === "failed" ? `failed${step.error ? `: ${step.error}` : ""}` : undefined,
19
+ step.offVideo ? `${step.actor ?? "another identity"} acts, not in this video` : undefined,
20
+ note,
21
+ ].filter(Boolean).join(" · ") } : {}),
22
+ outputStart: index * holdMs, outputEnd: (index + 1) * holdMs,
23
+ }));
24
+ const outputMs = holds.length * holdMs;
25
+ const fits = outputMs <= maxSeconds * 1000;
26
+ return {
27
+ segments: holds.map(({ atMs = 0 }, index) => ({ kind: "result", sourceStart: atMs, sourceEnd: atMs, speed: 1, holdMs, outputStart: index * holdMs, outputEnd: (index + 1) * holdMs })),
28
+ captions, outputMs, sourceMs, fits,
29
+ notes: ["Each step shows a frozen recorded app image; waits are omitted.", ...(!fits ? [`The demo is ${outputMs / 1000} s, above the ${maxSeconds} s asked for: ${holds.length} steps each need at least ${holdSeconds} s.`] : [])],
30
+ };
31
+ }
@@ -14,3 +14,4 @@ export * from "./journey-markdown.js";
14
14
  export * from "./journeys-declaration.js";
15
15
  export * from "./demo-cut.js";
16
16
  export * from "./demo-refused-error.js";
17
+ export * from "./demo-holds.js";
@@ -16,3 +16,4 @@ export * from "./journey-markdown.js";
16
16
  export * from "./journeys-declaration.js";
17
17
  export * from "./demo-cut.js";
18
18
  export * from "./demo-refused-error.js";
19
+ export * from "./demo-holds.js";
@@ -66,10 +66,18 @@ export interface RecordedStep {
66
66
  }[];
67
67
  }
68
68
  export interface RecordedFrame {
69
+ /** Video feature after-frame stability result; absent for asynchronous captures and older recordings. */
70
+ settled?: boolean;
71
+ /** Time spent checking load completion and visual stability, bounded by the configured cap. */
72
+ waitedMs?: number;
73
+ /** Present when the source checked the current document before imaging it. */
74
+ loadComplete?: boolean;
69
75
  /** Unique in the recording: `[a-z0-9-]`, also the image's file name without extension. */
70
76
  id: string;
71
- /** The recorded step it belongs to; absent for a checkpoint frame taken before any step. */
77
+ /** Atomic recorded step; absent for a direct feature step with no tool run, or an initial checkpoint. */
72
78
  step?: number;
79
+ /** Direct Gherkin step (1-based), including steps with no runtime run. */
80
+ featureStep?: number;
73
81
  /** The identity whose app it shows. */
74
82
  actor: string;
75
83
  phase: FramePhase;