@parity/product-lab 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -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 +3 -3
- package/adapters/environment/engine/src/engine/fork-refresh.js +72 -1
- 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 +5 -5
- 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/ai-kit/skills/product-lab-integration/references/notes-lab.md +1 -1
- package/entrypoints/cli/ai-kit/skills/product-lab-setup/SKILL.md +1 -0
- package/entrypoints/cli/ai-kit/skills/product-lab-setup/references/minimal-lab.md +1 -1
- 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
|
@@ -8,7 +8,9 @@ import type { ManageActors } from "../actors/manage-actors.js";
|
|
|
8
8
|
import type { LabState } from "../lab/lab-state.js";
|
|
9
9
|
import type { RunScenario } from "../runs/run-scenario.js";
|
|
10
10
|
import type { ScenarioStepChecks } from "./scenario-step-checks.js";
|
|
11
|
+
import type { FeatureRecording } from "./feature-recording.js";
|
|
11
12
|
export interface ScenarioDependencies {
|
|
13
|
+
recording?: FeatureRecording;
|
|
12
14
|
id: string;
|
|
13
15
|
state: LabState;
|
|
14
16
|
runScenario: RunScenario;
|
|
@@ -61,6 +63,7 @@ export declare class FeatureScenario implements FeatureScenarioHandle {
|
|
|
61
63
|
private readonly ownedRuns;
|
|
62
64
|
/** New actors created ahead of their steps (scenario-actor-creation.ts). */
|
|
63
65
|
private readonly creation;
|
|
66
|
+
private readonly directSteps;
|
|
64
67
|
/** Backlog 31: the controls this scenario set a condition through, by identity: each is reset when it ends. */
|
|
65
68
|
private readonly conditioned;
|
|
66
69
|
readonly outputs: StepOutputs;
|
|
@@ -78,22 +81,21 @@ export declare class FeatureScenario implements FeatureScenarioHandle {
|
|
|
78
81
|
/** What a paused run shows about this scenario (backlog 17; scenario-inspection.ts). */
|
|
79
82
|
inspect(next: number): ScenarioInspection;
|
|
80
83
|
/** Every output recorded so far, the ones the scenario began with included (backlog 21). */
|
|
81
|
-
recorded()
|
|
82
|
-
trace()
|
|
84
|
+
recorded: () => readonly RecordedOutput[];
|
|
85
|
+
trace: () => readonly FeatureStepTrace[];
|
|
83
86
|
run(index: number): Promise<FeatureStepTrace>;
|
|
84
87
|
private runStep;
|
|
88
|
+
startStep: (index: number) => void;
|
|
89
|
+
finishStep: (index: number, outcome: {
|
|
90
|
+
status: "passed" | "failed";
|
|
91
|
+
error?: string;
|
|
92
|
+
}) => Promise<void>;
|
|
85
93
|
private stepBody;
|
|
86
94
|
/**
|
|
87
95
|
* Backlog 40: after a step passed, each actor that acted in it is checked (a screenshot against its baseline, an
|
|
88
96
|
* accessibility scan); what the checks found is kept in the step's trace, and a failure fails the step.
|
|
89
97
|
*/
|
|
90
98
|
private checkStep;
|
|
91
|
-
/**
|
|
92
|
-
* A run that shows each actor's app on a surface of its own (the lab window's tiles): an identity call ends once each
|
|
93
|
-
* of its actors' surfaces shows the app ready, so no later step acts on, or conditions, an app that has not loaded.
|
|
94
|
-
* The session readied its own page; a tile is another document of the same app.
|
|
95
|
-
*/
|
|
96
|
-
private surfacesReady;
|
|
97
99
|
/**
|
|
98
100
|
* `{actor} is a new actor` / `these actors exist:`: create an identity per new name (manual mode reuses a bound
|
|
99
101
|
* one). When the environment creates several identities at once (`actorCreation.concurrency`), the new names of
|
|
@@ -14,8 +14,9 @@ import { RunScopedValues } from "./run-scoped-values.js";
|
|
|
14
14
|
import { ConditionProfiles, ConditionStep, describeNetwork } from "@parity/product-lab/domain/conditions";
|
|
15
15
|
import { conditionControl } from "./scenario-conditions.js";
|
|
16
16
|
import { raceStepDelay } from "./scenario-race.js";
|
|
17
|
-
import { readIdentities, timedFeatureStep } from "./feature-step-times.js";
|
|
17
|
+
import { readIdentities, readyFeatureSurfaces, timedFeatureStep } from "./feature-step-times.js";
|
|
18
18
|
import { ScenarioActorCreation } from "./scenario-actor-creation.js";
|
|
19
|
+
import { DirectFeatureSteps } from "./direct-feature-steps.js";
|
|
19
20
|
const NAVIGATION_WAIT_MS = 30_000;
|
|
20
21
|
export class FeatureScenario {
|
|
21
22
|
name;
|
|
@@ -38,6 +39,7 @@ export class FeatureScenario {
|
|
|
38
39
|
inFlight = new Set();
|
|
39
40
|
ownedRuns = new Set();
|
|
40
41
|
creation;
|
|
42
|
+
directSteps;
|
|
41
43
|
conditioned = new Map();
|
|
42
44
|
outputs;
|
|
43
45
|
fixture;
|
|
@@ -59,6 +61,7 @@ export class FeatureScenario {
|
|
|
59
61
|
this.outputs = this.view(undefined);
|
|
60
62
|
this.creation = new ScenarioActorCreation({ steps: plan.steps, manageActors: deps.manageActors, ...(deps.clock ? { clock: deps.clock } : {}), signal: this.abort.signal, environment: () => this.active.environment, bound: (name) => this.bindings.has(name), conditions: () => this.conditionedNames(), created: (id) => { if (this.binding === "fresh")
|
|
61
63
|
this.created.push(id); }, chosenHost: (name) => deps.hosts?.[name], scenario: this.name });
|
|
64
|
+
this.directSteps = new DirectFeatureSteps({ state: deps.state, scenario: name, now: () => deps.runScenario.clock.date().getTime(), redact: (text) => deps.redactor.text(text), identities: () => this.identities(), ...(deps.recording ? { recording: deps.recording } : {}), plan, ended: () => this.ended, ran: this.ran });
|
|
62
65
|
}
|
|
63
66
|
current() {
|
|
64
67
|
const active = this.deps.state.active;
|
|
@@ -90,12 +93,8 @@ export class FeatureScenario {
|
|
|
90
93
|
inspect(next) {
|
|
91
94
|
return inspectScenario(this.plan, next, this.store, this.bindings, this.deps.redactor);
|
|
92
95
|
}
|
|
93
|
-
recorded()
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
trace() {
|
|
97
|
-
return this.deps.redactor.value(this.steps);
|
|
98
|
-
}
|
|
96
|
+
recorded = () => this.store.entries();
|
|
97
|
+
trace = () => this.deps.redactor.value(this.steps);
|
|
99
98
|
run(index) {
|
|
100
99
|
if (this.ended)
|
|
101
100
|
return Promise.reject(new Error(`Scenario "${this.name}" has ended`));
|
|
@@ -117,8 +116,10 @@ export class FeatureScenario {
|
|
|
117
116
|
this.ran.add(index);
|
|
118
117
|
this.current();
|
|
119
118
|
const now = () => this.deps.runScenario.clock.date().getTime();
|
|
120
|
-
return timedFeatureStep(this.deps.state, this.name, index, step.text, now, (text) => this.deps.redactor.text(text), () => this.stepBody(index, step));
|
|
119
|
+
return timedFeatureStep(this.deps.state, this.name, index, step.text, now, (text) => this.deps.redactor.text(text), () => this.stepBody(index, step), { recording: this.deps.recording, identities: () => this.identities() });
|
|
121
120
|
}
|
|
121
|
+
startStep = (index) => this.directSteps.start(index);
|
|
122
|
+
finishStep = (index, outcome) => this.directSteps.finish(index, outcome);
|
|
122
123
|
async stepBody(index, step) {
|
|
123
124
|
const calls = step.calls.map((call) => ({
|
|
124
125
|
owner: call.entry.owner,
|
|
@@ -158,7 +159,7 @@ export class FeatureScenario {
|
|
|
158
159
|
await flush();
|
|
159
160
|
try {
|
|
160
161
|
await this.identity(call, calls[position], index);
|
|
161
|
-
await this.
|
|
162
|
+
await readyFeatureSurfaces(this.deps.surface, introducedNames(call), this.bindings, this.abort.signal);
|
|
162
163
|
calls[position].status = "complete";
|
|
163
164
|
}
|
|
164
165
|
catch (error) {
|
|
@@ -187,12 +188,6 @@ export class FeatureScenario {
|
|
|
187
188
|
if (failures.length)
|
|
188
189
|
throw FeatureStepError.of(`Step ${index + 1} ("${step.text}"): ${failures.join("; ")}`, this.deps.redactor.value(trace), reasons?.[0]);
|
|
189
190
|
}
|
|
190
|
-
async surfacesReady(names) {
|
|
191
|
-
if (!this.deps.surface)
|
|
192
|
-
return;
|
|
193
|
-
const ids = [...new Set(names.map((name) => this.bindings.get(name)).filter((id) => id !== undefined))];
|
|
194
|
-
await Promise.all(ids.map((id) => this.deps.surface(id)?.ready?.({ signal: this.abort.signal })));
|
|
195
|
-
}
|
|
196
191
|
async identity(call, trace, index) {
|
|
197
192
|
const names = introducedNames(call);
|
|
198
193
|
const unfunded = call.entry.owner === NEW_UNFUNDED_ACTOR_OWNER;
|
|
@@ -572,6 +567,7 @@ export class FeatureScenario {
|
|
|
572
567
|
run.interrupt(reason);
|
|
573
568
|
await Promise.allSettled([...this.inFlight]);
|
|
574
569
|
await this.creation.settled();
|
|
570
|
+
await this.deps.recording?.scenarioEnded(this.name);
|
|
575
571
|
await this.deps.state.beforeClose.settle();
|
|
576
572
|
const errors = [];
|
|
577
573
|
for (const [actorId, control] of this.conditioned) {
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
import type { RecordedFeatureStep } from "@parity/product-lab/domain/recordings";
|
|
1
2
|
import type { LabEnvironment } from "@parity/product-lab/ports/lab";
|
|
2
3
|
import type { LabState } from "../lab/lab-state.js";
|
|
4
|
+
import type { FeatureRecording } from "./feature-recording.js";
|
|
5
|
+
import type { FeatureSessionOptions } from "@parity/product-lab/ports/driving";
|
|
3
6
|
/** Run step `index` (`work`) of scenario `name`, keeping its times and outcome; `redact` cleans a failure's message. */
|
|
4
|
-
export declare function timedFeatureStep<T>(state: LabState, name: string, index: number, text: string, now: () => number, redact: (text: string) => string, work: () => Promise<T
|
|
7
|
+
export declare function timedFeatureStep<T>(state: LabState, name: string, index: number, text: string, now: () => number, redact: (text: string) => string, work: () => Promise<T>, observe?: {
|
|
8
|
+
recording?: FeatureRecording;
|
|
9
|
+
identities: () => string[];
|
|
10
|
+
}): Promise<T>;
|
|
11
|
+
/** Direct/custom definitions use the same timing data as steps run through the scheduler. */
|
|
12
|
+
export declare function startFeatureStep(state: LabState, name: string, index: number, text: string, at: number): RecordedFeatureStep;
|
|
13
|
+
/** A visible tile is a separate document from the session's own page; ready it before later steps use it. */
|
|
14
|
+
export declare function readyFeatureSurfaces(surface: FeatureSessionOptions["surface"], names: readonly string[], bindings: ReadonlyMap<string, string>, signal: AbortSignal): Promise<void>;
|
|
5
15
|
/**
|
|
6
16
|
* Backlog 160: what the environment reads of the identities a step made, on chain and on their screens, kept as the
|
|
7
17
|
* step's checks (`LabState.featureStepReadings`). A reading the environment cannot take fails the step: a new identity
|
|
@@ -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 =
|
|
5
|
-
|
|
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
|
|
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,
|
|
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
|
+
}
|