verifaied 0.21.1.dev45__tar.gz → 0.23.0.dev47__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 (57) hide show
  1. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/PKG-INFO +1 -1
  2. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/pyproject.toml +1 -1
  3. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/cli.py +38 -16
  4. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/client.py +5 -4
  5. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/record.py +21 -17
  6. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/testcmd.py +96 -23
  7. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_record.py +4 -3
  8. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_testcmd.py +167 -6
  9. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/uv.lock +1 -1
  10. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/.gitignore +0 -0
  11. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/LICENSE +0 -0
  12. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/README.md +0 -0
  13. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/__init__.py +0 -0
  14. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/__main__.py +0 -0
  15. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/analyzer.py +0 -0
  16. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/audit.py +0 -0
  17. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/config.py +0 -0
  18. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/failure_text.py +0 -0
  19. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/instrument.py +0 -0
  20. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/instrument_snippets.py +0 -0
  21. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/instrumenter.js +0 -0
  22. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/instrumenter.py +0 -0
  23. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/prompts.py +0 -0
  24. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/proxy.py +0 -0
  25. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/recorder_driver.js +0 -0
  26. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/recorder_observer.js +0 -0
  27. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/recorder_picker.js +0 -0
  28. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/recorder_refs.js +0 -0
  29. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/recorder_spec.js +0 -0
  30. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/repo_config.py +0 -0
  31. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/run_reporter.js +0 -0
  32. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/source_index.py +0 -0
  33. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/subprocess_util.py +0 -0
  34. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/suite_report.py +0 -0
  35. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/uploader.py +0 -0
  36. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/src/verifaied/varcmd.py +0 -0
  37. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/__init__.py +0 -0
  38. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/conftest.py +0 -0
  39. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_audit.py +0 -0
  40. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_check.py +0 -0
  41. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_check_done.py +0 -0
  42. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_clear_manual.py +0 -0
  43. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_cli.py +0 -0
  44. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_client.py +0 -0
  45. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_config.py +0 -0
  46. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_instrument.py +0 -0
  47. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_instrument_detect.py +0 -0
  48. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_instrument_snippets.py +0 -0
  49. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_instrumenter.py +0 -0
  50. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_prompt_parity.py +0 -0
  51. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_proxy.py +0 -0
  52. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_repo_config.py +0 -0
  53. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_reporter_parity.py +0 -0
  54. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_source_index.py +0 -0
  55. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_suite_report.py +0 -0
  56. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_uploader.py +0 -0
  57. {verifaied-0.21.1.dev45 → verifaied-0.23.0.dev47}/tests/test_varcmd.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: verifaied
3
- Version: 0.21.1.dev45
3
+ Version: 0.23.0.dev47
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "verifaied"
3
- version = "0.21.1.dev45"
3
+ version = "0.23.0.dev47"
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"
@@ -142,10 +142,12 @@ def upload_command(
142
142
  False,
143
143
  "--browser",
144
144
  help=(
145
- "Upload as browser-session coverage (the independent 'which code "
146
- "was exercised in a browser' dimension) instead of test-suite "
147
- "coverage. Accepts Istanbul (frontend harvest) or pytest-cov "
148
- "(coverage-instrumented backend); incompatible with --junit."
145
+ "Upload as browser coverage (the independent 'which code was "
146
+ "exercised in a browser' dimension) instead of test-suite "
147
+ "coverage. Counts as this branch's suite run, replacing what the "
148
+ "last wrapped run contributed and nothing else. Accepts Istanbul "
149
+ "(frontend harvest) or pytest-cov (coverage-instrumented "
150
+ "backend); incompatible with --junit."
149
151
  ),
150
152
  ),
151
153
  commit_sha: str | None = typer.Option(
@@ -523,9 +525,10 @@ def clear_manual_command(
523
525
  accumulation and start fresh: every function goes back to Unexercised,
524
526
  but the Manual Recording view stays visible.
525
527
 
526
- The e2e dimension (`verifaied upload --browser`, or
527
- `verifaied collect -- <cmd>`) has no clear: each run replaces the
528
- previous one, so that column always shows the latest.
528
+ Browser coverage (`verifaied upload --browser`, `verifaied test run`, or
529
+ `verifaied collect -- <cmd>`) has no clear: it is the union of what each
530
+ producer last reached, and every producer replaces its own contribution
531
+ on its next run.
529
532
 
530
533
  Exit codes:
531
534
  0 cleared
@@ -809,13 +812,14 @@ def record_command(
809
812
  arrived.
810
813
 
811
814
  The two modes feed two different dimensions, because they answer
812
- different questions. Wrapping a command uploads as **e2e**: a suite run
813
- is a complete snapshot, so each run **replaces** the last and the
814
- Browser Tests column on the branch view always shows the current one —
815
- there is nothing to clear. A bare session uploads as **manual**: a
816
- person covers a flow, stops, comes back, so those **accumulate** on the
817
- Manual Recording page until ``verifaied clear-manual`` starts a fresh
818
- pass.
815
+ different questions. Wrapping a command uploads as this branch's
816
+ **suite** contribution to its Browser coverage: a suite run is a
817
+ complete snapshot of itself, so each wrapped run **replaces** the last
818
+ one's contribution — and nothing else's, so replaying a single recorded
819
+ test never withdraws it. There is nothing to clear. A bare session
820
+ uploads as **manual**: a person covers a flow, stops, comes back, so
821
+ those **accumulate** on the Manual Recording page until
822
+ ``verifaied clear-manual`` starts a fresh pass.
819
823
 
820
824
  For a repeatable, named flow, use ``verifaied test record`` instead —
821
825
  it drives Playwright's recorder through verifAIed's own driver, saves
@@ -1728,12 +1732,20 @@ def test_run_command(
1728
1732
  metavar="[[<feature path>/]<name>]",
1729
1733
  help=(
1730
1734
  'Which test to run, e.g. "Settings/API tokens". The feature path '
1731
- "is optional and may nest. Omit with --all."
1735
+ "is optional and may nest. Omit with --all or --folder."
1732
1736
  ),
1733
1737
  ),
1734
1738
  run_all: bool = typer.Option(
1735
1739
  False, "--all", help="Run every recorded test, in order, and report the worst."
1736
1740
  ),
1741
+ folder: str | None = typer.Option(
1742
+ None,
1743
+ "--folder",
1744
+ help=(
1745
+ 'Run every recorded test under one folder, e.g. --folder "Settings" '
1746
+ "— nested folders included."
1747
+ ),
1748
+ ),
1737
1749
  proxy: bool = typer.Option(
1738
1750
  False,
1739
1751
  "--proxy",
@@ -1822,11 +1834,20 @@ def test_run_command(
1822
1834
  ) -> None:
1823
1835
  """Replay a recorded test, capturing video, trace and coverage.
1824
1836
 
1837
+ Three forms, exactly one at a time: a test by name, ``--folder
1838
+ "<folder path>"`` for everything nested under one folder, or ``--all``
1839
+ for every recorded test in the repository. A folder is matched the way a
1840
+ name is — by slug — so it is the same path the web composes.
1841
+
1825
1842
  Each run opens a session on the branch, so the video and the lines it
1826
1843
  reached sit together in verifAIed, and the run's pass/fail becomes the
1827
1844
  branch's answer for that test — which is what clears the
1828
1845
  ``unverified_recorded_test`` gap in ``check_done``.
1829
1846
 
1847
+ The coverage becomes **this test's** contribution to the branch's
1848
+ Browser coverage, replacing what the same test last reached and never
1849
+ touching the suite's or another test's.
1850
+
1830
1851
  ``--env`` picks which environment from ``.verifaied/config.toml``
1831
1852
  every spec in this run is pointed at: its ``base_url`` becomes
1832
1853
  Playwright's ``baseURL``, its ``reset`` runs before **each** spec so
@@ -1840,7 +1861,7 @@ def test_run_command(
1840
1861
  0 every test replayed cleanly
1841
1862
  * the worst exit code across the run
1842
1863
  2 a run was clean but its final coverage upload failed
1843
- 1 usage / config error (no token, unknown test, no Playwright)
1864
+ 1 usage / config error (no token, unknown test or folder, no Playwright)
1844
1865
  """
1845
1866
  resolved_url, resolved_token, repo_id = _test_prelude(
1846
1867
  repo=repo, api_url=api_url, token=token
@@ -1855,6 +1876,7 @@ def test_run_command(
1855
1876
  commit_sha=commit_sha,
1856
1877
  ref=ref,
1857
1878
  run_all=run_all,
1879
+ folder=folder,
1858
1880
  runner_dir=runner_dir,
1859
1881
  port=port,
1860
1882
  interval=interval,
@@ -785,10 +785,11 @@ def clear_manual_coverage(
785
785
  """DELETE the accumulated manual dimension for a (repo, branch).
786
786
 
787
787
  Manual `collect` sessions accumulate server-side; this resets them so
788
- the next pass starts fresh. The e2e dimension is untouched — it is
789
- replaced by each run and has no clear. Returns the parsed response
790
- (``{cleared_functions}``). Raises ``ApiError`` on any non-2xx — a 404
791
- means the branch has no local analysis to clear.
788
+ the next pass starts fresh. Browser coverage is untouched — every
789
+ producer there replaces its own contribution, so it has no clear.
790
+ Returns the parsed response (``{cleared_functions}``). Raises
791
+ ``ApiError`` on any non-2xx — a 404 means the branch has no local
792
+ analysis to clear.
792
793
  """
793
794
  url = f"{api_url.rstrip('/')}/repositories/{repo_id}/manual-coverage"
794
795
  try:
@@ -336,11 +336,13 @@ def flush_upload(
336
336
  ) -> UploadResult | None:
337
337
  """Upload the merged state as session coverage, if it changed.
338
338
 
339
- ``coverage_kind`` picks the dimension: ``"browser"`` (the e2e one) when
340
- this collector is wrapping a suite command, ``"manual"`` for a bare
341
- interactive session. They differ server-side — e2e replaces per run,
342
- manual accumulates until cleared — which is exactly the difference
343
- between "a suite just ran" and "someone is clicking around".
339
+ ``coverage_kind`` picks the dimension: ``"browser"`` when this collector
340
+ is wrapping a suite command, ``"manual"`` for a bare interactive
341
+ session. They differ server-side — a browser upload becomes this
342
+ branch's suite contribution, replacing what the last wrapped run
343
+ contributed and nothing else, while manual accumulates until cleared —
344
+ which is exactly the difference between "a suite just ran" and "someone
345
+ is clicking around".
344
346
 
345
347
  Returns None when nothing new arrived since the last *successful*
346
348
  upload. The merged JSON goes through a tempfile so the existing
@@ -396,9 +398,9 @@ def flush_upload(
396
398
  def format_upload_summary(result: UploadResult, coverage_kind: str = "manual") -> str:
397
399
  """One line per upload: what *this* session's dimension now looks like.
398
400
 
399
- A session must report its own numbers. Printing the e2e counts after a
400
- hand-driven pass would tell the operator their clicking had no effect —
401
- the two dimensions move independently by design.
401
+ A session must report its own numbers. Printing the branch's browser
402
+ counts after a hand-driven pass would tell the operator their clicking
403
+ had no effect — the two dimensions move independently by design.
402
404
  """
403
405
  if coverage_kind == "browser":
404
406
  exercised = result.browser_exercised_count
@@ -1103,10 +1105,10 @@ def run_record(
1103
1105
  flow), exiting 0 — or 2 if the final upload failed. Uploads as the
1104
1106
  **manual** dimension.
1105
1107
 
1106
- With ``command``: run it with the collector alongside (the e2e flow,
1108
+ With ``command``: run it with the collector alongside (the suite flow,
1107
1109
  ``verifaied collect -- npx playwright test``), then flush and exit with
1108
1110
  the command's own code, so the wrapper is transparent to CI. Uploads as
1109
- the **e2e** dimension.
1111
+ this branch's **suite** contribution to its browser coverage.
1110
1112
 
1111
1113
  ``port=None`` resolves per mode: the reporter's fixed default for a
1112
1114
  bare session, an ephemeral port for a wrapped one.
@@ -1138,12 +1140,14 @@ def run_record(
1138
1140
  thread = threading.Thread(target=server.serve_forever, daemon=True)
1139
1141
  thread.start()
1140
1142
 
1141
- # Wrapping a command means a suite just ran, which is the e2e
1142
- # dimension (replaced per run). A bare session is a person clicking
1143
- # around, which is the manual one (accumulated until cleared). The mode
1144
- # already tells us which, so the user never has to.
1143
+ # Wrapping a command means a suite just ran, which is the branch's
1144
+ # suite contribution to its browser coverage (replacing what the last
1145
+ # wrapped run contributed, and nothing else's). A bare session is a
1146
+ # person clicking around, which is the manual dimension (accumulated
1147
+ # until cleared). The mode already tells us which, so the user never
1148
+ # has to.
1145
1149
  coverage_kind = "browser" if command else "manual"
1146
- dimension = "e2e" if command else "manual"
1150
+ dimension = "suite" if command else "manual"
1147
1151
  console.print(
1148
1152
  f"[green]Recording[/green] {dimension} browser coverage on "
1149
1153
  f"[bold]http://{HOST}:{bound_port}[/bold] → branch "
@@ -1154,8 +1158,8 @@ def run_record(
1154
1158
  console.print(
1155
1159
  f"[dim]Running: {' '.join(command)} "
1156
1160
  f"(VITE_COVERAGE=true, VERIFAIED_RECORD_PORT={bound_port}). "
1157
- "Results land in the Browser Tests column of the branch "
1158
- "view.[/dim]"
1161
+ "Results land on the branch's Browser coverage page as the "
1162
+ "suite's contribution.[/dim]"
1159
1163
  )
1160
1164
  elif not proxy:
1161
1165
  console.print(
@@ -189,7 +189,7 @@ RUN_PROGRESS_MIN_INTERVAL = 0.5
189
189
  # the whole compatibility contract in one place: a CLI that predates runs
190
190
  # sends no field at all, is stored as record-only, and never takes a run
191
191
  # it would not know how to answer.
192
- STUDIO_KINDS: tuple[str, ...] = ("record", "run", "edit", "amend")
192
+ STUDIO_KINDS: tuple[str, ...] = ("record", "run", "run_headless", "edit", "amend")
193
193
 
194
194
  # ``major.minor`` prefixes of ``@playwright/test`` the recorder driver has
195
195
  # been verified against. **Fail-closed**: the driver reaches an internal
@@ -2828,9 +2828,14 @@ def replay_spec(
2828
2828
 
2829
2829
  This is where a recorded test becomes evidence: the spec runs with
2830
2830
  video and trace on, under the coverage collector, against a session
2831
- linked to the test. Video, trace and browser coverage all land on that
2832
- session, and the session's exit code becomes the branch's pass/fail for
2833
- the test when it closes.
2831
+ linked to the test. Video, trace and coverage all land on that session,
2832
+ and the session's exit code becomes the branch's pass/fail for the test
2833
+ when it closes.
2834
+
2835
+ The coverage becomes **this test's** contribution to the branch's
2836
+ browser coverage, replacing what the same test last reached and never
2837
+ touching the suite's or another test's — which is what makes "wrap your
2838
+ suite" and "run this test" two remedies that no longer undo each other.
2834
2839
 
2835
2840
  With pre-steps, what actually runs is a **composed** spec — their
2836
2841
  bodies then this one, in a throwaway file — and the coverage that
@@ -3223,8 +3228,8 @@ def _run_playwright(
3223
3228
 
3224
3229
  The child runs on a thread rather than in the foreground so the
3225
3230
  collector can upload on an interval — a long flow's coverage shouldn't
3226
- all land in one lump at the end, and the Browser Tests column filling
3227
- in as the replay proceeds is what makes a slow run legible.
3231
+ all land in one lump at the end, and the branch's Browser coverage
3232
+ filling in as the replay proceeds is what makes a slow run legible.
3228
3233
 
3229
3234
  ``var_env`` is the selected environment's variables, which is where a
3230
3235
  normalized spec's ``process.env.VERIFAIED_VAR_*`` references are
@@ -6801,9 +6806,16 @@ def run_test_studio(
6801
6806
  # against a service that hasn't been deployed yet.
6802
6807
  cancelled = False
6803
6808
  with keeper(busy_beat):
6804
- if request.get("kind") == "run":
6809
+ kind = request.get("kind")
6810
+ if kind in ("run", "run_headless"):
6805
6811
  _run_requested(
6806
6812
  request,
6813
+ # A single Run is somebody sitting in front of
6814
+ # this machine watching one flow; a whole folder
6815
+ # or a whole branch is not, and forty windows
6816
+ # opening in front of them is why nobody would
6817
+ # click it twice.
6818
+ headed=kind == "run",
6807
6819
  api_url=api_url,
6808
6820
  token=token,
6809
6821
  repo_id=repo_id,
@@ -6820,7 +6832,7 @@ def run_test_studio(
6820
6832
  runner=runner,
6821
6833
  shell_runner=shell_runner,
6822
6834
  )
6823
- elif request.get("kind") == "amend":
6835
+ elif kind == "amend":
6824
6836
  _amend_requested(
6825
6837
  request,
6826
6838
  api_url=api_url,
@@ -6840,7 +6852,7 @@ def run_test_studio(
6840
6852
  runner=runner,
6841
6853
  shell_runner=shell_runner,
6842
6854
  )
6843
- elif request.get("kind") == "edit":
6855
+ elif kind == "edit":
6844
6856
  cancelled = _edit_requested(
6845
6857
  request,
6846
6858
  api_url=api_url,
@@ -7185,6 +7197,7 @@ def _amend_requested(
7185
7197
  def _run_requested(
7186
7198
  request: dict[str, Any],
7187
7199
  *,
7200
+ headed: bool,
7188
7201
  api_url: str,
7189
7202
  token: str,
7190
7203
  repo_id: UUID,
@@ -7201,7 +7214,7 @@ def _run_requested(
7201
7214
  shell_runner: ShellRunner,
7202
7215
  env_name: str | None = None,
7203
7216
  ) -> None:
7204
- """Replay the one test a heartbeat handed over, headed.
7217
+ """Replay the one test a heartbeat handed over.
7205
7218
 
7206
7219
  The same shape as :func:`_record_requested`, and for the same reasons:
7207
7220
  ``recording`` goes out first because replaying a chain of pre-steps can
@@ -7209,10 +7222,13 @@ def _run_requested(
7209
7222
  it otherwise, and the rows are re-listed rather than trusted from the
7210
7223
  request, because the spec path and the stored chain live on the row.
7211
7224
 
7212
- Headed, unlike ``verifaied test run``: somebody clicked Run and is
7213
- sitting in front of this machine, so watching the browser do it is the
7214
- point. The run's own verdict lands on the session as it always does —
7215
- this reports only that the replay happened.
7225
+ ``headed`` is the whole difference between the two run kinds. A single
7226
+ Run is somebody clicking one test and sitting in front of this machine,
7227
+ so watching the browser do it is the point; a folder or a whole branch
7228
+ queued at once is nobody watching, and opening forty windows in front of
7229
+ them would be the reason they never clicked it again. The run's own
7230
+ verdict lands on the session either way — this reports only that the
7231
+ replay happened.
7216
7232
  """
7217
7233
  request_id = UUID(str(request["id"]))
7218
7234
  post_recording_request_status(
@@ -7239,7 +7255,7 @@ def _run_requested(
7239
7255
  console.print(
7240
7256
  f"[green]Running[/green] "
7241
7257
  f"[bold]{ref_of(test['feature'], test['name'])}[/bold] "
7242
- "from the queue."
7258
+ "from the queue." + ("" if headed else " [dim](headless)[/dim]")
7243
7259
  )
7244
7260
  replay_spec(
7245
7261
  api_url=api_url,
@@ -7258,7 +7274,7 @@ def _run_requested(
7258
7274
  console=console,
7259
7275
  err_console=err_console,
7260
7276
  all_rows=rows,
7261
- headed=True,
7277
+ headed=headed,
7262
7278
  env_name=env_name,
7263
7279
  runner=runner,
7264
7280
  shell_runner=shell_runner,
@@ -7291,6 +7307,34 @@ def _fail_request(
7291
7307
  err_console.print(f"[yellow]could not report the failure[/yellow]: {e}")
7292
7308
 
7293
7309
 
7310
+ def in_folder(row: dict[str, Any], folder_slug: str) -> bool:
7311
+ """Whether one listed test lives under a folder, at any depth.
7312
+
7313
+ Matched on the **slug**, the way :func:`find_test` matches a name, so
7314
+ ``--folder "API tokens"`` and ``--folder api-tokens`` are the same
7315
+ folder — and so the flag agrees with the path the web composes.
7316
+
7317
+ Nested tests count: a folder is a place, and "run Settings" meaning "but
7318
+ not the tests in Settings/Tokens" is a distinction nobody drew when they
7319
+ filed them there. The ``/`` in the prefix test is what keeps
7320
+ ``settings-api`` out of ``settings``.
7321
+ """
7322
+ slug = str(row.get("feature_slug") or "")
7323
+ return slug == folder_slug or slug.startswith(f"{folder_slug}/")
7324
+
7325
+
7326
+ def no_folder_match(folder: str, rows: list[dict[str, Any]]) -> str:
7327
+ """What to say when ``--folder`` named nothing that can be run.
7328
+
7329
+ The folders that do exist are the fix, so they are the message: a typo
7330
+ is answered by the list the user meant to pick from rather than by
7331
+ "nothing matched".
7332
+ """
7333
+ folders = sorted({str(row.get("feature") or "") for row in rows} - {""})
7334
+ known = ", ".join(folders) if folders else "none yet"
7335
+ return f'no recorded tests under "{folder}" — the folders here are: {known}'
7336
+
7337
+
7294
7338
  def run_test_run(
7295
7339
  *,
7296
7340
  api_url: str,
@@ -7301,6 +7345,7 @@ def run_test_run(
7301
7345
  commit_sha: str | None,
7302
7346
  ref: str | None,
7303
7347
  run_all: bool,
7348
+ folder: str | None = None,
7304
7349
  runner_dir: Path | None,
7305
7350
  port: int,
7306
7351
  interval: float,
@@ -7313,7 +7358,13 @@ def run_test_run(
7313
7358
  runner: ProcessRunner = run_process,
7314
7359
  shell_runner: ShellRunner = run_shell_process,
7315
7360
  ) -> int:
7316
- """Replay one recorded test, or every ready one.
7361
+ """Replay one recorded test, every ready one, or a folder of them.
7362
+
7363
+ Exactly one of the three says what to run: a name, ``--all``, or
7364
+ ``--folder "<path>"``. A folder is matched by **slug**, the way a name
7365
+ is, and it takes everything nested under it — a folder is a place, and
7366
+ "run Settings" meaning "not the tests in Settings/Tokens" would be a
7367
+ distinction nobody drew when they filed them there.
7317
7368
 
7318
7369
  Returns the **worst** exit code across the run: one broken flow in ten
7319
7370
  is a failure, and reporting the last one's code would let it hide
@@ -7329,24 +7380,40 @@ def run_test_run(
7329
7380
  one answer for the whole invocation, because "--all against stage"
7330
7381
  is one question.
7331
7382
  """
7332
- if (ref is None) == (not run_all):
7383
+ chosen = sum((ref is not None, run_all, folder is not None))
7384
+ if chosen != 1:
7333
7385
  raise RecordedTestError(
7334
- 'name a test — `verifaied test run "<feature path>/<name>"` — '
7335
- "or pass --all to run every recorded test"
7386
+ "say what to run, and only one of the three: name a test — "
7387
+ '`verifaied test run "<feature path>/<name>"` — or pass '
7388
+ '--folder "<folder path>" for everything under one folder, or '
7389
+ "--all for every recorded test"
7390
+ )
7391
+ # Before the listing, because it is a question about the flag rather
7392
+ # than about the repo: ``slugify_feature_path("")`` is ``""``, which
7393
+ # matches the rootless tests and nothing else — a silent answer to a
7394
+ # question the user did not ask.
7395
+ if folder is not None and not folder.strip():
7396
+ raise RecordedTestError(
7397
+ "`--folder` needs a folder path; use --all to run every recorded test"
7336
7398
  )
7337
7399
  resolved_branch = branch if branch is not None else detect_branch()
7338
7400
  resolved_runner_dir = find_runner_dir(root, runner_dir)
7339
7401
  rows = list_recorded_tests(
7340
7402
  api_url=api_url, token=token, repo_id=repo_id, branch=resolved_branch
7341
7403
  )
7342
- if run_all:
7404
+ if run_all or folder is not None:
7343
7405
  ready = [r for r in rows if r.get("status") == "ready"]
7406
+ if folder is not None:
7407
+ folder_slug = slugify_feature_path(folder)
7408
+ scoped = [r for r in ready if in_folder(r, folder_slug)]
7409
+ else:
7410
+ scoped = ready
7344
7411
  # Suite rows are born ready, so they would otherwise be swept up
7345
7412
  # by --all and replayed under a config that isn't theirs — see
7346
7413
  # ``suite_origin_error`` for why that is destructive rather than
7347
7414
  # merely useless.
7348
- targets = [r for r in ready if not is_suite_row(r)]
7349
- skipped = len(ready) - len(targets)
7415
+ targets = [r for r in scoped if not is_suite_row(r)]
7416
+ skipped = len(scoped) - len(targets)
7350
7417
  if skipped:
7351
7418
  console.print(
7352
7419
  f"[dim]Skipping {skipped} test(s) tracked from your Playwright "
@@ -7354,6 +7421,12 @@ def run_test_run(
7354
7421
  "`verifaied collect`.[/dim]"
7355
7422
  )
7356
7423
  if not targets:
7424
+ if folder is not None:
7425
+ # A named folder with nothing in it is a typo far more often
7426
+ # than it is an empty folder, so it is a refusal rather than
7427
+ # the clean zero ``--all`` answers with — and it names the
7428
+ # folders that do exist, which is the fix.
7429
+ raise RecordedTestError(no_folder_match(folder, rows))
7357
7430
  console.print(
7358
7431
  "[dim]No recorded tests to run yet — record one with "
7359
7432
  '`verifaied test record "<Feature path>/<Name>" --url <url>`. '
@@ -1160,12 +1160,13 @@ def test_wrapped_session_says_where_the_results_land(
1160
1160
  err_console=MagicMock(),
1161
1161
  )
1162
1162
  printed = " ".join(str(c) for c in console.print.call_args_list)
1163
- assert "e2e browser coverage" in printed
1164
- assert "Browser Tests column" in printed
1163
+ assert "suite browser coverage" in printed
1164
+ assert "Browser coverage page as the suite's contribution" in printed
1165
1165
 
1166
1166
 
1167
1167
  def test_summary_reports_the_session_s_own_dimension():
1168
- """A hand-driven pass must not be told the e2e numbers — the two move
1168
+ """A hand-driven pass must not be told the branch's browser numbers — the
1169
+ two move
1169
1170
  independently, and reporting the wrong one reads as "your clicking did
1170
1171
  nothing"."""
1171
1172
  result = _result(
@@ -2523,11 +2523,131 @@ def test_run_refuses_a_draft_and_names_the_record_command(tmp_path: Path):
2523
2523
  _run(tmp_path, rows=[_test_row(status="draft")], codes=[0])
2524
2524
 
2525
2525
 
2526
- def test_run_needs_exactly_one_of_a_ref_or_all(tmp_path: Path):
2527
- with pytest.raises(RecordedTestError, match="--all"):
2528
- _run(tmp_path, rows=[], codes=[], ref=None, run_all=False)
2529
- with pytest.raises(RecordedTestError, match="--all"):
2530
- _run(tmp_path, rows=[], codes=[], ref="Settings/API tokens", run_all=True)
2526
+ def test_run_needs_exactly_one_of_a_ref_all_or_folder(tmp_path: Path):
2527
+ """Three ways to say what to run, and saying two of them is as much a
2528
+ usage error as saying none."""
2529
+ for overrides in (
2530
+ {"ref": None, "run_all": False},
2531
+ {"ref": "Settings/API tokens", "run_all": True},
2532
+ {"ref": "Settings/API tokens", "folder": "Settings"},
2533
+ {"ref": None, "run_all": True, "folder": "Settings"},
2534
+ ):
2535
+ with pytest.raises(RecordedTestError, match="--folder") as excinfo:
2536
+ _run(tmp_path, rows=[], codes=[], **overrides)
2537
+ assert "--all" in str(excinfo.value)
2538
+
2539
+
2540
+ def test_run_folder_replays_everything_nested_under_it(tmp_path: Path):
2541
+ """A folder is a place: "run Settings" including Settings/Tokens is
2542
+ what anybody filing a test there meant."""
2543
+ top = _test_row(
2544
+ feature="Settings", name="Profile", feature_slug="settings", name_slug="profile"
2545
+ )
2546
+ nested = _test_row(
2547
+ feature="Settings/Tokens",
2548
+ name="Revoke",
2549
+ feature_slug="settings/tokens",
2550
+ name_slug="revoke",
2551
+ )
2552
+ elsewhere = _test_row(
2553
+ feature="Settings-api",
2554
+ name="Ping",
2555
+ feature_slug="settings-api",
2556
+ name_slug="ping",
2557
+ )
2558
+ rootless = _test_row(feature="", name="Log in", feature_slug="", name_slug="log-in")
2559
+
2560
+ code, replayed = _run(
2561
+ tmp_path,
2562
+ rows=[top, nested, elsewhere, rootless],
2563
+ codes=[0, 0],
2564
+ ref=None,
2565
+ folder="Settings",
2566
+ )
2567
+
2568
+ assert code == 0
2569
+ assert [c.kwargs["test"] for c in replayed.call_args_list] == [top, nested]
2570
+
2571
+
2572
+ def test_run_folder_matches_on_the_slug(tmp_path: Path):
2573
+ """The same rule `find_test` uses for a name, so the flag agrees with
2574
+ the path the web composes."""
2575
+ row = _test_row(
2576
+ feature="API tokens",
2577
+ name="Revoke",
2578
+ feature_slug="api-tokens",
2579
+ name_slug="revoke",
2580
+ )
2581
+
2582
+ code, replayed = _run(
2583
+ tmp_path, rows=[row], codes=[0], ref=None, folder="api-tokens"
2584
+ )
2585
+
2586
+ assert code == 0
2587
+ assert replayed.call_args.kwargs["test"] is row
2588
+
2589
+
2590
+ def test_run_folder_skips_drafts_and_suite_rows(tmp_path: Path):
2591
+ console = _console()
2592
+ ready = _test_row(
2593
+ feature="Settings", name="Profile", feature_slug="settings", name_slug="profile"
2594
+ )
2595
+ draft = _test_row(
2596
+ feature="Settings",
2597
+ name="Later",
2598
+ feature_slug="settings",
2599
+ name_slug="later",
2600
+ status="draft",
2601
+ )
2602
+ suite = _test_row(
2603
+ feature="Settings",
2604
+ name="Their own",
2605
+ feature_slug="settings",
2606
+ name_slug="their-own",
2607
+ origin="suite",
2608
+ )
2609
+
2610
+ code, replayed = _run(
2611
+ tmp_path,
2612
+ rows=[ready, draft, suite],
2613
+ codes=[0],
2614
+ ref=None,
2615
+ folder="Settings",
2616
+ console=console,
2617
+ )
2618
+
2619
+ assert code == 0
2620
+ assert [c.kwargs["test"] for c in replayed.call_args_list] == [ready]
2621
+ printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
2622
+ assert "Skipping 1 test(s) tracked from your Playwright suite" in printed
2623
+
2624
+
2625
+ def test_run_folder_with_no_match_refuses_and_names_the_folders(tmp_path: Path):
2626
+ """A typo is not a clean zero, unlike --all: the folders that do exist
2627
+ are the fix, so they are the message."""
2628
+ rows = [
2629
+ _test_row(
2630
+ feature="Settings/Tokens",
2631
+ name="Revoke",
2632
+ feature_slug="settings/tokens",
2633
+ name_slug="revoke",
2634
+ ),
2635
+ _test_row(feature="", name="Log in", feature_slug="", name_slug="log-in"),
2636
+ ]
2637
+
2638
+ with pytest.raises(RecordedTestError) as excinfo:
2639
+ _run(tmp_path, rows=rows, codes=[], ref=None, folder="Setings")
2640
+
2641
+ assert str(excinfo.value) == (
2642
+ 'no recorded tests under "Setings" — the folders here are: Settings/Tokens'
2643
+ )
2644
+
2645
+
2646
+ def test_run_folder_refuses_a_blank_path_before_it_slugs_it(tmp_path: Path):
2647
+ """`slugify_feature_path("")` is `""`, which matches the rootless
2648
+ tests and nothing else — an answer to a question nobody asked."""
2649
+ with pytest.raises(RecordedTestError, match="needs a folder path"):
2650
+ _run(tmp_path, rows=[_test_row()], codes=[], ref=None, folder=" ")
2531
2651
 
2532
2652
 
2533
2653
  def test_run_all_skips_drafts_and_reports_the_worst_code(tmp_path: Path):
@@ -2778,6 +2898,28 @@ def test_test_run_command_forwards_all(monkeypatch):
2778
2898
  assert run.call_args.kwargs["branch"] == "feature-a"
2779
2899
 
2780
2900
 
2901
+ def test_test_run_command_forwards_the_folder(monkeypatch):
2902
+ monkeypatch.setenv(ENV_API_TOKEN, "vr_live_tok")
2903
+ with patch("verifaied.cli.run_test_run", return_value=0) as run:
2904
+ result = runner.invoke(
2905
+ app,
2906
+ [
2907
+ "test",
2908
+ "run",
2909
+ "--folder",
2910
+ "Settings/Tokens",
2911
+ "--repo",
2912
+ str(uuid4()),
2913
+ "--api-url",
2914
+ "http://api.test",
2915
+ ],
2916
+ )
2917
+ assert result.exit_code == 0
2918
+ assert run.call_args.kwargs["folder"] == "Settings/Tokens"
2919
+ assert run.call_args.kwargs["run_all"] is False
2920
+ assert run.call_args.kwargs["ref"] is None
2921
+
2922
+
2781
2923
  def test_test_run_command_propagates_the_worst_code(monkeypatch):
2782
2924
  monkeypatch.setenv(ENV_API_TOKEN, "vr_live_tok")
2783
2925
  with patch("verifaied.cli.run_test_run", return_value=3):
@@ -8580,7 +8722,7 @@ def test_studio_declares_every_kind_on_every_beat(tmp_path: Path):
8580
8722
  """The service claims only what a studio says it can do, so this is
8581
8723
  the whole compatibility contract in one field."""
8582
8724
  studio = _studio(tmp_path, acks=[_ack()])
8583
- assert STUDIO_KINDS == ("record", "run", "edit", "amend")
8725
+ assert STUDIO_KINDS == ("record", "run", "run_headless", "edit", "amend")
8584
8726
  working = [beat for beat in studio.beats if not beat.get("closing")]
8585
8727
  assert working
8586
8728
  assert all(beat["kinds"] == list(STUDIO_KINDS) for beat in working)
@@ -8608,6 +8750,25 @@ def test_studio_replays_a_run_request_headed_and_records_nothing(
8608
8750
  assert [s["status"] for s in studio.statuses] == ["recording", "done"]
8609
8751
 
8610
8752
 
8753
+ def test_studio_replays_a_headless_run_request_with_no_window(tmp_path: Path):
8754
+ """A batch is nobody watching, and forty windows opening in front of
8755
+ somebody is the reason they would never click it twice."""
8756
+ row = _test_row()
8757
+ studio = _studio(
8758
+ tmp_path,
8759
+ acks=[_ack(_studio_request(row["id"], kind="run_headless"))],
8760
+ rows=[row],
8761
+ )
8762
+
8763
+ assert studio.records == []
8764
+ assert len(studio.replays) == 1
8765
+ call = studio.replays[0]
8766
+ assert call["headed"] is False
8767
+ assert call["driver"] == "suite"
8768
+ assert call["test"] is row
8769
+ assert [s["status"] for s in studio.statuses] == ["recording", "done"]
8770
+
8771
+
8611
8772
  def test_studio_reports_done_for_a_run_that_failed_its_assertions(
8612
8773
  tmp_path: Path,
8613
8774
  ):
@@ -442,7 +442,7 @@ wheels = [
442
442
 
443
443
  [[package]]
444
444
  name = "verifaied"
445
- version = "0.21.1"
445
+ version = "0.23.0"
446
446
  source = { editable = "." }
447
447
  dependencies = [
448
448
  { name = "httpx" },