@parity/product-lab 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +27 -0
  3. package/adapters/browser/playwright/README.md +2 -0
  4. package/adapters/browser/playwright/src/page-frame-capture.js +10 -2
  5. package/adapters/chain/papi/README.md +3 -1
  6. package/adapters/environment/engine/README.md +22 -4
  7. package/adapters/environment/engine/src/engine/engine-config.d.ts +1 -0
  8. package/adapters/environment/engine/src/engine/fork-bundle.d.ts +1 -1
  9. package/adapters/environment/engine/src/engine/fork-bundle.js +2 -2
  10. package/adapters/environment/engine/src/engine/fork-refresh.d.ts +4 -7
  11. package/adapters/environment/engine/src/engine/fork-refresh.js +52 -23
  12. package/adapters/environment/engine/src/engine/published-bundle.d.ts +19 -15
  13. package/adapters/environment/engine/src/engine/published-bundle.js +44 -51
  14. package/adapters/environment/engine/src/saved-fork/saved-fork-tooling.d.ts +1 -1
  15. package/adapters/environment/engine/src/saved-fork/saved-fork-tooling.js +5 -2
  16. package/adapters/environment/engine/src/saved-fork/saved-fork.js +1 -1
  17. package/adapters/environment/engine/src/setup/engine-build.d.ts +4 -4
  18. package/adapters/environment/engine/src/setup/engine-build.js +14 -4
  19. package/adapters/environment/engine/src/setup/patch-catalogue.d.ts +6 -0
  20. package/adapters/environment/engine/src/setup/patch-catalogue.js +5 -0
  21. package/adapters/environment/engine/src/setup/prepare-engine.d.ts +6 -5
  22. package/adapters/environment/engine/src/setup/prepare-engine.js +27 -10
  23. package/adapters/features/cucumber/README.md +4 -0
  24. package/adapters/features/cucumber/src/steps/register-feature-steps.js +15 -1
  25. package/adapters/system/node/README.md +3 -3
  26. package/adapters/video/ffmpeg/README.md +4 -0
  27. package/adapters/video/ffmpeg/src/ffmpeg-video-editor.d.ts +1 -1
  28. package/adapters/video/ffmpeg/src/ffmpeg-video-editor.js +19 -6
  29. package/core/application/README.md +30 -1
  30. package/core/application/src/features/direct-feature-steps.d.ts +23 -0
  31. package/core/application/src/features/direct-feature-steps.js +25 -0
  32. package/core/application/src/features/feature-recording.d.ts +6 -0
  33. package/core/application/src/features/feature-recording.js +3 -0
  34. package/core/application/src/features/feature-scenario.d.ts +10 -8
  35. package/core/application/src/features/feature-scenario.js +11 -15
  36. package/core/application/src/features/feature-step-times.d.ts +11 -1
  37. package/core/application/src/features/feature-step-times.js +20 -6
  38. package/core/application/src/features/lab-feature-session.d.ts +3 -1
  39. package/core/application/src/features/lab-feature-session.js +5 -2
  40. package/core/application/src/features/run-feature-steps.d.ts +3 -1
  41. package/core/application/src/features/run-feature-steps.js +4 -2
  42. package/core/application/src/lab-runtime-use-cases.js +5 -1
  43. package/core/application/src/recordings/demo-markup.d.ts +8 -0
  44. package/core/application/src/recordings/demo-markup.js +11 -1
  45. package/core/application/src/recordings/demo-recording.d.ts +0 -2
  46. package/core/application/src/recordings/demo-recording.js +40 -23
  47. package/core/application/src/recordings/frame-sources.d.ts +20 -1
  48. package/core/application/src/recordings/frame-sources.js +89 -0
  49. package/core/application/src/recordings/record-runs.d.ts +7 -1
  50. package/core/application/src/recordings/record-runs.js +66 -11
  51. package/core/application/src/recordings/recording-draft.d.ts +4 -0
  52. package/core/application/src/recordings/recording-draft.js +13 -4
  53. package/core/domain/README.md +14 -0
  54. package/core/domain/src/recordings/demo-cut.js +6 -4
  55. package/core/domain/src/recordings/demo-holds.d.ts +12 -0
  56. package/core/domain/src/recordings/demo-holds.js +31 -0
  57. package/core/domain/src/recordings/index.d.ts +1 -0
  58. package/core/domain/src/recordings/index.js +1 -0
  59. package/core/domain/src/recordings/recording-data.d.ts +9 -1
  60. package/core/domain/src/recordings/recording-settings.d.ts +5 -1
  61. package/core/domain/src/recordings/recording-settings.js +7 -0
  62. package/core/domain/src/recordings/run-recording.d.ts +2 -0
  63. package/core/domain/src/recordings/run-recording.js +8 -1
  64. package/core/ports/README.md +6 -0
  65. package/core/ports/src/driven/recording/demo-ports.d.ts +2 -0
  66. package/core/ports/src/driven/recording/frame-capture.d.ts +4 -0
  67. package/core/ports/src/driving/demo.d.ts +2 -0
  68. package/core/ports/src/driving/feature-session.d.ts +7 -0
  69. package/entrypoints/cli/README.md +6 -3
  70. package/entrypoints/cli/src/demo-command.d.ts +1 -1
  71. package/entrypoints/cli/src/demo-command.js +10 -5
  72. package/entrypoints/mcp/README.md +3 -0
  73. package/entrypoints/panel/README.md +6 -3
  74. package/entrypoints/panel/src/visible/detached-identities.js +5 -1
  75. package/entrypoints/panel/src/visible/window-frames.js +14 -1
  76. package/package.json +1 -1
@@ -9,6 +9,7 @@ export declare const RECORDING_VARIABLES: {
9
9
  readonly maxAgeDays: "PRODUCT_LAB_RECORDINGS_MAX_AGE_DAYS";
10
10
  readonly maxMb: "PRODUCT_LAB_RECORDINGS_MAX_MB";
11
11
  readonly video: "PRODUCT_LAB_RECORDINGS_VIDEO";
12
+ readonly settleTimeoutMs: "PRODUCT_LAB_RECORDINGS_SETTLE_MS";
12
13
  readonly hostLogs: "PRODUCT_LAB_RECORDINGS_HOST_LOGS";
13
14
  readonly hostLogKb: "PRODUCT_LAB_RECORDINGS_HOST_LOG_KB";
14
15
  };
@@ -25,13 +26,15 @@ export declare class RecordingSettings {
25
26
  static readonly DEFAULT: RecordingSettings;
26
27
  readonly mode: RecordingMode;
27
28
  readonly retention: RecordingRetention;
28
- /** A capture that takes longer is recorded as failed (it never holds the run). */
29
+ /** A capture that takes longer is recorded as failed; only video feature boundaries wait for it. */
29
30
  readonly captureTimeoutMs: number;
30
31
  /**
31
32
  * Backlog 18b: headless identities' pages are recorded as video into the recordings directory's `videos/` folder,
32
33
  * and each recording refers to the videos of the pages its steps acted in. Default off (`product-lab test --video`).
33
34
  */
34
35
  readonly video: boolean;
36
+ /** Video feature after-frames compare screenshots for at most this long (default 3000 ms). */
37
+ readonly settleTimeoutMs: number;
35
38
  /**
36
39
  * Backlog 192: each host's own log is kept with a failed run's recording (`failed`), with every run's (`always`), or
37
40
  * never (`off`); at most `hostLogBytes` of each, its tail.
@@ -43,6 +46,7 @@ export declare class RecordingSettings {
43
46
  retention: RecordingRetention;
44
47
  captureTimeoutMs: number;
45
48
  video?: boolean;
49
+ settleTimeoutMs?: number;
46
50
  hostLogs?: HostLogsMode;
47
51
  hostLogBytes?: number;
48
52
  });
@@ -9,6 +9,7 @@ export const RECORDING_VARIABLES = {
9
9
  maxAgeDays: "PRODUCT_LAB_RECORDINGS_MAX_AGE_DAYS",
10
10
  maxMb: "PRODUCT_LAB_RECORDINGS_MAX_MB",
11
11
  video: "PRODUCT_LAB_RECORDINGS_VIDEO",
12
+ settleTimeoutMs: "PRODUCT_LAB_RECORDINGS_SETTLE_MS",
12
13
  hostLogs: "PRODUCT_LAB_RECORDINGS_HOST_LOGS",
13
14
  hostLogKb: "PRODUCT_LAB_RECORDINGS_HOST_LOG_KB",
14
15
  };
@@ -21,6 +22,7 @@ export class RecordingSettings {
21
22
  retention;
22
23
  captureTimeoutMs;
23
24
  video;
25
+ settleTimeoutMs;
24
26
  hostLogs;
25
27
  hostLogBytes;
26
28
  constructor(settings) {
@@ -31,6 +33,10 @@ export class RecordingSettings {
31
33
  this.mode = settings.mode;
32
34
  this.retention = settings.retention;
33
35
  this.captureTimeoutMs = settings.captureTimeoutMs;
36
+ const settleTimeoutMs = settings.settleTimeoutMs ?? 3000;
37
+ if (!Number.isFinite(settleTimeoutMs) || settleTimeoutMs <= 0)
38
+ throw new RecordingFormatError("A frame stability wait needs a positive timeout");
39
+ this.settleTimeoutMs = Math.min(settleTimeoutMs, this.captureTimeoutMs - Math.min(1000, this.captureTimeoutMs / 2));
34
40
  this.video = settings.mode !== "off" && (settings.video ?? false);
35
41
  const hostLogs = settings.hostLogs ?? "failed";
36
42
  if (!["failed", "always", "off"].includes(hostLogs))
@@ -74,6 +80,7 @@ export class RecordingSettings {
74
80
  }),
75
81
  captureTimeoutMs: RecordingSettings.DEFAULT.captureTimeoutMs,
76
82
  video: video === "on",
83
+ settleTimeoutMs: number(RECORDING_VARIABLES.settleTimeoutMs, RecordingSettings.DEFAULT.settleTimeoutMs, 1, false),
77
84
  hostLogs,
78
85
  hostLogBytes: number(RECORDING_VARIABLES.hostLogKb, HOST_LOG_BYTES, 1024, true),
79
86
  });
@@ -51,6 +51,8 @@ export declare class RunRecording {
51
51
  before: FrameChoice;
52
52
  after: FrameChoice;
53
53
  };
54
+ /** A cap frame remains available, with its uncertainty visible wherever the viewer chooses it. */
55
+ private labelStability;
54
56
  private framesOf;
55
57
  private before;
56
58
  private after;
@@ -179,6 +179,8 @@ export class RunRecording {
179
179
  throw new RecordingFormatError(`Recording frame ${id} has an unknown status`);
180
180
  if (frame.step !== undefined && (typeof frame.step !== "number" || !Number.isInteger(frame.step) || frame.step < 0 || frame.step >= steps.length))
181
181
  throw new RecordingFormatError(`Recording frame ${id} names step ${String(frame.step)}, which the recording does not have`);
182
+ if (frame.featureStep !== undefined && (typeof frame.featureStep !== "number" || !Number.isSafeInteger(frame.featureStep) || frame.featureStep < 1))
183
+ throw new RecordingFormatError("Recording frame featureStep must be a positive step number");
182
184
  time(`frames[${index}].requestedAt`, frame.requestedAt);
183
185
  if (!Array.isArray(frame.overlapping))
184
186
  throw new RecordingFormatError(`Recording frame ${id} must list the steps it overlapped`);
@@ -239,7 +241,12 @@ export class RunRecording {
239
241
  const who = actor ?? step.actor;
240
242
  if (!who)
241
243
  return { before: RunRecording.missing("This step acted as no identity: pick one to see its app"), after: RunRecording.missing("This step acted as no identity: pick one to see its app") };
242
- return { actor: who, before: this.before(step, who), after: this.after(step, who) };
244
+ return { actor: who, before: this.labelStability(this.before(step, who)), after: this.labelStability(this.after(step, who)) };
245
+ }
246
+ labelStability(choice) {
247
+ if (choice.status !== "captured" || choice.frame?.settled !== false)
248
+ return choice;
249
+ return { ...choice, note: `Taken at the stability cap; visual stability was not confirmed. ${choice.note.replace(/^Settled(?=:|,)/, "Capture")}` };
243
250
  }
244
251
  framesOf(actor) {
245
252
  return this.data.frames.filter((frame) => frame.actor === actor);
@@ -456,3 +456,9 @@ JSON, only when `extrinsics(blockHash, { includeCallArguments: true })` requests
456
456
  neither copy nor retain arguments. Consumers can check transaction contents beside the exact finalized hash; absent
457
457
  is unknown, and `null` is a decoded call with no arguments. JSON conversion cuts nesting beyond 12 levels to `"…"`;
458
458
  `decoded: "complete"` describes SCALE decoding, not JSON depth coverage. The chain adapter owns decoding.
459
+
460
+ ## Held feature demos
461
+
462
+ FeatureScenarioHandle optionally exposes startStep and finishStep for custom definitions: observation only, without scheduling their work. VideoEditRequest.stills supplies one complete recorded image per segment. DemoRequest.holdSeconds defaults to 2 and cannot be below 2. A full demo exports step-001.png and later images decoded from each encoded hold midpoint, with a caption band below the app.
463
+
464
+ `CaptureOptions.loadComplete` optionally requests a read of the current document until loading completes, within `timeoutMs`; `CapturedFrame.loadComplete` reports when the source verified it. The recorder requests this only during video feature after-frame stability checks. Sources without this support leave the result absent.
@@ -18,6 +18,8 @@ export interface VideoEditRequest {
18
18
  readonly source: string;
19
19
  readonly plan: DemoPlan;
20
20
  readonly overlays: readonly VideoOverlay[];
21
+ /** Already composed recorded stills, one per plan segment; replaces the video inputs when supplied. */
22
+ readonly stills?: readonly Uint8Array[];
21
23
  /** Where the cut is written (its parent exists); replaced whole. */
22
24
  readonly out: string;
23
25
  readonly format: "mp4" | "webm";
@@ -7,6 +7,8 @@ export interface CapturedFrame {
7
7
  bytes: Uint8Array;
8
8
  /** How long the capture waited for the app's screen to settle (`CaptureOptions.settled`), when it was asked to. */
9
9
  settledMs?: number;
10
+ /** Whether the source verified the current document completed loading before this image. */
11
+ loadComplete?: boolean;
10
12
  }
11
13
  /** Why no image of that identity's app can be taken (no page, a surface that cannot be captured, …). */
12
14
  export interface UnavailableFrame {
@@ -35,6 +37,8 @@ export interface CaptureOptions {
35
37
  signal?: AbortSignal;
36
38
  timeoutMs?: number;
37
39
  mask?: readonly string[];
40
+ /** Read the current document until readyState is complete before imaging it, bounded by timeoutMs. */
41
+ loadComplete?: boolean;
38
42
  settled?: {
39
43
  readonly selectors: readonly string[];
40
44
  readonly timeoutMs: number;
@@ -8,6 +8,8 @@ export interface DemoRequest {
8
8
  out?: string;
9
9
  /** The longest the cut should be (default 60). */
10
10
  maxSeconds?: number;
11
+ /** Each after-step image is held this long (default 2, minimum 2 seconds). */
12
+ holdSeconds?: number;
11
13
  format?: "mp4" | "webm";
12
14
  /** Only the cut's plan: demo.json and demo.vtt, no video, no slide (needs no ffmpeg). */
13
15
  planOnly?: boolean;
@@ -230,6 +230,13 @@ export interface FeatureScenarioHandle {
230
230
  readonly name: string;
231
231
  /** Run step `index` once (a custom step is run by its own definition, not here). */
232
232
  run(index: number): Promise<FeatureStepTrace>;
233
+ /** Observe a direct/custom definition, without running it through the scheduler. Generated steps time themselves. */
234
+ startStep?(index: number): void;
235
+ /** Observe its after-state; only video runs await capture before the next step or pause. Skipped steps have no boundary. */
236
+ finishStep?(index: number, outcome: {
237
+ status: "passed" | "failed";
238
+ error?: string;
239
+ }): Promise<void>;
233
240
  /** The identity a scenario actor is bound to. */
234
241
  actor(name: string): ActorSession;
235
242
  /** The scenario's outputs so far. */
@@ -795,9 +795,8 @@ blank. It loads no lab. `pr-suite.yml` posts it. Test: `tests/pr-comment.test.ts
795
795
 
796
796
  ## Recorded runs
797
797
 
798
- `product-lab demo [<recording-id>] [--actor <role> | --window] [--out <dir>] [--max-seconds 60] [--format mp4|webm] [--plan | --slide-only] [--failed] [--json]`
799
- (backlog 36) cuts a recorded feature scenario into a demo through `LabRuntime.demo`: about a
800
- minute, each action at real speed with its result held, waits sped up and labelled, a caption per Gherkin step in the
798
+ `product-lab demo [<recording-id>] [--actor <role> | --window] [--out <dir>] [--max-seconds 60] [--hold-seconds 2] [--format mp4|webm] [--plan | --slide-only] [--failed] [--json]`
799
+ (backlog 36) cuts a recorded feature scenario into a demo through `LabRuntime.demo`: one recorded after-state per step held for at least two seconds, a caption band below the app in the
801
800
  lab window's style, with `demo.vtt`, `demo.json` and a slide (`slide.png`, `slide.html`) in
802
801
  `<lab>/.product-lab/demos/<recording-id>`. The video needs `ffmpeg` and `ffprobe` on `PATH` (or `PRODUCT_LAB_FFMPEG`
803
802
  naming their folder; never bundled); `--plan` and `--slide-only` do not. Starts nothing. Exit 2 when refused (no such
@@ -893,3 +892,7 @@ failure; this detail does not change retries or cancellation. Test:
893
892
  `tests/abort-diagnostics.test.ts`.
894
893
 
895
894
  The generated minimal reference uses the released SDK's native PAPI 3 transaction creator and carries its app's current SDK and client install pins. Its file list and dependency test follow that source; no local signer bridge is copied. The rebased migration is awaiting qualification.
895
+
896
+ ## Held feature demos
897
+
898
+ Demo holds default to 2 s per feature step; --hold-seconds tunes them upward and --max-seconds never shortens them. Captions occupy a separate band below the app. Full renders also write step-001.png and later files decoded from the midpoint of each hold. Direct/custom feature steps are recorded with their start/end times and after-state images. Default frames-only capture is asynchronous; --video awaits bounded after-step captures and retains a 2 s tail before scenario cleanup. Old footage without a matching after-state is refused rather than replaced with an unrelated screen.
@@ -1,4 +1,4 @@
1
1
  import type { DemoReport, LabRuntime } from "@parity/product-lab/ports/driving";
2
- export declare const DEMO_USAGE = "Usage: product-lab demo [<recording-id>] [--actor <role> | --window] [--out <dir>] [--max-seconds 60]\n [--format mp4|webm] [--plan | --slide-only] [--failed] [--json] [--config lab.config.ts]\n\nA demo of a recorded feature scenario: its video cut to about a minute, every action at real speed with its result\nheld, waits sped up and labelled (\u00D78 waiting for finality), a caption per Gherkin step, plus demo.vtt (the captions),\ndemo.json (the edit, segment by segment) and a slide (slide.png, slide.html: the scenario, its steps, who and where,\nthe last screen). Record a run with video first (product-lab test --video, or the lab window's recording).\n <recording-id> default: the newest passed feature recording\n --actor <role> cut that actor's app video (default: the lab window's, else the actor in most steps)\n --window cut the lab window's video\n --out <dir> default: <lab>/.product-lab/demos/<recording-id>\n --max-seconds <n> the longest the cut should be (default 60); over it the waits speed up first\n --format mp4|webm default mp4 (H.264); webm is VP9\n --plan only demo.json and demo.vtt (needs no ffmpeg)\n --slide-only the plan and the slide, no video (needs no ffmpeg)\n --failed allow a failed run: every frame and the slide say it failed\n --json print the report as JSON\n The video needs ffmpeg and ffprobe on PATH (or PRODUCT_LAB_FFMPEG naming their folder); product-lab never bundles them.\n Exit code: 0 made, 2 refused (no such recording, no video, a failed run without --failed, no ffmpeg) or bad arguments,\n 1 when the lab does not load or the cut fails.";
2
+ export declare const DEMO_USAGE = "Usage: product-lab demo [<recording-id>] [--actor <role> | --window] [--out <dir>] [--max-seconds 60] [--hold-seconds 2]\n [--format mp4|webm] [--plan | --slide-only] [--failed] [--json] [--config lab.config.ts]\n\nA demo of a recorded feature scenario: its real after-step images held for at least 2 seconds each,\nwith captions in a band under the app and step-001.png (one encoded midpoint PNG per step), plus demo.vtt (the captions),\ndemo.json (the edit, segment by segment) and a slide (slide.png, slide.html: the scenario, its steps, who and where,\nthe last screen). Record a run with video first (product-lab test --video, or the lab window's recording).\n <recording-id> default: the newest passed feature recording\n --actor <role> cut that actor's app video (default: the lab window's, else the actor in most steps)\n --window cut the lab window's video\n --out <dir> default: <lab>/.product-lab/demos/<recording-id>\n --max-seconds <n> the target length (default 60); never shortens the step holds\n --hold-seconds <n> hold each after-step image (default 2, minimum 2 seconds)\n --format mp4|webm default mp4 (H.264); webm is VP9\n --plan only demo.json and demo.vtt (needs no ffmpeg)\n --slide-only the plan and the slide, no video (needs no ffmpeg)\n --failed allow a failed run: every frame and the slide say it failed\n --json print the report as JSON\n The video needs ffmpeg and ffprobe on PATH (or PRODUCT_LAB_FFMPEG naming their folder); product-lab never bundles them.\n Exit code: 0 made, 2 refused (no such recording, no video, a failed run without --failed, no ffmpeg) or bad arguments,\n 1 when the lab does not load or the cut fails.";
3
3
  export declare function renderDemo(report: DemoReport): string;
4
4
  export declare function runDemoCommand(runtime: LabRuntime, argv: string[], signal?: AbortSignal, out?: (text: string) => void, err?: (text: string) => void): Promise<number>;
@@ -2,18 +2,19 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { parseArgs } from "node:util";
4
4
  import { DemoRefusedError } from "@parity/product-lab/domain/recordings";
5
- export const DEMO_USAGE = `Usage: product-lab demo [<recording-id>] [--actor <role> | --window] [--out <dir>] [--max-seconds 60]
5
+ export const DEMO_USAGE = `Usage: product-lab demo [<recording-id>] [--actor <role> | --window] [--out <dir>] [--max-seconds 60] [--hold-seconds 2]
6
6
  [--format mp4|webm] [--plan | --slide-only] [--failed] [--json] [--config lab.config.ts]
7
7
 
8
- A demo of a recorded feature scenario: its video cut to about a minute, every action at real speed with its result
9
- held, waits sped up and labelled (×8 waiting for finality), a caption per Gherkin step, plus demo.vtt (the captions),
8
+ A demo of a recorded feature scenario: its real after-step images held for at least 2 seconds each,
9
+ with captions in a band under the app and step-001.png (one encoded midpoint PNG per step), plus demo.vtt (the captions),
10
10
  demo.json (the edit, segment by segment) and a slide (slide.png, slide.html: the scenario, its steps, who and where,
11
11
  the last screen). Record a run with video first (product-lab test --video, or the lab window's recording).
12
12
  <recording-id> default: the newest passed feature recording
13
13
  --actor <role> cut that actor's app video (default: the lab window's, else the actor in most steps)
14
14
  --window cut the lab window's video
15
15
  --out <dir> default: <lab>/.product-lab/demos/<recording-id>
16
- --max-seconds <n> the longest the cut should be (default 60); over it the waits speed up first
16
+ --max-seconds <n> the target length (default 60); never shortens the step holds
17
+ --hold-seconds <n> hold each after-step image (default 2, minimum 2 seconds)
17
18
  --format mp4|webm default mp4 (H.264); webm is VP9
18
19
  --plan only demo.json and demo.vtt (needs no ffmpeg)
19
20
  --slide-only the plan and the slide, no video (needs no ffmpeg)
@@ -48,6 +49,7 @@ export async function runDemoCommand(runtime, argv, signal, out = (text) => proc
48
49
  window: { type: "boolean" },
49
50
  out: { type: "string" },
50
51
  "max-seconds": { type: "string" },
52
+ "hold-seconds": { type: "string" },
51
53
  format: { type: "string" },
52
54
  plan: { type: "boolean" },
53
55
  "slide-only": { type: "boolean" },
@@ -66,12 +68,14 @@ export async function runDemoCommand(runtime, argv, signal, out = (text) => proc
66
68
  return 0;
67
69
  }
68
70
  const maxSeconds = values["max-seconds"] === undefined ? undefined : Number(values["max-seconds"]);
71
+ const holdSeconds = values["hold-seconds"] === undefined ? undefined : Number(values["hold-seconds"]);
69
72
  const problem = positionals.length > 1 ? `Unexpected arguments: ${positionals.slice(1).join(" ")}`
70
73
  : values.actor !== undefined && values.window ? "--actor and --window cannot be used together"
71
74
  : values.plan && values["slide-only"] ? "--plan and --slide-only cannot be used together"
72
75
  : values.format !== undefined && values.format !== "mp4" && values.format !== "webm" ? `--format must be mp4 or webm, not ${values.format}`
73
76
  : maxSeconds !== undefined && !(Number.isFinite(maxSeconds) && maxSeconds >= 5) ? `--max-seconds must be a number of seconds, at least 5, not ${values["max-seconds"]}`
74
- : undefined;
77
+ : holdSeconds !== undefined && !(Number.isFinite(holdSeconds) && holdSeconds >= 2) ? `--hold-seconds must be a number of seconds, at least 2, not ${values["hold-seconds"]}`
78
+ : undefined;
75
79
  if (problem) {
76
80
  err(`${problem}\n${DEMO_USAGE}\n`);
77
81
  return 2;
@@ -84,6 +88,7 @@ export async function runDemoCommand(runtime, argv, signal, out = (text) => proc
84
88
  ...(values.window ? { window: true } : {}),
85
89
  ...(values.out !== undefined ? { out: values.out } : {}),
86
90
  ...(maxSeconds !== undefined ? { maxSeconds } : {}),
91
+ ...(holdSeconds !== undefined ? { holdSeconds } : {}),
87
92
  ...(values.format ? { format: values.format } : {}),
88
93
  ...(values.plan ? { planOnly: true } : {}),
89
94
  ...(values["slide-only"] ? { slideOnly: true } : {}),
@@ -202,3 +202,6 @@ the file. Neither live script is part of the unit suite.
202
202
 
203
203
  The reload test creates its ES-module lab in an OS temporary directory, linking this package's `node_modules`.
204
204
  Concurrent lint can no longer discover a generated lab under the repository's `tests/` without a TypeScript project.
205
+
206
+ Design-resource hashes track the current topic text, including the distinction between passive frames-only
207
+ capture and awaited video capture at feature boundaries.
@@ -417,9 +417,10 @@ Every running actor's app tile has a compact, browser-like address bar in the co
417
417
  (`data-frame`), so a re-created frame never receives an old frame's address. The bar never shows a guessed location.
418
418
 
419
419
  Tests: `node --import tsx --test entrypoints/panel/tests/address-bar.test.ts` (served shell, headless Chromium,
420
- loopback fixture apps on ephemeral ports; a normal tab and the lab window via `openLabWindow`, including real
421
- `framenavigated` reports and the acting guard). Optional `ADDRESS_BAR_SCREENS=<directory>` writes review screenshots
422
- (unset: none).
420
+ loopback fixture apps on ephemeral ports that count browser requests by `Sec-Fetch-Dest`, which Chromium sends
421
+ to these loopback origins, ignoring a local port probe without that header;
422
+ a normal tab and the lab window via `openLabWindow`, including real `framenavigated` reports and the acting guard).
423
+ Optional `ADDRESS_BAR_SCREENS=<directory>` writes review screenshots (unset: none).
423
424
 
424
425
  ## Visible execution: `@product-lab/shell/visible`
425
426
 
@@ -1771,3 +1772,5 @@ Framing reuse (item82): the framing section above retains the original item66 be
1771
1772
  The drawer puts its existing tabs in a full-width row below the title and Close control. Tabs wrap at narrow widths, keeping Recordings reachable without replacing the live app frame. Watch controls the displayed app in a window opened through `openLabWindow`; the server can keep running in the background while that window remains visible.
1772
1773
 
1773
1774
  Opening a recording displays its captured screenshots over the mounted app, with Play/Pause and previous/next screenshot controls. The viewer labels its fixed1.5s image cadence and absence of video; it cannot recover typing or clicks between captures. Back to live restores the app and run controls. Active runs are checked before and after loading the recording, using the latest panel snapshot. The run audit remains available even with zero recorded steps, and incomplete audits explicitly explain that proof is missing.
1775
+
1776
+ Recording captures of tiles and detached windows honor `CaptureOptions.loadComplete` before taking their image and report `loadComplete: true` when checked. Only video feature after-frame checks request this wait; normal captures retain their existing policy. Tests: `tests/window-frames.test.ts` and `tests/detached-identities.test.ts`.
@@ -195,6 +195,10 @@ export function detachedFrameCapture(tiles, detached, options = {}) {
195
195
  const timeoutMs = captureOptions?.timeoutMs ?? options.timeoutMs ?? DETACHED_CAPTURE_TIMEOUT_MS;
196
196
  if (signal?.aborted)
197
197
  throw new Error(`${window.actor.name}'s detached window was not captured: the recorder stopped waiting`);
198
+ if (captureOptions?.loadComplete) {
199
+ await window.app.waitForFunction(() => document.readyState === "complete", undefined, { timeout: timeoutMs });
200
+ signal?.throwIfAborted();
201
+ }
198
202
  const started = Date.now();
199
203
  const unsettled = captureOptions?.settled?.selectors.length ? await settle(window.app, captureOptions.settled) : undefined;
200
204
  if (unsettled)
@@ -211,7 +215,7 @@ export function detachedFrameCapture(tiles, detached, options = {}) {
211
215
  throw new Error(`${window.actor.name}'s detached window gave no frame within ${timeoutMs} ms`, { cause: error });
212
216
  throw new Error(`${window.actor.name}'s detached window could not be captured: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
213
217
  }
214
- return { status: "captured", mediaType: "image/png", bytes: new Uint8Array(bytes), ...settled };
218
+ return { status: "captured", mediaType: "image/png", bytes: new Uint8Array(bytes), ...settled, ...(captureOptions?.loadComplete ? { loadComplete: true } : {}) };
215
219
  },
216
220
  };
217
221
  }
@@ -13,6 +13,19 @@ export function windowFrameCapture(page, frameSelector, media, videos, uiCalls)
13
13
  video: () => windowVideo(page, videos, { source: "lab-window", scope: "window" }),
14
14
  async capture(actorId, options) {
15
15
  const selector = frameSelector(actorId);
16
+ if (options?.loadComplete) {
17
+ const element = await page.locator(selector).elementHandle({ timeout: options.timeoutMs });
18
+ try {
19
+ const app = await element?.contentFrame();
20
+ if (!app)
21
+ return { status: "unavailable", reason: "The displayed app has no current document" };
22
+ await app.waitForLoadState("load", { timeout: options.timeoutMs });
23
+ }
24
+ finally {
25
+ await element?.dispose();
26
+ }
27
+ options.signal?.throwIfAborted();
28
+ }
16
29
  const started = Date.now();
17
30
  const unsettled = options?.settled?.selectors.length ? await settle(page.frameLocator(selector), options.settled) : undefined;
18
31
  if (unsettled)
@@ -45,7 +58,7 @@ export function windowFrameCapture(page, frameSelector, media, videos, uiCalls)
45
58
  const bytes = await cutInWorker(await shot, view.width, { rect: { x, y, width, height } });
46
59
  const after = await page.evaluate(steadyFrameBox, { selector, settleMs: 0 });
47
60
  if (!("moving" in after) && sameBox(after, box))
48
- return { status: "captured", mediaType: "image/png", bytes: new Uint8Array(bytes), ...settled };
61
+ return { status: "captured", mediaType: "image/png", bytes: new Uint8Array(bytes), ...settled, ...(options?.loadComplete ? { loadComplete: true } : {}) };
49
62
  }
50
63
  finally {
51
64
  if (!shot)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parity/product-lab",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Run and test Polkadot products against real local chains, hosts and browsers: the framework and the product-lab CLI.",
6
6
  "license": "Apache-2.0",