@parity/product-lab 0.1.0 → 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.
Files changed (64) hide show
  1. package/CHANGELOG.md +15 -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 +3 -3
  7. package/adapters/environment/engine/src/engine/fork-refresh.js +72 -1
  8. package/adapters/features/cucumber/README.md +4 -0
  9. package/adapters/features/cucumber/src/steps/register-feature-steps.js +15 -1
  10. package/adapters/system/node/README.md +5 -5
  11. package/adapters/video/ffmpeg/README.md +4 -0
  12. package/adapters/video/ffmpeg/src/ffmpeg-video-editor.d.ts +1 -1
  13. package/adapters/video/ffmpeg/src/ffmpeg-video-editor.js +19 -6
  14. package/core/application/README.md +30 -1
  15. package/core/application/src/features/direct-feature-steps.d.ts +23 -0
  16. package/core/application/src/features/direct-feature-steps.js +25 -0
  17. package/core/application/src/features/feature-recording.d.ts +6 -0
  18. package/core/application/src/features/feature-recording.js +3 -0
  19. package/core/application/src/features/feature-scenario.d.ts +10 -8
  20. package/core/application/src/features/feature-scenario.js +11 -15
  21. package/core/application/src/features/feature-step-times.d.ts +11 -1
  22. package/core/application/src/features/feature-step-times.js +20 -6
  23. package/core/application/src/features/lab-feature-session.d.ts +3 -1
  24. package/core/application/src/features/lab-feature-session.js +5 -2
  25. package/core/application/src/features/run-feature-steps.d.ts +3 -1
  26. package/core/application/src/features/run-feature-steps.js +4 -2
  27. package/core/application/src/lab-runtime-use-cases.js +5 -1
  28. package/core/application/src/recordings/demo-markup.d.ts +8 -0
  29. package/core/application/src/recordings/demo-markup.js +11 -1
  30. package/core/application/src/recordings/demo-recording.d.ts +0 -2
  31. package/core/application/src/recordings/demo-recording.js +40 -23
  32. package/core/application/src/recordings/frame-sources.d.ts +20 -1
  33. package/core/application/src/recordings/frame-sources.js +89 -0
  34. package/core/application/src/recordings/record-runs.d.ts +7 -1
  35. package/core/application/src/recordings/record-runs.js +66 -11
  36. package/core/application/src/recordings/recording-draft.d.ts +4 -0
  37. package/core/application/src/recordings/recording-draft.js +13 -4
  38. package/core/domain/README.md +14 -0
  39. package/core/domain/src/recordings/demo-cut.js +6 -4
  40. package/core/domain/src/recordings/demo-holds.d.ts +12 -0
  41. package/core/domain/src/recordings/demo-holds.js +31 -0
  42. package/core/domain/src/recordings/index.d.ts +1 -0
  43. package/core/domain/src/recordings/index.js +1 -0
  44. package/core/domain/src/recordings/recording-data.d.ts +9 -1
  45. package/core/domain/src/recordings/recording-settings.d.ts +5 -1
  46. package/core/domain/src/recordings/recording-settings.js +7 -0
  47. package/core/domain/src/recordings/run-recording.d.ts +2 -0
  48. package/core/domain/src/recordings/run-recording.js +8 -1
  49. package/core/ports/README.md +6 -0
  50. package/core/ports/src/driven/recording/demo-ports.d.ts +2 -0
  51. package/core/ports/src/driven/recording/frame-capture.d.ts +4 -0
  52. package/core/ports/src/driving/demo.d.ts +2 -0
  53. package/core/ports/src/driving/feature-session.d.ts +7 -0
  54. package/entrypoints/cli/README.md +6 -3
  55. package/entrypoints/cli/ai-kit/skills/product-lab-integration/references/notes-lab.md +1 -1
  56. package/entrypoints/cli/ai-kit/skills/product-lab-setup/SKILL.md +1 -0
  57. package/entrypoints/cli/ai-kit/skills/product-lab-setup/references/minimal-lab.md +1 -1
  58. package/entrypoints/cli/src/demo-command.d.ts +1 -1
  59. package/entrypoints/cli/src/demo-command.js +10 -5
  60. package/entrypoints/mcp/README.md +3 -0
  61. package/entrypoints/panel/README.md +6 -3
  62. package/entrypoints/panel/src/visible/detached-identities.js +5 -1
  63. package/entrypoints/panel/src/visible/window-frames.js +14 -1
  64. package/package.json +1 -1
@@ -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;
@@ -9,6 +9,7 @@ export declare const RECORDING_VARIABLES: {
9
9
  readonly maxAgeDays: "PRODUCT_LAB_RECORDINGS_MAX_AGE_DAYS";
10
10
  readonly maxMb: "PRODUCT_LAB_RECORDINGS_MAX_MB";
11
11
  readonly video: "PRODUCT_LAB_RECORDINGS_VIDEO";
12
+ readonly settleTimeoutMs: "PRODUCT_LAB_RECORDINGS_SETTLE_MS";
12
13
  readonly hostLogs: "PRODUCT_LAB_RECORDINGS_HOST_LOGS";
13
14
  readonly hostLogKb: "PRODUCT_LAB_RECORDINGS_HOST_LOG_KB";
14
15
  };
@@ -25,13 +26,15 @@ export declare class RecordingSettings {
25
26
  static readonly DEFAULT: RecordingSettings;
26
27
  readonly mode: RecordingMode;
27
28
  readonly retention: RecordingRetention;
28
- /** A capture that takes longer is recorded as failed (it never holds the run). */
29
+ /** A capture that takes longer is recorded as failed; only video feature boundaries wait for it. */
29
30
  readonly captureTimeoutMs: number;
30
31
  /**
31
32
  * Backlog 18b: headless identities' pages are recorded as video into the recordings directory's `videos/` folder,
32
33
  * and each recording refers to the videos of the pages its steps acted in. Default off (`product-lab test --video`).
33
34
  */
34
35
  readonly video: boolean;
36
+ /** Video feature after-frames compare screenshots for at most this long (default 3000 ms). */
37
+ readonly settleTimeoutMs: number;
35
38
  /**
36
39
  * Backlog 192: each host's own log is kept with a failed run's recording (`failed`), with every run's (`always`), or
37
40
  * never (`off`); at most `hostLogBytes` of each, its tail.
@@ -43,6 +46,7 @@ export declare class RecordingSettings {
43
46
  retention: RecordingRetention;
44
47
  captureTimeoutMs: number;
45
48
  video?: boolean;
49
+ settleTimeoutMs?: number;
46
50
  hostLogs?: HostLogsMode;
47
51
  hostLogBytes?: number;
48
52
  });
@@ -9,6 +9,7 @@ export const RECORDING_VARIABLES = {
9
9
  maxAgeDays: "PRODUCT_LAB_RECORDINGS_MAX_AGE_DAYS",
10
10
  maxMb: "PRODUCT_LAB_RECORDINGS_MAX_MB",
11
11
  video: "PRODUCT_LAB_RECORDINGS_VIDEO",
12
+ settleTimeoutMs: "PRODUCT_LAB_RECORDINGS_SETTLE_MS",
12
13
  hostLogs: "PRODUCT_LAB_RECORDINGS_HOST_LOGS",
13
14
  hostLogKb: "PRODUCT_LAB_RECORDINGS_HOST_LOG_KB",
14
15
  };
@@ -21,6 +22,7 @@ export class RecordingSettings {
21
22
  retention;
22
23
  captureTimeoutMs;
23
24
  video;
25
+ settleTimeoutMs;
24
26
  hostLogs;
25
27
  hostLogBytes;
26
28
  constructor(settings) {
@@ -31,6 +33,10 @@ export class RecordingSettings {
31
33
  this.mode = settings.mode;
32
34
  this.retention = settings.retention;
33
35
  this.captureTimeoutMs = settings.captureTimeoutMs;
36
+ const settleTimeoutMs = settings.settleTimeoutMs ?? 3000;
37
+ if (!Number.isFinite(settleTimeoutMs) || settleTimeoutMs <= 0)
38
+ throw new RecordingFormatError("A frame stability wait needs a positive timeout");
39
+ this.settleTimeoutMs = Math.min(settleTimeoutMs, this.captureTimeoutMs - Math.min(1000, this.captureTimeoutMs / 2));
34
40
  this.video = settings.mode !== "off" && (settings.video ?? false);
35
41
  const hostLogs = settings.hostLogs ?? "failed";
36
42
  if (!["failed", "always", "off"].includes(hostLogs))
@@ -74,6 +80,7 @@ export class RecordingSettings {
74
80
  }),
75
81
  captureTimeoutMs: RecordingSettings.DEFAULT.captureTimeoutMs,
76
82
  video: video === "on",
83
+ settleTimeoutMs: number(RECORDING_VARIABLES.settleTimeoutMs, RecordingSettings.DEFAULT.settleTimeoutMs, 1, false),
77
84
  hostLogs,
78
85
  hostLogBytes: number(RECORDING_VARIABLES.hostLogKb, HOST_LOG_BYTES, 1024, true),
79
86
  });
@@ -51,6 +51,8 @@ export declare class RunRecording {
51
51
  before: FrameChoice;
52
52
  after: FrameChoice;
53
53
  };
54
+ /** A cap frame remains available, with its uncertainty visible wherever the viewer chooses it. */
55
+ private labelStability;
54
56
  private framesOf;
55
57
  private before;
56
58
  private after;
@@ -179,6 +179,8 @@ export class RunRecording {
179
179
  throw new RecordingFormatError(`Recording frame ${id} has an unknown status`);
180
180
  if (frame.step !== undefined && (typeof frame.step !== "number" || !Number.isInteger(frame.step) || frame.step < 0 || frame.step >= steps.length))
181
181
  throw new RecordingFormatError(`Recording frame ${id} names step ${String(frame.step)}, which the recording does not have`);
182
+ if (frame.featureStep !== undefined && (typeof frame.featureStep !== "number" || !Number.isSafeInteger(frame.featureStep) || frame.featureStep < 1))
183
+ throw new RecordingFormatError("Recording frame featureStep must be a positive step number");
182
184
  time(`frames[${index}].requestedAt`, frame.requestedAt);
183
185
  if (!Array.isArray(frame.overlapping))
184
186
  throw new RecordingFormatError(`Recording frame ${id} must list the steps it overlapped`);
@@ -239,7 +241,12 @@ export class RunRecording {
239
241
  const who = actor ?? step.actor;
240
242
  if (!who)
241
243
  return { before: RunRecording.missing("This step acted as no identity: pick one to see its app"), after: RunRecording.missing("This step acted as no identity: pick one to see its app") };
242
- return { actor: who, before: this.before(step, who), after: this.after(step, who) };
244
+ return { actor: who, before: this.labelStability(this.before(step, who)), after: this.labelStability(this.after(step, who)) };
245
+ }
246
+ labelStability(choice) {
247
+ if (choice.status !== "captured" || choice.frame?.settled !== false)
248
+ return choice;
249
+ return { ...choice, note: `Taken at the stability cap; visual stability was not confirmed. ${choice.note.replace(/^Settled(?=:|,)/, "Capture")}` };
243
250
  }
244
251
  framesOf(actor) {
245
252
  return this.data.frames.filter((frame) => frame.actor === actor);
@@ -456,3 +456,9 @@ JSON, only when `extrinsics(blockHash, { includeCallArguments: true })` requests
456
456
  neither copy nor retain arguments. Consumers can check transaction contents beside the exact finalized hash; absent
457
457
  is unknown, and `null` is a decoded call with no arguments. JSON conversion cuts nesting beyond 12 levels to `"…"`;
458
458
  `decoded: "complete"` describes SCALE decoding, not JSON depth coverage. The chain adapter owns decoding.
459
+
460
+ ## Held feature demos
461
+
462
+ FeatureScenarioHandle optionally exposes startStep and finishStep for custom definitions: observation only, without scheduling their work. VideoEditRequest.stills supplies one complete recorded image per segment. DemoRequest.holdSeconds defaults to 2 and cannot be below 2. A full demo exports step-001.png and later images decoded from each encoded hold midpoint, with a caption band below the app.
463
+
464
+ `CaptureOptions.loadComplete` optionally requests a read of the current document until loading completes, within `timeoutMs`; `CapturedFrame.loadComplete` reports when the source verified it. The recorder requests this only during video feature after-frame stability checks. Sources without this support leave the result absent.
@@ -18,6 +18,8 @@ export interface VideoEditRequest {
18
18
  readonly source: string;
19
19
  readonly plan: DemoPlan;
20
20
  readonly overlays: readonly VideoOverlay[];
21
+ /** Already composed recorded stills, one per plan segment; replaces the video inputs when supplied. */
22
+ readonly stills?: readonly Uint8Array[];
21
23
  /** Where the cut is written (its parent exists); replaced whole. */
22
24
  readonly out: string;
23
25
  readonly format: "mp4" | "webm";
@@ -7,6 +7,8 @@ export interface CapturedFrame {
7
7
  bytes: Uint8Array;
8
8
  /** How long the capture waited for the app's screen to settle (`CaptureOptions.settled`), when it was asked to. */
9
9
  settledMs?: number;
10
+ /** Whether the source verified the current document completed loading before this image. */
11
+ loadComplete?: boolean;
10
12
  }
11
13
  /** Why no image of that identity's app can be taken (no page, a surface that cannot be captured, …). */
12
14
  export interface UnavailableFrame {
@@ -35,6 +37,8 @@ export interface CaptureOptions {
35
37
  signal?: AbortSignal;
36
38
  timeoutMs?: number;
37
39
  mask?: readonly string[];
40
+ /** Read the current document until readyState is complete before imaging it, bounded by timeoutMs. */
41
+ loadComplete?: boolean;
38
42
  settled?: {
39
43
  readonly selectors: readonly string[];
40
44
  readonly timeoutMs: number;
@@ -8,6 +8,8 @@ export interface DemoRequest {
8
8
  out?: string;
9
9
  /** The longest the cut should be (default 60). */
10
10
  maxSeconds?: number;
11
+ /** Each after-step image is held this long (default 2, minimum 2 seconds). */
12
+ holdSeconds?: number;
11
13
  format?: "mp4" | "webm";
12
14
  /** Only the cut's plan: demo.json and demo.vtt, no video, no slide (needs no ffmpeg). */
13
15
  planOnly?: boolean;
@@ -230,6 +230,13 @@ export interface FeatureScenarioHandle {
230
230
  readonly name: string;
231
231
  /** Run step `index` once (a custom step is run by its own definition, not here). */
232
232
  run(index: number): Promise<FeatureStepTrace>;
233
+ /** Observe a direct/custom definition, without running it through the scheduler. Generated steps time themselves. */
234
+ startStep?(index: number): void;
235
+ /** Observe its after-state; only video runs await capture before the next step or pause. Skipped steps have no boundary. */
236
+ finishStep?(index: number, outcome: {
237
+ status: "passed" | "failed";
238
+ error?: string;
239
+ }): Promise<void>;
233
240
  /** The identity a scenario actor is bound to. */
234
241
  actor(name: string): ActorSession;
235
242
  /** The scenario's outputs so far. */
@@ -795,9 +795,8 @@ blank. It loads no lab. `pr-suite.yml` posts it. Test: `tests/pr-comment.test.ts
795
795
 
796
796
  ## Recorded runs
797
797
 
798
- `product-lab demo [<recording-id>] [--actor <role> | --window] [--out <dir>] [--max-seconds 60] [--format mp4|webm] [--plan | --slide-only] [--failed] [--json]`
799
- (backlog 36) cuts a recorded feature scenario into a demo through `LabRuntime.demo`: about a
800
- minute, each action at real speed with its result held, waits sped up and labelled, a caption per Gherkin step in the
798
+ `product-lab demo [<recording-id>] [--actor <role> | --window] [--out <dir>] [--max-seconds 60] [--hold-seconds 2] [--format mp4|webm] [--plan | --slide-only] [--failed] [--json]`
799
+ (backlog 36) cuts a recorded feature scenario into a demo through `LabRuntime.demo`: one recorded after-state per step held for at least two seconds, a caption band below the app in the
801
800
  lab window's style, with `demo.vtt`, `demo.json` and a slide (`slide.png`, `slide.html`) in
802
801
  `<lab>/.product-lab/demos/<recording-id>`. The video needs `ffmpeg` and `ffprobe` on `PATH` (or `PRODUCT_LAB_FFMPEG`
803
802
  naming their folder; never bundled); `--plan` and `--slide-only` do not. Starts nothing. Exit 2 when refused (no such
@@ -893,3 +892,7 @@ failure; this detail does not change retries or cancellation. Test:
893
892
  `tests/abort-diagnostics.test.ts`.
894
893
 
895
894
  The generated minimal reference uses the released SDK's native PAPI 3 transaction creator and carries its app's current SDK and client install pins. Its file list and dependency test follow that source; no local signer bridge is copied. The rebased migration is awaiting qualification.
895
+
896
+ ## Held feature demos
897
+
898
+ Demo holds default to 2 s per feature step; --hold-seconds tunes them upward and --max-seconds never shortens them. Captions occupy a separate band below the app. Full renders also write step-001.png and later files decoded from the midpoint of each hold. Direct/custom feature steps are recorded with their start/end times and after-state images. Default frames-only capture is asynchronous; --video awaits bounded after-step captures and retains a 2 s tail before scenario cleanup. Old footage without a matching after-state is refused rather than replaced with an unrelated screen.
@@ -9,7 +9,7 @@ A tiny notes SPA tested end to end: the local dev chain (Omni Node) with People
9
9
  1. Install the dev dependencies the files import before the first MCP call (pin these versions; `@cucumber/cucumber` must be the same version product-lab uses, so step files and the testing kit share one Cucumber). A tool call that fails with a module resolution error right after an install succeeds when repeated: the server reloads the lab.
10
10
 
11
11
  ```sh
12
- npm install --save-dev @cucumber/cucumber@12.9.0 playwright@1.63.0 @noble/hashes@1.8.0 @polkadot-labs/hdkd@0.0.32 @polkadot-labs/hdkd-helpers@0.0.34 @polkadot-api/metadata-builders@0.14.3 @polkadot-api/substrate-bindings@0.20.3 @polkadot-api/ws-provider@0.9.1 @parity/product-sdk-host@0.29.0 polkadot-api@2.2.2 vite@7.3.6
12
+ npm install --save-dev @cucumber/cucumber@12.9.0 playwright@1.63.0 @noble/hashes@1.8.0 @polkadot-labs/hdkd@0.0.32 @polkadot-labs/hdkd-helpers@0.0.34 @polkadot-api/metadata-builders@0.14.3 @polkadot-api/substrate-bindings@0.20.3 @polkadot-api/ws-provider@0.9.1 @parity/product-sdk-host@0.29.0 polkadot-api@2.2.2 vite@7.3.7
13
13
  ```
14
14
 
15
15
  Only for `launch: "deployed-local"` (lab/deployed-local.ts deploys to Bulletin): also `npm install --save-dev bulletin-deploy@0.21.0`. It is an optional peer dependency of `@parity/product-lab` under GPL-3.0-or-later (product-lab itself is Apache-2.0), so it is not installed with product-lab; without it a deployed-local run fails with an error naming this command.
@@ -98,6 +98,7 @@ In CI, cache the shared cache keyed on `lab.lock.json`, run `npx --no-install pr
98
98
  | `PRODUCT_LAB_RECORDINGS`, `PRODUCT_LAB_RECORDINGS_DIR`, `…_KEEP`, `…_MAX_AGE_DAYS`, `…_MAX_MB` | Run recordings and their retention |
99
99
  | `PRODUCT_LAB_STEP_TIMEOUT_MINUTES`, `PRODUCT_LAB_STARTUP_TIMEOUT_MINUTES`, `PRODUCT_LAB_CLEANUP_TIMEOUT_MINUTES` | Feature-run timeouts (10, 30, 5) |
100
100
  | `PRODUCT_LAB_BASE` | Git base a mode's `onlyAffected` compares with (`test --mode <name>`; default `main`) |
101
+ | `PRODUCT_LAB_FORK_BUNDLE` | `keep` or `fresh` (default): what a start of a local copy of a network does about a newer published fork bundle. `fresh` downloads it in the background; `keep` only reads the release record and says how old the kept bundle is. `lab --refresh` works under both |
101
102
 
102
103
  Your lab's own variables (a port base, an app directory) are yours to name. Codex passes only listed variables to the MCP server: list them under `env_vars` in `.codex/config.toml` (`ai init` writes product-lab's).
103
104
 
@@ -9,7 +9,7 @@ One page, one button, one signed remark on People, checked on the finalized chai
9
9
  1. Install the dev dependencies the files import (pin these versions):
10
10
 
11
11
  ```sh
12
- npm install --save-dev @parity/product-lab playwright@1.63.0 @noble/hashes@1.8.0 polkadot-api@3.2.1 @parity/product-sdk-host@0.29.0 vite@7.3.6
12
+ npm install --save-dev @parity/product-lab playwright@1.63.0 @noble/hashes@1.8.0 polkadot-api@3.2.1 @parity/product-sdk-host@0.29.0 vite@7.3.7
13
13
  ```
14
14
 
15
15
  2. Copy `lab.config.ts`, `lab/` and `lab.lock.json` into your repository. Run `npx --no-install product-lab fetch` (it caches the runtime and node binary the lock pins) and `npx --no-install product-lab doctor` (what this machine still lacks, and what fits it). `lab.config.ts` below already runs your people on the released truAPI WASM test host, which `@parity/product-lab` installs (`defaultHost: "truapi-wasm"`, and its modes' hosts): changed from the truAPI CLI host the minimal lab was written for. That host's locked official release takes the lab's local endpoints (https://github.com/paritytech/host-rust-core/pull/1166), so it runs a product's local environment too; `npx --no-install product-lab doctor` says which release is locked and what it provides. No environment variable is required.