doc-detective 4.12.1 → 4.13.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 (54) hide show
  1. package/dist/common/src/schemas/schemas.json +878 -70
  2. package/dist/common/src/types/generated/config_v3.d.ts +4 -0
  3. package/dist/common/src/types/generated/config_v3.d.ts.map +1 -1
  4. package/dist/common/src/types/generated/record_v3.d.ts +4 -0
  5. package/dist/common/src/types/generated/record_v3.d.ts.map +1 -1
  6. package/dist/common/src/types/generated/report_v3.d.ts +4 -0
  7. package/dist/common/src/types/generated/report_v3.d.ts.map +1 -1
  8. package/dist/common/src/types/generated/resolvedTests_v3.d.ts +8 -0
  9. package/dist/common/src/types/generated/resolvedTests_v3.d.ts.map +1 -1
  10. package/dist/common/src/types/generated/spec_v3.d.ts +4 -0
  11. package/dist/common/src/types/generated/spec_v3.d.ts.map +1 -1
  12. package/dist/common/src/types/generated/step_v3.d.ts +24 -2
  13. package/dist/common/src/types/generated/step_v3.d.ts.map +1 -1
  14. package/dist/common/src/types/generated/stopRecord_v3.d.ts +20 -2
  15. package/dist/common/src/types/generated/stopRecord_v3.d.ts.map +1 -1
  16. package/dist/common/src/types/generated/test_v3.d.ts +52 -4
  17. package/dist/common/src/types/generated/test_v3.d.ts.map +1 -1
  18. package/dist/core/tests/ffmpegRecorder.d.ts +11 -2
  19. package/dist/core/tests/ffmpegRecorder.d.ts.map +1 -1
  20. package/dist/core/tests/ffmpegRecorder.js +155 -8
  21. package/dist/core/tests/ffmpegRecorder.js.map +1 -1
  22. package/dist/core/tests/findElement.d.ts.map +1 -1
  23. package/dist/core/tests/findElement.js +2 -1
  24. package/dist/core/tests/findElement.js.map +1 -1
  25. package/dist/core/tests/saveScreenshot.d.ts.map +1 -1
  26. package/dist/core/tests/saveScreenshot.js +26 -9
  27. package/dist/core/tests/saveScreenshot.js.map +1 -1
  28. package/dist/core/tests/startRecording.d.ts.map +1 -1
  29. package/dist/core/tests/startRecording.js +36 -2
  30. package/dist/core/tests/startRecording.js.map +1 -1
  31. package/dist/core/tests/stopRecording.d.ts.map +1 -1
  32. package/dist/core/tests/stopRecording.js +51 -16
  33. package/dist/core/tests/stopRecording.js.map +1 -1
  34. package/dist/core/tests/typeKeys.d.ts.map +1 -1
  35. package/dist/core/tests/typeKeys.js +3 -2
  36. package/dist/core/tests/typeKeys.js.map +1 -1
  37. package/dist/core/tests.d.ts +12 -1
  38. package/dist/core/tests.d.ts.map +1 -1
  39. package/dist/core/tests.js +188 -34
  40. package/dist/core/tests.js.map +1 -1
  41. package/dist/core/utils.d.ts.map +1 -1
  42. package/dist/core/utils.js +13 -8
  43. package/dist/core/utils.js.map +1 -1
  44. package/dist/debug/provenance.d.ts.map +1 -1
  45. package/dist/debug/provenance.js +6 -0
  46. package/dist/debug/provenance.js.map +1 -1
  47. package/dist/hints/hints.d.ts.map +1 -1
  48. package/dist/hints/hints.js +19 -0
  49. package/dist/hints/hints.js.map +1 -1
  50. package/dist/index.cjs +1588 -558
  51. package/dist/utils.d.ts.map +1 -1
  52. package/dist/utils.js +7 -0
  53. package/dist/utils.js.map +1 -1
  54. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -7600,6 +7600,14 @@ var init_schemas = __esm({
7600
7600
  "false"
7601
7601
  ]
7602
7602
  },
7603
+ name: {
7604
+ type: "string",
7605
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
7606
+ pattern: "\\S",
7607
+ transform: [
7608
+ "trim"
7609
+ ]
7610
+ },
7603
7611
  engine: {
7604
7612
  title: "Recording engine",
7605
7613
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -7695,6 +7703,14 @@ var init_schemas = __esm({
7695
7703
  "false"
7696
7704
  ]
7697
7705
  },
7706
+ name: {
7707
+ type: "string",
7708
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
7709
+ pattern: "\\S",
7710
+ transform: [
7711
+ "trim"
7712
+ ]
7713
+ },
7698
7714
  engine: {
7699
7715
  title: "Recording engine",
7700
7716
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -7930,13 +7946,52 @@ var init_schemas = __esm({
7930
7946
  stopRecord: {
7931
7947
  $schema: "http://json-schema.org/draft-07/schema#",
7932
7948
  title: "stopRecord",
7933
- description: "Stop the current recording.",
7934
- type: [
7935
- "boolean",
7936
- "null"
7949
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
7950
+ anyOf: [
7951
+ {
7952
+ type: "boolean",
7953
+ title: "stopRecord (boolean)",
7954
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
7955
+ },
7956
+ {
7957
+ type: "null",
7958
+ title: "stopRecord (null)",
7959
+ description: "Stops the most recently started active recording (LIFO)."
7960
+ },
7961
+ {
7962
+ type: "string",
7963
+ title: "stopRecord (name)",
7964
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
7965
+ pattern: "\\S",
7966
+ transform: [
7967
+ "trim"
7968
+ ]
7969
+ },
7970
+ {
7971
+ type: "object",
7972
+ title: "stopRecord (detailed)",
7973
+ additionalProperties: false,
7974
+ required: [
7975
+ "name"
7976
+ ],
7977
+ properties: {
7978
+ name: {
7979
+ type: "string",
7980
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
7981
+ pattern: "\\S",
7982
+ transform: [
7983
+ "trim"
7984
+ ]
7985
+ }
7986
+ }
7987
+ }
7937
7988
  ],
7938
7989
  examples: [
7939
- true
7990
+ true,
7991
+ "demo",
7992
+ {
7993
+ name: "demo"
7994
+ }
7940
7995
  ]
7941
7996
  }
7942
7997
  },
@@ -10233,6 +10288,11 @@ var init_schemas = __esm({
10233
10288
  type: "boolean",
10234
10289
  default: false
10235
10290
  },
10291
+ autoRecord: {
10292
+ description: "If `true`, records a video of every test context that runs in a browser, in addition to any explicit `record` steps. The recording wraps the whole context (it starts before the first step and stops after the last) and always uses the `ffmpeg` engine. Videos are saved in the per-run artifact directory (`<output>/.doc-detective/run-<runId>/`) at paths derived from spec, test, and context IDs (for example, `recordings/<specId>/<testId>/<contextId>.mp4`), so the same context lands on the same relative path in every run's folder for run-over-run comparison. Specs and tests can override this value with their own `autoRecord` fields (test level wins over spec level, which wins over config level). Equivalent to `--auto-record` on the CLI.",
10293
+ type: "boolean",
10294
+ default: false
10295
+ },
10236
10296
  autoUpdate: {
10237
10297
  description: "If `true` (default), the CLI checks for a newer published `doc-detective` on startup and self-updates before running tests. Updates happen for global (`npm i -g`) and `npx` installs only \u2014 local installs (where `doc-detective` is a project dep) get an informational message instead, since auto-updating would mutate the user's lockfile. CI environments and the `DOC_DETECTIVE_SKIP_AUTO_UPDATE=1` env var also skip the check. Set to `false` to pin to the installed version. Equivalent to `--no-auto-update` on the CLI.",
10238
10298
  type: "boolean",
@@ -16640,6 +16700,14 @@ var init_schemas = __esm({
16640
16700
  "false"
16641
16701
  ]
16642
16702
  },
16703
+ name: {
16704
+ type: "string",
16705
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
16706
+ pattern: "\\S",
16707
+ transform: [
16708
+ "trim"
16709
+ ]
16710
+ },
16643
16711
  engine: {
16644
16712
  title: "Recording engine",
16645
16713
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -16735,6 +16803,14 @@ var init_schemas = __esm({
16735
16803
  "false"
16736
16804
  ]
16737
16805
  },
16806
+ name: {
16807
+ type: "string",
16808
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
16809
+ pattern: "\\S",
16810
+ transform: [
16811
+ "trim"
16812
+ ]
16813
+ },
16738
16814
  engine: {
16739
16815
  title: "Recording engine",
16740
16816
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -16970,13 +17046,52 @@ var init_schemas = __esm({
16970
17046
  stopRecord: {
16971
17047
  $schema: "http://json-schema.org/draft-07/schema#",
16972
17048
  title: "stopRecord",
16973
- description: "Stop the current recording.",
16974
- type: [
16975
- "boolean",
16976
- "null"
17049
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
17050
+ anyOf: [
17051
+ {
17052
+ type: "boolean",
17053
+ title: "stopRecord (boolean)",
17054
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
17055
+ },
17056
+ {
17057
+ type: "null",
17058
+ title: "stopRecord (null)",
17059
+ description: "Stops the most recently started active recording (LIFO)."
17060
+ },
17061
+ {
17062
+ type: "string",
17063
+ title: "stopRecord (name)",
17064
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
17065
+ pattern: "\\S",
17066
+ transform: [
17067
+ "trim"
17068
+ ]
17069
+ },
17070
+ {
17071
+ type: "object",
17072
+ title: "stopRecord (detailed)",
17073
+ additionalProperties: false,
17074
+ required: [
17075
+ "name"
17076
+ ],
17077
+ properties: {
17078
+ name: {
17079
+ type: "string",
17080
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
17081
+ pattern: "\\S",
17082
+ transform: [
17083
+ "trim"
17084
+ ]
17085
+ }
17086
+ }
17087
+ }
16977
17088
  ],
16978
17089
  examples: [
16979
- true
17090
+ true,
17091
+ "demo",
17092
+ {
17093
+ name: "demo"
17094
+ }
16980
17095
  ]
16981
17096
  }
16982
17097
  },
@@ -22955,6 +23070,14 @@ var init_schemas = __esm({
22955
23070
  "false"
22956
23071
  ]
22957
23072
  },
23073
+ name: {
23074
+ type: "string",
23075
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
23076
+ pattern: "\\S",
23077
+ transform: [
23078
+ "trim"
23079
+ ]
23080
+ },
22958
23081
  engine: {
22959
23082
  title: "Recording engine",
22960
23083
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -23050,6 +23173,14 @@ var init_schemas = __esm({
23050
23173
  "false"
23051
23174
  ]
23052
23175
  },
23176
+ name: {
23177
+ type: "string",
23178
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
23179
+ pattern: "\\S",
23180
+ transform: [
23181
+ "trim"
23182
+ ]
23183
+ },
23053
23184
  engine: {
23054
23185
  title: "Recording engine",
23055
23186
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -30292,6 +30423,14 @@ var init_schemas = __esm({
30292
30423
  "false"
30293
30424
  ]
30294
30425
  },
30426
+ name: {
30427
+ type: "string",
30428
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
30429
+ pattern: "\\S",
30430
+ transform: [
30431
+ "trim"
30432
+ ]
30433
+ },
30295
30434
  engine: {
30296
30435
  title: "Recording engine",
30297
30436
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -30387,6 +30526,14 @@ var init_schemas = __esm({
30387
30526
  "false"
30388
30527
  ]
30389
30528
  },
30529
+ name: {
30530
+ type: "string",
30531
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
30532
+ pattern: "\\S",
30533
+ transform: [
30534
+ "trim"
30535
+ ]
30536
+ },
30390
30537
  engine: {
30391
30538
  title: "Recording engine",
30392
30539
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -30622,133 +30769,172 @@ var init_schemas = __esm({
30622
30769
  stopRecord: {
30623
30770
  $schema: "http://json-schema.org/draft-07/schema#",
30624
30771
  title: "stopRecord",
30625
- description: "Stop the current recording.",
30626
- type: [
30627
- "boolean",
30628
- "null"
30629
- ],
30630
- examples: [
30631
- true
30632
- ]
30633
- }
30634
- },
30635
- title: "stopRecord"
30636
- }
30637
- ]
30638
- },
30639
- {
30640
- allOf: [
30641
- {
30642
- type: "object",
30643
- dynamicDefaults: {
30644
- stepId: "uuid"
30645
- },
30646
- properties: {
30647
- $schema: {
30648
- description: "JSON Schema for this object.",
30649
- type: "string",
30650
- enum: [
30651
- "https://raw.githubusercontent.com/doc-detective/common/refs/heads/main/dist/schemas/step_v3.schema.json"
30652
- ]
30653
- },
30654
- stepId: {
30655
- type: "string",
30656
- description: "ID of the step."
30657
- },
30658
- description: {
30659
- type: "string",
30660
- description: "Description of the step."
30661
- },
30662
- unsafe: {
30663
- type: "boolean",
30664
- description: "Whether or not the step may be unsafe. Unsafe steps may perform actions that could modify the system or environment in unexpected ways. Unsafe steps are only performed within Docker containers or if unsafe steps are enabled with the `allowUnsafeSteps` config property or the `--allow-unsafe` flag.",
30665
- default: false
30666
- },
30667
- outputs: {
30668
- type: "object",
30669
- description: "Outputs from step processes and user-defined expressions. Use the `outputs` object to reference outputs in subsequent steps. If a user-defined output matches the key for a step-defined output, the user-defined output takes precedence.",
30670
- default: {},
30671
- patternProperties: {
30672
- "^[A-Za-z0-9_]+$": {
30673
- type: "string",
30674
- description: "Runtime expression for a user-defined output value."
30675
- }
30676
- },
30677
- title: "Outputs (step)"
30678
- },
30679
- variables: {
30680
- type: "object",
30681
- description: "Environment variables to set from user-defined expressions.",
30682
- default: {},
30683
- patternProperties: {
30684
- "^[A-Za-z0-9_]+$": {
30685
- type: "string",
30686
- description: "Runtime expression for a user-defined output value."
30687
- }
30688
- },
30689
- title: "Variables (step)"
30690
- },
30691
- breakpoint: {
30692
- type: "boolean",
30693
- description: "Whether or not this step should act as a breakpoint when debugging is enabled. When `true`, execution will pause at this step when debug mode is enabled.",
30694
- default: false
30695
- },
30696
- location: {
30697
- type: "object",
30698
- description: "Source location where this step was detected in the original file. This is system-populated metadata and should not be set manually.",
30699
- readOnly: true,
30700
- properties: {
30701
- line: {
30702
- type: "integer",
30703
- description: "1-indexed line number in the source file where the step was detected.",
30704
- minimum: 1
30772
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
30773
+ anyOf: [
30774
+ {
30775
+ type: "boolean",
30776
+ title: "stopRecord (boolean)",
30777
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
30705
30778
  },
30706
- startIndex: {
30707
- type: "integer",
30708
- description: "0-indexed character offset from the start of the source file where the step begins.",
30709
- minimum: 0
30779
+ {
30780
+ type: "null",
30781
+ title: "stopRecord (null)",
30782
+ description: "Stops the most recently started active recording (LIFO)."
30710
30783
  },
30711
- endIndex: {
30712
- type: "integer",
30713
- description: "0-indexed character offset from the start of the source file where the step ends (exclusive).",
30714
- minimum: 0
30784
+ {
30785
+ type: "string",
30786
+ title: "stopRecord (name)",
30787
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
30788
+ pattern: "\\S",
30789
+ transform: [
30790
+ "trim"
30791
+ ]
30792
+ },
30793
+ {
30794
+ type: "object",
30795
+ title: "stopRecord (detailed)",
30796
+ additionalProperties: false,
30797
+ required: [
30798
+ "name"
30799
+ ],
30800
+ properties: {
30801
+ name: {
30802
+ type: "string",
30803
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
30804
+ pattern: "\\S",
30805
+ transform: [
30806
+ "trim"
30807
+ ]
30808
+ }
30809
+ }
30715
30810
  }
30716
- },
30717
- required: [
30718
- "line",
30719
- "startIndex",
30720
- "endIndex"
30721
30811
  ],
30722
- additionalProperties: false,
30723
- title: "Source Location"
30724
- },
30725
- autoScreenshot: {
30726
- type: "string",
30727
- minLength: 1,
30728
- pattern: "^(?![A-Za-z]:|[\\\\/])[^\\\\]+$",
30729
- description: "Path, relative to the run's artifact directory (the report's `runDir`), of the screenshot captured automatically after this step. Always a non-empty, forward-slash, relative path. Present only in test results, when `autoScreenshot` is enabled and the capture succeeded. This is system-populated metadata and should not be set manually.",
30730
- readOnly: true
30731
- }
30732
- },
30733
- title: "Common"
30734
- },
30735
- {
30736
- title: "loadVariables",
30737
- type: "object",
30738
- required: [
30739
- "loadVariables"
30740
- ],
30741
- properties: {
30742
- loadVariables: {
30743
- $schema: "http://json-schema.org/draft-07/schema#",
30744
- title: "loadVariables",
30745
- type: "string",
30746
- description: "Load environment variables from the specified `.env` file.",
30747
30812
  examples: [
30748
- ".env"
30813
+ true,
30814
+ "demo",
30815
+ {
30816
+ name: "demo"
30817
+ }
30749
30818
  ]
30750
30819
  }
30751
- }
30820
+ },
30821
+ title: "stopRecord"
30822
+ }
30823
+ ]
30824
+ },
30825
+ {
30826
+ allOf: [
30827
+ {
30828
+ type: "object",
30829
+ dynamicDefaults: {
30830
+ stepId: "uuid"
30831
+ },
30832
+ properties: {
30833
+ $schema: {
30834
+ description: "JSON Schema for this object.",
30835
+ type: "string",
30836
+ enum: [
30837
+ "https://raw.githubusercontent.com/doc-detective/common/refs/heads/main/dist/schemas/step_v3.schema.json"
30838
+ ]
30839
+ },
30840
+ stepId: {
30841
+ type: "string",
30842
+ description: "ID of the step."
30843
+ },
30844
+ description: {
30845
+ type: "string",
30846
+ description: "Description of the step."
30847
+ },
30848
+ unsafe: {
30849
+ type: "boolean",
30850
+ description: "Whether or not the step may be unsafe. Unsafe steps may perform actions that could modify the system or environment in unexpected ways. Unsafe steps are only performed within Docker containers or if unsafe steps are enabled with the `allowUnsafeSteps` config property or the `--allow-unsafe` flag.",
30851
+ default: false
30852
+ },
30853
+ outputs: {
30854
+ type: "object",
30855
+ description: "Outputs from step processes and user-defined expressions. Use the `outputs` object to reference outputs in subsequent steps. If a user-defined output matches the key for a step-defined output, the user-defined output takes precedence.",
30856
+ default: {},
30857
+ patternProperties: {
30858
+ "^[A-Za-z0-9_]+$": {
30859
+ type: "string",
30860
+ description: "Runtime expression for a user-defined output value."
30861
+ }
30862
+ },
30863
+ title: "Outputs (step)"
30864
+ },
30865
+ variables: {
30866
+ type: "object",
30867
+ description: "Environment variables to set from user-defined expressions.",
30868
+ default: {},
30869
+ patternProperties: {
30870
+ "^[A-Za-z0-9_]+$": {
30871
+ type: "string",
30872
+ description: "Runtime expression for a user-defined output value."
30873
+ }
30874
+ },
30875
+ title: "Variables (step)"
30876
+ },
30877
+ breakpoint: {
30878
+ type: "boolean",
30879
+ description: "Whether or not this step should act as a breakpoint when debugging is enabled. When `true`, execution will pause at this step when debug mode is enabled.",
30880
+ default: false
30881
+ },
30882
+ location: {
30883
+ type: "object",
30884
+ description: "Source location where this step was detected in the original file. This is system-populated metadata and should not be set manually.",
30885
+ readOnly: true,
30886
+ properties: {
30887
+ line: {
30888
+ type: "integer",
30889
+ description: "1-indexed line number in the source file where the step was detected.",
30890
+ minimum: 1
30891
+ },
30892
+ startIndex: {
30893
+ type: "integer",
30894
+ description: "0-indexed character offset from the start of the source file where the step begins.",
30895
+ minimum: 0
30896
+ },
30897
+ endIndex: {
30898
+ type: "integer",
30899
+ description: "0-indexed character offset from the start of the source file where the step ends (exclusive).",
30900
+ minimum: 0
30901
+ }
30902
+ },
30903
+ required: [
30904
+ "line",
30905
+ "startIndex",
30906
+ "endIndex"
30907
+ ],
30908
+ additionalProperties: false,
30909
+ title: "Source Location"
30910
+ },
30911
+ autoScreenshot: {
30912
+ type: "string",
30913
+ minLength: 1,
30914
+ pattern: "^(?![A-Za-z]:|[\\\\/])[^\\\\]+$",
30915
+ description: "Path, relative to the run's artifact directory (the report's `runDir`), of the screenshot captured automatically after this step. Always a non-empty, forward-slash, relative path. Present only in test results, when `autoScreenshot` is enabled and the capture succeeded. This is system-populated metadata and should not be set manually.",
30916
+ readOnly: true
30917
+ }
30918
+ },
30919
+ title: "Common"
30920
+ },
30921
+ {
30922
+ title: "loadVariables",
30923
+ type: "object",
30924
+ required: [
30925
+ "loadVariables"
30926
+ ],
30927
+ properties: {
30928
+ loadVariables: {
30929
+ $schema: "http://json-schema.org/draft-07/schema#",
30930
+ title: "loadVariables",
30931
+ type: "string",
30932
+ description: "Load environment variables from the specified `.env` file.",
30933
+ examples: [
30934
+ ".env"
30935
+ ]
30936
+ }
30937
+ }
30752
30938
  }
30753
30939
  ]
30754
30940
  },
@@ -32925,6 +33111,11 @@ var init_schemas = __esm({
32925
33111
  type: "boolean",
32926
33112
  default: false
32927
33113
  },
33114
+ autoRecord: {
33115
+ description: "If `true`, records a video of every test context that runs in a browser, in addition to any explicit `record` steps. The recording wraps the whole context (it starts before the first step and stops after the last) and always uses the `ffmpeg` engine. Videos are saved in the per-run artifact directory (`<output>/.doc-detective/run-<runId>/`) at paths derived from spec, test, and context IDs (for example, `recordings/<specId>/<testId>/<contextId>.mp4`), so the same context lands on the same relative path in every run's folder for run-over-run comparison. Specs and tests can override this value with their own `autoRecord` fields (test level wins over spec level, which wins over config level). Equivalent to `--auto-record` on the CLI.",
33116
+ type: "boolean",
33117
+ default: false
33118
+ },
32928
33119
  autoUpdate: {
32929
33120
  description: "If `true` (default), the CLI checks for a newer published `doc-detective` on startup and self-updates before running tests. Updates happen for global (`npm i -g`) and `npx` installs only \u2014 local installs (where `doc-detective` is a project dep) get an informational message instead, since auto-updating would mutate the user's lockfile. CI environments and the `DOC_DETECTIVE_SKIP_AUTO_UPDATE=1` env var also skip the check. Set to `false` to pin to the installed version. Equivalent to `--no-auto-update` on the CLI.",
32930
33121
  type: "boolean",
@@ -39332,6 +39523,14 @@ var init_schemas = __esm({
39332
39523
  "false"
39333
39524
  ]
39334
39525
  },
39526
+ name: {
39527
+ type: "string",
39528
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
39529
+ pattern: "\\S",
39530
+ transform: [
39531
+ "trim"
39532
+ ]
39533
+ },
39335
39534
  engine: {
39336
39535
  title: "Recording engine",
39337
39536
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -39427,6 +39626,14 @@ var init_schemas = __esm({
39427
39626
  "false"
39428
39627
  ]
39429
39628
  },
39629
+ name: {
39630
+ type: "string",
39631
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
39632
+ pattern: "\\S",
39633
+ transform: [
39634
+ "trim"
39635
+ ]
39636
+ },
39430
39637
  engine: {
39431
39638
  title: "Recording engine",
39432
39639
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -39662,13 +39869,52 @@ var init_schemas = __esm({
39662
39869
  stopRecord: {
39663
39870
  $schema: "http://json-schema.org/draft-07/schema#",
39664
39871
  title: "stopRecord",
39665
- description: "Stop the current recording.",
39666
- type: [
39667
- "boolean",
39668
- "null"
39872
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
39873
+ anyOf: [
39874
+ {
39875
+ type: "boolean",
39876
+ title: "stopRecord (boolean)",
39877
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
39878
+ },
39879
+ {
39880
+ type: "null",
39881
+ title: "stopRecord (null)",
39882
+ description: "Stops the most recently started active recording (LIFO)."
39883
+ },
39884
+ {
39885
+ type: "string",
39886
+ title: "stopRecord (name)",
39887
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
39888
+ pattern: "\\S",
39889
+ transform: [
39890
+ "trim"
39891
+ ]
39892
+ },
39893
+ {
39894
+ type: "object",
39895
+ title: "stopRecord (detailed)",
39896
+ additionalProperties: false,
39897
+ required: [
39898
+ "name"
39899
+ ],
39900
+ properties: {
39901
+ name: {
39902
+ type: "string",
39903
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
39904
+ pattern: "\\S",
39905
+ transform: [
39906
+ "trim"
39907
+ ]
39908
+ }
39909
+ }
39910
+ }
39669
39911
  ],
39670
39912
  examples: [
39671
- true
39913
+ true,
39914
+ "demo",
39915
+ {
39916
+ name: "demo"
39917
+ }
39672
39918
  ]
39673
39919
  }
39674
39920
  },
@@ -42158,6 +42404,10 @@ var init_schemas = __esm({
42158
42404
  type: "boolean",
42159
42405
  description: "If `true`, captures a screenshot after every step in this spec's tests that runs in a browser. Overrides the config-level `autoScreenshot`; individual tests can override this value with their own `autoScreenshot`. When unset, defers to the config level."
42160
42406
  },
42407
+ autoRecord: {
42408
+ type: "boolean",
42409
+ description: "If `true`, records a video of every browser context in this spec's tests. Overrides the config-level `autoRecord`; individual tests can override this value with their own `autoRecord`. When unset, defers to the config level."
42410
+ },
42161
42411
  tests: {
42162
42412
  description: "[Tests](test) to perform.",
42163
42413
  type: "array",
@@ -42191,6 +42441,10 @@ var init_schemas = __esm({
42191
42441
  type: "boolean",
42192
42442
  description: "If `true`, captures a screenshot after every step in this test that runs in a browser. Overrides `autoScreenshot` set at the spec or config level. When unset, defers to the spec level, then the config level."
42193
42443
  },
42444
+ autoRecord: {
42445
+ type: "boolean",
42446
+ description: "If `true`, records a video of every browser context in this test. Overrides `autoRecord` set at the spec or config level. When unset, defers to the spec level, then the config level."
42447
+ },
42194
42448
  runOn: {
42195
42449
  type: "array",
42196
42450
  description: "Contexts to run the test in. Overrides contexts defined at the config and spec levels.",
@@ -49047,6 +49301,14 @@ var init_schemas = __esm({
49047
49301
  "false"
49048
49302
  ]
49049
49303
  },
49304
+ name: {
49305
+ type: "string",
49306
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
49307
+ pattern: "\\S",
49308
+ transform: [
49309
+ "trim"
49310
+ ]
49311
+ },
49050
49312
  engine: {
49051
49313
  title: "Recording engine",
49052
49314
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -49142,6 +49404,14 @@ var init_schemas = __esm({
49142
49404
  "false"
49143
49405
  ]
49144
49406
  },
49407
+ name: {
49408
+ type: "string",
49409
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
49410
+ pattern: "\\S",
49411
+ transform: [
49412
+ "trim"
49413
+ ]
49414
+ },
49145
49415
  engine: {
49146
49416
  title: "Recording engine",
49147
49417
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -49377,13 +49647,52 @@ var init_schemas = __esm({
49377
49647
  stopRecord: {
49378
49648
  $schema: "http://json-schema.org/draft-07/schema#",
49379
49649
  title: "stopRecord",
49380
- description: "Stop the current recording.",
49381
- type: [
49382
- "boolean",
49383
- "null"
49650
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
49651
+ anyOf: [
49652
+ {
49653
+ type: "boolean",
49654
+ title: "stopRecord (boolean)",
49655
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
49656
+ },
49657
+ {
49658
+ type: "null",
49659
+ title: "stopRecord (null)",
49660
+ description: "Stops the most recently started active recording (LIFO)."
49661
+ },
49662
+ {
49663
+ type: "string",
49664
+ title: "stopRecord (name)",
49665
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
49666
+ pattern: "\\S",
49667
+ transform: [
49668
+ "trim"
49669
+ ]
49670
+ },
49671
+ {
49672
+ type: "object",
49673
+ title: "stopRecord (detailed)",
49674
+ additionalProperties: false,
49675
+ required: [
49676
+ "name"
49677
+ ],
49678
+ properties: {
49679
+ name: {
49680
+ type: "string",
49681
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
49682
+ pattern: "\\S",
49683
+ transform: [
49684
+ "trim"
49685
+ ]
49686
+ }
49687
+ }
49688
+ }
49384
49689
  ],
49385
49690
  examples: [
49386
- true
49691
+ true,
49692
+ "demo",
49693
+ {
49694
+ name: "demo"
49695
+ }
49387
49696
  ]
49388
49697
  }
49389
49698
  },
@@ -57521,6 +57830,14 @@ var init_schemas = __esm({
57521
57830
  "false"
57522
57831
  ]
57523
57832
  },
57833
+ name: {
57834
+ type: "string",
57835
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
57836
+ pattern: "\\S",
57837
+ transform: [
57838
+ "trim"
57839
+ ]
57840
+ },
57524
57841
  engine: {
57525
57842
  title: "Recording engine",
57526
57843
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -57616,6 +57933,14 @@ var init_schemas = __esm({
57616
57933
  "false"
57617
57934
  ]
57618
57935
  },
57936
+ name: {
57937
+ type: "string",
57938
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
57939
+ pattern: "\\S",
57940
+ transform: [
57941
+ "trim"
57942
+ ]
57943
+ },
57619
57944
  engine: {
57620
57945
  title: "Recording engine",
57621
57946
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -57851,13 +58176,52 @@ var init_schemas = __esm({
57851
58176
  stopRecord: {
57852
58177
  $schema: "http://json-schema.org/draft-07/schema#",
57853
58178
  title: "stopRecord",
57854
- description: "Stop the current recording.",
57855
- type: [
57856
- "boolean",
57857
- "null"
58179
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
58180
+ anyOf: [
58181
+ {
58182
+ type: "boolean",
58183
+ title: "stopRecord (boolean)",
58184
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
58185
+ },
58186
+ {
58187
+ type: "null",
58188
+ title: "stopRecord (null)",
58189
+ description: "Stops the most recently started active recording (LIFO)."
58190
+ },
58191
+ {
58192
+ type: "string",
58193
+ title: "stopRecord (name)",
58194
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
58195
+ pattern: "\\S",
58196
+ transform: [
58197
+ "trim"
58198
+ ]
58199
+ },
58200
+ {
58201
+ type: "object",
58202
+ title: "stopRecord (detailed)",
58203
+ additionalProperties: false,
58204
+ required: [
58205
+ "name"
58206
+ ],
58207
+ properties: {
58208
+ name: {
58209
+ type: "string",
58210
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
58211
+ pattern: "\\S",
58212
+ transform: [
58213
+ "trim"
58214
+ ]
58215
+ }
58216
+ }
58217
+ }
57858
58218
  ],
57859
58219
  examples: [
57860
- true
58220
+ true,
58221
+ "demo",
58222
+ {
58223
+ name: "demo"
58224
+ }
57861
58225
  ]
57862
58226
  }
57863
58227
  },
@@ -60834,6 +61198,10 @@ var init_schemas = __esm({
60834
61198
  type: "boolean",
60835
61199
  description: "If `true`, captures a screenshot after every step in this spec's tests that runs in a browser. Overrides the config-level `autoScreenshot`; individual tests can override this value with their own `autoScreenshot`. When unset, defers to the config level."
60836
61200
  },
61201
+ autoRecord: {
61202
+ type: "boolean",
61203
+ description: "If `true`, records a video of every browser context in this spec's tests. Overrides the config-level `autoRecord`; individual tests can override this value with their own `autoRecord`. When unset, defers to the config level."
61204
+ },
60837
61205
  tests: {
60838
61206
  description: "[Tests](test) to perform.",
60839
61207
  type: "array",
@@ -60867,6 +61235,10 @@ var init_schemas = __esm({
60867
61235
  type: "boolean",
60868
61236
  description: "If `true`, captures a screenshot after every step in this test that runs in a browser. Overrides `autoScreenshot` set at the spec or config level. When unset, defers to the spec level, then the config level."
60869
61237
  },
61238
+ autoRecord: {
61239
+ type: "boolean",
61240
+ description: "If `true`, records a video of every browser context in this test. Overrides `autoRecord` set at the spec or config level. When unset, defers to the spec level, then the config level."
61241
+ },
60870
61242
  runOn: {
60871
61243
  type: "array",
60872
61244
  description: "Contexts to run the test in. Overrides contexts defined at the config and spec levels.",
@@ -67723,6 +68095,14 @@ var init_schemas = __esm({
67723
68095
  "false"
67724
68096
  ]
67725
68097
  },
68098
+ name: {
68099
+ type: "string",
68100
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
68101
+ pattern: "\\S",
68102
+ transform: [
68103
+ "trim"
68104
+ ]
68105
+ },
67726
68106
  engine: {
67727
68107
  title: "Recording engine",
67728
68108
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -67818,6 +68198,14 @@ var init_schemas = __esm({
67818
68198
  "false"
67819
68199
  ]
67820
68200
  },
68201
+ name: {
68202
+ type: "string",
68203
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
68204
+ pattern: "\\S",
68205
+ transform: [
68206
+ "trim"
68207
+ ]
68208
+ },
67821
68209
  engine: {
67822
68210
  title: "Recording engine",
67823
68211
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -68053,13 +68441,52 @@ var init_schemas = __esm({
68053
68441
  stopRecord: {
68054
68442
  $schema: "http://json-schema.org/draft-07/schema#",
68055
68443
  title: "stopRecord",
68056
- description: "Stop the current recording.",
68057
- type: [
68058
- "boolean",
68059
- "null"
68444
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
68445
+ anyOf: [
68446
+ {
68447
+ type: "boolean",
68448
+ title: "stopRecord (boolean)",
68449
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
68450
+ },
68451
+ {
68452
+ type: "null",
68453
+ title: "stopRecord (null)",
68454
+ description: "Stops the most recently started active recording (LIFO)."
68455
+ },
68456
+ {
68457
+ type: "string",
68458
+ title: "stopRecord (name)",
68459
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
68460
+ pattern: "\\S",
68461
+ transform: [
68462
+ "trim"
68463
+ ]
68464
+ },
68465
+ {
68466
+ type: "object",
68467
+ title: "stopRecord (detailed)",
68468
+ additionalProperties: false,
68469
+ required: [
68470
+ "name"
68471
+ ],
68472
+ properties: {
68473
+ name: {
68474
+ type: "string",
68475
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
68476
+ pattern: "\\S",
68477
+ transform: [
68478
+ "trim"
68479
+ ]
68480
+ }
68481
+ }
68482
+ }
68060
68483
  ],
68061
68484
  examples: [
68062
- true
68485
+ true,
68486
+ "demo",
68487
+ {
68488
+ name: "demo"
68489
+ }
68063
68490
  ]
68064
68491
  }
68065
68492
  },
@@ -76197,6 +76624,14 @@ var init_schemas = __esm({
76197
76624
  "false"
76198
76625
  ]
76199
76626
  },
76627
+ name: {
76628
+ type: "string",
76629
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
76630
+ pattern: "\\S",
76631
+ transform: [
76632
+ "trim"
76633
+ ]
76634
+ },
76200
76635
  engine: {
76201
76636
  title: "Recording engine",
76202
76637
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -76292,6 +76727,14 @@ var init_schemas = __esm({
76292
76727
  "false"
76293
76728
  ]
76294
76729
  },
76730
+ name: {
76731
+ type: "string",
76732
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
76733
+ pattern: "\\S",
76734
+ transform: [
76735
+ "trim"
76736
+ ]
76737
+ },
76295
76738
  engine: {
76296
76739
  title: "Recording engine",
76297
76740
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -76527,13 +76970,52 @@ var init_schemas = __esm({
76527
76970
  stopRecord: {
76528
76971
  $schema: "http://json-schema.org/draft-07/schema#",
76529
76972
  title: "stopRecord",
76530
- description: "Stop the current recording.",
76531
- type: [
76532
- "boolean",
76533
- "null"
76973
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
76974
+ anyOf: [
76975
+ {
76976
+ type: "boolean",
76977
+ title: "stopRecord (boolean)",
76978
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
76979
+ },
76980
+ {
76981
+ type: "null",
76982
+ title: "stopRecord (null)",
76983
+ description: "Stops the most recently started active recording (LIFO)."
76984
+ },
76985
+ {
76986
+ type: "string",
76987
+ title: "stopRecord (name)",
76988
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
76989
+ pattern: "\\S",
76990
+ transform: [
76991
+ "trim"
76992
+ ]
76993
+ },
76994
+ {
76995
+ type: "object",
76996
+ title: "stopRecord (detailed)",
76997
+ additionalProperties: false,
76998
+ required: [
76999
+ "name"
77000
+ ],
77001
+ properties: {
77002
+ name: {
77003
+ type: "string",
77004
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
77005
+ pattern: "\\S",
77006
+ transform: [
77007
+ "trim"
77008
+ ]
77009
+ }
77010
+ }
77011
+ }
76534
77012
  ],
76535
77013
  examples: [
76536
- true
77014
+ true,
77015
+ "demo",
77016
+ {
77017
+ name: "demo"
77018
+ }
76537
77019
  ]
76538
77020
  }
76539
77021
  },
@@ -80888,6 +81370,10 @@ var init_schemas = __esm({
80888
81370
  type: "boolean",
80889
81371
  description: "If `true`, captures a screenshot after every step in this spec's tests that runs in a browser. Overrides the config-level `autoScreenshot`; individual tests can override this value with their own `autoScreenshot`. When unset, defers to the config level."
80890
81372
  },
81373
+ autoRecord: {
81374
+ type: "boolean",
81375
+ description: "If `true`, records a video of every browser context in this spec's tests. Overrides the config-level `autoRecord`; individual tests can override this value with their own `autoRecord`. When unset, defers to the config level."
81376
+ },
80891
81377
  tests: {
80892
81378
  description: "[Tests](test) to perform.",
80893
81379
  type: "array",
@@ -80921,6 +81407,10 @@ var init_schemas = __esm({
80921
81407
  type: "boolean",
80922
81408
  description: "If `true`, captures a screenshot after every step in this test that runs in a browser. Overrides `autoScreenshot` set at the spec or config level. When unset, defers to the spec level, then the config level."
80923
81409
  },
81410
+ autoRecord: {
81411
+ type: "boolean",
81412
+ description: "If `true`, records a video of every browser context in this test. Overrides `autoRecord` set at the spec or config level. When unset, defers to the spec level, then the config level."
81413
+ },
80924
81414
  runOn: {
80925
81415
  type: "array",
80926
81416
  description: "Contexts to run the test in. Overrides contexts defined at the config and spec levels.",
@@ -87777,6 +88267,14 @@ var init_schemas = __esm({
87777
88267
  "false"
87778
88268
  ]
87779
88269
  },
88270
+ name: {
88271
+ type: "string",
88272
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
88273
+ pattern: "\\S",
88274
+ transform: [
88275
+ "trim"
88276
+ ]
88277
+ },
87780
88278
  engine: {
87781
88279
  title: "Recording engine",
87782
88280
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -87872,6 +88370,14 @@ var init_schemas = __esm({
87872
88370
  "false"
87873
88371
  ]
87874
88372
  },
88373
+ name: {
88374
+ type: "string",
88375
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
88376
+ pattern: "\\S",
88377
+ transform: [
88378
+ "trim"
88379
+ ]
88380
+ },
87875
88381
  engine: {
87876
88382
  title: "Recording engine",
87877
88383
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -88107,13 +88613,52 @@ var init_schemas = __esm({
88107
88613
  stopRecord: {
88108
88614
  $schema: "http://json-schema.org/draft-07/schema#",
88109
88615
  title: "stopRecord",
88110
- description: "Stop the current recording.",
88111
- type: [
88112
- "boolean",
88113
- "null"
88616
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
88617
+ anyOf: [
88618
+ {
88619
+ type: "boolean",
88620
+ title: "stopRecord (boolean)",
88621
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
88622
+ },
88623
+ {
88624
+ type: "null",
88625
+ title: "stopRecord (null)",
88626
+ description: "Stops the most recently started active recording (LIFO)."
88627
+ },
88628
+ {
88629
+ type: "string",
88630
+ title: "stopRecord (name)",
88631
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
88632
+ pattern: "\\S",
88633
+ transform: [
88634
+ "trim"
88635
+ ]
88636
+ },
88637
+ {
88638
+ type: "object",
88639
+ title: "stopRecord (detailed)",
88640
+ additionalProperties: false,
88641
+ required: [
88642
+ "name"
88643
+ ],
88644
+ properties: {
88645
+ name: {
88646
+ type: "string",
88647
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
88648
+ pattern: "\\S",
88649
+ transform: [
88650
+ "trim"
88651
+ ]
88652
+ }
88653
+ }
88654
+ }
88114
88655
  ],
88115
88656
  examples: [
88116
- true
88657
+ true,
88658
+ "demo",
88659
+ {
88660
+ name: "demo"
88661
+ }
88117
88662
  ]
88118
88663
  }
88119
88664
  },
@@ -96251,6 +96796,14 @@ var init_schemas = __esm({
96251
96796
  "false"
96252
96797
  ]
96253
96798
  },
96799
+ name: {
96800
+ type: "string",
96801
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
96802
+ pattern: "\\S",
96803
+ transform: [
96804
+ "trim"
96805
+ ]
96806
+ },
96254
96807
  engine: {
96255
96808
  title: "Recording engine",
96256
96809
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -96346,6 +96899,14 @@ var init_schemas = __esm({
96346
96899
  "false"
96347
96900
  ]
96348
96901
  },
96902
+ name: {
96903
+ type: "string",
96904
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
96905
+ pattern: "\\S",
96906
+ transform: [
96907
+ "trim"
96908
+ ]
96909
+ },
96349
96910
  engine: {
96350
96911
  title: "Recording engine",
96351
96912
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -96581,13 +97142,52 @@ var init_schemas = __esm({
96581
97142
  stopRecord: {
96582
97143
  $schema: "http://json-schema.org/draft-07/schema#",
96583
97144
  title: "stopRecord",
96584
- description: "Stop the current recording.",
96585
- type: [
96586
- "boolean",
96587
- "null"
97145
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
97146
+ anyOf: [
97147
+ {
97148
+ type: "boolean",
97149
+ title: "stopRecord (boolean)",
97150
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
97151
+ },
97152
+ {
97153
+ type: "null",
97154
+ title: "stopRecord (null)",
97155
+ description: "Stops the most recently started active recording (LIFO)."
97156
+ },
97157
+ {
97158
+ type: "string",
97159
+ title: "stopRecord (name)",
97160
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
97161
+ pattern: "\\S",
97162
+ transform: [
97163
+ "trim"
97164
+ ]
97165
+ },
97166
+ {
97167
+ type: "object",
97168
+ title: "stopRecord (detailed)",
97169
+ additionalProperties: false,
97170
+ required: [
97171
+ "name"
97172
+ ],
97173
+ properties: {
97174
+ name: {
97175
+ type: "string",
97176
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
97177
+ pattern: "\\S",
97178
+ transform: [
97179
+ "trim"
97180
+ ]
97181
+ }
97182
+ }
97183
+ }
96588
97184
  ],
96589
97185
  examples: [
96590
- true
97186
+ true,
97187
+ "demo",
97188
+ {
97189
+ name: "demo"
97190
+ }
96591
97191
  ]
96592
97192
  }
96593
97193
  },
@@ -105005,6 +105605,14 @@ var init_schemas = __esm({
105005
105605
  "false"
105006
105606
  ]
105007
105607
  },
105608
+ name: {
105609
+ type: "string",
105610
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
105611
+ pattern: "\\S",
105612
+ transform: [
105613
+ "trim"
105614
+ ]
105615
+ },
105008
105616
  engine: {
105009
105617
  title: "Recording engine",
105010
105618
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -105100,6 +105708,14 @@ var init_schemas = __esm({
105100
105708
  "false"
105101
105709
  ]
105102
105710
  },
105711
+ name: {
105712
+ type: "string",
105713
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
105714
+ pattern: "\\S",
105715
+ transform: [
105716
+ "trim"
105717
+ ]
105718
+ },
105103
105719
  engine: {
105104
105720
  title: "Recording engine",
105105
105721
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -105335,13 +105951,52 @@ var init_schemas = __esm({
105335
105951
  stopRecord: {
105336
105952
  $schema: "http://json-schema.org/draft-07/schema#",
105337
105953
  title: "stopRecord",
105338
- description: "Stop the current recording.",
105339
- type: [
105340
- "boolean",
105341
- "null"
105954
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
105955
+ anyOf: [
105956
+ {
105957
+ type: "boolean",
105958
+ title: "stopRecord (boolean)",
105959
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
105960
+ },
105961
+ {
105962
+ type: "null",
105963
+ title: "stopRecord (null)",
105964
+ description: "Stops the most recently started active recording (LIFO)."
105965
+ },
105966
+ {
105967
+ type: "string",
105968
+ title: "stopRecord (name)",
105969
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
105970
+ pattern: "\\S",
105971
+ transform: [
105972
+ "trim"
105973
+ ]
105974
+ },
105975
+ {
105976
+ type: "object",
105977
+ title: "stopRecord (detailed)",
105978
+ additionalProperties: false,
105979
+ required: [
105980
+ "name"
105981
+ ],
105982
+ properties: {
105983
+ name: {
105984
+ type: "string",
105985
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
105986
+ pattern: "\\S",
105987
+ transform: [
105988
+ "trim"
105989
+ ]
105990
+ }
105991
+ }
105992
+ }
105342
105993
  ],
105343
105994
  examples: [
105344
- true
105995
+ true,
105996
+ "demo",
105997
+ {
105998
+ name: "demo"
105999
+ }
105345
106000
  ]
105346
106001
  }
105347
106002
  },
@@ -106944,13 +107599,52 @@ var init_schemas = __esm({
106944
107599
  stopRecord_v3: {
106945
107600
  $schema: "http://json-schema.org/draft-07/schema#",
106946
107601
  title: "stopRecord",
106947
- description: "Stop the current recording.",
106948
- type: [
106949
- "boolean",
106950
- "null"
107602
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
107603
+ anyOf: [
107604
+ {
107605
+ type: "boolean",
107606
+ title: "stopRecord (boolean)",
107607
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
107608
+ },
107609
+ {
107610
+ type: "null",
107611
+ title: "stopRecord (null)",
107612
+ description: "Stops the most recently started active recording (LIFO)."
107613
+ },
107614
+ {
107615
+ type: "string",
107616
+ title: "stopRecord (name)",
107617
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
107618
+ pattern: "\\S",
107619
+ transform: [
107620
+ "trim"
107621
+ ]
107622
+ },
107623
+ {
107624
+ type: "object",
107625
+ title: "stopRecord (detailed)",
107626
+ additionalProperties: false,
107627
+ required: [
107628
+ "name"
107629
+ ],
107630
+ properties: {
107631
+ name: {
107632
+ type: "string",
107633
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
107634
+ pattern: "\\S",
107635
+ transform: [
107636
+ "trim"
107637
+ ]
107638
+ }
107639
+ }
107640
+ }
106951
107641
  ],
106952
107642
  examples: [
106953
- true
107643
+ true,
107644
+ "demo",
107645
+ {
107646
+ name: "demo"
107647
+ }
106954
107648
  ]
106955
107649
  },
106956
107650
  test_v3: {
@@ -106980,6 +107674,10 @@ var init_schemas = __esm({
106980
107674
  type: "boolean",
106981
107675
  description: "If `true`, captures a screenshot after every step in this test that runs in a browser. Overrides `autoScreenshot` set at the spec or config level. When unset, defers to the spec level, then the config level."
106982
107676
  },
107677
+ autoRecord: {
107678
+ type: "boolean",
107679
+ description: "If `true`, records a video of every browser context in this test. Overrides `autoRecord` set at the spec or config level. When unset, defers to the spec level, then the config level."
107680
+ },
106983
107681
  runOn: {
106984
107682
  type: "array",
106985
107683
  description: "Contexts to run the test in. Overrides contexts defined at the config and spec levels.",
@@ -113836,6 +114534,14 @@ var init_schemas = __esm({
113836
114534
  "false"
113837
114535
  ]
113838
114536
  },
114537
+ name: {
114538
+ type: "string",
114539
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
114540
+ pattern: "\\S",
114541
+ transform: [
114542
+ "trim"
114543
+ ]
114544
+ },
113839
114545
  engine: {
113840
114546
  title: "Recording engine",
113841
114547
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -113931,6 +114637,14 @@ var init_schemas = __esm({
113931
114637
  "false"
113932
114638
  ]
113933
114639
  },
114640
+ name: {
114641
+ type: "string",
114642
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
114643
+ pattern: "\\S",
114644
+ transform: [
114645
+ "trim"
114646
+ ]
114647
+ },
113934
114648
  engine: {
113935
114649
  title: "Recording engine",
113936
114650
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -114166,13 +114880,52 @@ var init_schemas = __esm({
114166
114880
  stopRecord: {
114167
114881
  $schema: "http://json-schema.org/draft-07/schema#",
114168
114882
  title: "stopRecord",
114169
- description: "Stop the current recording.",
114170
- type: [
114171
- "boolean",
114172
- "null"
114883
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
114884
+ anyOf: [
114885
+ {
114886
+ type: "boolean",
114887
+ title: "stopRecord (boolean)",
114888
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
114889
+ },
114890
+ {
114891
+ type: "null",
114892
+ title: "stopRecord (null)",
114893
+ description: "Stops the most recently started active recording (LIFO)."
114894
+ },
114895
+ {
114896
+ type: "string",
114897
+ title: "stopRecord (name)",
114898
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
114899
+ pattern: "\\S",
114900
+ transform: [
114901
+ "trim"
114902
+ ]
114903
+ },
114904
+ {
114905
+ type: "object",
114906
+ title: "stopRecord (detailed)",
114907
+ additionalProperties: false,
114908
+ required: [
114909
+ "name"
114910
+ ],
114911
+ properties: {
114912
+ name: {
114913
+ type: "string",
114914
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
114915
+ pattern: "\\S",
114916
+ transform: [
114917
+ "trim"
114918
+ ]
114919
+ }
114920
+ }
114921
+ }
114173
114922
  ],
114174
114923
  examples: [
114175
- true
114924
+ true,
114925
+ "demo",
114926
+ {
114927
+ name: "demo"
114928
+ }
114176
114929
  ]
114177
114930
  }
114178
114931
  },
@@ -122310,6 +123063,14 @@ var init_schemas = __esm({
122310
123063
  "false"
122311
123064
  ]
122312
123065
  },
123066
+ name: {
123067
+ type: "string",
123068
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
123069
+ pattern: "\\S",
123070
+ transform: [
123071
+ "trim"
123072
+ ]
123073
+ },
122313
123074
  engine: {
122314
123075
  title: "Recording engine",
122315
123076
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -122405,6 +123166,14 @@ var init_schemas = __esm({
122405
123166
  "false"
122406
123167
  ]
122407
123168
  },
123169
+ name: {
123170
+ type: "string",
123171
+ description: 'Identifier for this recording. A later `stopRecord` step can target it by name (`stopRecord: "<name>"`), which is how you stop a specific recording when several overlap. Names must be unique among recordings that are active at the same time. If omitted, the recording is anonymous and is stopped LIFO by an untargeted `stopRecord`.',
123172
+ pattern: "\\S",
123173
+ transform: [
123174
+ "trim"
123175
+ ]
123176
+ },
122408
123177
  engine: {
122409
123178
  title: "Recording engine",
122410
123179
  description: "Recording engine to use. Either a string shorthand selecting the engine with defaults, or an object for full control. If unset, defaults to the `browser` engine when a visible Chrome context is available and to `ffmpeg` otherwise.",
@@ -122640,13 +123409,52 @@ var init_schemas = __esm({
122640
123409
  stopRecord: {
122641
123410
  $schema: "http://json-schema.org/draft-07/schema#",
122642
123411
  title: "stopRecord",
122643
- description: "Stop the current recording.",
122644
- type: [
122645
- "boolean",
122646
- "null"
123412
+ description: 'Stop a recording started by an earlier `record` step. With no target (`true`/`null`), stops the most recently started recording that is still active (LIFO). To stop a specific recording when several overlap, target it by name with a string (`stopRecord: "<name>"`) or an object (`stopRecord: { name: "<name>" }`).',
123413
+ anyOf: [
123414
+ {
123415
+ type: "boolean",
123416
+ title: "stopRecord (boolean)",
123417
+ description: "If `true`, stops the most recently started active recording (LIFO). If `false`, does nothing \u2014 an explicit no-op (mirrors `record: false`)."
123418
+ },
123419
+ {
123420
+ type: "null",
123421
+ title: "stopRecord (null)",
123422
+ description: "Stops the most recently started active recording (LIFO)."
123423
+ },
123424
+ {
123425
+ type: "string",
123426
+ title: "stopRecord (name)",
123427
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
123428
+ pattern: "\\S",
123429
+ transform: [
123430
+ "trim"
123431
+ ]
123432
+ },
123433
+ {
123434
+ type: "object",
123435
+ title: "stopRecord (detailed)",
123436
+ additionalProperties: false,
123437
+ required: [
123438
+ "name"
123439
+ ],
123440
+ properties: {
123441
+ name: {
123442
+ type: "string",
123443
+ description: "Name of the recording to stop. Matches the `name` given to a `record` step.",
123444
+ pattern: "\\S",
123445
+ transform: [
123446
+ "trim"
123447
+ ]
123448
+ }
123449
+ }
123450
+ }
122647
123451
  ],
122648
123452
  examples: [
122649
- true
123453
+ true,
123454
+ "demo",
123455
+ {
123456
+ name: "demo"
123457
+ }
122650
123458
  ]
122651
123459
  }
122652
123460
  },
@@ -134404,10 +135212,10 @@ function getRunOutputDir(config, { create = true } = {}) {
134404
135212
  function runArchivesArtifacts(config = {}, specs = []) {
134405
135213
  const list = Array.isArray(specs) ? specs : [];
134406
135214
  if (list.length > 0) {
134407
- const writesScreenshot = list.some((spec) => (spec?.tests ?? []).some((test) => Boolean(test?.autoScreenshot ?? spec?.autoScreenshot ?? config?.autoScreenshot)));
134408
- if (writesScreenshot)
135215
+ const writesArtifact = list.some((spec) => (spec?.tests ?? []).some((test) => Boolean(test?.autoScreenshot ?? spec?.autoScreenshot ?? config?.autoScreenshot) || Boolean(test?.autoRecord ?? spec?.autoRecord ?? config?.autoRecord)));
135216
+ if (writesArtifact)
134409
135217
  return true;
134410
- } else if (Boolean(config?.autoScreenshot)) {
135218
+ } else if (Boolean(config?.autoScreenshot) || Boolean(config?.autoRecord)) {
134411
135219
  return true;
134412
135220
  }
134413
135221
  const active = Array.isArray(config?.reporters) && config.reporters.length > 0 ? config.reporters : ["terminal", "json", "runFolder"];
@@ -139774,6 +140582,375 @@ async function findElementByCriteria({ selector, elementText, elementId, element
139774
140582
  // dist/core/tests/typeKeys.js
139775
140583
  init_validate();
139776
140584
  init_loader();
140585
+
140586
+ // dist/core/tests/ffmpegRecorder.js
140587
+ var import_node_child_process4 = require("node:child_process");
140588
+ var import_node_os6 = __toESM(require("node:os"), 1);
140589
+ var import_node_path10 = __toESM(require("node:path"), 1);
140590
+ var import_node_fs10 = __toESM(require("node:fs"), 1);
140591
+ var import_node_crypto5 = __toESM(require("node:crypto"), 1);
140592
+ init_loader();
140593
+ init_utils();
140594
+ var XVFB_SCREEN_SIZE = "1920x1080";
140595
+ function isRecordingActive(driver) {
140596
+ return Array.isArray(driver?.state?.recordings) && driver.state.recordings.length > 0;
140597
+ }
140598
+ function recordStepName(record) {
140599
+ if (record && typeof record === "object" && typeof record.name === "string") {
140600
+ const trimmed = record.name.trim();
140601
+ return trimmed.length > 0 ? trimmed : void 0;
140602
+ }
140603
+ return void 0;
140604
+ }
140605
+ function stopRecordTargetName(stopRecord) {
140606
+ if (typeof stopRecord === "string") {
140607
+ const trimmed = stopRecord.trim();
140608
+ return trimmed.length > 0 ? trimmed : void 0;
140609
+ }
140610
+ if (stopRecord && typeof stopRecord === "object" && typeof stopRecord.name === "string") {
140611
+ const trimmed = stopRecord.name.trim();
140612
+ return trimmed.length > 0 ? trimmed : void 0;
140613
+ }
140614
+ return void 0;
140615
+ }
140616
+ function selectRecordingToStop(recordings, stopRecord, { includeSynthetic = false } = {}) {
140617
+ if (!Array.isArray(recordings) || recordings.length === 0)
140618
+ return void 0;
140619
+ const target = stopRecordTargetName(stopRecord);
140620
+ if (target !== void 0) {
140621
+ return recordings.find((r) => r && r.name === target);
140622
+ }
140623
+ for (let i = recordings.length - 1; i >= 0; i--) {
140624
+ const r = recordings[i];
140625
+ if (includeSynthetic || !r?.synthetic)
140626
+ return r;
140627
+ }
140628
+ return void 0;
140629
+ }
140630
+ function detectRecordingNameConflict(steps) {
140631
+ if (!Array.isArray(steps))
140632
+ return null;
140633
+ const active = [];
140634
+ for (const step of steps) {
140635
+ if (!step || typeof step !== "object")
140636
+ continue;
140637
+ const isStart = typeof step.record !== "undefined" && step.record !== false;
140638
+ const isStop = typeof step.stopRecord !== "undefined" && step.stopRecord !== false;
140639
+ if (isStart) {
140640
+ const name = recordStepName(step.record);
140641
+ if (name !== void 0 && active.includes(name))
140642
+ return name;
140643
+ active.push(name);
140644
+ } else if (isStop) {
140645
+ const target = stopRecordTargetName(step.stopRecord);
140646
+ if (target !== void 0) {
140647
+ const idx = active.lastIndexOf(target);
140648
+ if (idx !== -1)
140649
+ active.splice(idx, 1);
140650
+ } else if (active.length > 0) {
140651
+ active.pop();
140652
+ }
140653
+ }
140654
+ }
140655
+ return null;
140656
+ }
140657
+ function safeContextId(contextId) {
140658
+ const raw = String(contextId ?? "ctx");
140659
+ const base = sanitizeFilesystemName(raw, "ctx");
140660
+ if (base === raw)
140661
+ return base;
140662
+ const hash = import_node_crypto5.default.createHash("sha1").update(raw).digest("hex").slice(0, 8);
140663
+ return `${base}-${hash}`;
140664
+ }
140665
+ function browserCaptureTitle(contextId) {
140666
+ return `RECORD_ME_${safeContextId(contextId)}`;
140667
+ }
140668
+ function browserDownloadDir(contextId) {
140669
+ return import_node_path10.default.join(import_node_os6.default.tmpdir(), "doc-detective", "recordings", safeContextId(contextId));
140670
+ }
140671
+ function engineFields(record) {
140672
+ const engine = record && typeof record === "object" ? record.engine : void 0;
140673
+ if (typeof engine === "string")
140674
+ return { name: engine };
140675
+ if (engine && typeof engine === "object")
140676
+ return { name: engine.name, target: engine.target, fps: engine.fps };
140677
+ return {};
140678
+ }
140679
+ function resolveRecordPlan({ step, context }) {
140680
+ const { name, target, fps } = engineFields(step?.record);
140681
+ let engineName = name;
140682
+ if (!engineName) {
140683
+ const b = context?.browser;
140684
+ engineName = b?.name === "chrome" && b?.headless === false ? "browser" : "ffmpeg";
140685
+ }
140686
+ return {
140687
+ name: engineName,
140688
+ target: target || "display",
140689
+ fps: fps ?? 30
140690
+ };
140691
+ }
140692
+ function hasRecordStepWithoutEngine(context) {
140693
+ const steps = Array.isArray(context?.steps) ? context.steps : [];
140694
+ return steps.some((s) => {
140695
+ if (!s?.record)
140696
+ return false;
140697
+ return engineFields(s.record).name === void 0;
140698
+ });
140699
+ }
140700
+ function coerceRecordContextBrowser({ context, availableApps }) {
140701
+ if (context?.browser)
140702
+ return null;
140703
+ if (!hasRecordStepWithoutEngine(context))
140704
+ return null;
140705
+ const chromeAvailable = Array.isArray(availableApps) && availableApps.some((a) => a?.name === "chrome");
140706
+ if (!chromeAvailable)
140707
+ return null;
140708
+ return { name: "chrome", headless: false };
140709
+ }
140710
+ function buildCaptureArgs({ platform, fps, displayEnv, outputPath, screenIndex, screenSize }) {
140711
+ const rate = String(fps ?? 30);
140712
+ let input;
140713
+ switch (platform) {
140714
+ case "win32":
140715
+ input = ["-f", "gdigrab", "-framerate", rate, "-i", "desktop"];
140716
+ break;
140717
+ case "darwin":
140718
+ input = [
140719
+ "-f",
140720
+ "avfoundation",
140721
+ "-framerate",
140722
+ rate,
140723
+ "-i",
140724
+ `${screenIndex ?? "0"}:none`
140725
+ ];
140726
+ break;
140727
+ case "linux":
140728
+ input = [
140729
+ "-f",
140730
+ "x11grab",
140731
+ "-framerate",
140732
+ rate,
140733
+ ...screenSize ? ["-video_size", screenSize] : [],
140734
+ "-i",
140735
+ displayEnv || ":0.0"
140736
+ ];
140737
+ break;
140738
+ default:
140739
+ throw new Error(`Screen recording isn't supported on platform '${platform}'.`);
140740
+ }
140741
+ return ["-y", ...input, "-pix_fmt", "yuv420p", outputPath];
140742
+ }
140743
+ async function resolveCropGeometry({ driver, target }) {
140744
+ if (target === "viewport") {
140745
+ const m = await driver.execute(() => {
140746
+ return {
140747
+ sx: window.screenX,
140748
+ sy: window.screenY,
140749
+ iw: window.innerWidth,
140750
+ ih: window.innerHeight,
140751
+ ow: window.outerWidth,
140752
+ oh: window.outerHeight,
140753
+ dpr: window.devicePixelRatio || 1
140754
+ };
140755
+ });
140756
+ const dpr = m.dpr || 1;
140757
+ const border = Math.max(0, (m.ow - m.iw) / 2);
140758
+ const topChrome = Math.max(0, m.oh - m.ih - border);
140759
+ return {
140760
+ x: Math.round((m.sx + border) * dpr),
140761
+ y: Math.round((m.sy + topChrome) * dpr),
140762
+ w: Math.round(m.iw * dpr),
140763
+ h: Math.round(m.ih * dpr)
140764
+ };
140765
+ }
140766
+ if (target === "window") {
140767
+ const r = await driver.getWindowRect();
140768
+ let dpr;
140769
+ try {
140770
+ dpr = await driver.execute(() => window.devicePixelRatio || 1) || 1;
140771
+ } catch {
140772
+ dpr = 1;
140773
+ }
140774
+ return {
140775
+ x: Math.round(r.x * dpr),
140776
+ y: Math.round(r.y * dpr),
140777
+ w: Math.round(r.width * dpr),
140778
+ h: Math.round(r.height * dpr)
140779
+ };
140780
+ }
140781
+ return null;
140782
+ }
140783
+ function jobIsFfmpegRecording(job) {
140784
+ const context = job?.context;
140785
+ const steps = Array.isArray(context?.steps) ? context.steps : [];
140786
+ const activeBrowser = [];
140787
+ for (const s of steps) {
140788
+ const isStop = typeof s?.stopRecord !== "undefined" && s.stopRecord !== false;
140789
+ if (isStop) {
140790
+ const target = stopRecordTargetName(s.stopRecord);
140791
+ if (target !== void 0) {
140792
+ const idx = activeBrowser.lastIndexOf(target);
140793
+ if (idx !== -1)
140794
+ activeBrowser.splice(idx, 1);
140795
+ } else if (activeBrowser.length > 0) {
140796
+ activeBrowser.pop();
140797
+ }
140798
+ continue;
140799
+ }
140800
+ if (!s?.record || s.record === false)
140801
+ continue;
140802
+ const plan = resolveRecordPlan({ step: s, context });
140803
+ if (plan.name === "ffmpeg")
140804
+ return true;
140805
+ const supportsBrowser = context?.browser?.name === "chrome" && !context?.browser?.headless;
140806
+ if (!supportsBrowser)
140807
+ continue;
140808
+ if (activeBrowser.length > 0)
140809
+ return true;
140810
+ activeBrowser.push(recordStepName(s.record));
140811
+ }
140812
+ return false;
140813
+ }
140814
+ function computeEffectiveConcurrency({ requestedLimit, jobs, platform, xvfbAvailable, allowOverlappingCaptures = false }) {
140815
+ const ffmpegJobs = (jobs || []).filter(jobIsFfmpegRecording);
140816
+ if (ffmpegJobs.length === 0) {
140817
+ return { limit: requestedLimit, xvfbContexts: [], forcedSerial: false };
140818
+ }
140819
+ if (platform === "linux" && xvfbAvailable) {
140820
+ return {
140821
+ limit: requestedLimit,
140822
+ xvfbContexts: ffmpegJobs.map((j) => j.context),
140823
+ forcedSerial: false
140824
+ };
140825
+ }
140826
+ if (allowOverlappingCaptures) {
140827
+ return {
140828
+ limit: requestedLimit,
140829
+ xvfbContexts: [],
140830
+ forcedSerial: false,
140831
+ overlappingCaptures: true
140832
+ };
140833
+ }
140834
+ return { limit: 1, xvfbContexts: [], forcedSerial: requestedLimit > 1 };
140835
+ }
140836
+ async function getFfmpegPath(ctx = {}) {
140837
+ const mod = await loadHeavyDep("@ffmpeg-installer/ffmpeg", { ctx });
140838
+ const candidate = mod && (mod.path ?? mod.default?.path);
140839
+ if (typeof candidate !== "string" || candidate.length === 0) {
140840
+ throw new Error("ffmpeg binary path is missing or malformed in the installed @ffmpeg-installer/ffmpeg package. Try `doc-detective install runtime --force` to reinstall.");
140841
+ }
140842
+ return candidate;
140843
+ }
140844
+ function parseMacScreenIndex(listing) {
140845
+ const m = /\[(\d+)\]\s+Capture screen/i.exec(listing || "");
140846
+ return m ? m[1] : null;
140847
+ }
140848
+ async function detectMacScreenIndex(ffmpegPath) {
140849
+ return new Promise((resolve) => {
140850
+ let out = "";
140851
+ let settled = false;
140852
+ let proc = null;
140853
+ const done = (v) => {
140854
+ if (settled)
140855
+ return;
140856
+ settled = true;
140857
+ try {
140858
+ proc?.kill();
140859
+ } catch {
140860
+ }
140861
+ resolve(v);
140862
+ };
140863
+ try {
140864
+ proc = (0, import_node_child_process4.spawn)(ffmpegPath, ["-f", "avfoundation", "-list_devices", "true", "-i", ""], { stdio: ["ignore", "ignore", "pipe"] });
140865
+ proc.stderr?.on("data", (d) => {
140866
+ out += d.toString();
140867
+ });
140868
+ proc.on("error", () => done(null));
140869
+ proc.on("close", () => done(parseMacScreenIndex(out)));
140870
+ setTimeout(() => done(null), 5e3);
140871
+ } catch {
140872
+ done(null);
140873
+ }
140874
+ });
140875
+ }
140876
+ async function detectX11ScreenSize(display) {
140877
+ return new Promise((resolve) => {
140878
+ let out = "";
140879
+ let settled = false;
140880
+ let proc = null;
140881
+ const done = (v) => {
140882
+ if (settled)
140883
+ return;
140884
+ settled = true;
140885
+ try {
140886
+ proc?.kill();
140887
+ } catch {
140888
+ }
140889
+ resolve(v);
140890
+ };
140891
+ try {
140892
+ const env = display ? { ...process.env, DISPLAY: display } : process.env;
140893
+ proc = (0, import_node_child_process4.spawn)("xdpyinfo", [], { env, stdio: ["ignore", "pipe", "ignore"] });
140894
+ proc.stdout?.on("data", (d) => {
140895
+ out += d.toString();
140896
+ });
140897
+ proc.on("error", () => done(null));
140898
+ proc.on("close", () => {
140899
+ const m = /dimensions:\s+(\d+x\d+)\s+pixels/i.exec(out);
140900
+ done(m ? m[1] : null);
140901
+ });
140902
+ setTimeout(() => done(null), 5e3);
140903
+ } catch {
140904
+ done(null);
140905
+ }
140906
+ });
140907
+ }
140908
+ async function checkSystemBinary(name) {
140909
+ return new Promise((resolve) => {
140910
+ try {
140911
+ const proc = (0, import_node_child_process4.spawn)(name, ["-help"], { stdio: "ignore" });
140912
+ proc.on("error", () => resolve(false));
140913
+ proc.on("close", () => resolve(true));
140914
+ } catch {
140915
+ resolve(false);
140916
+ }
140917
+ });
140918
+ }
140919
+ function xvfbDisplay(index) {
140920
+ return `:${99 + index}`;
140921
+ }
140922
+ async function startXvfb(display, opts = {}) {
140923
+ const num = display.replace(/^:/, "").split(".")[0];
140924
+ const [defW, defH] = XVFB_SCREEN_SIZE.split("x").map(Number);
140925
+ const w = opts.width ?? defW;
140926
+ const h = opts.height ?? defH;
140927
+ const startMs = Date.now();
140928
+ const proc = (0, import_node_child_process4.spawn)("Xvfb", [display, "-screen", "0", `${w}x${h}x24`, "-nolisten", "tcp"], { stdio: "ignore" });
140929
+ let spawnErr = null;
140930
+ proc.on("error", (e) => {
140931
+ spawnErr = e;
140932
+ });
140933
+ const lock = `/tmp/.X${num}-lock`;
140934
+ for (let i = 0; i < 50; i++) {
140935
+ if (spawnErr)
140936
+ throw spawnErr;
140937
+ if (proc.exitCode !== null)
140938
+ throw new Error(`Xvfb exited early on ${display} (code ${proc.exitCode})`);
140939
+ try {
140940
+ if (import_node_fs10.default.statSync(lock).mtimeMs >= startMs)
140941
+ return proc;
140942
+ } catch {
140943
+ }
140944
+ await new Promise((r) => setTimeout(r, 100));
140945
+ }
140946
+ try {
140947
+ proc.kill();
140948
+ } catch {
140949
+ }
140950
+ throw new Error(`Xvfb did not become ready on ${display} within 5s.`);
140951
+ }
140952
+
140953
+ // dist/core/tests/typeKeys.js
139777
140954
  var _specialKeyMap = null;
139778
140955
  async function getSpecialKeyMap(ctx = {}) {
139779
140956
  if (_specialKeyMap)
@@ -139900,7 +141077,7 @@ async function typeKeys({ config, step, driver }) {
139900
141077
  return result;
139901
141078
  }
139902
141079
  }
139903
- if (driver?.state?.recording) {
141080
+ if (isRecordingActive(driver)) {
139904
141081
  let keys = [];
139905
141082
  step.type.keys.forEach((key) => {
139906
141083
  if (key.startsWith("$") && key.endsWith("$")) {
@@ -139930,7 +141107,7 @@ async function typeKeys({ config, step, driver }) {
139930
141107
  });
139931
141108
  }
139932
141109
  try {
139933
- if (driver?.state?.recording) {
141110
+ if (isRecordingActive(driver)) {
139934
141111
  for (let i = 0; i < step.type.keys.length; i++) {
139935
141112
  await driver.keys(step.type.keys[i]);
139936
141113
  await new Promise((resolve) => setTimeout(resolve, step.type.inputDelay));
@@ -140094,7 +141271,7 @@ async function findElement({ config, step, driver, click }) {
140094
141271
  result.description += " Typed keys.";
140095
141272
  }
140096
141273
  }
140097
- if (driver?.state?.recording) {
141274
+ if (isRecordingActive(driver)) {
140098
141275
  await wait({ config, step: { wait: 2e3 }, driver });
140099
141276
  }
140100
141277
  return result;
@@ -140483,8 +141660,8 @@ async function waitForDOMStable(driver, idleTime, timeout) {
140483
141660
  // dist/core/tests/runShell.js
140484
141661
  init_validate();
140485
141662
  init_utils();
140486
- var import_node_fs10 = __toESM(require("node:fs"), 1);
140487
- var import_node_path10 = __toESM(require("node:path"), 1);
141663
+ var import_node_fs11 = __toESM(require("node:fs"), 1);
141664
+ var import_node_path11 = __toESM(require("node:path"), 1);
140488
141665
  async function runShell({ config, step }) {
140489
141666
  const result = {
140490
141667
  status: "PASS",
@@ -140557,24 +141734,24 @@ async function runShell({ config, step }) {
140557
141734
  }
140558
141735
  }
140559
141736
  if (step.runShell.path) {
140560
- const dir = import_node_path10.default.dirname(step.runShell.path);
140561
- if (!import_node_fs10.default.existsSync(dir)) {
140562
- import_node_fs10.default.mkdirSync(dir, { recursive: true });
141737
+ const dir = import_node_path11.default.dirname(step.runShell.path);
141738
+ if (!import_node_fs11.default.existsSync(dir)) {
141739
+ import_node_fs11.default.mkdirSync(dir, { recursive: true });
140563
141740
  }
140564
141741
  let filePath = step.runShell.path;
140565
141742
  log(config, "debug", `Saving stdio to file: ${filePath}`);
140566
- if (!import_node_fs10.default.existsSync(filePath)) {
140567
- import_node_fs10.default.writeFileSync(filePath, result.outputs.stdio.stdout);
141743
+ if (!import_node_fs11.default.existsSync(filePath)) {
141744
+ import_node_fs11.default.writeFileSync(filePath, result.outputs.stdio.stdout);
140568
141745
  } else {
140569
141746
  if (step.runShell.overwrite == "false") {
140570
141747
  result.description = result.description + ` Didn't save output. File already exists.`;
140571
141748
  }
140572
- const existingFile = import_node_fs10.default.readFileSync(filePath, "utf8");
141749
+ const existingFile = import_node_fs11.default.readFileSync(filePath, "utf8");
140573
141750
  const fractionalDiff = calculateFractionalDifference(existingFile, result.outputs.stdio.stdout);
140574
141751
  log(config, "debug", `Fractional difference: ${fractionalDiff}`);
140575
141752
  if (fractionalDiff > step.runShell.maxVariation) {
140576
141753
  if (step.runShell.overwrite == "aboveVariation") {
140577
- import_node_fs10.default.writeFileSync(filePath, result.outputs.stdio.stdout);
141754
+ import_node_fs11.default.writeFileSync(filePath, result.outputs.stdio.stdout);
140578
141755
  result.description += ` Saved output to file.`;
140579
141756
  }
140580
141757
  result.status = "WARNING";
@@ -140582,7 +141759,7 @@ async function runShell({ config, step }) {
140582
141759
  return result;
140583
141760
  }
140584
141761
  if (step.runShell.overwrite == "true") {
140585
- import_node_fs10.default.writeFileSync(filePath, result.outputs.stdio.stdout);
141762
+ import_node_fs11.default.writeFileSync(filePath, result.outputs.stdio.stdout);
140586
141763
  result.description += ` Saved output to file.`;
140587
141764
  }
140588
141765
  }
@@ -140766,8 +141943,8 @@ async function checkLink({ config, step }) {
140766
141943
  // dist/core/tests/saveScreenshot.js
140767
141944
  init_validate();
140768
141945
  init_utils();
140769
- var import_node_path11 = __toESM(require("node:path"), 1);
140770
- var import_node_fs11 = __toESM(require("node:fs"), 1);
141946
+ var import_node_path12 = __toESM(require("node:path"), 1);
141947
+ var import_node_fs12 = __toESM(require("node:fs"), 1);
140771
141948
  init_loader();
140772
141949
  var _pngjs = null;
140773
141950
  var _sharp = null;
@@ -140855,7 +142032,7 @@ async function saveScreenshot({ config, step, driver }) {
140855
142032
  if (typeof step.screenshot.path === "undefined") {
140856
142033
  step.screenshot.path = `${step.stepId}.png`;
140857
142034
  if (step.screenshot.directory) {
140858
- step.screenshot.path = import_node_path11.default.resolve(step.screenshot.directory, step.screenshot.path);
142035
+ step.screenshot.path = import_node_path12.default.resolve(step.screenshot.directory, step.screenshot.path);
140859
142036
  }
140860
142037
  }
140861
142038
  step.screenshot = {
@@ -140891,17 +142068,17 @@ async function saveScreenshot({ config, step, driver }) {
140891
142068
  } catch {
140892
142069
  urlPathname = originalUrlPath;
140893
142070
  }
140894
- const rawBase = import_node_path11.default.basename(urlPathname.split("?")[0].split("#")[0].replace(/\\/g, "/"));
142071
+ const rawBase = import_node_path12.default.basename(urlPathname.split("?")[0].split("#")[0].replace(/\\/g, "/"));
140895
142072
  const safeBase = sanitizeFilesystemName(rawBase, `${step.stepId}.png`);
140896
- dir = import_node_path11.default.join(process.cwd(), "doc-detective-runs", getOrInitRunTimestamp(config));
140897
- if (!import_node_fs11.default.existsSync(dir)) {
140898
- import_node_fs11.default.mkdirSync(dir, { recursive: true });
142073
+ dir = import_node_path12.default.join(process.cwd(), "doc-detective-runs", getOrInitRunTimestamp(config));
142074
+ if (!import_node_fs12.default.existsSync(dir)) {
142075
+ import_node_fs12.default.mkdirSync(dir, { recursive: true });
140899
142076
  }
140900
142077
  const captureId = `${step.stepId || "screenshot"}_${Date.now()}`;
140901
- filePath = import_node_path11.default.join(dir, `${captureId}_${safeBase}`);
140902
- const resolvedDir = import_node_path11.default.resolve(dir);
140903
- const resolvedFile = import_node_path11.default.resolve(filePath);
140904
- if (!resolvedFile.startsWith(resolvedDir + import_node_path11.default.sep)) {
142078
+ filePath = import_node_path12.default.join(dir, `${captureId}_${safeBase}`);
142079
+ const resolvedDir = import_node_path12.default.resolve(dir);
142080
+ const resolvedFile = import_node_path12.default.resolve(filePath);
142081
+ if (!resolvedFile.startsWith(resolvedDir + import_node_path12.default.sep)) {
140905
142082
  result.status = "FAIL";
140906
142083
  result.description = `Refusing to write screenshot outside run folder: ${resolvedFile}`;
140907
142084
  return result;
@@ -140910,18 +142087,18 @@ async function saveScreenshot({ config, step, driver }) {
140910
142087
  log(config, "debug", `Screenshot path is a URL (${redactedUrl}); overwrite is ignored, running comparison only.`);
140911
142088
  }
140912
142089
  } else {
140913
- dir = import_node_path11.default.dirname(step.screenshot.path);
140914
- if (!import_node_fs11.default.existsSync(dir)) {
140915
- import_node_fs11.default.mkdirSync(dir, { recursive: true });
142090
+ dir = import_node_path12.default.dirname(step.screenshot.path);
142091
+ if (!import_node_fs12.default.existsSync(dir)) {
142092
+ import_node_fs12.default.mkdirSync(dir, { recursive: true });
140916
142093
  }
140917
- if (import_node_fs11.default.existsSync(filePath)) {
142094
+ if (import_node_fs12.default.existsSync(filePath)) {
140918
142095
  if (step.screenshot.overwrite == "false") {
140919
142096
  result.status = "SKIPPED";
140920
142097
  result.description = `File already exists: ${filePath}`;
140921
142098
  return result;
140922
142099
  } else {
140923
142100
  existFilePath = filePath;
140924
- filePath = import_node_path11.default.join(dir, `${step.stepId}_${Date.now()}.png`);
142101
+ filePath = import_node_path12.default.join(dir, `${step.stepId}_${Date.now()}.png`);
140925
142102
  }
140926
142103
  }
140927
142104
  }
@@ -140995,25 +142172,34 @@ async function saveScreenshot({ config, step, driver }) {
140995
142172
  }, element, padding);
140996
142173
  await driver.pause(100);
140997
142174
  }
142175
+ const recordingActive = isRecordingActive(driver);
140998
142176
  try {
140999
- if (driver?.state?.recording) {
142177
+ if (recordingActive) {
141000
142178
  await driver.execute(() => {
141001
- document.querySelector("dd-mouse-pointer").style.display = "none";
142179
+ const pointer = document.querySelector("dd-mouse-pointer");
142180
+ if (pointer)
142181
+ pointer.style.display = "none";
141002
142182
  });
141003
142183
  }
141004
142184
  await driver.saveScreenshot(filePath);
141005
- if (driver?.state?.recording) {
141006
- await driver.execute(() => {
141007
- document.querySelector("dd-mouse-pointer").style.display = "block";
141008
- });
141009
- }
141010
142185
  } catch (error) {
141011
142186
  result.status = "FAIL";
141012
142187
  result.description = `Couldn't save screenshot. ${error}`;
141013
- if (existFilePath && filePath !== existFilePath && import_node_fs11.default.existsSync(filePath)) {
141014
- import_node_fs11.default.unlinkSync(filePath);
142188
+ if (existFilePath && filePath !== existFilePath && import_node_fs12.default.existsSync(filePath)) {
142189
+ import_node_fs12.default.unlinkSync(filePath);
141015
142190
  }
141016
142191
  return result;
142192
+ } finally {
142193
+ if (recordingActive) {
142194
+ try {
142195
+ await driver.execute(() => {
142196
+ const pointer = document.querySelector("dd-mouse-pointer");
142197
+ if (pointer)
142198
+ pointer.style.display = "block";
142199
+ });
142200
+ } catch {
142201
+ }
142202
+ }
141017
142203
  }
141018
142204
  if (step.screenshot.crop) {
141019
142205
  let padding = { top: 0, right: 0, bottom: 0, left: 0 };
@@ -141055,7 +142241,7 @@ async function saveScreenshot({ config, step, driver }) {
141055
142241
  rect.width = clamped.width;
141056
142242
  rect.height = clamped.height;
141057
142243
  log(config, "debug", { padded_rect: rect });
141058
- const croppedPath = import_node_path11.default.join(dir, `cropped_${step.stepId || Date.now()}.png`);
142244
+ const croppedPath = import_node_path12.default.join(dir, `cropped_${step.stepId || Date.now()}.png`);
141059
142245
  try {
141060
142246
  await sharp(filePath).extract({
141061
142247
  left: rect.x,
@@ -141063,12 +142249,12 @@ async function saveScreenshot({ config, step, driver }) {
141063
142249
  width: rect.width,
141064
142250
  height: rect.height
141065
142251
  }).toFile(croppedPath);
141066
- import_node_fs11.default.renameSync(croppedPath, filePath);
142252
+ import_node_fs12.default.renameSync(croppedPath, filePath);
141067
142253
  } catch (error) {
141068
142254
  result.status = "FAIL";
141069
142255
  result.description = `Couldn't crop image. ${error}`;
141070
- if (existFilePath && filePath !== existFilePath && import_node_fs11.default.existsSync(filePath)) {
141071
- import_node_fs11.default.unlinkSync(filePath);
142256
+ if (existFilePath && filePath !== existFilePath && import_node_fs12.default.existsSync(filePath)) {
142257
+ import_node_fs12.default.unlinkSync(filePath);
141072
142258
  }
141073
142259
  return result;
141074
142260
  }
@@ -141076,7 +142262,7 @@ async function saveScreenshot({ config, step, driver }) {
141076
142262
  if (existFilePath) {
141077
142263
  if (step.screenshot.overwrite == "true" && !isUrlPath) {
141078
142264
  result.description += ` Overwrote existing file.`;
141079
- import_node_fs11.default.renameSync(filePath, existFilePath);
142265
+ import_node_fs12.default.renameSync(filePath, existFilePath);
141080
142266
  result.outputs.screenshotPath = existFilePath;
141081
142267
  result.outputs.changed = true;
141082
142268
  if (step.screenshot.sourceIntegration) {
@@ -141089,21 +142275,21 @@ async function saveScreenshot({ config, step, driver }) {
141089
142275
  let img1;
141090
142276
  let img2;
141091
142277
  try {
141092
- img1 = PNG.sync.read(import_node_fs11.default.readFileSync(existFilePath));
141093
- img2 = PNG.sync.read(import_node_fs11.default.readFileSync(filePath));
142278
+ img1 = PNG.sync.read(import_node_fs12.default.readFileSync(existFilePath));
142279
+ img2 = PNG.sync.read(import_node_fs12.default.readFileSync(filePath));
141094
142280
  } catch (error) {
141095
142281
  result.status = "FAIL";
141096
142282
  result.description = isUrlPath ? `Couldn't decode PNG for comparison. The URL reference (${redactedUrl}) may not be a valid PNG. ${error}` : `Couldn't decode PNG for comparison. ${error}`;
141097
- if (!isUrlPath && filePath !== existFilePath && import_node_fs11.default.existsSync(filePath)) {
141098
- import_node_fs11.default.unlinkSync(filePath);
142283
+ if (!isUrlPath && filePath !== existFilePath && import_node_fs12.default.existsSync(filePath)) {
142284
+ import_node_fs12.default.unlinkSync(filePath);
141099
142285
  }
141100
142286
  return result;
141101
142287
  }
141102
142288
  if (!aspectRatiosMatch(img1, img2)) {
141103
142289
  result.status = "FAIL";
141104
142290
  result.description = `Couldn't compare images. Images have different aspect ratios.`;
141105
- if (!isUrlPath && filePath !== existFilePath && import_node_fs11.default.existsSync(filePath)) {
141106
- import_node_fs11.default.unlinkSync(filePath);
142291
+ if (!isUrlPath && filePath !== existFilePath && import_node_fs12.default.existsSync(filePath)) {
142292
+ import_node_fs12.default.unlinkSync(filePath);
141107
142293
  }
141108
142294
  return result;
141109
142295
  }
@@ -141130,8 +142316,8 @@ async function saveScreenshot({ config, step, driver }) {
141130
142316
  } catch (error) {
141131
142317
  result.status = "FAIL";
141132
142318
  result.description = `Couldn't load screenshot comparison dependency (pixelmatch). ${error?.message ?? error}`;
141133
- if (!isUrlPath && filePath !== existFilePath && import_node_fs11.default.existsSync(filePath)) {
141134
- import_node_fs11.default.unlinkSync(filePath);
142319
+ if (!isUrlPath && filePath !== existFilePath && import_node_fs12.default.existsSync(filePath)) {
142320
+ import_node_fs12.default.unlinkSync(filePath);
141135
142321
  }
141136
142322
  return result;
141137
142323
  }
@@ -141144,7 +142330,7 @@ async function saveScreenshot({ config, step, driver }) {
141144
142330
  });
141145
142331
  if (fractionalDiff > step.screenshot.maxVariation) {
141146
142332
  if (step.screenshot.overwrite == "aboveVariation" && !isUrlPath) {
141147
- import_node_fs11.default.renameSync(filePath, existFilePath);
142333
+ import_node_fs12.default.renameSync(filePath, existFilePath);
141148
142334
  }
141149
142335
  result.status = "WARNING";
141150
142336
  result.description += ` The difference between the existing screenshot and new screenshot (${fractionalDiff.toFixed(2)}) is greater than the max accepted variation (${step.screenshot.maxVariation}).`;
@@ -141170,7 +142356,7 @@ async function saveScreenshot({ config, step, driver }) {
141170
142356
  result.outputs.sourceIntegration = step.screenshot.sourceIntegration;
141171
142357
  }
141172
142358
  if (step.screenshot.overwrite != "true") {
141173
- import_node_fs11.default.unlinkSync(filePath);
142359
+ import_node_fs12.default.unlinkSync(filePath);
141174
142360
  }
141175
142361
  }
141176
142362
  }
@@ -141189,283 +142375,8 @@ async function saveScreenshot({ config, step, driver }) {
141189
142375
  // dist/core/tests/startRecording.js
141190
142376
  init_validate();
141191
142377
  init_utils();
141192
-
141193
- // dist/core/tests/ffmpegRecorder.js
141194
- var import_node_child_process4 = require("node:child_process");
141195
- var import_node_os6 = __toESM(require("node:os"), 1);
141196
- var import_node_path12 = __toESM(require("node:path"), 1);
141197
- var import_node_fs12 = __toESM(require("node:fs"), 1);
141198
- var import_node_crypto5 = __toESM(require("node:crypto"), 1);
141199
- init_loader();
141200
- init_utils();
141201
- var XVFB_SCREEN_SIZE = "1920x1080";
141202
- function safeContextId(contextId) {
141203
- const raw = String(contextId ?? "ctx");
141204
- const base = sanitizeFilesystemName(raw, "ctx");
141205
- if (base === raw)
141206
- return base;
141207
- const hash = import_node_crypto5.default.createHash("sha1").update(raw).digest("hex").slice(0, 8);
141208
- return `${base}-${hash}`;
141209
- }
141210
- function browserCaptureTitle(contextId) {
141211
- return `RECORD_ME_${safeContextId(contextId)}`;
141212
- }
141213
- function browserDownloadDir(contextId) {
141214
- return import_node_path12.default.join(import_node_os6.default.tmpdir(), "doc-detective", "recordings", safeContextId(contextId));
141215
- }
141216
- function engineFields(record) {
141217
- const engine = record && typeof record === "object" ? record.engine : void 0;
141218
- if (typeof engine === "string")
141219
- return { name: engine };
141220
- if (engine && typeof engine === "object")
141221
- return { name: engine.name, target: engine.target, fps: engine.fps };
141222
- return {};
141223
- }
141224
- function resolveRecordPlan({ step, context }) {
141225
- const { name, target, fps } = engineFields(step?.record);
141226
- let engineName = name;
141227
- if (!engineName) {
141228
- const b = context?.browser;
141229
- engineName = b?.name === "chrome" && b?.headless === false ? "browser" : "ffmpeg";
141230
- }
141231
- return {
141232
- name: engineName,
141233
- target: target || "display",
141234
- fps: fps ?? 30
141235
- };
141236
- }
141237
- function hasRecordStepWithoutEngine(context) {
141238
- const steps = Array.isArray(context?.steps) ? context.steps : [];
141239
- return steps.some((s) => {
141240
- if (!s?.record)
141241
- return false;
141242
- return engineFields(s.record).name === void 0;
141243
- });
141244
- }
141245
- function coerceRecordContextBrowser({ context, availableApps }) {
141246
- if (context?.browser)
141247
- return null;
141248
- if (!hasRecordStepWithoutEngine(context))
141249
- return null;
141250
- const chromeAvailable = Array.isArray(availableApps) && availableApps.some((a) => a?.name === "chrome");
141251
- if (!chromeAvailable)
141252
- return null;
141253
- return { name: "chrome", headless: false };
141254
- }
141255
- function buildCaptureArgs({ platform, fps, displayEnv, outputPath, screenIndex, screenSize }) {
141256
- const rate = String(fps ?? 30);
141257
- let input;
141258
- switch (platform) {
141259
- case "win32":
141260
- input = ["-f", "gdigrab", "-framerate", rate, "-i", "desktop"];
141261
- break;
141262
- case "darwin":
141263
- input = [
141264
- "-f",
141265
- "avfoundation",
141266
- "-framerate",
141267
- rate,
141268
- "-i",
141269
- `${screenIndex ?? "0"}:none`
141270
- ];
141271
- break;
141272
- case "linux":
141273
- input = [
141274
- "-f",
141275
- "x11grab",
141276
- "-framerate",
141277
- rate,
141278
- ...screenSize ? ["-video_size", screenSize] : [],
141279
- "-i",
141280
- displayEnv || ":0.0"
141281
- ];
141282
- break;
141283
- default:
141284
- throw new Error(`Screen recording isn't supported on platform '${platform}'.`);
141285
- }
141286
- return ["-y", ...input, "-pix_fmt", "yuv420p", outputPath];
141287
- }
141288
- async function resolveCropGeometry({ driver, target }) {
141289
- if (target === "viewport") {
141290
- const m = await driver.execute(() => {
141291
- return {
141292
- sx: window.screenX,
141293
- sy: window.screenY,
141294
- iw: window.innerWidth,
141295
- ih: window.innerHeight,
141296
- ow: window.outerWidth,
141297
- oh: window.outerHeight,
141298
- dpr: window.devicePixelRatio || 1
141299
- };
141300
- });
141301
- const dpr = m.dpr || 1;
141302
- const border = Math.max(0, (m.ow - m.iw) / 2);
141303
- const topChrome = Math.max(0, m.oh - m.ih - border);
141304
- return {
141305
- x: Math.round((m.sx + border) * dpr),
141306
- y: Math.round((m.sy + topChrome) * dpr),
141307
- w: Math.round(m.iw * dpr),
141308
- h: Math.round(m.ih * dpr)
141309
- };
141310
- }
141311
- if (target === "window") {
141312
- const r = await driver.getWindowRect();
141313
- let dpr;
141314
- try {
141315
- dpr = await driver.execute(() => window.devicePixelRatio || 1) || 1;
141316
- } catch {
141317
- dpr = 1;
141318
- }
141319
- return {
141320
- x: Math.round(r.x * dpr),
141321
- y: Math.round(r.y * dpr),
141322
- w: Math.round(r.width * dpr),
141323
- h: Math.round(r.height * dpr)
141324
- };
141325
- }
141326
- return null;
141327
- }
141328
- function jobIsFfmpegRecording(job) {
141329
- const steps = Array.isArray(job?.context?.steps) ? job.context.steps : [];
141330
- return steps.some((s) => {
141331
- if (!s?.record)
141332
- return false;
141333
- return resolveRecordPlan({ step: s, context: job.context }).name === "ffmpeg";
141334
- });
141335
- }
141336
- function computeEffectiveConcurrency({ requestedLimit, jobs, platform, xvfbAvailable }) {
141337
- const ffmpegJobs = (jobs || []).filter(jobIsFfmpegRecording);
141338
- if (ffmpegJobs.length === 0) {
141339
- return { limit: requestedLimit, xvfbContexts: [], forcedSerial: false };
141340
- }
141341
- if (platform === "linux" && xvfbAvailable) {
141342
- return {
141343
- limit: requestedLimit,
141344
- xvfbContexts: ffmpegJobs.map((j) => j.context),
141345
- forcedSerial: false
141346
- };
141347
- }
141348
- return { limit: 1, xvfbContexts: [], forcedSerial: requestedLimit > 1 };
141349
- }
141350
- async function getFfmpegPath(ctx = {}) {
141351
- const mod = await loadHeavyDep("@ffmpeg-installer/ffmpeg", { ctx });
141352
- const candidate = mod && (mod.path ?? mod.default?.path);
141353
- if (typeof candidate !== "string" || candidate.length === 0) {
141354
- throw new Error("ffmpeg binary path is missing or malformed in the installed @ffmpeg-installer/ffmpeg package. Try `doc-detective install runtime --force` to reinstall.");
141355
- }
141356
- return candidate;
141357
- }
141358
- function parseMacScreenIndex(listing) {
141359
- const m = /\[(\d+)\]\s+Capture screen/i.exec(listing || "");
141360
- return m ? m[1] : null;
141361
- }
141362
- async function detectMacScreenIndex(ffmpegPath) {
141363
- return new Promise((resolve) => {
141364
- let out = "";
141365
- let settled = false;
141366
- let proc = null;
141367
- const done = (v) => {
141368
- if (settled)
141369
- return;
141370
- settled = true;
141371
- try {
141372
- proc?.kill();
141373
- } catch {
141374
- }
141375
- resolve(v);
141376
- };
141377
- try {
141378
- proc = (0, import_node_child_process4.spawn)(ffmpegPath, ["-f", "avfoundation", "-list_devices", "true", "-i", ""], { stdio: ["ignore", "ignore", "pipe"] });
141379
- proc.stderr?.on("data", (d) => {
141380
- out += d.toString();
141381
- });
141382
- proc.on("error", () => done(null));
141383
- proc.on("close", () => done(parseMacScreenIndex(out)));
141384
- setTimeout(() => done(null), 5e3);
141385
- } catch {
141386
- done(null);
141387
- }
141388
- });
141389
- }
141390
- async function detectX11ScreenSize(display) {
141391
- return new Promise((resolve) => {
141392
- let out = "";
141393
- let settled = false;
141394
- let proc = null;
141395
- const done = (v) => {
141396
- if (settled)
141397
- return;
141398
- settled = true;
141399
- try {
141400
- proc?.kill();
141401
- } catch {
141402
- }
141403
- resolve(v);
141404
- };
141405
- try {
141406
- const env = display ? { ...process.env, DISPLAY: display } : process.env;
141407
- proc = (0, import_node_child_process4.spawn)("xdpyinfo", [], { env, stdio: ["ignore", "pipe", "ignore"] });
141408
- proc.stdout?.on("data", (d) => {
141409
- out += d.toString();
141410
- });
141411
- proc.on("error", () => done(null));
141412
- proc.on("close", () => {
141413
- const m = /dimensions:\s+(\d+x\d+)\s+pixels/i.exec(out);
141414
- done(m ? m[1] : null);
141415
- });
141416
- setTimeout(() => done(null), 5e3);
141417
- } catch {
141418
- done(null);
141419
- }
141420
- });
141421
- }
141422
- async function checkSystemBinary(name) {
141423
- return new Promise((resolve) => {
141424
- try {
141425
- const proc = (0, import_node_child_process4.spawn)(name, ["-help"], { stdio: "ignore" });
141426
- proc.on("error", () => resolve(false));
141427
- proc.on("close", () => resolve(true));
141428
- } catch {
141429
- resolve(false);
141430
- }
141431
- });
141432
- }
141433
- function xvfbDisplay(index) {
141434
- return `:${99 + index}`;
141435
- }
141436
- async function startXvfb(display, opts = {}) {
141437
- const num = display.replace(/^:/, "").split(".")[0];
141438
- const [defW, defH] = XVFB_SCREEN_SIZE.split("x").map(Number);
141439
- const w = opts.width ?? defW;
141440
- const h = opts.height ?? defH;
141441
- const startMs = Date.now();
141442
- const proc = (0, import_node_child_process4.spawn)("Xvfb", [display, "-screen", "0", `${w}x${h}x24`, "-nolisten", "tcp"], { stdio: "ignore" });
141443
- let spawnErr = null;
141444
- proc.on("error", (e) => {
141445
- spawnErr = e;
141446
- });
141447
- const lock = `/tmp/.X${num}-lock`;
141448
- for (let i = 0; i < 50; i++) {
141449
- if (spawnErr)
141450
- throw spawnErr;
141451
- if (proc.exitCode !== null)
141452
- throw new Error(`Xvfb exited early on ${display} (code ${proc.exitCode})`);
141453
- try {
141454
- if (import_node_fs12.default.statSync(lock).mtimeMs >= startMs)
141455
- return proc;
141456
- } catch {
141457
- }
141458
- await new Promise((r) => setTimeout(r, 100));
141459
- }
141460
- try {
141461
- proc.kill();
141462
- } catch {
141463
- }
141464
- throw new Error(`Xvfb did not become ready on ${display} within 5s.`);
141465
- }
141466
-
141467
- // dist/core/tests/startRecording.js
141468
142378
  var import_node_child_process5 = require("node:child_process");
142379
+ var import_node_crypto6 = require("node:crypto");
141469
142380
  var import_node_path13 = __toESM(require("node:path"), 1);
141470
142381
  var import_node_fs13 = __toESM(require("node:fs"), 1);
141471
142382
  var import_node_os7 = __toESM(require("node:os"), 1);
@@ -141513,7 +142424,22 @@ async function startRecording({ config, context, step, driver }) {
141513
142424
  result.description = `File already exists: ${filePath}`;
141514
142425
  return result;
141515
142426
  }
141516
- const plan = resolveRecordPlan({ step, context });
142427
+ const normalizeActiveTarget = (p) => {
142428
+ const resolved = import_node_path13.default.resolve(p);
142429
+ return process.platform === "win32" || process.platform === "darwin" ? resolved.toLowerCase() : resolved;
142430
+ };
142431
+ const normalizedTarget = normalizeActiveTarget(filePath);
142432
+ if (Array.isArray(driver?.state?.recordings) && driver.state.recordings.some((r) => typeof r?.targetPath === "string" && normalizeActiveTarget(r.targetPath) === normalizedTarget)) {
142433
+ result.status = "SKIPPED";
142434
+ result.description = `Recording target is already in use by an active recording: ${filePath}`;
142435
+ return result;
142436
+ }
142437
+ let plan = resolveRecordPlan({ step, context });
142438
+ const hasActiveBrowserRecording = Array.isArray(driver?.state?.recordings) && driver.state.recordings.some((r) => r?.type === "MediaRecorder");
142439
+ if (plan.name === "browser" && hasActiveBrowserRecording) {
142440
+ log(config, "warning", "A browser-engine recording is already active in this context; recording this one with the ffmpeg engine (viewport target) instead, since only one browser-engine recording can run at a time.");
142441
+ plan = { name: "ffmpeg", target: "viewport", fps: plan.fps };
142442
+ }
141517
142443
  if (plan.name === "browser") {
141518
142444
  if (context.browser?.headless) {
141519
142445
  result.status = "SKIPPED";
@@ -141663,7 +142589,7 @@ async function startRecording({ config, context, step, driver }) {
141663
142589
  const tempDir = import_node_path13.default.join(import_node_os7.default.tmpdir(), "doc-detective", "recordings");
141664
142590
  if (!import_node_fs13.default.existsSync(tempDir))
141665
142591
  import_node_fs13.default.mkdirSync(tempDir, { recursive: true });
141666
- const tempPath = import_node_path13.default.join(tempDir, `${safeContextId(context.contextId)}-${baseName}.mkv`);
142592
+ const tempPath = import_node_path13.default.join(tempDir, `${safeContextId(context.contextId)}-${baseName}-${(0, import_node_crypto6.randomUUID)().slice(0, 8)}.mkv`);
141667
142593
  let ffmpegPath;
141668
142594
  try {
141669
142595
  ffmpegPath = await getFfmpegPath({ cacheDir: config?.cacheDir });
@@ -141735,12 +142661,32 @@ async function stopRecording({ config, step, driver }) {
141735
142661
  result.description = `Invalid step definition: ${isValidStep.errors}`;
141736
142662
  return result;
141737
142663
  }
141738
- const recording = driver?.state?.recording;
142664
+ if (step.stopRecord === false) {
142665
+ result.status = "SKIPPED";
142666
+ result.description = "Recording stop is disabled (stopRecord: false).";
142667
+ return result;
142668
+ }
142669
+ const recordings = Array.isArray(driver?.state?.recordings) ? driver.state.recordings : [];
142670
+ const recording = selectRecordingToStop(recordings, step.stopRecord, {
142671
+ includeSynthetic: step?.__stopAny === true
142672
+ });
141739
142673
  if (!recording) {
141740
142674
  result.status = "SKIPPED";
141741
- result.description = `Recording isn't started.`;
142675
+ const target = stopRecordTargetName(step.stopRecord);
142676
+ if (target !== void 0) {
142677
+ result.description = `No active recording named '${target}'.`;
142678
+ } else if (recordings.length > 0) {
142679
+ result.description = `No user-stoppable recording is active; an automatic (autoRecord) recording is still running and stops at the end of the context.`;
142680
+ } else {
142681
+ result.description = `Recording isn't started.`;
142682
+ }
141742
142683
  return result;
141743
142684
  }
142685
+ const dropHandle = () => {
142686
+ const idx = recordings.indexOf(recording);
142687
+ if (idx !== -1)
142688
+ recordings.splice(idx, 1);
142689
+ };
141744
142690
  try {
141745
142691
  if (recording.type === "MediaRecorder") {
141746
142692
  await driver.switchToWindow(recording.tab);
@@ -141756,7 +142702,7 @@ async function stopRecording({ config, step, driver }) {
141756
142702
  if (remainingHandles2.length > 0) {
141757
142703
  await driver.switchToWindow(remainingHandles2[0]);
141758
142704
  }
141759
- driver.state.recording = null;
142705
+ dropHandle();
141760
142706
  return result;
141761
142707
  }
141762
142708
  await driver.execute(() => {
@@ -141766,7 +142712,7 @@ async function stopRecording({ config, step, driver }) {
141766
142712
  if (!downloaded) {
141767
142713
  result.status = "FAIL";
141768
142714
  result.description = "Recording download timed out.";
141769
- driver.state.recording = null;
142715
+ dropHandle();
141770
142716
  return result;
141771
142717
  }
141772
142718
  const allHandles = await driver.getWindowHandles();
@@ -141781,7 +142727,7 @@ async function stopRecording({ config, step, driver }) {
141781
142727
  targetPath: recording.targetPath,
141782
142728
  deleteSource: true
141783
142729
  });
141784
- driver.state.recording = null;
142730
+ dropHandle();
141785
142731
  } else if (recording.type === "ffmpeg") {
141786
142732
  const proc = recording.process;
141787
142733
  try {
@@ -141820,12 +142766,12 @@ async function stopRecording({ config, step, driver }) {
141820
142766
  deleteSource: true,
141821
142767
  crop: recording.crop
141822
142768
  });
141823
- driver.state.recording = null;
142769
+ dropHandle();
141824
142770
  }
141825
142771
  } catch (error) {
141826
142772
  result.status = "FAIL";
141827
142773
  result.description = `Couldn't stop recording. ${error}`;
141828
- driver.state.recording = null;
142774
+ dropHandle();
141829
142775
  return result;
141830
142776
  }
141831
142777
  return result;
@@ -141883,7 +142829,7 @@ async function waitForStableFile(filePath, maxSeconds) {
141883
142829
  }
141884
142830
  if (size > 0 && size === lastSize) {
141885
142831
  stableReads++;
141886
- if (stableReads >= 1)
142832
+ if (stableReads >= 2)
141887
142833
  return true;
141888
142834
  } else {
141889
142835
  stableReads = 0;
@@ -143009,7 +143955,7 @@ async function dragAndDropElement({ config, step, driver }) {
143009
143955
  // dist/core/tests.js
143010
143956
  var import_node_path19 = __toESM(require("node:path"), 1);
143011
143957
  var import_node_child_process7 = require("node:child_process");
143012
- var import_node_crypto6 = require("node:crypto");
143958
+ var import_node_crypto7 = require("node:crypto");
143013
143959
  init_appium();
143014
143960
 
143015
143961
  // dist/core/expressions.js
@@ -143744,6 +144690,7 @@ async function runSpecs({ resolvedTests }) {
143744
144690
  let limit = resolveConcurrentRunners(config);
143745
144691
  log(config, "info", "Running test specs.");
143746
144692
  const jobs = [];
144693
+ let autoRecordInjected = false;
143747
144694
  for (const spec of specs) {
143748
144695
  log(config, "debug", `SPEC: ${spec.specId}`);
143749
144696
  metaValues.specs[spec.specId] ??= { tests: {} };
@@ -143765,6 +144712,17 @@ async function runSpecs({ resolvedTests }) {
143765
144712
  contexts: new Array(test.contexts.length)
143766
144713
  };
143767
144714
  specReport.tests.push(testReport);
144715
+ let recordingNameConflict = null;
144716
+ for (const c of test.contexts) {
144717
+ const conflict = detectRecordingNameConflict(c?.steps);
144718
+ if (conflict) {
144719
+ recordingNameConflict = conflict;
144720
+ break;
144721
+ }
144722
+ }
144723
+ if (recordingNameConflict) {
144724
+ log(config, "warning", `Skipping test '${test.testId}': recording name '${recordingNameConflict}' is reused while a recording with that name is still active. Names must be unique among recordings that overlap in time.`);
144725
+ }
143768
144726
  const usedContextIds = new Set(test.contexts.map((c) => c.contextId).filter(Boolean));
143769
144727
  test.contexts.forEach((context, slot) => {
143770
144728
  if (!context.contextId) {
@@ -143777,6 +144735,25 @@ async function runSpecs({ resolvedTests }) {
143777
144735
  usedContextIds.add(id);
143778
144736
  context.contextId = id;
143779
144737
  }
144738
+ if (recordingNameConflict) {
144739
+ testReport.contexts[slot] = {
144740
+ contextId: context.contextId,
144741
+ platform: context.platform,
144742
+ browser: context.browser,
144743
+ result: "SKIPPED",
144744
+ resultDescription: `Skipped \u2014 recording name '${recordingNameConflict}' is reused while still active; names must be unique among overlapping recordings.`,
144745
+ steps: []
144746
+ };
144747
+ return;
144748
+ }
144749
+ if (resolveAutoRecord({ config, spec, test })) {
144750
+ const autoStep = buildAutoRecordStep({ config, spec, test, context });
144751
+ if (autoStep) {
144752
+ const authored = Array.isArray(context.steps) ? context.steps.filter((s) => !s?.__autoRecord) : [];
144753
+ context.steps = [autoStep, ...authored];
144754
+ autoRecordInjected = true;
144755
+ }
144756
+ }
143780
144757
  const coercedBrowser = coerceRecordContextBrowser({
143781
144758
  context,
143782
144759
  availableApps: runnerDetails.availableApps
@@ -143792,16 +144769,25 @@ async function runSpecs({ resolvedTests }) {
143792
144769
  if (anyFfmpegRecording && process.platform === "linux") {
143793
144770
  xvfbAvailable = await checkSystemBinary("Xvfb");
143794
144771
  }
144772
+ const hasExplicitFfmpegRecording = jobs.some((job) => jobIsFfmpegRecording({
144773
+ context: {
144774
+ ...job.context,
144775
+ steps: Array.isArray(job.context?.steps) ? job.context.steps.filter((s) => !s?.__autoRecord) : []
144776
+ }
144777
+ }));
143795
144778
  const concurrency = computeEffectiveConcurrency({
143796
144779
  requestedLimit: limit,
143797
144780
  jobs,
143798
144781
  platform: process.platform,
143799
- xvfbAvailable
144782
+ xvfbAvailable,
144783
+ allowOverlappingCaptures: autoRecordInjected && !hasExplicitFfmpegRecording
143800
144784
  });
143801
144785
  limit = concurrency.limit;
143802
144786
  if (concurrency.forcedSerial) {
143803
144787
  log(config, "warning", 'Recording with the ffmpeg engine needs exclusive use of the display, so this run is executing serially (concurrentRunners=1). To record concurrently, use the Chrome browser engine (record: { engine: "browser" }) or, on Linux, install Xvfb.');
143804
144788
  report.recordingForcedSerial = true;
144789
+ } else if (concurrency.overlappingCaptures && limit > 1) {
144790
+ log(config, "warning", "autoRecord is running ffmpeg recordings concurrently on a shared display, so the captured videos will overlap (each context records the whole screen). For isolated concurrent recordings, run on Linux with Xvfb installed.");
143805
144791
  }
143806
144792
  const driverJobCount = jobs.filter((job) => isDriverRequired({ test: job.context })).length;
143807
144793
  let appiumServers = [];
@@ -144040,6 +145026,24 @@ async function warmUpContexts({ jobs, config, runnerDetails, appiumPool, install
144040
145026
  function resolveAutoScreenshot({ config, spec, test }) {
144041
145027
  return Boolean(test?.autoScreenshot ?? spec?.autoScreenshot ?? config?.autoScreenshot);
144042
145028
  }
145029
+ function resolveAutoRecord({ config, spec, test }) {
145030
+ return Boolean(test?.autoRecord ?? spec?.autoRecord ?? config?.autoRecord);
145031
+ }
145032
+ function buildAutoRecordStep({ config, spec, test, context }) {
145033
+ if (!isDriverRequired({ test: context }))
145034
+ return null;
145035
+ const runDir = getRunOutputDir(config, { create: false });
145036
+ const fileName = `${capPathSegment(sanitizeFilesystemName(String(context.contextId ?? ""), "context"))}.mp4`;
145037
+ const recordPath = import_node_path19.default.join(runDir, "recordings", capPathSegment(sanitizeFilesystemName(String(spec.specId ?? ""), "spec")), capPathSegment(sanitizeFilesystemName(String(test.testId ?? ""), "test")), fileName);
145038
+ return {
145039
+ record: { path: recordPath, overwrite: "true", engine: "ffmpeg" },
145040
+ description: "Automatic full-context recording",
145041
+ stepId: `${sanitizeFilesystemName(String(test.testId ?? ""), "test")}~autorecord`,
145042
+ // Internal marker — the runStep record dispatch flags the started handle as
145043
+ // synthetic so it survives untargeted stopRecord and is swept by cleanup.
145044
+ __autoRecord: true
145045
+ };
145046
+ }
144043
145047
  function capPathSegment(segment, max = 64) {
144044
145048
  return segment.length <= max ? segment : segment.slice(segment.length - max);
144045
145049
  }
@@ -144232,7 +145236,7 @@ ${JSON.stringify(context, null, 2)}`);
144232
145236
  const usedStepIds = new Set(context.steps.map((s) => s.stepId).filter(Boolean));
144233
145237
  for (const [stepIndex, step] of context.steps.entries()) {
144234
145238
  if (!step.stepId) {
144235
- const baseId = sanitizeFilesystemName(`${test.testId}~s${contentHash(step)}`, `step-${(0, import_node_crypto6.randomUUID)()}`);
145239
+ const baseId = sanitizeFilesystemName(`${test.testId}~s${contentHash(step)}`, `step-${(0, import_node_crypto7.randomUUID)()}`);
144236
145240
  let stepId = baseId;
144237
145241
  let suffix = 2;
144238
145242
  while (usedStepIds.has(stepId)) {
@@ -144302,32 +145306,13 @@ ${JSON.stringify(stepResult, null, 2)}`);
144302
145306
  stepExecutionFailed = true;
144303
145307
  }
144304
145308
  }
144305
- if (driver?.state?.recording) {
144306
- const stopRecordStep = {
144307
- stopRecord: true,
144308
- description: "Stopping recording",
144309
- stepId: (0, import_node_crypto6.randomUUID)()
144310
- };
144311
- const stepResult = await runStep({
144312
- config,
144313
- context,
144314
- step: stopRecordStep,
144315
- driver,
144316
- options: {
144317
- openApiDefinitions: context.openApi || []
144318
- }
144319
- });
144320
- stepResult.result = stepResult.status;
144321
- stepResult.resultDescription = stepResult.description;
144322
- delete stepResult.status;
144323
- delete stepResult.description;
144324
- const stepReport = {
144325
- ...stopRecordStep,
144326
- ...stepResult
144327
- };
144328
- contextReport.steps.push(stepReport);
144329
- }
145309
+ await stopAllRecordings({ config, context, driver, contextReport });
144330
145310
  } finally {
145311
+ try {
145312
+ await stopAllRecordings({ config, context, driver, contextReport });
145313
+ } catch (error) {
145314
+ clog("error", `Failed to stop recordings during cleanup: ${error?.message ?? error}`);
145315
+ }
144331
145316
  if (driver) {
144332
145317
  try {
144333
145318
  await driver.deleteSession();
@@ -144342,6 +145327,42 @@ ${JSON.stringify(stepResult, null, 2)}`);
144342
145327
  contextReport.result = rollUpResults(contextReport.steps);
144343
145328
  return contextReport;
144344
145329
  }
145330
+ async function stopAllRecordings({ config, context, driver, contextReport }) {
145331
+ if (!Array.isArray(driver?.state?.recordings))
145332
+ return;
145333
+ let guard = driver.state.recordings.length + 1;
145334
+ while (driver.state.recordings.length > 0 && guard-- > 0) {
145335
+ const stopRecordStep = {
145336
+ stopRecord: true,
145337
+ __stopAny: true,
145338
+ description: "Stopping recording",
145339
+ stepId: (0, import_node_crypto7.randomUUID)()
145340
+ };
145341
+ try {
145342
+ const stepResult = await runStep({
145343
+ config,
145344
+ context,
145345
+ step: stopRecordStep,
145346
+ driver,
145347
+ options: { openApiDefinitions: context.openApi || [] }
145348
+ });
145349
+ stepResult.result = stepResult.status;
145350
+ stepResult.resultDescription = stepResult.description;
145351
+ delete stepResult.status;
145352
+ delete stepResult.description;
145353
+ delete stopRecordStep.__stopAny;
145354
+ contextReport.steps.push({ ...stopRecordStep, ...stepResult });
145355
+ } catch (error) {
145356
+ delete stopRecordStep.__stopAny;
145357
+ driver.state.recordings.pop();
145358
+ contextReport.steps.push({
145359
+ ...stopRecordStep,
145360
+ result: "FAIL",
145361
+ resultDescription: `Couldn't stop recording. ${error?.message ?? error}`
145362
+ });
145363
+ }
145364
+ }
145365
+ }
144345
145366
  async function runStep({ config = {}, context = {}, step, driver, metaValues = {}, options = {} }) {
144346
145367
  let actionResult;
144347
145368
  step = replaceEnvs(step);
@@ -144392,7 +145413,16 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
144392
145413
  step,
144393
145414
  driver
144394
145415
  });
144395
- driver.state.recording = actionResult.recording ?? null;
145416
+ if (actionResult.recording) {
145417
+ if (!Array.isArray(driver.state.recordings))
145418
+ driver.state.recordings = [];
145419
+ const handle = actionResult.recording;
145420
+ handle.id = handle.id ?? (0, import_node_crypto7.randomUUID)();
145421
+ handle.name = handle.name ?? recordStepName(step.record);
145422
+ if (step.__autoRecord)
145423
+ handle.synthetic = true;
145424
+ driver.state.recordings.push(handle);
145425
+ }
144396
145426
  } else if (typeof step.runCode !== "undefined") {
144397
145427
  actionResult = await runCode({ config, step });
144398
145428
  } else if (typeof step.runShell !== "undefined") {
@@ -144417,7 +145447,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
144417
145447
  description: `Unknown step action: ${JSON.stringify(step)}`
144418
145448
  };
144419
145449
  }
144420
- if (driver?.state?.recording) {
145450
+ if (isRecordingActive(driver)) {
144421
145451
  const currentUrl = await driver.getUrl();
144422
145452
  if (currentUrl !== driver.state.url) {
144423
145453
  driver.state.url = currentUrl;
@@ -144503,7 +145533,7 @@ async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
144503
145533
  waitforTimeout: 12e4
144504
145534
  // 2 minutes
144505
145535
  });
144506
- driver.state = { url: "", x: null, y: null, recording: null };
145536
+ driver.state = { url: "", x: null, y: null, recordings: [] };
144507
145537
  return driver;
144508
145538
  } catch (err) {
144509
145539
  lastError = err;