verifaied 0.29.0.dev76__tar.gz → 0.30.0.dev77__tar.gz

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 (77) hide show
  1. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/PKG-INFO +74 -7
  2. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/README.md +73 -6
  3. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/pyproject.toml +1 -1
  4. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/agent_mcp.py +110 -8
  5. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/cli.py +12 -7
  6. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/client.py +31 -0
  7. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/recorder_driver.js +11 -3
  8. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/recorder_observer.js +92 -21
  9. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/recorder_spec.js +5 -1
  10. verifaied-0.30.0.dev77/src/verifaied/replay_harvest.ts +606 -0
  11. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/repo_config.py +263 -24
  12. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/stepcheck.py +28 -1
  13. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/testcmd.py +909 -58
  14. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/conftest.py +17 -0
  15. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_agent_mcp.py +293 -2
  16. verifaied-0.30.0.dev77/tests/test_browser_settings_parity.py +148 -0
  17. verifaied-0.30.0.dev77/tests/test_caption_parity.py +263 -0
  18. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_client.py +40 -0
  19. verifaied-0.30.0.dev77/tests/test_pause_parity.py +79 -0
  20. verifaied-0.30.0.dev77/tests/test_replay_harvest_runner.py +557 -0
  21. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_repo_config.py +333 -6
  22. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_stepcheck_parity.py +66 -3
  23. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_testcmd.py +2175 -37
  24. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/uv.lock +1 -1
  25. verifaied-0.29.0.dev76/src/verifaied/replay_harvest.ts +0 -77
  26. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/.gitignore +0 -0
  27. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/LICENSE +0 -0
  28. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/__init__.py +0 -0
  29. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/__main__.py +0 -0
  30. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/agent_steps.py +0 -0
  31. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/analyzer.py +0 -0
  32. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/audit.py +0 -0
  33. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/collect_browser.js +0 -0
  34. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/collect_browser.py +0 -0
  35. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/config.py +0 -0
  36. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/failure_text.py +0 -0
  37. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/instrument.py +0 -0
  38. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/instrument_snippets.py +0 -0
  39. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/instrumenter.js +0 -0
  40. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/instrumenter.py +0 -0
  41. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/prompts.py +0 -0
  42. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/proxy.py +0 -0
  43. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/record.py +0 -0
  44. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/recorder_picker.js +0 -0
  45. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/recorder_refs.js +0 -0
  46. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/run_reporter.js +0 -0
  47. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/runner_dir.py +0 -0
  48. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/skill/SKILL.md +0 -0
  49. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/skill/reference/commands.md +0 -0
  50. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/skillcmd.py +0 -0
  51. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/source_index.py +0 -0
  52. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/subprocess_util.py +0 -0
  53. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/suite_report.py +0 -0
  54. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/uploader.py +0 -0
  55. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/src/verifaied/varcmd.py +0 -0
  56. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/__init__.py +0 -0
  57. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_audit.py +0 -0
  58. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_check.py +0 -0
  59. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_check_done.py +0 -0
  60. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_clear_manual.py +0 -0
  61. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_cli.py +0 -0
  62. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_collect_browser.py +0 -0
  63. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_config.py +0 -0
  64. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_console_label_parity.py +0 -0
  65. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_instrument.py +0 -0
  66. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_instrument_detect.py +0 -0
  67. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_instrument_snippets.py +0 -0
  68. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_instrumenter.py +0 -0
  69. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_prompt_parity.py +0 -0
  70. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_proxy.py +0 -0
  71. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_record.py +0 -0
  72. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_skill_parity.py +0 -0
  73. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_skillcmd.py +0 -0
  74. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_source_index.py +0 -0
  75. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_suite_report.py +0 -0
  76. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_uploader.py +0 -0
  77. {verifaied-0.29.0.dev76 → verifaied-0.30.0.dev77}/tests/test_varcmd.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: verifaied
3
- Version: 0.29.0.dev76
3
+ Version: 0.30.0.dev77
4
4
  Summary: Find what's untested in your code — locally, no account required
5
5
  Project-URL: Homepage, https://pypi.org/project/verifaied/
6
6
  Author: Kyle Richards
@@ -470,8 +470,10 @@ verifaied test run --all
470
470
  verifaied test run --all --env stage
471
471
 
472
472
  # How much of the run's clock goes on making the video watchable.
473
- # `fast` (the default) marks what each step clicks and moves on;
474
- # `watch` lingers on every action; `off` records without annotations.
473
+ # `fast` (the default) glides a cursor to what each step acts on, marks
474
+ # it, types at five characters a second and draws each step's caption;
475
+ # `watch` lingers on every action; `off` records without any of it and
476
+ # skips the flow's Pause steps and the holds after its checks.
475
477
  verifaied test run "Settings/API tokens" --video watch
476
478
 
477
479
  # What's recorded, and how each last went on this branch.
@@ -599,7 +601,67 @@ the delete is **refused and the step comes back** rather than taking a
599
601
  neighbour with it.
600
602
 
601
603
  Once the spec is saved it is a plain file in your repo, and your editor is
602
- the better tool for it.
604
+ the better tool for it — though every step's caption, and a check's hold,
605
+ can still be changed from the test's page without recording anything: the
606
+ pencil on a block stages the change, and one Save rewrites the file and
607
+ replays it.
608
+
609
+ ### Holds and captions — what the video says
610
+
611
+ A replay's video is the record a person reviews, so two things are drawn
612
+ on it beyond the steps themselves. **Every check holds**: after an
613
+ assertion passes the replay lingers for a moment — two seconds by default
614
+ — so the viewer sees the state that was checked before the next step
615
+ moves on. **Every step is captioned**: a short line in plain English —
616
+ "Open the settings page", "The token appears in the list" — drawn at the
617
+ bottom of the video while the step runs.
618
+
619
+ Both are settings on `[browser-tests]` in `.verifaied/config.toml` — or,
620
+ for the whole repository at once, on its Settings page in the web under
621
+ **Browser tests**, where every replay-video key sits beside them. A
622
+ value saved on the page applies to every machine that records or replays
623
+ the repository and wins over the file; a key left on the machine default
624
+ keeps reading the file, and a `--video` flag or the Video settings menu
625
+ still wins over both for one run:
626
+
627
+ ```toml
628
+ [browser-tests]
629
+ # How long the replay holds after each check passes. 0 for no hold.
630
+ assertion_pause_ms = 2000
631
+ # Whether steps are captioned as they land while recording.
632
+ captions = true
633
+ ```
634
+
635
+ The studio reads them when it connects and shows them under **Settings**
636
+ beside Variables, where either can be changed for one recording. Any
637
+ check can be given its own hold from its Edit form, and any step's caption
638
+ can be edited, regenerated or switched off from the flow — a caption you
639
+ switch off stays off. While a recording is live the studio page writes
640
+ the captions itself, one per step as it lands, from a model given the
641
+ line and its place in the flow; it is a paid call at the cheapest tier,
642
+ and a plan or spend cap that refuses it stops the auto-captioning for that
643
+ recording and says so, leaving the steps to be captioned by hand. The
644
+ assistant captions the steps it writes, and an agent recording through
645
+ `verifaied test mcp` passes a `caption` with each step and `hold_seconds`
646
+ with each check — nothing captions a step behind your back.
647
+
648
+ Generated captions are short, concise and in simplified technical
649
+ English: one sentence, one idea, present tense, active voice, at most ten
650
+ words, naming what a person sees and never a selector, a value or a
651
+ password. A typed one may be anything up to 120 characters on one line,
652
+ except the words a caption-less step is called (`Pause 2s`, `Hold 2s`).
653
+
654
+ In the spec a captioned or held step is one named step around the line:
655
+
656
+ ```ts
657
+ await test.step("The token appears in the list", async () => { await expect(page.getByText("tok_…")).toBeVisible(); await page.waitForTimeout(2000); });
658
+ ```
659
+
660
+ A held check with no caption is titled `Hold 2s`; a step with neither is
661
+ written bare, exactly as before. Holds wait in every replay except one
662
+ run with `--video off` (or `replay_pauses = false`), which skips them
663
+ with the Pause steps; captions are drawn unless `replay_captions = false`
664
+ or the video is off. Neither counts against the replay's time budget.
603
665
 
604
666
  ### Running another test inside this one
605
667
 
@@ -976,9 +1038,14 @@ was clicked with `video.show`. 1.60 is where the last of those shipped,
976
1038
  so it is a floor and not an allowlist: anything newer is allowed, and
977
1039
  anything older is refused rather than opening a browser that would
978
1040
  record nothing. `test run` stays ungated. Below 1.60 the annotation is
979
- not available, so set `replay_show_actions_ms = 0` and
980
- `replay_video_step_banner = false` there — that writes exactly the
981
- config the CLI wrote before this option existed.
1041
+ not available, so use `--video off` there, or set every replay key to
1042
+ its off value (`replay_show_actions_ms = 0`, `replay_cursor_ms = 0`,
1043
+ `replay_type_delay_ms = 0`, `replay_pauses = false`,
1044
+ `replay_captions = false` and `replay_video_step_banner = false`) — that
1045
+ writes exactly the config the CLI wrote before any of it existed. Every
1046
+ one of those keys can also be set once for the repository on its
1047
+ Settings page, which the CLI reads at the start of each recording and
1048
+ replay and lays over the file.
982
1049
 
983
1050
  ### Exit codes
984
1051
 
@@ -424,8 +424,10 @@ verifaied test run --all
424
424
  verifaied test run --all --env stage
425
425
 
426
426
  # How much of the run's clock goes on making the video watchable.
427
- # `fast` (the default) marks what each step clicks and moves on;
428
- # `watch` lingers on every action; `off` records without annotations.
427
+ # `fast` (the default) glides a cursor to what each step acts on, marks
428
+ # it, types at five characters a second and draws each step's caption;
429
+ # `watch` lingers on every action; `off` records without any of it and
430
+ # skips the flow's Pause steps and the holds after its checks.
429
431
  verifaied test run "Settings/API tokens" --video watch
430
432
 
431
433
  # What's recorded, and how each last went on this branch.
@@ -553,7 +555,67 @@ the delete is **refused and the step comes back** rather than taking a
553
555
  neighbour with it.
554
556
 
555
557
  Once the spec is saved it is a plain file in your repo, and your editor is
556
- the better tool for it.
558
+ the better tool for it — though every step's caption, and a check's hold,
559
+ can still be changed from the test's page without recording anything: the
560
+ pencil on a block stages the change, and one Save rewrites the file and
561
+ replays it.
562
+
563
+ ### Holds and captions — what the video says
564
+
565
+ A replay's video is the record a person reviews, so two things are drawn
566
+ on it beyond the steps themselves. **Every check holds**: after an
567
+ assertion passes the replay lingers for a moment — two seconds by default
568
+ — so the viewer sees the state that was checked before the next step
569
+ moves on. **Every step is captioned**: a short line in plain English —
570
+ "Open the settings page", "The token appears in the list" — drawn at the
571
+ bottom of the video while the step runs.
572
+
573
+ Both are settings on `[browser-tests]` in `.verifaied/config.toml` — or,
574
+ for the whole repository at once, on its Settings page in the web under
575
+ **Browser tests**, where every replay-video key sits beside them. A
576
+ value saved on the page applies to every machine that records or replays
577
+ the repository and wins over the file; a key left on the machine default
578
+ keeps reading the file, and a `--video` flag or the Video settings menu
579
+ still wins over both for one run:
580
+
581
+ ```toml
582
+ [browser-tests]
583
+ # How long the replay holds after each check passes. 0 for no hold.
584
+ assertion_pause_ms = 2000
585
+ # Whether steps are captioned as they land while recording.
586
+ captions = true
587
+ ```
588
+
589
+ The studio reads them when it connects and shows them under **Settings**
590
+ beside Variables, where either can be changed for one recording. Any
591
+ check can be given its own hold from its Edit form, and any step's caption
592
+ can be edited, regenerated or switched off from the flow — a caption you
593
+ switch off stays off. While a recording is live the studio page writes
594
+ the captions itself, one per step as it lands, from a model given the
595
+ line and its place in the flow; it is a paid call at the cheapest tier,
596
+ and a plan or spend cap that refuses it stops the auto-captioning for that
597
+ recording and says so, leaving the steps to be captioned by hand. The
598
+ assistant captions the steps it writes, and an agent recording through
599
+ `verifaied test mcp` passes a `caption` with each step and `hold_seconds`
600
+ with each check — nothing captions a step behind your back.
601
+
602
+ Generated captions are short, concise and in simplified technical
603
+ English: one sentence, one idea, present tense, active voice, at most ten
604
+ words, naming what a person sees and never a selector, a value or a
605
+ password. A typed one may be anything up to 120 characters on one line,
606
+ except the words a caption-less step is called (`Pause 2s`, `Hold 2s`).
607
+
608
+ In the spec a captioned or held step is one named step around the line:
609
+
610
+ ```ts
611
+ await test.step("The token appears in the list", async () => { await expect(page.getByText("tok_…")).toBeVisible(); await page.waitForTimeout(2000); });
612
+ ```
613
+
614
+ A held check with no caption is titled `Hold 2s`; a step with neither is
615
+ written bare, exactly as before. Holds wait in every replay except one
616
+ run with `--video off` (or `replay_pauses = false`), which skips them
617
+ with the Pause steps; captions are drawn unless `replay_captions = false`
618
+ or the video is off. Neither counts against the replay's time budget.
557
619
 
558
620
  ### Running another test inside this one
559
621
 
@@ -930,9 +992,14 @@ was clicked with `video.show`. 1.60 is where the last of those shipped,
930
992
  so it is a floor and not an allowlist: anything newer is allowed, and
931
993
  anything older is refused rather than opening a browser that would
932
994
  record nothing. `test run` stays ungated. Below 1.60 the annotation is
933
- not available, so set `replay_show_actions_ms = 0` and
934
- `replay_video_step_banner = false` there — that writes exactly the
935
- config the CLI wrote before this option existed.
995
+ not available, so use `--video off` there, or set every replay key to
996
+ its off value (`replay_show_actions_ms = 0`, `replay_cursor_ms = 0`,
997
+ `replay_type_delay_ms = 0`, `replay_pauses = false`,
998
+ `replay_captions = false` and `replay_video_step_banner = false`) — that
999
+ writes exactly the config the CLI wrote before any of it existed. Every
1000
+ one of those keys can also be set once for the repository on its
1001
+ Settings page, which the CLI reads at the start of each recording and
1002
+ replay and lays over the file.
936
1003
 
937
1004
  ### Exit codes
938
1005
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "verifaied"
3
- version = "0.29.0.dev76"
3
+ version = "0.30.0.dev77"
4
4
  description = "Find what's untested in your code — locally, no account required"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -44,6 +44,7 @@ from __future__ import annotations
44
44
 
45
45
  import asyncio
46
46
  import atexit
47
+ import math
47
48
  import queue as queue_module
48
49
  import signal
49
50
  import subprocess
@@ -66,10 +67,12 @@ from verifaied.agent_steps import (
66
67
  report,
67
68
  )
68
69
  from verifaied.config import web_url_for
70
+ from verifaied.repo_config import RepoConfigError, web_authoring_keys
69
71
  from verifaied.stepcheck import (
70
72
  MAX_FLOW_STEPS,
71
73
  MAX_SCRIPT_CHARS,
72
74
  compose_script,
75
+ validate_caption_text,
73
76
  validate_step_line,
74
77
  )
75
78
  from verifaied.testcmd import (
@@ -80,6 +83,8 @@ from verifaied.testcmd import (
80
83
  SessionHooks,
81
84
  ShellRunner,
82
85
  StreamRunner,
86
+ authoring_settings,
87
+ compose_pause,
83
88
  detect_playwright_version,
84
89
  discard_test_row,
85
90
  ensure_supported_playwright,
@@ -87,6 +92,10 @@ from verifaied.testcmd import (
87
92
  list_recorded_tests,
88
93
  normalize_start_url,
89
94
  parse_test_ref,
95
+ pause_label,
96
+ pause_refusal,
97
+ read_repo_config,
98
+ read_service_settings,
90
99
  record_one,
91
100
  ref_of,
92
101
  resolve_or_create_test,
@@ -192,7 +201,7 @@ Guessing what a page will look like after a click is how flows break.
192
201
 
193
202
  {_LOCATOR_DOCTRINE}
194
203
 
195
- Each step is ONE of three shapes and nothing else:
204
+ Each step is ONE of four shapes and nothing else:
196
205
 
197
206
  - an ACTION (kind "action"): `await page.<locator>.<verb>(...);` with
198
207
  <verb> one of click, dblclick, fill, press, check, uncheck,
@@ -208,6 +217,22 @@ Each step is ONE of three shapes and nothing else:
208
217
  is wrapped in `await page.evaluate(async () => {{ … }});` for you, so
209
218
  `await` works inside it. It lands in the flow as an action labelled
210
219
  "Run a script in the page".
220
+ - a PAUSE (kind "pause"): `{{"kind": "pause", "seconds": 2}}`, no code.
221
+ It holds the replay's video still for that long so a reader can see
222
+ the result of the step before it — a toast, a filled form, a page
223
+ that just arrived. Between 0.1 and 30 seconds. It is skipped when the
224
+ video is off, so it must never be what a flow *depends* on: a pause
225
+ never waits for the app — an assertion does that, and waits until
226
+ the page agrees.
227
+
228
+ Any step may carry a `"caption"`: one short sentence shown under the
229
+ video while the step runs, for a reviewer who does not read code — one
230
+ idea, present tense, active voice, at most 8 words; an action is an
231
+ imperative ("Open the settings page"), a check says what is true ("The
232
+ token appears in the list"), a pause says what the viewer watches ("The
233
+ chart loads"); name what a person sees, never a selector or a value.
234
+ An `assert` may carry `"hold_seconds"`: how long the video holds on what
235
+ it checked (0 for none); left out, the repo's own default applies.
211
236
 
212
237
  Rules for the code:
213
238
 
@@ -303,9 +328,9 @@ today and fails next week. Replace it: `delete_step` the line and queue a
303
328
  corrected one naming the stable part yourself (`getByText(/Matches/)`,
304
329
  `toContainText('Matches')`).
305
330
 
306
- Arguments: `steps` is a list of `{{"kind": "action"|"assert"|"script", "code":
307
- "<one statement, or a script's body>", "label": "<a few words saying what it
308
- does>"}}`."""
331
+ Arguments: `steps` is a list of `{{"kind": "action"|"assert"|"script"|"pause",
332
+ "code": "<one statement, or a script's body>", "label": "<a few words saying
333
+ what it does>"}}` — a pause carries `"seconds"` instead of code."""
309
334
 
310
335
 
311
336
  # What each ending actually does, so a refusal says why waiting for the
@@ -445,6 +470,18 @@ class AgentRecorderServer:
445
470
  self.token = token
446
471
  self.repo_id = repo_id
447
472
  self.root = root
473
+ # The repo's authoring defaults: what a check holds the video for
474
+ # when the agent says nothing, and whether captions are expected.
475
+ # Read once; the MCP process is one recording long.
476
+ try:
477
+ service = read_service_settings(
478
+ api_url=api_url, token=token, repo_id=repo_id
479
+ )
480
+ self._settings = authoring_settings(
481
+ read_repo_config(root, service).authoring, web_authoring_keys(service)
482
+ )
483
+ except (RecordedTestError, RepoConfigError):
484
+ self._settings = {}
448
485
  self.branch = branch
449
486
  self.commit_sha = commit_sha
450
487
  self.env_name = env_name
@@ -778,17 +815,78 @@ class AgentRecorderServer:
778
815
  raw = str(fields.get("code") or "")
779
816
  code = raw.strip()
780
817
  label = str(fields.get("label") or "").strip() or code
781
- reason = validate_step_line(kind, raw if kind == "script" else code)
782
- if reason:
783
- entries.append(f"REFUSED — {reason}")
818
+ # A caption and, on a check, a hold ride beside the line and
819
+ # are composed into the step by the session — the line itself
820
+ # stays what every validator reads. The hold defaults to the
821
+ # repo's own, so an agent that says nothing gets the same
822
+ # video a person does.
823
+ caption = fields.get("caption")
824
+ if caption is not None:
825
+ reason = validate_caption_text(caption)
826
+ if reason:
827
+ entries.append(f"REFUSED — {reason}")
828
+ continue
829
+ caption = str(caption).strip()
830
+ hold_ms: int | None = None
831
+ if kind == "assert":
832
+ hold_seconds = fields.get("hold_seconds")
833
+ if hold_seconds is None:
834
+ hold_ms = self._settings.get("assertion_pause_ms") or None
835
+ elif isinstance(hold_seconds, bool) or not isinstance(
836
+ hold_seconds, (int, float)
837
+ ):
838
+ entries.append(
839
+ "REFUSED — `hold_seconds` is a number of seconds, or 0"
840
+ )
841
+ continue
842
+ elif not math.isfinite(float(hold_seconds)):
843
+ entries.append(
844
+ "REFUSED — `hold_seconds` is a number of seconds, or 0"
845
+ )
846
+ continue
847
+ else:
848
+ ms = round(float(hold_seconds) * 1000)
849
+ if ms != 0 and pause_refusal(hold_seconds):
850
+ entries.append(
851
+ f"REFUSED — a hold {pause_refusal(hold_seconds)[8:]}"
852
+ )
853
+ continue
854
+ hold_ms = ms or None
855
+ elif fields.get("hold_seconds") is not None:
856
+ entries.append(
857
+ "REFUSED — a hold follows a check — only an `assert` holds"
858
+ )
784
859
  continue
860
+ if kind == "pause":
861
+ # No code to validate: the seconds are the whole of it,
862
+ # and the line is composed here exactly as the studio's
863
+ # Pause tab composes it, so the block reopens there.
864
+ reason = pause_refusal(fields.get("seconds"))
865
+ if reason:
866
+ entries.append(f"REFUSED — {reason}")
867
+ continue
868
+ ms = round(float(fields.get("seconds")) * 1000)
869
+ kind, code, label = "action", compose_pause(ms), pause_label(ms)
870
+ else:
871
+ reason = validate_step_line(kind, raw if kind == "script" else code)
872
+ if reason:
873
+ entries.append(f"REFUSED — {reason}")
874
+ continue
785
875
  if kind == "script":
786
876
  # The body is the agent's; the wrapper and the label are
787
877
  # not. Composed exactly as the studio's Run Code tab
788
878
  # composes, so the block reopens there and keeps its name
789
879
  # once the spec is saved.
790
880
  kind, code, label = "action", compose_script(raw), CONSOLE_LABEL
791
- accepted.append({"kind": kind, "code": code, "label": label})
881
+ accepted.append(
882
+ {
883
+ "kind": kind,
884
+ "code": code,
885
+ "label": label,
886
+ "caption": caption,
887
+ "hold_ms": hold_ms,
888
+ }
889
+ )
792
890
  entries.append(
793
891
  AgentStep(
794
892
  handle=recording.next_handle(),
@@ -817,6 +915,10 @@ class AgentRecorderServer:
817
915
  "flow_index": position - 1,
818
916
  "flow_size": len(accepted),
819
917
  }
918
+ if step.get("caption") is not None:
919
+ payload["caption"] = step["caption"]
920
+ if step.get("hold_ms"):
921
+ payload["hold_ms"] = step["hold_ms"]
820
922
  if recording.page.snapshot_id is not None:
821
923
  # Which look at the page this line's refs were read from.
822
924
  # The driver compares it against the tree it holds and
@@ -1818,9 +1818,12 @@ def test_record_command(
1818
1818
  "--video",
1819
1819
  help=(
1820
1820
  "How the replay that follows this recording should be paced. "
1821
- "`fast` marks each click and moves on; `watch` lingers on "
1822
- "every action; `off` records without annotations. Overrides "
1823
- "replay_show_actions_ms / replay_slow_mo_ms for this run."
1821
+ "`fast` moves a cursor to each target, marks it and types at a "
1822
+ "readable pace; `watch` lingers on every action; `off` records "
1823
+ "without any of it and skips the pauses the flow asks for. "
1824
+ "Overrides replay_show_actions_ms / replay_slow_mo_ms / "
1825
+ "replay_cursor_ms / replay_type_delay_ms / replay_pauses for "
1826
+ "this run."
1824
1827
  ),
1825
1828
  ),
1826
1829
  ) -> None:
@@ -2042,10 +2045,12 @@ def test_run_command(
2042
2045
  None,
2043
2046
  "--video",
2044
2047
  help=(
2045
- "How the video should be paced. `fast` (the default) marks "
2046
- "each click and moves on; `watch` lingers on every action; "
2047
- "`off` records without annotations. Overrides "
2048
- "replay_show_actions_ms / replay_slow_mo_ms for this run."
2048
+ "How the video should be paced. `fast` (the default) moves a "
2049
+ "cursor to each target, marks it and types at a readable pace; "
2050
+ "`watch` lingers on every action; `off` records without any of "
2051
+ "it and skips the pauses the flow asks for. Overrides "
2052
+ "replay_show_actions_ms / replay_slow_mo_ms / replay_cursor_ms / "
2053
+ "replay_type_delay_ms / replay_pauses for this run."
2049
2054
  ),
2050
2055
  ),
2051
2056
  ) -> None:
@@ -386,6 +386,37 @@ def _media_type(path: Path) -> str:
386
386
  return _MEDIA_TYPES.get(path.suffix.lower(), "application/octet-stream")
387
387
 
388
388
 
389
+ def get_browser_test_settings(
390
+ *,
391
+ api_url: str,
392
+ token: str,
393
+ repo_id: UUID,
394
+ timeout: float = 10.0,
395
+ ) -> dict[str, Any]:
396
+ """The repository's browser-test settings as saved on the web.
397
+
398
+ The keys are the ``[browser-tests]`` keys, each null when the web
399
+ never set it — the CLI lays what is set over the file
400
+ (``apply_service_settings``). Raises :class:`ApiError` for anything
401
+ but a settings object, and the caller turns that into "the file
402
+ decides".
403
+ """
404
+ url = f"{api_url.rstrip('/')}/repositories/{repo_id}/browser-test-settings"
405
+ try:
406
+ response = httpx.get(
407
+ url, headers={"Authorization": f"Bearer {token}"}, timeout=timeout
408
+ )
409
+ except httpx.HTTPError as e:
410
+ raise ApiError(0, f"could not reach {url}: {e}") from e
411
+ if response.status_code >= 400:
412
+ raise ApiError(response.status_code, _detail(response))
413
+ data = response.json()
414
+ settings = data.get("settings") if isinstance(data, dict) else None
415
+ if not isinstance(settings, dict):
416
+ raise ApiError(0, f"unexpected response from {url}: no settings object")
417
+ return settings
418
+
419
+
389
420
  def list_recorded_tests(
390
421
  *,
391
422
  api_url: str,
@@ -232,12 +232,18 @@ function loadPlaywright() {
232
232
  // locator expression, for the same reason.
233
233
  const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
234
234
 
235
+ // What `test` means inside a body run this way. A saved spec wraps an
236
+ // inserted test and a pause in `test.step(...)`, which only exists under
237
+ // the test runner; here the step is simply its body, run — so a pause in
238
+ // a chain waits during the recording's warm-up, and a nested insert runs.
239
+ const TEST_SHIM = { step: async (_title, body) => body() };
240
+
235
241
  async function runPreSteps(preSteps, page, expect) {
236
242
  for (let index = 0; index < preSteps.length; index += 1) {
237
243
  const step = preSteps[index];
238
244
  emit({ type: "pre_step", index, label: step.label, status: "running" });
239
- const body = new AsyncFunction("page", "expect", step.body);
240
- await body(page, expect);
245
+ const body = new AsyncFunction("page", "expect", "test", step.body);
246
+ await body(page, expect, TEST_SHIM);
241
247
  emit({ type: "pre_step", index, label: step.label, status: "done" });
242
248
  }
243
249
  }
@@ -831,6 +837,7 @@ async function main() {
831
837
  "new-tab": "a new tab opened — actions in it are not recorded yet",
832
838
  dialog: "a browser dialog is not recorded yet",
833
839
  download: "a download is not recorded yet",
840
+ key: "a key this keyboard layout types is not recorded yet",
834
841
  };
835
842
  async function note(reason) {
836
843
  let docId = "";
@@ -1324,9 +1331,10 @@ async function main() {
1324
1331
  let timer = null;
1325
1332
  await setState({ selfActing: true });
1326
1333
  try {
1327
- const run = new AsyncFunction("page", "expect", body)(
1334
+ const run = new AsyncFunction("page", "expect", "test", body)(
1328
1335
  page,
1329
1336
  playwright.expect,
1337
+ TEST_SHIM,
1330
1338
  );
1331
1339
  // Attached before the race: a rejection arriving after the deadline
1332
1340
  // would otherwise be an unhandled rejection, and Node kills the