@parity/product-lab 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +27 -0
- package/adapters/browser/playwright/README.md +2 -0
- package/adapters/browser/playwright/src/page-frame-capture.js +10 -2
- package/adapters/chain/papi/README.md +3 -1
- package/adapters/environment/engine/README.md +22 -4
- package/adapters/environment/engine/src/engine/engine-config.d.ts +1 -0
- package/adapters/environment/engine/src/engine/fork-bundle.d.ts +1 -1
- package/adapters/environment/engine/src/engine/fork-bundle.js +2 -2
- package/adapters/environment/engine/src/engine/fork-refresh.d.ts +4 -7
- package/adapters/environment/engine/src/engine/fork-refresh.js +52 -23
- package/adapters/environment/engine/src/engine/published-bundle.d.ts +19 -15
- package/adapters/environment/engine/src/engine/published-bundle.js +44 -51
- package/adapters/environment/engine/src/saved-fork/saved-fork-tooling.d.ts +1 -1
- package/adapters/environment/engine/src/saved-fork/saved-fork-tooling.js +5 -2
- package/adapters/environment/engine/src/saved-fork/saved-fork.js +1 -1
- package/adapters/environment/engine/src/setup/engine-build.d.ts +4 -4
- package/adapters/environment/engine/src/setup/engine-build.js +14 -4
- package/adapters/environment/engine/src/setup/patch-catalogue.d.ts +6 -0
- package/adapters/environment/engine/src/setup/patch-catalogue.js +5 -0
- package/adapters/environment/engine/src/setup/prepare-engine.d.ts +6 -5
- package/adapters/environment/engine/src/setup/prepare-engine.js +27 -10
- package/adapters/features/cucumber/README.md +4 -0
- package/adapters/features/cucumber/src/steps/register-feature-steps.js +15 -1
- package/adapters/system/node/README.md +3 -3
- package/adapters/video/ffmpeg/README.md +4 -0
- package/adapters/video/ffmpeg/src/ffmpeg-video-editor.d.ts +1 -1
- package/adapters/video/ffmpeg/src/ffmpeg-video-editor.js +19 -6
- package/core/application/README.md +30 -1
- package/core/application/src/features/direct-feature-steps.d.ts +23 -0
- package/core/application/src/features/direct-feature-steps.js +25 -0
- package/core/application/src/features/feature-recording.d.ts +6 -0
- package/core/application/src/features/feature-recording.js +3 -0
- package/core/application/src/features/feature-scenario.d.ts +10 -8
- package/core/application/src/features/feature-scenario.js +11 -15
- package/core/application/src/features/feature-step-times.d.ts +11 -1
- package/core/application/src/features/feature-step-times.js +20 -6
- package/core/application/src/features/lab-feature-session.d.ts +3 -1
- package/core/application/src/features/lab-feature-session.js +5 -2
- package/core/application/src/features/run-feature-steps.d.ts +3 -1
- package/core/application/src/features/run-feature-steps.js +4 -2
- package/core/application/src/lab-runtime-use-cases.js +5 -1
- package/core/application/src/recordings/demo-markup.d.ts +8 -0
- package/core/application/src/recordings/demo-markup.js +11 -1
- package/core/application/src/recordings/demo-recording.d.ts +0 -2
- package/core/application/src/recordings/demo-recording.js +40 -23
- package/core/application/src/recordings/frame-sources.d.ts +20 -1
- package/core/application/src/recordings/frame-sources.js +89 -0
- package/core/application/src/recordings/record-runs.d.ts +7 -1
- package/core/application/src/recordings/record-runs.js +66 -11
- package/core/application/src/recordings/recording-draft.d.ts +4 -0
- package/core/application/src/recordings/recording-draft.js +13 -4
- package/core/domain/README.md +14 -0
- package/core/domain/src/recordings/demo-cut.js +6 -4
- package/core/domain/src/recordings/demo-holds.d.ts +12 -0
- package/core/domain/src/recordings/demo-holds.js +31 -0
- package/core/domain/src/recordings/index.d.ts +1 -0
- package/core/domain/src/recordings/index.js +1 -0
- package/core/domain/src/recordings/recording-data.d.ts +9 -1
- package/core/domain/src/recordings/recording-settings.d.ts +5 -1
- package/core/domain/src/recordings/recording-settings.js +7 -0
- package/core/domain/src/recordings/run-recording.d.ts +2 -0
- package/core/domain/src/recordings/run-recording.js +8 -1
- package/core/ports/README.md +6 -0
- package/core/ports/src/driven/recording/demo-ports.d.ts +2 -0
- package/core/ports/src/driven/recording/frame-capture.d.ts +4 -0
- package/core/ports/src/driving/demo.d.ts +2 -0
- package/core/ports/src/driving/feature-session.d.ts +7 -0
- package/entrypoints/cli/README.md +6 -3
- package/entrypoints/cli/src/demo-command.d.ts +1 -1
- package/entrypoints/cli/src/demo-command.js +10 -5
- package/entrypoints/mcp/README.md +3 -0
- package/entrypoints/panel/README.md +6 -3
- package/entrypoints/panel/src/visible/detached-identities.js +5 -1
- package/entrypoints/panel/src/visible/window-frames.js +14 -1
- package/package.json +1 -1
|
@@ -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,
|
|
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 {
|
|
6
|
-
|
|
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
|
|
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
|
|
107
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
210
|
-
const
|
|
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 {
|
|
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
|
|
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(() =>
|
|
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, {
|
|
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 =
|
|
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("
|
|
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 } : {}),
|
package/core/domain/README.md
CHANGED
|
@@ -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
|
|
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
|
+
}
|
|
@@ -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
|
-
/**
|
|
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;
|