doc-detective 4.12.0 → 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 (55) 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 +198 -36
  40. package/dist/core/tests.js.map +1 -1
  41. package/dist/core/utils.d.ts +5 -2
  42. package/dist/core/utils.d.ts.map +1 -1
  43. package/dist/core/utils.js +87 -20
  44. package/dist/core/utils.js.map +1 -1
  45. package/dist/debug/provenance.d.ts.map +1 -1
  46. package/dist/debug/provenance.js +6 -0
  47. package/dist/debug/provenance.js.map +1 -1
  48. package/dist/hints/hints.d.ts.map +1 -1
  49. package/dist/hints/hints.js +19 -0
  50. package/dist/hints/hints.js.map +1 -1
  51. package/dist/index.cjs +1617 -568
  52. package/dist/utils.d.ts.map +1 -1
  53. package/dist/utils.js +45 -9
  54. package/dist/utils.js.map +1 -1
  55. 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
  },
@@ -134369,33 +135177,50 @@ function getOrInitRunTimestamp(config) {
134369
135177
  }
134370
135178
  return config.__runTimestamp;
134371
135179
  }
134372
- function getRunOutputDir(config) {
134373
- if (config?.__runOutputDir)
135180
+ function getRunOutputDir(config, { create = true } = {}) {
135181
+ if (config?.__runOutputDir) {
135182
+ if (create)
135183
+ import_node_fs.default.mkdirSync(config.__runOutputDir, { recursive: true });
134374
135184
  return config.__runOutputDir;
135185
+ }
134375
135186
  let base = String(config?.output || ".");
134376
135187
  const reportFileExtensions = [".json", ".html", ".htm"];
134377
135188
  if (reportFileExtensions.some((ext) => base.toLowerCase().endsWith(ext))) {
134378
135189
  base = import_node_path.default.dirname(base);
134379
135190
  }
134380
135191
  const runsRoot = import_node_path.default.resolve(base, ".doc-detective");
134381
- import_node_fs.default.mkdirSync(runsRoot, { recursive: true });
134382
135192
  const runId = getOrInitRunTimestamp(config);
134383
135193
  let dir = import_node_path.default.join(runsRoot, `run-${runId}`);
134384
- let suffix = 2;
134385
- for (; ; ) {
134386
- try {
134387
- import_node_fs.default.mkdirSync(dir);
134388
- break;
134389
- } catch (error) {
134390
- if (error?.code !== "EEXIST")
134391
- throw error;
134392
- dir = import_node_path.default.join(runsRoot, `run-${runId}-${suffix++}`);
135194
+ if (create) {
135195
+ import_node_fs.default.mkdirSync(runsRoot, { recursive: true });
135196
+ let suffix = 2;
135197
+ for (; ; ) {
135198
+ try {
135199
+ import_node_fs.default.mkdirSync(dir);
135200
+ break;
135201
+ } catch (error) {
135202
+ if (error?.code !== "EEXIST")
135203
+ throw error;
135204
+ dir = import_node_path.default.join(runsRoot, `run-${runId}-${suffix++}`);
135205
+ }
134393
135206
  }
134394
135207
  }
134395
135208
  if (config)
134396
135209
  config.__runOutputDir = dir;
134397
135210
  return dir;
134398
135211
  }
135212
+ function runArchivesArtifacts(config = {}, specs = []) {
135213
+ const list = Array.isArray(specs) ? specs : [];
135214
+ if (list.length > 0) {
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)
135217
+ return true;
135218
+ } else if (Boolean(config?.autoScreenshot) || Boolean(config?.autoRecord)) {
135219
+ return true;
135220
+ }
135221
+ const active = Array.isArray(config?.reporters) && config.reporters.length > 0 ? config.reporters : ["terminal", "json", "runFolder"];
135222
+ return active.some((reporter) => typeof reporter === "string" && (reporter.toLowerCase() === "runfolder" || reporter === "runFolderReporter"));
135223
+ }
134399
135224
  async function spawnCommand(cmd, args = [], options = {}) {
134400
135225
  const spawnOptions = {
134401
135226
  shell: true
@@ -139757,6 +140582,375 @@ async function findElementByCriteria({ selector, elementText, elementId, element
139757
140582
  // dist/core/tests/typeKeys.js
139758
140583
  init_validate();
139759
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
139760
140954
  var _specialKeyMap = null;
139761
140955
  async function getSpecialKeyMap(ctx = {}) {
139762
140956
  if (_specialKeyMap)
@@ -139883,7 +141077,7 @@ async function typeKeys({ config, step, driver }) {
139883
141077
  return result;
139884
141078
  }
139885
141079
  }
139886
- if (driver?.state?.recording) {
141080
+ if (isRecordingActive(driver)) {
139887
141081
  let keys = [];
139888
141082
  step.type.keys.forEach((key) => {
139889
141083
  if (key.startsWith("$") && key.endsWith("$")) {
@@ -139913,7 +141107,7 @@ async function typeKeys({ config, step, driver }) {
139913
141107
  });
139914
141108
  }
139915
141109
  try {
139916
- if (driver?.state?.recording) {
141110
+ if (isRecordingActive(driver)) {
139917
141111
  for (let i = 0; i < step.type.keys.length; i++) {
139918
141112
  await driver.keys(step.type.keys[i]);
139919
141113
  await new Promise((resolve) => setTimeout(resolve, step.type.inputDelay));
@@ -140077,7 +141271,7 @@ async function findElement({ config, step, driver, click }) {
140077
141271
  result.description += " Typed keys.";
140078
141272
  }
140079
141273
  }
140080
- if (driver?.state?.recording) {
141274
+ if (isRecordingActive(driver)) {
140081
141275
  await wait({ config, step: { wait: 2e3 }, driver });
140082
141276
  }
140083
141277
  return result;
@@ -140466,8 +141660,8 @@ async function waitForDOMStable(driver, idleTime, timeout) {
140466
141660
  // dist/core/tests/runShell.js
140467
141661
  init_validate();
140468
141662
  init_utils();
140469
- var import_node_fs10 = __toESM(require("node:fs"), 1);
140470
- 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);
140471
141665
  async function runShell({ config, step }) {
140472
141666
  const result = {
140473
141667
  status: "PASS",
@@ -140540,24 +141734,24 @@ async function runShell({ config, step }) {
140540
141734
  }
140541
141735
  }
140542
141736
  if (step.runShell.path) {
140543
- const dir = import_node_path10.default.dirname(step.runShell.path);
140544
- if (!import_node_fs10.default.existsSync(dir)) {
140545
- 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 });
140546
141740
  }
140547
141741
  let filePath = step.runShell.path;
140548
141742
  log(config, "debug", `Saving stdio to file: ${filePath}`);
140549
- if (!import_node_fs10.default.existsSync(filePath)) {
140550
- 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);
140551
141745
  } else {
140552
141746
  if (step.runShell.overwrite == "false") {
140553
141747
  result.description = result.description + ` Didn't save output. File already exists.`;
140554
141748
  }
140555
- const existingFile = import_node_fs10.default.readFileSync(filePath, "utf8");
141749
+ const existingFile = import_node_fs11.default.readFileSync(filePath, "utf8");
140556
141750
  const fractionalDiff = calculateFractionalDifference(existingFile, result.outputs.stdio.stdout);
140557
141751
  log(config, "debug", `Fractional difference: ${fractionalDiff}`);
140558
141752
  if (fractionalDiff > step.runShell.maxVariation) {
140559
141753
  if (step.runShell.overwrite == "aboveVariation") {
140560
- import_node_fs10.default.writeFileSync(filePath, result.outputs.stdio.stdout);
141754
+ import_node_fs11.default.writeFileSync(filePath, result.outputs.stdio.stdout);
140561
141755
  result.description += ` Saved output to file.`;
140562
141756
  }
140563
141757
  result.status = "WARNING";
@@ -140565,7 +141759,7 @@ async function runShell({ config, step }) {
140565
141759
  return result;
140566
141760
  }
140567
141761
  if (step.runShell.overwrite == "true") {
140568
- import_node_fs10.default.writeFileSync(filePath, result.outputs.stdio.stdout);
141762
+ import_node_fs11.default.writeFileSync(filePath, result.outputs.stdio.stdout);
140569
141763
  result.description += ` Saved output to file.`;
140570
141764
  }
140571
141765
  }
@@ -140749,8 +141943,8 @@ async function checkLink({ config, step }) {
140749
141943
  // dist/core/tests/saveScreenshot.js
140750
141944
  init_validate();
140751
141945
  init_utils();
140752
- var import_node_path11 = __toESM(require("node:path"), 1);
140753
- 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);
140754
141948
  init_loader();
140755
141949
  var _pngjs = null;
140756
141950
  var _sharp = null;
@@ -140838,7 +142032,7 @@ async function saveScreenshot({ config, step, driver }) {
140838
142032
  if (typeof step.screenshot.path === "undefined") {
140839
142033
  step.screenshot.path = `${step.stepId}.png`;
140840
142034
  if (step.screenshot.directory) {
140841
- 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);
140842
142036
  }
140843
142037
  }
140844
142038
  step.screenshot = {
@@ -140874,17 +142068,17 @@ async function saveScreenshot({ config, step, driver }) {
140874
142068
  } catch {
140875
142069
  urlPathname = originalUrlPath;
140876
142070
  }
140877
- 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, "/"));
140878
142072
  const safeBase = sanitizeFilesystemName(rawBase, `${step.stepId}.png`);
140879
- dir = import_node_path11.default.join(process.cwd(), "doc-detective-runs", getOrInitRunTimestamp(config));
140880
- if (!import_node_fs11.default.existsSync(dir)) {
140881
- 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 });
140882
142076
  }
140883
142077
  const captureId = `${step.stepId || "screenshot"}_${Date.now()}`;
140884
- filePath = import_node_path11.default.join(dir, `${captureId}_${safeBase}`);
140885
- const resolvedDir = import_node_path11.default.resolve(dir);
140886
- const resolvedFile = import_node_path11.default.resolve(filePath);
140887
- 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)) {
140888
142082
  result.status = "FAIL";
140889
142083
  result.description = `Refusing to write screenshot outside run folder: ${resolvedFile}`;
140890
142084
  return result;
@@ -140893,18 +142087,18 @@ async function saveScreenshot({ config, step, driver }) {
140893
142087
  log(config, "debug", `Screenshot path is a URL (${redactedUrl}); overwrite is ignored, running comparison only.`);
140894
142088
  }
140895
142089
  } else {
140896
- dir = import_node_path11.default.dirname(step.screenshot.path);
140897
- if (!import_node_fs11.default.existsSync(dir)) {
140898
- 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 });
140899
142093
  }
140900
- if (import_node_fs11.default.existsSync(filePath)) {
142094
+ if (import_node_fs12.default.existsSync(filePath)) {
140901
142095
  if (step.screenshot.overwrite == "false") {
140902
142096
  result.status = "SKIPPED";
140903
142097
  result.description = `File already exists: ${filePath}`;
140904
142098
  return result;
140905
142099
  } else {
140906
142100
  existFilePath = filePath;
140907
- 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`);
140908
142102
  }
140909
142103
  }
140910
142104
  }
@@ -140978,25 +142172,34 @@ async function saveScreenshot({ config, step, driver }) {
140978
142172
  }, element, padding);
140979
142173
  await driver.pause(100);
140980
142174
  }
142175
+ const recordingActive = isRecordingActive(driver);
140981
142176
  try {
140982
- if (driver?.state?.recording) {
142177
+ if (recordingActive) {
140983
142178
  await driver.execute(() => {
140984
- document.querySelector("dd-mouse-pointer").style.display = "none";
142179
+ const pointer = document.querySelector("dd-mouse-pointer");
142180
+ if (pointer)
142181
+ pointer.style.display = "none";
140985
142182
  });
140986
142183
  }
140987
142184
  await driver.saveScreenshot(filePath);
140988
- if (driver?.state?.recording) {
140989
- await driver.execute(() => {
140990
- document.querySelector("dd-mouse-pointer").style.display = "block";
140991
- });
140992
- }
140993
142185
  } catch (error) {
140994
142186
  result.status = "FAIL";
140995
142187
  result.description = `Couldn't save screenshot. ${error}`;
140996
- if (existFilePath && filePath !== existFilePath && import_node_fs11.default.existsSync(filePath)) {
140997
- import_node_fs11.default.unlinkSync(filePath);
142188
+ if (existFilePath && filePath !== existFilePath && import_node_fs12.default.existsSync(filePath)) {
142189
+ import_node_fs12.default.unlinkSync(filePath);
140998
142190
  }
140999
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
+ }
141000
142203
  }
141001
142204
  if (step.screenshot.crop) {
141002
142205
  let padding = { top: 0, right: 0, bottom: 0, left: 0 };
@@ -141038,7 +142241,7 @@ async function saveScreenshot({ config, step, driver }) {
141038
142241
  rect.width = clamped.width;
141039
142242
  rect.height = clamped.height;
141040
142243
  log(config, "debug", { padded_rect: rect });
141041
- 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`);
141042
142245
  try {
141043
142246
  await sharp(filePath).extract({
141044
142247
  left: rect.x,
@@ -141046,12 +142249,12 @@ async function saveScreenshot({ config, step, driver }) {
141046
142249
  width: rect.width,
141047
142250
  height: rect.height
141048
142251
  }).toFile(croppedPath);
141049
- import_node_fs11.default.renameSync(croppedPath, filePath);
142252
+ import_node_fs12.default.renameSync(croppedPath, filePath);
141050
142253
  } catch (error) {
141051
142254
  result.status = "FAIL";
141052
142255
  result.description = `Couldn't crop image. ${error}`;
141053
- if (existFilePath && filePath !== existFilePath && import_node_fs11.default.existsSync(filePath)) {
141054
- import_node_fs11.default.unlinkSync(filePath);
142256
+ if (existFilePath && filePath !== existFilePath && import_node_fs12.default.existsSync(filePath)) {
142257
+ import_node_fs12.default.unlinkSync(filePath);
141055
142258
  }
141056
142259
  return result;
141057
142260
  }
@@ -141059,7 +142262,7 @@ async function saveScreenshot({ config, step, driver }) {
141059
142262
  if (existFilePath) {
141060
142263
  if (step.screenshot.overwrite == "true" && !isUrlPath) {
141061
142264
  result.description += ` Overwrote existing file.`;
141062
- import_node_fs11.default.renameSync(filePath, existFilePath);
142265
+ import_node_fs12.default.renameSync(filePath, existFilePath);
141063
142266
  result.outputs.screenshotPath = existFilePath;
141064
142267
  result.outputs.changed = true;
141065
142268
  if (step.screenshot.sourceIntegration) {
@@ -141072,21 +142275,21 @@ async function saveScreenshot({ config, step, driver }) {
141072
142275
  let img1;
141073
142276
  let img2;
141074
142277
  try {
141075
- img1 = PNG.sync.read(import_node_fs11.default.readFileSync(existFilePath));
141076
- 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));
141077
142280
  } catch (error) {
141078
142281
  result.status = "FAIL";
141079
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}`;
141080
- if (!isUrlPath && filePath !== existFilePath && import_node_fs11.default.existsSync(filePath)) {
141081
- import_node_fs11.default.unlinkSync(filePath);
142283
+ if (!isUrlPath && filePath !== existFilePath && import_node_fs12.default.existsSync(filePath)) {
142284
+ import_node_fs12.default.unlinkSync(filePath);
141082
142285
  }
141083
142286
  return result;
141084
142287
  }
141085
142288
  if (!aspectRatiosMatch(img1, img2)) {
141086
142289
  result.status = "FAIL";
141087
142290
  result.description = `Couldn't compare images. Images have different aspect ratios.`;
141088
- if (!isUrlPath && filePath !== existFilePath && import_node_fs11.default.existsSync(filePath)) {
141089
- import_node_fs11.default.unlinkSync(filePath);
142291
+ if (!isUrlPath && filePath !== existFilePath && import_node_fs12.default.existsSync(filePath)) {
142292
+ import_node_fs12.default.unlinkSync(filePath);
141090
142293
  }
141091
142294
  return result;
141092
142295
  }
@@ -141113,8 +142316,8 @@ async function saveScreenshot({ config, step, driver }) {
141113
142316
  } catch (error) {
141114
142317
  result.status = "FAIL";
141115
142318
  result.description = `Couldn't load screenshot comparison dependency (pixelmatch). ${error?.message ?? error}`;
141116
- if (!isUrlPath && filePath !== existFilePath && import_node_fs11.default.existsSync(filePath)) {
141117
- import_node_fs11.default.unlinkSync(filePath);
142319
+ if (!isUrlPath && filePath !== existFilePath && import_node_fs12.default.existsSync(filePath)) {
142320
+ import_node_fs12.default.unlinkSync(filePath);
141118
142321
  }
141119
142322
  return result;
141120
142323
  }
@@ -141127,7 +142330,7 @@ async function saveScreenshot({ config, step, driver }) {
141127
142330
  });
141128
142331
  if (fractionalDiff > step.screenshot.maxVariation) {
141129
142332
  if (step.screenshot.overwrite == "aboveVariation" && !isUrlPath) {
141130
- import_node_fs11.default.renameSync(filePath, existFilePath);
142333
+ import_node_fs12.default.renameSync(filePath, existFilePath);
141131
142334
  }
141132
142335
  result.status = "WARNING";
141133
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}).`;
@@ -141153,7 +142356,7 @@ async function saveScreenshot({ config, step, driver }) {
141153
142356
  result.outputs.sourceIntegration = step.screenshot.sourceIntegration;
141154
142357
  }
141155
142358
  if (step.screenshot.overwrite != "true") {
141156
- import_node_fs11.default.unlinkSync(filePath);
142359
+ import_node_fs12.default.unlinkSync(filePath);
141157
142360
  }
141158
142361
  }
141159
142362
  }
@@ -141172,283 +142375,8 @@ async function saveScreenshot({ config, step, driver }) {
141172
142375
  // dist/core/tests/startRecording.js
141173
142376
  init_validate();
141174
142377
  init_utils();
141175
-
141176
- // dist/core/tests/ffmpegRecorder.js
141177
- var import_node_child_process4 = require("node:child_process");
141178
- var import_node_os6 = __toESM(require("node:os"), 1);
141179
- var import_node_path12 = __toESM(require("node:path"), 1);
141180
- var import_node_fs12 = __toESM(require("node:fs"), 1);
141181
- var import_node_crypto5 = __toESM(require("node:crypto"), 1);
141182
- init_loader();
141183
- init_utils();
141184
- var XVFB_SCREEN_SIZE = "1920x1080";
141185
- function safeContextId(contextId) {
141186
- const raw = String(contextId ?? "ctx");
141187
- const base = sanitizeFilesystemName(raw, "ctx");
141188
- if (base === raw)
141189
- return base;
141190
- const hash = import_node_crypto5.default.createHash("sha1").update(raw).digest("hex").slice(0, 8);
141191
- return `${base}-${hash}`;
141192
- }
141193
- function browserCaptureTitle(contextId) {
141194
- return `RECORD_ME_${safeContextId(contextId)}`;
141195
- }
141196
- function browserDownloadDir(contextId) {
141197
- return import_node_path12.default.join(import_node_os6.default.tmpdir(), "doc-detective", "recordings", safeContextId(contextId));
141198
- }
141199
- function engineFields(record) {
141200
- const engine = record && typeof record === "object" ? record.engine : void 0;
141201
- if (typeof engine === "string")
141202
- return { name: engine };
141203
- if (engine && typeof engine === "object")
141204
- return { name: engine.name, target: engine.target, fps: engine.fps };
141205
- return {};
141206
- }
141207
- function resolveRecordPlan({ step, context }) {
141208
- const { name, target, fps } = engineFields(step?.record);
141209
- let engineName = name;
141210
- if (!engineName) {
141211
- const b = context?.browser;
141212
- engineName = b?.name === "chrome" && b?.headless === false ? "browser" : "ffmpeg";
141213
- }
141214
- return {
141215
- name: engineName,
141216
- target: target || "display",
141217
- fps: fps ?? 30
141218
- };
141219
- }
141220
- function hasRecordStepWithoutEngine(context) {
141221
- const steps = Array.isArray(context?.steps) ? context.steps : [];
141222
- return steps.some((s) => {
141223
- if (!s?.record)
141224
- return false;
141225
- return engineFields(s.record).name === void 0;
141226
- });
141227
- }
141228
- function coerceRecordContextBrowser({ context, availableApps }) {
141229
- if (context?.browser)
141230
- return null;
141231
- if (!hasRecordStepWithoutEngine(context))
141232
- return null;
141233
- const chromeAvailable = Array.isArray(availableApps) && availableApps.some((a) => a?.name === "chrome");
141234
- if (!chromeAvailable)
141235
- return null;
141236
- return { name: "chrome", headless: false };
141237
- }
141238
- function buildCaptureArgs({ platform, fps, displayEnv, outputPath, screenIndex, screenSize }) {
141239
- const rate = String(fps ?? 30);
141240
- let input;
141241
- switch (platform) {
141242
- case "win32":
141243
- input = ["-f", "gdigrab", "-framerate", rate, "-i", "desktop"];
141244
- break;
141245
- case "darwin":
141246
- input = [
141247
- "-f",
141248
- "avfoundation",
141249
- "-framerate",
141250
- rate,
141251
- "-i",
141252
- `${screenIndex ?? "0"}:none`
141253
- ];
141254
- break;
141255
- case "linux":
141256
- input = [
141257
- "-f",
141258
- "x11grab",
141259
- "-framerate",
141260
- rate,
141261
- ...screenSize ? ["-video_size", screenSize] : [],
141262
- "-i",
141263
- displayEnv || ":0.0"
141264
- ];
141265
- break;
141266
- default:
141267
- throw new Error(`Screen recording isn't supported on platform '${platform}'.`);
141268
- }
141269
- return ["-y", ...input, "-pix_fmt", "yuv420p", outputPath];
141270
- }
141271
- async function resolveCropGeometry({ driver, target }) {
141272
- if (target === "viewport") {
141273
- const m = await driver.execute(() => {
141274
- return {
141275
- sx: window.screenX,
141276
- sy: window.screenY,
141277
- iw: window.innerWidth,
141278
- ih: window.innerHeight,
141279
- ow: window.outerWidth,
141280
- oh: window.outerHeight,
141281
- dpr: window.devicePixelRatio || 1
141282
- };
141283
- });
141284
- const dpr = m.dpr || 1;
141285
- const border = Math.max(0, (m.ow - m.iw) / 2);
141286
- const topChrome = Math.max(0, m.oh - m.ih - border);
141287
- return {
141288
- x: Math.round((m.sx + border) * dpr),
141289
- y: Math.round((m.sy + topChrome) * dpr),
141290
- w: Math.round(m.iw * dpr),
141291
- h: Math.round(m.ih * dpr)
141292
- };
141293
- }
141294
- if (target === "window") {
141295
- const r = await driver.getWindowRect();
141296
- let dpr;
141297
- try {
141298
- dpr = await driver.execute(() => window.devicePixelRatio || 1) || 1;
141299
- } catch {
141300
- dpr = 1;
141301
- }
141302
- return {
141303
- x: Math.round(r.x * dpr),
141304
- y: Math.round(r.y * dpr),
141305
- w: Math.round(r.width * dpr),
141306
- h: Math.round(r.height * dpr)
141307
- };
141308
- }
141309
- return null;
141310
- }
141311
- function jobIsFfmpegRecording(job) {
141312
- const steps = Array.isArray(job?.context?.steps) ? job.context.steps : [];
141313
- return steps.some((s) => {
141314
- if (!s?.record)
141315
- return false;
141316
- return resolveRecordPlan({ step: s, context: job.context }).name === "ffmpeg";
141317
- });
141318
- }
141319
- function computeEffectiveConcurrency({ requestedLimit, jobs, platform, xvfbAvailable }) {
141320
- const ffmpegJobs = (jobs || []).filter(jobIsFfmpegRecording);
141321
- if (ffmpegJobs.length === 0) {
141322
- return { limit: requestedLimit, xvfbContexts: [], forcedSerial: false };
141323
- }
141324
- if (platform === "linux" && xvfbAvailable) {
141325
- return {
141326
- limit: requestedLimit,
141327
- xvfbContexts: ffmpegJobs.map((j) => j.context),
141328
- forcedSerial: false
141329
- };
141330
- }
141331
- return { limit: 1, xvfbContexts: [], forcedSerial: requestedLimit > 1 };
141332
- }
141333
- async function getFfmpegPath(ctx = {}) {
141334
- const mod = await loadHeavyDep("@ffmpeg-installer/ffmpeg", { ctx });
141335
- const candidate = mod && (mod.path ?? mod.default?.path);
141336
- if (typeof candidate !== "string" || candidate.length === 0) {
141337
- 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.");
141338
- }
141339
- return candidate;
141340
- }
141341
- function parseMacScreenIndex(listing) {
141342
- const m = /\[(\d+)\]\s+Capture screen/i.exec(listing || "");
141343
- return m ? m[1] : null;
141344
- }
141345
- async function detectMacScreenIndex(ffmpegPath) {
141346
- return new Promise((resolve) => {
141347
- let out = "";
141348
- let settled = false;
141349
- let proc = null;
141350
- const done = (v) => {
141351
- if (settled)
141352
- return;
141353
- settled = true;
141354
- try {
141355
- proc?.kill();
141356
- } catch {
141357
- }
141358
- resolve(v);
141359
- };
141360
- try {
141361
- proc = (0, import_node_child_process4.spawn)(ffmpegPath, ["-f", "avfoundation", "-list_devices", "true", "-i", ""], { stdio: ["ignore", "ignore", "pipe"] });
141362
- proc.stderr?.on("data", (d) => {
141363
- out += d.toString();
141364
- });
141365
- proc.on("error", () => done(null));
141366
- proc.on("close", () => done(parseMacScreenIndex(out)));
141367
- setTimeout(() => done(null), 5e3);
141368
- } catch {
141369
- done(null);
141370
- }
141371
- });
141372
- }
141373
- async function detectX11ScreenSize(display) {
141374
- return new Promise((resolve) => {
141375
- let out = "";
141376
- let settled = false;
141377
- let proc = null;
141378
- const done = (v) => {
141379
- if (settled)
141380
- return;
141381
- settled = true;
141382
- try {
141383
- proc?.kill();
141384
- } catch {
141385
- }
141386
- resolve(v);
141387
- };
141388
- try {
141389
- const env = display ? { ...process.env, DISPLAY: display } : process.env;
141390
- proc = (0, import_node_child_process4.spawn)("xdpyinfo", [], { env, stdio: ["ignore", "pipe", "ignore"] });
141391
- proc.stdout?.on("data", (d) => {
141392
- out += d.toString();
141393
- });
141394
- proc.on("error", () => done(null));
141395
- proc.on("close", () => {
141396
- const m = /dimensions:\s+(\d+x\d+)\s+pixels/i.exec(out);
141397
- done(m ? m[1] : null);
141398
- });
141399
- setTimeout(() => done(null), 5e3);
141400
- } catch {
141401
- done(null);
141402
- }
141403
- });
141404
- }
141405
- async function checkSystemBinary(name) {
141406
- return new Promise((resolve) => {
141407
- try {
141408
- const proc = (0, import_node_child_process4.spawn)(name, ["-help"], { stdio: "ignore" });
141409
- proc.on("error", () => resolve(false));
141410
- proc.on("close", () => resolve(true));
141411
- } catch {
141412
- resolve(false);
141413
- }
141414
- });
141415
- }
141416
- function xvfbDisplay(index) {
141417
- return `:${99 + index}`;
141418
- }
141419
- async function startXvfb(display, opts = {}) {
141420
- const num = display.replace(/^:/, "").split(".")[0];
141421
- const [defW, defH] = XVFB_SCREEN_SIZE.split("x").map(Number);
141422
- const w = opts.width ?? defW;
141423
- const h = opts.height ?? defH;
141424
- const startMs = Date.now();
141425
- const proc = (0, import_node_child_process4.spawn)("Xvfb", [display, "-screen", "0", `${w}x${h}x24`, "-nolisten", "tcp"], { stdio: "ignore" });
141426
- let spawnErr = null;
141427
- proc.on("error", (e) => {
141428
- spawnErr = e;
141429
- });
141430
- const lock = `/tmp/.X${num}-lock`;
141431
- for (let i = 0; i < 50; i++) {
141432
- if (spawnErr)
141433
- throw spawnErr;
141434
- if (proc.exitCode !== null)
141435
- throw new Error(`Xvfb exited early on ${display} (code ${proc.exitCode})`);
141436
- try {
141437
- if (import_node_fs12.default.statSync(lock).mtimeMs >= startMs)
141438
- return proc;
141439
- } catch {
141440
- }
141441
- await new Promise((r) => setTimeout(r, 100));
141442
- }
141443
- try {
141444
- proc.kill();
141445
- } catch {
141446
- }
141447
- throw new Error(`Xvfb did not become ready on ${display} within 5s.`);
141448
- }
141449
-
141450
- // dist/core/tests/startRecording.js
141451
142378
  var import_node_child_process5 = require("node:child_process");
142379
+ var import_node_crypto6 = require("node:crypto");
141452
142380
  var import_node_path13 = __toESM(require("node:path"), 1);
141453
142381
  var import_node_fs13 = __toESM(require("node:fs"), 1);
141454
142382
  var import_node_os7 = __toESM(require("node:os"), 1);
@@ -141496,7 +142424,22 @@ async function startRecording({ config, context, step, driver }) {
141496
142424
  result.description = `File already exists: ${filePath}`;
141497
142425
  return result;
141498
142426
  }
141499
- 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
+ }
141500
142443
  if (plan.name === "browser") {
141501
142444
  if (context.browser?.headless) {
141502
142445
  result.status = "SKIPPED";
@@ -141646,7 +142589,7 @@ async function startRecording({ config, context, step, driver }) {
141646
142589
  const tempDir = import_node_path13.default.join(import_node_os7.default.tmpdir(), "doc-detective", "recordings");
141647
142590
  if (!import_node_fs13.default.existsSync(tempDir))
141648
142591
  import_node_fs13.default.mkdirSync(tempDir, { recursive: true });
141649
- 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`);
141650
142593
  let ffmpegPath;
141651
142594
  try {
141652
142595
  ffmpegPath = await getFfmpegPath({ cacheDir: config?.cacheDir });
@@ -141718,12 +142661,32 @@ async function stopRecording({ config, step, driver }) {
141718
142661
  result.description = `Invalid step definition: ${isValidStep.errors}`;
141719
142662
  return result;
141720
142663
  }
141721
- 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
+ });
141722
142673
  if (!recording) {
141723
142674
  result.status = "SKIPPED";
141724
- 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
+ }
141725
142683
  return result;
141726
142684
  }
142685
+ const dropHandle = () => {
142686
+ const idx = recordings.indexOf(recording);
142687
+ if (idx !== -1)
142688
+ recordings.splice(idx, 1);
142689
+ };
141727
142690
  try {
141728
142691
  if (recording.type === "MediaRecorder") {
141729
142692
  await driver.switchToWindow(recording.tab);
@@ -141739,7 +142702,7 @@ async function stopRecording({ config, step, driver }) {
141739
142702
  if (remainingHandles2.length > 0) {
141740
142703
  await driver.switchToWindow(remainingHandles2[0]);
141741
142704
  }
141742
- driver.state.recording = null;
142705
+ dropHandle();
141743
142706
  return result;
141744
142707
  }
141745
142708
  await driver.execute(() => {
@@ -141749,7 +142712,7 @@ async function stopRecording({ config, step, driver }) {
141749
142712
  if (!downloaded) {
141750
142713
  result.status = "FAIL";
141751
142714
  result.description = "Recording download timed out.";
141752
- driver.state.recording = null;
142715
+ dropHandle();
141753
142716
  return result;
141754
142717
  }
141755
142718
  const allHandles = await driver.getWindowHandles();
@@ -141764,7 +142727,7 @@ async function stopRecording({ config, step, driver }) {
141764
142727
  targetPath: recording.targetPath,
141765
142728
  deleteSource: true
141766
142729
  });
141767
- driver.state.recording = null;
142730
+ dropHandle();
141768
142731
  } else if (recording.type === "ffmpeg") {
141769
142732
  const proc = recording.process;
141770
142733
  try {
@@ -141803,12 +142766,12 @@ async function stopRecording({ config, step, driver }) {
141803
142766
  deleteSource: true,
141804
142767
  crop: recording.crop
141805
142768
  });
141806
- driver.state.recording = null;
142769
+ dropHandle();
141807
142770
  }
141808
142771
  } catch (error) {
141809
142772
  result.status = "FAIL";
141810
142773
  result.description = `Couldn't stop recording. ${error}`;
141811
- driver.state.recording = null;
142774
+ dropHandle();
141812
142775
  return result;
141813
142776
  }
141814
142777
  return result;
@@ -141866,7 +142829,7 @@ async function waitForStableFile(filePath, maxSeconds) {
141866
142829
  }
141867
142830
  if (size > 0 && size === lastSize) {
141868
142831
  stableReads++;
141869
- if (stableReads >= 1)
142832
+ if (stableReads >= 2)
141870
142833
  return true;
141871
142834
  } else {
141872
142835
  stableReads = 0;
@@ -142992,7 +143955,7 @@ async function dragAndDropElement({ config, step, driver }) {
142992
143955
  // dist/core/tests.js
142993
143956
  var import_node_path19 = __toESM(require("node:path"), 1);
142994
143957
  var import_node_child_process7 = require("node:child_process");
142995
- var import_node_crypto6 = require("node:crypto");
143958
+ var import_node_crypto7 = require("node:crypto");
142996
143959
  init_appium();
142997
143960
 
142998
143961
  // dist/core/expressions.js
@@ -143689,7 +144652,9 @@ async function runSpecs({ resolvedTests }) {
143689
144652
  const metaValues = { specs: {} };
143690
144653
  const installAttempts = /* @__PURE__ */ new Map();
143691
144654
  const warmUpResults = /* @__PURE__ */ new Map();
143692
- const runDir = getRunOutputDir(config);
144655
+ const runDir = getRunOutputDir(config, {
144656
+ create: runArchivesArtifacts(config, specs)
144657
+ });
143693
144658
  const runId = import_node_path19.default.basename(runDir).replace(/^run-/, "");
143694
144659
  const report = {
143695
144660
  runId,
@@ -143725,6 +144690,7 @@ async function runSpecs({ resolvedTests }) {
143725
144690
  let limit = resolveConcurrentRunners(config);
143726
144691
  log(config, "info", "Running test specs.");
143727
144692
  const jobs = [];
144693
+ let autoRecordInjected = false;
143728
144694
  for (const spec of specs) {
143729
144695
  log(config, "debug", `SPEC: ${spec.specId}`);
143730
144696
  metaValues.specs[spec.specId] ??= { tests: {} };
@@ -143746,6 +144712,17 @@ async function runSpecs({ resolvedTests }) {
143746
144712
  contexts: new Array(test.contexts.length)
143747
144713
  };
143748
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
+ }
143749
144726
  const usedContextIds = new Set(test.contexts.map((c) => c.contextId).filter(Boolean));
143750
144727
  test.contexts.forEach((context, slot) => {
143751
144728
  if (!context.contextId) {
@@ -143758,6 +144735,25 @@ async function runSpecs({ resolvedTests }) {
143758
144735
  usedContextIds.add(id);
143759
144736
  context.contextId = id;
143760
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
+ }
143761
144757
  const coercedBrowser = coerceRecordContextBrowser({
143762
144758
  context,
143763
144759
  availableApps: runnerDetails.availableApps
@@ -143773,16 +144769,25 @@ async function runSpecs({ resolvedTests }) {
143773
144769
  if (anyFfmpegRecording && process.platform === "linux") {
143774
144770
  xvfbAvailable = await checkSystemBinary("Xvfb");
143775
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
+ }));
143776
144778
  const concurrency = computeEffectiveConcurrency({
143777
144779
  requestedLimit: limit,
143778
144780
  jobs,
143779
144781
  platform: process.platform,
143780
- xvfbAvailable
144782
+ xvfbAvailable,
144783
+ allowOverlappingCaptures: autoRecordInjected && !hasExplicitFfmpegRecording
143781
144784
  });
143782
144785
  limit = concurrency.limit;
143783
144786
  if (concurrency.forcedSerial) {
143784
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.');
143785
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.");
143786
144791
  }
143787
144792
  const driverJobCount = jobs.filter((job) => isDriverRequired({ test: job.context })).length;
143788
144793
  let appiumServers = [];
@@ -144021,6 +145026,24 @@ async function warmUpContexts({ jobs, config, runnerDetails, appiumPool, install
144021
145026
  function resolveAutoScreenshot({ config, spec, test }) {
144022
145027
  return Boolean(test?.autoScreenshot ?? spec?.autoScreenshot ?? config?.autoScreenshot);
144023
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
+ }
144024
145047
  function capPathSegment(segment, max = 64) {
144025
145048
  return segment.length <= max ? segment : segment.slice(segment.length - max);
144026
145049
  }
@@ -144213,7 +145236,7 @@ ${JSON.stringify(context, null, 2)}`);
144213
145236
  const usedStepIds = new Set(context.steps.map((s) => s.stepId).filter(Boolean));
144214
145237
  for (const [stepIndex, step] of context.steps.entries()) {
144215
145238
  if (!step.stepId) {
144216
- 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)()}`);
144217
145240
  let stepId = baseId;
144218
145241
  let suffix = 2;
144219
145242
  while (usedStepIds.has(stepId)) {
@@ -144283,32 +145306,13 @@ ${JSON.stringify(stepResult, null, 2)}`);
144283
145306
  stepExecutionFailed = true;
144284
145307
  }
144285
145308
  }
144286
- if (driver?.state?.recording) {
144287
- const stopRecordStep = {
144288
- stopRecord: true,
144289
- description: "Stopping recording",
144290
- stepId: (0, import_node_crypto6.randomUUID)()
144291
- };
144292
- const stepResult = await runStep({
144293
- config,
144294
- context,
144295
- step: stopRecordStep,
144296
- driver,
144297
- options: {
144298
- openApiDefinitions: context.openApi || []
144299
- }
144300
- });
144301
- stepResult.result = stepResult.status;
144302
- stepResult.resultDescription = stepResult.description;
144303
- delete stepResult.status;
144304
- delete stepResult.description;
144305
- const stepReport = {
144306
- ...stopRecordStep,
144307
- ...stepResult
144308
- };
144309
- contextReport.steps.push(stepReport);
144310
- }
145309
+ await stopAllRecordings({ config, context, driver, contextReport });
144311
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
+ }
144312
145316
  if (driver) {
144313
145317
  try {
144314
145318
  await driver.deleteSession();
@@ -144323,6 +145327,42 @@ ${JSON.stringify(stepResult, null, 2)}`);
144323
145327
  contextReport.result = rollUpResults(contextReport.steps);
144324
145328
  return contextReport;
144325
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
+ }
144326
145366
  async function runStep({ config = {}, context = {}, step, driver, metaValues = {}, options = {} }) {
144327
145367
  let actionResult;
144328
145368
  step = replaceEnvs(step);
@@ -144373,7 +145413,16 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
144373
145413
  step,
144374
145414
  driver
144375
145415
  });
144376
- 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
+ }
144377
145426
  } else if (typeof step.runCode !== "undefined") {
144378
145427
  actionResult = await runCode({ config, step });
144379
145428
  } else if (typeof step.runShell !== "undefined") {
@@ -144398,7 +145447,7 @@ async function runStep({ config = {}, context = {}, step, driver, metaValues = {
144398
145447
  description: `Unknown step action: ${JSON.stringify(step)}`
144399
145448
  };
144400
145449
  }
144401
- if (driver?.state?.recording) {
145450
+ if (isRecordingActive(driver)) {
144402
145451
  const currentUrl = await driver.getUrl();
144403
145452
  if (currentUrl !== driver.state.url) {
144404
145453
  driver.state.url = currentUrl;
@@ -144484,7 +145533,7 @@ async function driverStart(capabilities, port, maxAttempts = 4, ctx = {}) {
144484
145533
  waitforTimeout: 12e4
144485
145534
  // 2 minutes
144486
145535
  });
144487
- driver.state = { url: "", x: null, y: null, recording: null };
145536
+ driver.state = { url: "", x: null, y: null, recordings: [] };
144488
145537
  return driver;
144489
145538
  } catch (err) {
144490
145539
  lastError = err;