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