@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +8 -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 +1 -1
  7. package/adapters/features/cucumber/README.md +4 -0
  8. package/adapters/features/cucumber/src/steps/register-feature-steps.js +15 -1
  9. package/adapters/system/node/README.md +3 -3
  10. package/adapters/video/ffmpeg/README.md +4 -0
  11. package/adapters/video/ffmpeg/src/ffmpeg-video-editor.d.ts +1 -1
  12. package/adapters/video/ffmpeg/src/ffmpeg-video-editor.js +19 -6
  13. package/core/application/README.md +30 -1
  14. package/core/application/src/features/direct-feature-steps.d.ts +23 -0
  15. package/core/application/src/features/direct-feature-steps.js +25 -0
  16. package/core/application/src/features/feature-recording.d.ts +6 -0
  17. package/core/application/src/features/feature-recording.js +3 -0
  18. package/core/application/src/features/feature-scenario.d.ts +10 -8
  19. package/core/application/src/features/feature-scenario.js +11 -15
  20. package/core/application/src/features/feature-step-times.d.ts +11 -1
  21. package/core/application/src/features/feature-step-times.js +20 -6
  22. package/core/application/src/features/lab-feature-session.d.ts +3 -1
  23. package/core/application/src/features/lab-feature-session.js +5 -2
  24. package/core/application/src/features/run-feature-steps.d.ts +3 -1
  25. package/core/application/src/features/run-feature-steps.js +4 -2
  26. package/core/application/src/lab-runtime-use-cases.js +5 -1
  27. package/core/application/src/recordings/demo-markup.d.ts +8 -0
  28. package/core/application/src/recordings/demo-markup.js +11 -1
  29. package/core/application/src/recordings/demo-recording.d.ts +0 -2
  30. package/core/application/src/recordings/demo-recording.js +40 -23
  31. package/core/application/src/recordings/frame-sources.d.ts +20 -1
  32. package/core/application/src/recordings/frame-sources.js +89 -0
  33. package/core/application/src/recordings/record-runs.d.ts +7 -1
  34. package/core/application/src/recordings/record-runs.js +66 -11
  35. package/core/application/src/recordings/recording-draft.d.ts +4 -0
  36. package/core/application/src/recordings/recording-draft.js +13 -4
  37. package/core/domain/README.md +14 -0
  38. package/core/domain/src/recordings/demo-cut.js +6 -4
  39. package/core/domain/src/recordings/demo-holds.d.ts +12 -0
  40. package/core/domain/src/recordings/demo-holds.js +31 -0
  41. package/core/domain/src/recordings/index.d.ts +1 -0
  42. package/core/domain/src/recordings/index.js +1 -0
  43. package/core/domain/src/recordings/recording-data.d.ts +9 -1
  44. package/core/domain/src/recordings/recording-settings.d.ts +5 -1
  45. package/core/domain/src/recordings/recording-settings.js +7 -0
  46. package/core/domain/src/recordings/run-recording.d.ts +2 -0
  47. package/core/domain/src/recordings/run-recording.js +8 -1
  48. package/core/ports/README.md +6 -0
  49. package/core/ports/src/driven/recording/demo-ports.d.ts +2 -0
  50. package/core/ports/src/driven/recording/frame-capture.d.ts +4 -0
  51. package/core/ports/src/driving/demo.d.ts +2 -0
  52. package/core/ports/src/driving/feature-session.d.ts +7 -0
  53. package/entrypoints/cli/README.md +6 -3
  54. package/entrypoints/cli/src/demo-command.d.ts +1 -1
  55. package/entrypoints/cli/src/demo-command.js +10 -5
  56. package/entrypoints/mcp/README.md +3 -0
  57. package/entrypoints/panel/README.md +6 -3
  58. package/entrypoints/panel/src/visible/detached-identities.js +5 -1
  59. package/entrypoints/panel/src/visible/window-frames.js +14 -1
  60. package/package.json +1 -1
@@ -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.
@@ -1,4 +1,4 @@
1
1
  import type { DemoReport, LabRuntime } from "@parity/product-lab/ports/driving";
2
- export declare const DEMO_USAGE = "Usage: product-lab demo [<recording-id>] [--actor <role> | --window] [--out <dir>] [--max-seconds 60]\n [--format mp4|webm] [--plan | --slide-only] [--failed] [--json] [--config lab.config.ts]\n\nA demo of a recorded feature scenario: its video cut to about a minute, every action at real speed with its result\nheld, waits sped up and labelled (\u00D78 waiting for finality), a caption per Gherkin step, plus demo.vtt (the captions),\ndemo.json (the edit, segment by segment) and a slide (slide.png, slide.html: the scenario, its steps, who and where,\nthe last screen). Record a run with video first (product-lab test --video, or the lab window's recording).\n <recording-id> default: the newest passed feature recording\n --actor <role> cut that actor's app video (default: the lab window's, else the actor in most steps)\n --window cut the lab window's video\n --out <dir> default: <lab>/.product-lab/demos/<recording-id>\n --max-seconds <n> the longest the cut should be (default 60); over it the waits speed up first\n --format mp4|webm default mp4 (H.264); webm is VP9\n --plan only demo.json and demo.vtt (needs no ffmpeg)\n --slide-only the plan and the slide, no video (needs no ffmpeg)\n --failed allow a failed run: every frame and the slide say it failed\n --json print the report as JSON\n The video needs ffmpeg and ffprobe on PATH (or PRODUCT_LAB_FFMPEG naming their folder); product-lab never bundles them.\n Exit code: 0 made, 2 refused (no such recording, no video, a failed run without --failed, no ffmpeg) or bad arguments,\n 1 when the lab does not load or the cut fails.";
2
+ export declare const DEMO_USAGE = "Usage: product-lab demo [<recording-id>] [--actor <role> | --window] [--out <dir>] [--max-seconds 60] [--hold-seconds 2]\n [--format mp4|webm] [--plan | --slide-only] [--failed] [--json] [--config lab.config.ts]\n\nA demo of a recorded feature scenario: its real after-step images held for at least 2 seconds each,\nwith captions in a band under the app and step-001.png (one encoded midpoint PNG per step), plus demo.vtt (the captions),\ndemo.json (the edit, segment by segment) and a slide (slide.png, slide.html: the scenario, its steps, who and where,\nthe last screen). Record a run with video first (product-lab test --video, or the lab window's recording).\n <recording-id> default: the newest passed feature recording\n --actor <role> cut that actor's app video (default: the lab window's, else the actor in most steps)\n --window cut the lab window's video\n --out <dir> default: <lab>/.product-lab/demos/<recording-id>\n --max-seconds <n> the target length (default 60); never shortens the step holds\n --hold-seconds <n> hold each after-step image (default 2, minimum 2 seconds)\n --format mp4|webm default mp4 (H.264); webm is VP9\n --plan only demo.json and demo.vtt (needs no ffmpeg)\n --slide-only the plan and the slide, no video (needs no ffmpeg)\n --failed allow a failed run: every frame and the slide say it failed\n --json print the report as JSON\n The video needs ffmpeg and ffprobe on PATH (or PRODUCT_LAB_FFMPEG naming their folder); product-lab never bundles them.\n Exit code: 0 made, 2 refused (no such recording, no video, a failed run without --failed, no ffmpeg) or bad arguments,\n 1 when the lab does not load or the cut fails.";
3
3
  export declare function renderDemo(report: DemoReport): string;
4
4
  export declare function runDemoCommand(runtime: LabRuntime, argv: string[], signal?: AbortSignal, out?: (text: string) => void, err?: (text: string) => void): Promise<number>;
@@ -2,18 +2,19 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { parseArgs } from "node:util";
4
4
  import { DemoRefusedError } from "@parity/product-lab/domain/recordings";
5
- export const DEMO_USAGE = `Usage: product-lab demo [<recording-id>] [--actor <role> | --window] [--out <dir>] [--max-seconds 60]
5
+ export const DEMO_USAGE = `Usage: product-lab demo [<recording-id>] [--actor <role> | --window] [--out <dir>] [--max-seconds 60] [--hold-seconds 2]
6
6
  [--format mp4|webm] [--plan | --slide-only] [--failed] [--json] [--config lab.config.ts]
7
7
 
8
- A demo of a recorded feature scenario: its video cut to about a minute, every action at real speed with its result
9
- held, waits sped up and labelled (×8 waiting for finality), a caption per Gherkin step, plus demo.vtt (the captions),
8
+ A demo of a recorded feature scenario: its real after-step images held for at least 2 seconds each,
9
+ with captions in a band under the app and step-001.png (one encoded midpoint PNG per step), plus demo.vtt (the captions),
10
10
  demo.json (the edit, segment by segment) and a slide (slide.png, slide.html: the scenario, its steps, who and where,
11
11
  the last screen). Record a run with video first (product-lab test --video, or the lab window's recording).
12
12
  <recording-id> default: the newest passed feature recording
13
13
  --actor <role> cut that actor's app video (default: the lab window's, else the actor in most steps)
14
14
  --window cut the lab window's video
15
15
  --out <dir> default: <lab>/.product-lab/demos/<recording-id>
16
- --max-seconds <n> the longest the cut should be (default 60); over it the waits speed up first
16
+ --max-seconds <n> the target length (default 60); never shortens the step holds
17
+ --hold-seconds <n> hold each after-step image (default 2, minimum 2 seconds)
17
18
  --format mp4|webm default mp4 (H.264); webm is VP9
18
19
  --plan only demo.json and demo.vtt (needs no ffmpeg)
19
20
  --slide-only the plan and the slide, no video (needs no ffmpeg)
@@ -48,6 +49,7 @@ export async function runDemoCommand(runtime, argv, signal, out = (text) => proc
48
49
  window: { type: "boolean" },
49
50
  out: { type: "string" },
50
51
  "max-seconds": { type: "string" },
52
+ "hold-seconds": { type: "string" },
51
53
  format: { type: "string" },
52
54
  plan: { type: "boolean" },
53
55
  "slide-only": { type: "boolean" },
@@ -66,12 +68,14 @@ export async function runDemoCommand(runtime, argv, signal, out = (text) => proc
66
68
  return 0;
67
69
  }
68
70
  const maxSeconds = values["max-seconds"] === undefined ? undefined : Number(values["max-seconds"]);
71
+ const holdSeconds = values["hold-seconds"] === undefined ? undefined : Number(values["hold-seconds"]);
69
72
  const problem = positionals.length > 1 ? `Unexpected arguments: ${positionals.slice(1).join(" ")}`
70
73
  : values.actor !== undefined && values.window ? "--actor and --window cannot be used together"
71
74
  : values.plan && values["slide-only"] ? "--plan and --slide-only cannot be used together"
72
75
  : values.format !== undefined && values.format !== "mp4" && values.format !== "webm" ? `--format must be mp4 or webm, not ${values.format}`
73
76
  : maxSeconds !== undefined && !(Number.isFinite(maxSeconds) && maxSeconds >= 5) ? `--max-seconds must be a number of seconds, at least 5, not ${values["max-seconds"]}`
74
- : undefined;
77
+ : holdSeconds !== undefined && !(Number.isFinite(holdSeconds) && holdSeconds >= 2) ? `--hold-seconds must be a number of seconds, at least 2, not ${values["hold-seconds"]}`
78
+ : undefined;
75
79
  if (problem) {
76
80
  err(`${problem}\n${DEMO_USAGE}\n`);
77
81
  return 2;
@@ -84,6 +88,7 @@ export async function runDemoCommand(runtime, argv, signal, out = (text) => proc
84
88
  ...(values.window ? { window: true } : {}),
85
89
  ...(values.out !== undefined ? { out: values.out } : {}),
86
90
  ...(maxSeconds !== undefined ? { maxSeconds } : {}),
91
+ ...(holdSeconds !== undefined ? { holdSeconds } : {}),
87
92
  ...(values.format ? { format: values.format } : {}),
88
93
  ...(values.plan ? { planOnly: true } : {}),
89
94
  ...(values["slide-only"] ? { slideOnly: true } : {}),