@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
@@ -1,11 +1,8 @@
1
1
  // Copyright 2026 Parity Technologies (UK) Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- export async function timedFeatureStep(state, name, index, text, now, redact, work) {
4
- const kept = { featureStep: index + 1, text, status: "running", startedAt: now() };
5
- let steps = state.scenarioSteps.get(name);
6
- if (!steps)
7
- state.scenarioSteps.set(name, (steps = []));
8
- steps.push(kept);
3
+ export async function timedFeatureStep(state, name, index, text, now, redact, work, observe) {
4
+ const kept = startFeatureStep(state, name, index, text, now());
5
+ observe?.recording?.stepStarted(name, kept.startedAt);
9
6
  try {
10
7
  const result = await work();
11
8
  Object.assign(kept, { status: "passed", finishedAt: now() });
@@ -15,6 +12,23 @@ export async function timedFeatureStep(state, name, index, text, now, redact, wo
15
12
  Object.assign(kept, { status: "failed", finishedAt: now(), error: redact(error instanceof Error ? error.message : String(error)) });
16
13
  throw error;
17
14
  }
15
+ finally {
16
+ await observe?.recording?.stepFinished(name, index + 1, observe.identities());
17
+ }
18
+ }
19
+ export function startFeatureStep(state, name, index, text, at) {
20
+ const kept = { featureStep: index + 1, text, status: "running", startedAt: at };
21
+ let steps = state.scenarioSteps.get(name);
22
+ if (!steps)
23
+ state.scenarioSteps.set(name, (steps = []));
24
+ steps.push(kept);
25
+ return kept;
26
+ }
27
+ export async function readyFeatureSurfaces(surface, names, bindings, signal) {
28
+ if (!surface)
29
+ return;
30
+ const ids = [...new Set(names.map((name) => bindings.get(name)).filter((id) => id !== undefined))];
31
+ await Promise.all(ids.map((id) => surface(id)?.ready?.({ signal })));
18
32
  }
19
33
  export async function readIdentities(at, ids) {
20
34
  const read = at.environment.actorReadings;
@@ -10,6 +10,7 @@ import type { PrepareScenarioFixture } from "../fixtures/prepare-scenario-fixtur
10
10
  import type { IsolateScenario } from "../fixtures/isolate-scenario.js";
11
11
  import type { KeepRunStart } from "../fixtures/keep-run-start.js";
12
12
  import type { StepChecks } from "./step-checks.js";
13
+ import type { FeatureRecording } from "./feature-recording.js";
13
14
  export declare class LabFeatureSession implements FeatureSession {
14
15
  private readonly state;
15
16
  private readonly runScenario;
@@ -28,6 +29,7 @@ export declare class LabFeatureSession implements FeatureSession {
28
29
  private readonly clock?;
29
30
  /** Keeps the state a scenario without a fixture starts on, when the lab's setting asks (5 October 2026). */
30
31
  private readonly runStart?;
32
+ private readonly recording?;
31
33
  /** The latest scenario begun under each name (what a pause gate names). */
32
34
  private readonly begun;
33
35
  constructor(state: LabState, runScenario: RunScenario, manageActors: ManageActors, redactor: Redactor, ids: IdGenerator, options: FeatureSessionOptions, conditions?: ReadScenarioConditions | undefined,
@@ -40,7 +42,7 @@ export declare class LabFeatureSession implements FeatureSession {
40
42
  /** Times of new actors created ahead of their steps (scenario-actor-creation.ts). */
41
43
  clock?: Clock | undefined,
42
44
  /** Keeps the state a scenario without a fixture starts on, when the lab's setting asks (5 October 2026). */
43
- runStart?: KeepRunStart | undefined);
45
+ runStart?: KeepRunStart | undefined, recording?: FeatureRecording | undefined);
44
46
  /** The scenario's tags that do not fit the running environment (read once per environment session; unknown: none). */
45
47
  notFor(tags: readonly string[]): Promise<readonly UnmetCondition[]>;
46
48
  sentences(): SentenceView[];
@@ -28,13 +28,14 @@ export class LabFeatureSession {
28
28
  stepChecks;
29
29
  clock;
30
30
  runStart;
31
+ recording;
31
32
  begun = new Map();
32
33
  constructor(state, runScenario, manageActors, redactor, ids, options, conditions,
33
34
  fixtures,
34
35
  isolation,
35
36
  stepChecks,
36
37
  clock,
37
- runStart) {
38
+ runStart, recording) {
38
39
  this.state = state;
39
40
  this.runScenario = runScenario;
40
41
  this.manageActors = manageActors;
@@ -47,6 +48,7 @@ export class LabFeatureSession {
47
48
  this.stepChecks = stepChecks;
48
49
  this.clock = clock;
49
50
  this.runStart = runStart;
51
+ this.recording = recording;
50
52
  }
51
53
  async notFor(tags) {
52
54
  return this.conditions ? this.conditions.unmet(tags) : [];
@@ -83,7 +85,7 @@ export class LabFeatureSession {
83
85
  tags: request.tags ?? [],
84
86
  ...(request.fixtureGroup !== undefined ? { group: request.fixtureGroup } : {}),
85
87
  ...(this.options.fixtures ? { policy: this.options.fixtures } : {}),
86
- setupSession: (fixtureSetup) => new LabFeatureSession(this.state, this.runScenario, this.manageActors, this.redactor, this.ids, { binding: "manual", fixtureSetup, ...(this.options.surface ? { surface: this.options.surface } : {}), ...(this.options.surfaceConditions ? { surfaceConditions: this.options.surfaceConditions } : {}) }, this.conditions, this.fixtures, this.isolation, this.stepChecks, this.clock),
88
+ setupSession: (fixtureSetup) => new LabFeatureSession(this.state, this.runScenario, this.manageActors, this.redactor, this.ids, { binding: "manual", fixtureSetup, ...(this.options.surface ? { surface: this.options.surface } : {}), ...(this.options.surfaceConditions ? { surfaceConditions: this.options.surfaceConditions } : {}) }, this.conditions, this.fixtures, this.isolation, this.stepChecks, this.clock, undefined, this.recording),
87
89
  });
88
90
  const start = fixture || isolatedScenario(request.tags ?? []) ? undefined : await this.runStart?.execute(this.ids.next());
89
91
  const kept = start && "kept" in start ? start.kept : undefined;
@@ -167,6 +169,7 @@ export class LabFeatureSession {
167
169
  ...(race ? { race: race.jitter } : {}),
168
170
  ...(request.outputs?.length ? { outputs: request.outputs } : {}),
169
171
  ...(this.clock ? { clock: this.clock } : {}),
172
+ ...(this.recording ? { recording: this.recording } : {}),
170
173
  ...(request.hosts && Object.keys(request.hosts).length ? { hosts: request.hosts } : {}),
171
174
  });
172
175
  this.begun.set(request.name, scenario);
@@ -9,6 +9,7 @@ import type { PrepareScenarioFixture } from "../fixtures/prepare-scenario-fixtur
9
9
  import type { IsolateScenario } from "../fixtures/isolate-scenario.js";
10
10
  import type { KeepRunStart } from "../fixtures/keep-run-start.js";
11
11
  import type { StepChecks } from "./step-checks.js";
12
+ import type { FeatureRecording } from "./feature-recording.js";
12
13
  export declare class RunFeatureSteps {
13
14
  private readonly state;
14
15
  private readonly runScenario;
@@ -26,6 +27,7 @@ export declare class RunFeatureSteps {
26
27
  private readonly clock?;
27
28
  /** "Keep each fork run's starting state" (5 October 2026). */
28
29
  private readonly runStart?;
30
+ private readonly recording?;
29
31
  constructor(state: LabState, runScenario: RunScenario, manageActors: ManageActors, redactor: Redactor, ids: IdGenerator, conditions?: ReadScenarioConditions | undefined,
30
32
  /** Restores the fixture a scenario's tags name (`@fixture:<name>`) before it is planned (backlog 1b). */
31
33
  fixtures?: PrepareScenarioFixture | undefined,
@@ -36,6 +38,6 @@ export declare class RunFeatureSteps {
36
38
  /** Times of new actors created ahead of their steps (scenario-actor-creation.ts). */
37
39
  clock?: Clock | undefined,
38
40
  /** "Keep each fork run's starting state" (5 October 2026). */
39
- runStart?: KeepRunStart | undefined);
41
+ runStart?: KeepRunStart | undefined, recording?: FeatureRecording | undefined);
40
42
  open(options?: FeatureSessionOptions): LabFeatureSession;
41
43
  }
@@ -13,12 +13,13 @@ export class RunFeatureSteps {
13
13
  stepChecks;
14
14
  clock;
15
15
  runStart;
16
+ recording;
16
17
  constructor(state, runScenario, manageActors, redactor, ids, conditions,
17
18
  fixtures,
18
19
  isolation,
19
20
  stepChecks,
20
21
  clock,
21
- runStart) {
22
+ runStart, recording) {
22
23
  this.state = state;
23
24
  this.runScenario = runScenario;
24
25
  this.manageActors = manageActors;
@@ -30,8 +31,9 @@ export class RunFeatureSteps {
30
31
  this.stepChecks = stepChecks;
31
32
  this.clock = clock;
32
33
  this.runStart = runStart;
34
+ this.recording = recording;
33
35
  }
34
36
  open(options = {}) {
35
- return new LabFeatureSession(this.state, this.runScenario, this.manageActors, this.redactor, this.ids, options, this.conditions, this.fixtures, this.isolation, this.stepChecks, this.clock, this.runStart);
37
+ return new LabFeatureSession(this.state, this.runScenario, this.manageActors, this.redactor, this.ids, options, this.conditions, this.fixtures, this.isolation, this.stepChecks, this.clock, this.runStart, this.recording);
36
38
  }
37
39
  }
@@ -105,7 +105,11 @@ export function labRuntimeUseCases(ports) {
105
105
  const stepChecks = new StepChecks(state, frameSources, ports.stepChecks ?? {}, redactor);
106
106
  const manageLabSettings = new ManageLabSettings(ports.labSettings);
107
107
  const keepRunStart = new KeepRunStart(state, ports.fixtures, manageLabSettings, saveFixture, ports.clock);
108
- const runFeatureSteps = new RunFeatureSteps(state, runScenario, manageActors, redactor, ports.ids, readScenarioConditions, prepareScenarioFixture, isolateScenario, stepChecks, ports.clock, keepRunStart);
108
+ const runFeatureSteps = new RunFeatureSteps(state, runScenario, manageActors, redactor, ports.ids, readScenarioConditions, prepareScenarioFixture, isolateScenario, stepChecks, ports.clock, keepRunStart, {
109
+ stepStarted: (scenario, at) => recordRuns.stepStarted(scenario, at),
110
+ stepFinished: (scenario, step, identities) => recordRuns.stepFinished(scenario, step, identities),
111
+ scenarioEnded: (scenario) => recordRuns.scenarioEnded(scenario),
112
+ });
109
113
  const manageBreakpoints = new ManageBreakpoints(listFeatures, ports.breakpoints);
110
114
  const lintLab = new LintLab(state, listFeatures, ports.featureRunner, redactor, ports.clientBundles);
111
115
  const runFeatures = new RunFeatures(state, ports.featureRunner, listFeatures, ports.clock, ports.ids, redactor, judgeNegativeControl, policy, runFeatureSteps, readScenarioConditions, events, manageBreakpoints, lintLab);
@@ -37,6 +37,13 @@ export declare function waitLabelHtml(label: string): string;
37
37
  export declare function footerHtml(text: string): string;
38
38
  /** A failed run's banner, on every frame: the demo never reads as a pass. */
39
39
  export declare function failureBannerHtml(text: string): string;
40
+ /** A complete held frame. The app keeps a 1280×720 area; all text lives in the separate band below it. */
41
+ export declare function demoFrameHtml(image: string, caption: {
42
+ number: number;
43
+ keyword: string;
44
+ text: string;
45
+ note?: string;
46
+ }, phase: GherkinPhase, footer: string, banner?: string): string;
40
47
  export interface DemoSlide {
41
48
  title: string;
42
49
  feature: string;
@@ -60,6 +67,7 @@ export interface DemoSlide {
60
67
  recordedAt: string;
61
68
  /** The last settled screen, as a data URL. */
62
69
  image?: string;
70
+ imageNote?: string;
63
71
  banner?: string;
64
72
  }
65
73
  /** The demo's slide (1600×900): the scenario, each step and its outcome, who and where, and the last screen. */
@@ -56,6 +56,16 @@ export function failureBannerHtml(text) {
56
56
  return page(`body{background:transparent;height:100vh}
57
57
  .banner{position:absolute;top:0;left:0;right:0;background:var(--danger);color:#fff;font-weight:640;font-size:18px;text-align:center;padding:10px 20px}`, `<div class="banner">${escapeHtml(text)}</div>`);
58
58
  }
59
+ export function demoFrameHtml(image, caption, phase, footer, banner) {
60
+ return page(`body{width:1280px;height:960px;background:var(--canvas);display:grid;grid-template-rows:720px 240px}
61
+ .app{width:1280px;height:720px;display:flex;align-items:center;justify-content:center;background:var(--canvas);overflow:hidden}
62
+ .app img{width:100%;height:100%;object-fit:contain;display:block}
63
+ .band{height:240px;border-top:1px solid var(--line);padding:12px 24px;display:flex;flex-direction:column;gap:7px;overflow:hidden;background:var(--surface)}
64
+ .row{display:flex;gap:12px;align-items:baseline;font-size:23px;line-height:1.25}
65
+ .n{font:600 16px var(--font-mono);color:var(--secondary)}.k{font-weight:700;color:var(--phase-${phase})}.t{font-weight:540}
66
+ .note{font-size:17px;line-height:1.2;color:var(--secondary)}.failure{font-size:17px;line-height:1.2;color:var(--danger);font-weight:700}
67
+ .footer{margin-top:auto;font-size:12px;line-height:1.2;color:var(--secondary)}.brand{color:var(--accent);font-weight:700}`, `<div class="app"><img src="${escapeHtml(image)}" alt="Recorded app state"></div><div class="band">${banner ? `<div class="failure">${escapeHtml(banner)}</div>` : ""}<div class="row"><span class="n">${caption.number}</span><span class="k">${escapeHtml(caption.keyword)}</span><span class="t">${escapeHtml(caption.text)}</span></div>${caption.note ? `<div class="note">${escapeHtml(caption.note)}</div>` : ""}<div class="footer"><span class="brand">product·lab</span> · ${escapeHtml(footer)} · recorded still</div></div>`);
68
+ }
59
69
  const seconds = (value) => `${value < 10 ? value.toFixed(1) : value.toFixed(0)} s`;
60
70
  export function slideHtml(slide) {
61
71
  const phases = gherkinPhases(slide.steps.map((step) => step.keyword));
@@ -86,5 +96,5 @@ dl{display:grid;grid-template-columns:120px 1fr;gap:6px 14px;margin:28px 0 0;fon
86
96
  .shot img{max-width:100%;max-height:786px;display:block}
87
97
  .empty{color:var(--secondary);padding:40px;font-size:15px}
88
98
  .foot{font-size:12px;color:var(--secondary);margin-top:20px}
89
- .banner{grid-column:1/-1;background:var(--danger);color:#fff;border-radius:var(--radius-md);padding:10px 16px;font-weight:640;margin:-28px 0 0}`, `${slide.banner ? `<div class="banner">${escapeHtml(slide.banner)}</div>` : ""}<section><div class="brand">product·lab</div><h1>${escapeHtml(slide.title)}</h1><div class="feature">${escapeHtml(slide.feature)}</div><span class="status ${slide.status}">${slide.status === "passed" ? "Passed" : "Failed"}</span><ol>${rows}</ol><dl>${facts.map(([name, value]) => `<dt>${escapeHtml(name)}</dt><dd>${escapeHtml(value)}</dd>`).join("")}</dl><div class="foot">Recorded run ${escapeHtml(slide.recording)}, ${escapeHtml(slide.recordedAt)}. Screens are the apps as they painted themselves.</div></section><div class="shot">${slide.image ? `<img src="${slide.image}" alt="The last screen">` : `<div class="empty">No screenshot of the last screen was recorded.</div>`}</div>`);
99
+ .banner{grid-column:1/-1;background:var(--danger);color:#fff;border-radius:var(--radius-md);padding:10px 16px;font-weight:640;margin:-28px 0 0}`, `${slide.banner ? `<div class="banner">${escapeHtml(slide.banner)}</div>` : ""}<section><div class="brand">product·lab</div><h1>${escapeHtml(slide.title)}</h1><div class="feature">${escapeHtml(slide.feature)}</div><span class="status ${slide.status}">${slide.status === "passed" ? "Passed" : "Failed"}</span><ol>${rows}</ol><dl>${facts.map(([name, value]) => `<dt>${escapeHtml(name)}</dt><dd>${escapeHtml(value)}</dd>`).join("")}</dl><div class="foot">Recorded run ${escapeHtml(slide.recording)}, ${escapeHtml(slide.recordedAt)}. Screens are the apps as they painted themselves.${slide.imageNote ? ` ${escapeHtml(slide.imageNote)}` : ""}</div></section><div class="shot">${slide.image ? `<img src="${slide.image}" alt="${slide.imageNote ? "Last available recorded frame" : "The last screen"}">` : `<div class="empty">No screenshot of the last screen was recorded.</div>`}</div>`);
90
100
  }
@@ -26,7 +26,5 @@ export declare class DemoRecording {
26
26
  private video;
27
27
  /** The scenario's Gherkin, as the .feature file writes it. */
28
28
  private scenario;
29
- /** The recording's last after-frame of the video's identity (or any), when stored. */
30
- private lastFrame;
31
29
  private failure;
32
30
  }
@@ -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;