verifaied 0.23.1.dev48__tar.gz → 0.24.0.dev49__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.23.1.dev48 → verifaied-0.24.0.dev49}/PKG-INFO +1 -1
  2. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/pyproject.toml +1 -1
  3. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/client.py +35 -0
  4. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/testcmd.py +126 -13
  5. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_client.py +45 -0
  6. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_testcmd.py +299 -4
  7. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/uv.lock +1 -1
  8. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/.gitignore +0 -0
  9. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/LICENSE +0 -0
  10. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/README.md +0 -0
  11. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/__init__.py +0 -0
  12. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/__main__.py +0 -0
  13. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/analyzer.py +0 -0
  14. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/audit.py +0 -0
  15. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/cli.py +0 -0
  16. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/config.py +0 -0
  17. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/failure_text.py +0 -0
  18. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/instrument.py +0 -0
  19. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/instrument_snippets.py +0 -0
  20. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/instrumenter.js +0 -0
  21. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/instrumenter.py +0 -0
  22. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/prompts.py +0 -0
  23. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/proxy.py +0 -0
  24. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/record.py +0 -0
  25. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/recorder_driver.js +0 -0
  26. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/recorder_observer.js +0 -0
  27. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/recorder_picker.js +0 -0
  28. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/recorder_refs.js +0 -0
  29. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/recorder_spec.js +0 -0
  30. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/repo_config.py +0 -0
  31. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/run_reporter.js +0 -0
  32. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/source_index.py +0 -0
  33. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/subprocess_util.py +0 -0
  34. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/suite_report.py +0 -0
  35. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/uploader.py +0 -0
  36. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/src/verifaied/varcmd.py +0 -0
  37. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/__init__.py +0 -0
  38. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/conftest.py +0 -0
  39. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_audit.py +0 -0
  40. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_check.py +0 -0
  41. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_check_done.py +0 -0
  42. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_clear_manual.py +0 -0
  43. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_cli.py +0 -0
  44. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_config.py +0 -0
  45. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_instrument.py +0 -0
  46. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_instrument_detect.py +0 -0
  47. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_instrument_snippets.py +0 -0
  48. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_instrumenter.py +0 -0
  49. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_prompt_parity.py +0 -0
  50. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_proxy.py +0 -0
  51. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_record.py +0 -0
  52. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_repo_config.py +0 -0
  53. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_reporter_parity.py +0 -0
  54. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_source_index.py +0 -0
  55. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_suite_report.py +0 -0
  56. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_uploader.py +0 -0
  57. {verifaied-0.23.1.dev48 → verifaied-0.24.0.dev49}/tests/test_varcmd.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: verifaied
3
- Version: 0.23.1.dev48
3
+ Version: 0.24.0.dev49
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.23.1.dev48"
3
+ version = "0.24.0.dev49"
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"
@@ -741,6 +741,41 @@ def post_recording_request_status(
741
741
  raise ApiError(response.status_code, _detail(response))
742
742
 
743
743
 
744
+ def delete_recorded_test(
745
+ *,
746
+ api_url: str,
747
+ token: str,
748
+ test_id: UUID,
749
+ timeout: float = 30.0,
750
+ ) -> bool:
751
+ """Remove a test row. True when it was there, False when it wasn't.
752
+
753
+ The discard's other half. The browser closes without saving, and the
754
+ row a first recording created is then no use to anybody — so the
755
+ machine that did the recording is what removes it, because deleting
756
+ a row for a recording that may still be running is not something the
757
+ service can safely do on its own.
758
+
759
+ A 404 is not an error: the row is gone, which is the state the caller
760
+ was asking for. Everything else raises, so a failure can be said out
761
+ loud rather than leaving somebody believing a test was removed.
762
+ """
763
+ url = f"{api_url.rstrip('/')}/recorded-tests/{test_id}"
764
+ try:
765
+ response = httpx.delete(
766
+ url,
767
+ headers={"Authorization": f"Bearer {token}"},
768
+ timeout=timeout,
769
+ )
770
+ except httpx.HTTPError as e:
771
+ raise ApiError(0, f"could not reach {url}: {e}") from e
772
+ if response.status_code == 404:
773
+ return False
774
+ if response.status_code >= 400:
775
+ raise ApiError(response.status_code, _detail(response))
776
+ return True
777
+
778
+
744
779
  def check_done(
745
780
  *,
746
781
  api_url: str,
@@ -111,6 +111,7 @@ from rich.table import Table
111
111
  from verifaied.client import (
112
112
  ApiError,
113
113
  create_recorded_test,
114
+ delete_recorded_test,
114
115
  list_recorded_tests,
115
116
  patch_recorded_test,
116
117
  post_recorder_heartbeat,
@@ -3934,6 +3935,12 @@ class RecordingSession:
3934
3935
  # the save path still reads it if the browser dies before the
3935
3936
  # `cancelled` event makes it back out.
3936
3937
  self.cancelled = False
3938
+ # Whether that discard also asked for the test row itself to go.
3939
+ # Set beside ``cancelled``, for its reason, and acted on by
3940
+ # whoever called ``record_one`` rather than here: this session has
3941
+ # no idea what a row is, and the delete has to happen after the
3942
+ # closing posts have landed.
3943
+ self.discard = False
3937
3944
  self.error: str | None = None
3938
3945
  self._picked: dict[str, Any] | None = None
3939
3946
  self._last_action: dict[str, Any] | None = None
@@ -4651,15 +4658,23 @@ class RecordingSession:
4651
4658
  # rather than as the deliberate stop it was.
4652
4659
  self._cancel_all_flows()
4653
4660
  self._send({"cmd": "finish", "id": command_id})
4654
- elif kind == "cancel_recording":
4661
+ elif kind in ("cancel_recording", "discard_recording"):
4655
4662
  # The discard, and finish's twin in every way but the one that
4656
4663
  # matters: nothing is written. The flag is set here rather than
4657
4664
  # when the driver answers, because the save path has to know a
4658
4665
  # cancel was asked for even if the browser goes away before the
4659
4666
  # `cancelled` event gets back out. Waiting flows are cancelled
4660
4667
  # first for exactly finish's reason.
4668
+ #
4669
+ # The two kinds travel the identical road down stdin — the
4670
+ # driver knows one ending, not two — and differ only in what
4671
+ # the *caller* does once the browser has closed and the
4672
+ # closing posts have landed: `discard_recording` also asks for
4673
+ # the row to be removed, which is only ever offered on a test
4674
+ # that has never produced a spec.
4661
4675
  self._cancel_all_flows()
4662
4676
  self.cancelled = True
4677
+ self.discard = kind == "discard_recording"
4663
4678
  self._send({"cmd": "cancel", "id": command_id})
4664
4679
  elif kind == "assert":
4665
4680
  code = str(payload.get("code") or "")
@@ -5335,10 +5350,12 @@ def run_test_record(
5335
5350
  has_pre=bool(pre),
5336
5351
  base_url=base_url,
5337
5352
  )
5338
- # Only the code: a terminal recording that was discarded is a
5339
- # deliberate 0, and the studio is the one caller that has anywhere to
5340
- # report the difference to.
5341
- return record_one(
5353
+ # A terminal recording that was discarded is a deliberate 0, and the
5354
+ # studio is the one caller that has anywhere to report *that*
5355
+ # difference to — but the row a discard asks for is this caller's to
5356
+ # remove, exactly as it is the loop's, and it goes after the closing
5357
+ # posts for the same reason.
5358
+ outcome = record_one(
5342
5359
  api_url=api_url,
5343
5360
  token=token,
5344
5361
  repo_id=repo_id,
@@ -5365,7 +5382,17 @@ def run_test_record(
5365
5382
  runner=runner,
5366
5383
  stream_runner=stream_runner,
5367
5384
  shell_runner=shell_runner,
5368
- ).code
5385
+ )
5386
+ if outcome.discarded:
5387
+ discard_test_row(
5388
+ api_url=api_url,
5389
+ token=token,
5390
+ test_id=UUID(str(test["id"])),
5391
+ label=ref_of(feature, name),
5392
+ console=console,
5393
+ err_console=err_console,
5394
+ )
5395
+ return outcome.code
5369
5396
 
5370
5397
 
5371
5398
  def saved_flowchart(
@@ -5422,10 +5449,52 @@ class RecordOutcome(NamedTuple):
5422
5449
  which the studio has to report as its own ending rather than as
5423
5450
  ``done`` — that word means the recording was saved, and a discard is
5424
5451
  precisely the case where nothing was.
5452
+
5453
+ ``discarded`` is the narrower half of that: the user asked for the
5454
+ test itself to be removed, not just for this attempt to be thrown
5455
+ away. The removal is the **caller's** — ``record_one`` never deletes
5456
+ a row — because the closing posts have to land first, and only the
5457
+ caller knows whether there is also a request row riding on it.
5425
5458
  """
5426
5459
 
5427
5460
  code: int
5428
5461
  cancelled: bool = False
5462
+ discarded: bool = False
5463
+
5464
+
5465
+ def discard_test_row(
5466
+ *,
5467
+ api_url: str,
5468
+ token: str,
5469
+ test_id: UUID,
5470
+ label: str,
5471
+ console: Console,
5472
+ err_console: Console,
5473
+ ) -> None:
5474
+ """Remove the row a discarded first recording left behind.
5475
+
5476
+ Fail-soft, and deliberately: the recording is already over and the
5477
+ browser is already closed, so a delete that cannot land is worth one
5478
+ yellow line and nothing more. The row simply stays a draft, which is
5479
+ exactly where it was a moment ago and which the user can remove from
5480
+ the Tests page.
5481
+
5482
+ Never raises — a caller reporting the recording's own ending must not
5483
+ have that ending rewritten by the tidy-up after it.
5484
+ """
5485
+ try:
5486
+ removed = delete_recorded_test(api_url=api_url, token=token, test_id=test_id)
5487
+ except ApiError as e:
5488
+ err_console.print(
5489
+ f'[yellow]could not remove "{escape(label)}"[/yellow] — '
5490
+ f"it stays a draft: {escape(str(e))}"
5491
+ )
5492
+ return
5493
+ console.print(
5494
+ f'[dim]"{escape(label)}" removed from verifAIed.[/dim]'
5495
+ if removed
5496
+ else f'[dim]"{escape(label)}" was already gone.[/dim]'
5497
+ )
5429
5498
 
5430
5499
 
5431
5500
  def record_one(
@@ -5658,6 +5727,21 @@ def record_one(
5658
5727
  # recording, and this post is the only channel that can put
5659
5728
  # the saved spec's flow back — PATCH never writes steps, by
5660
5729
  # design.
5730
+ #
5731
+ # A discard of a *draft* asks for one more thing: the row goes
5732
+ # too. The endpoint already refuses that on a row with a spec,
5733
+ # and the status is checked again here so an older service
5734
+ # that let one through degrades to a plain cancel rather than
5735
+ # deleting somebody's test. The delete itself is the caller's
5736
+ # — the closing posts have to land first.
5737
+ discard = session.discard and str(test.get("status")) == "draft"
5738
+ if discard:
5739
+ session.finish([])
5740
+ console.print(
5741
+ "[yellow]recording discarded[/yellow] — nothing was "
5742
+ f'saved, and "{label}" is being removed.'
5743
+ )
5744
+ return RecordOutcome(0, cancelled=True, discarded=True)
5661
5745
  session.finish(saved_flowchart(spec_path, pre_refs))
5662
5746
  note = (
5663
5747
  f' — nothing was saved, so "{label}" stays a draft'
@@ -6134,6 +6218,12 @@ def edit_one(
6134
6218
  # Nothing is written, so the spec on disk is the one that was
6135
6219
  # there — and the closing post has to put its flow back, because
6136
6220
  # the live feed has been clobbering `steps` with this session's.
6221
+ #
6222
+ # ``session.discard`` is deliberately not read here. An edit is
6223
+ # only ever offered on a test that already has a spec, which is
6224
+ # exactly the row the discard is fenced off from — so a session
6225
+ # that somehow collected one still ends as a plain cancel and
6226
+ # the saved flow stands.
6137
6227
  session.finish(saved_flowchart(spec_path, pre_refs))
6138
6228
  console.print(
6139
6229
  f"[yellow]edit cancelled[/yellow] — nothing was saved, so "
@@ -6805,6 +6895,11 @@ def run_test_studio(
6805
6895
  # than requiring the field keeps an upgraded CLI working
6806
6896
  # against a service that hasn't been deployed yet.
6807
6897
  cancelled = False
6898
+ # Whether the recording also asked for its row to go. Held
6899
+ # until after the status post below, because the request
6900
+ # row cascades with the test it names — deleting first
6901
+ # would leave that post with nothing to report to.
6902
+ discarded = False
6808
6903
  with keeper(busy_beat):
6809
6904
  kind = request.get("kind")
6810
6905
  if kind in ("run", "run_headless"):
@@ -6874,7 +6969,7 @@ def run_test_studio(
6874
6969
  shell_runner=shell_runner,
6875
6970
  )
6876
6971
  else:
6877
- cancelled = _record_requested(
6972
+ outcome = _record_requested(
6878
6973
  request,
6879
6974
  api_url=api_url,
6880
6975
  token=token,
@@ -6894,6 +6989,8 @@ def run_test_studio(
6894
6989
  stream_runner=stream_runner,
6895
6990
  shell_runner=shell_runner,
6896
6991
  )
6992
+ cancelled = outcome.cancelled
6993
+ discarded = outcome.discarded
6897
6994
  # Done means "the recording was saved" — or, for a run,
6898
6995
  # "the replay finished", or, for an amendment, "the new
6899
6996
  # lines are in the file", whatever the exit code was.
@@ -6912,6 +7009,19 @@ def run_test_studio(
6912
7009
  request_id=request_id,
6913
7010
  status="cancelled" if cancelled else "done",
6914
7011
  )
7012
+ if discarded:
7013
+ # **After** the status post, never before: this request
7014
+ # row cascades with the test it names, so deleting
7015
+ # first would leave the ending with nowhere to land and
7016
+ # the page watching a request that simply vanished.
7017
+ discard_test_row(
7018
+ api_url=api_url,
7019
+ token=token,
7020
+ test_id=UUID(str(request["recorded_test_id"])),
7021
+ label=ref_of(request["feature"], request["name"]),
7022
+ console=console,
7023
+ err_console=err_console,
7024
+ )
6915
7025
  except RecordedTestError as e:
6916
7026
  err_console.print(f"[red]VerifAIed error[/red]: {e}")
6917
7027
  _fail_request(
@@ -6974,12 +7084,15 @@ def _record_requested(
6974
7084
  stream_runner: StreamRunner,
6975
7085
  shell_runner: ShellRunner,
6976
7086
  env_name: str | None = None,
6977
- ) -> bool:
6978
- """Record the one flow a heartbeat handed over. True when discarded.
7087
+ ) -> RecordOutcome:
7088
+ """Record the one flow a heartbeat handed over.
6979
7089
 
6980
- The return value is the one thing the loop cannot see for itself:
6981
- ``done`` means the recording was saved, so a recording the user
6982
- threw away has to be reported as its own ending instead.
7090
+ The outcome is the two things the loop cannot see for itself.
7091
+ ``cancelled`` — ``done`` means the recording was saved, so a recording
7092
+ the user threw away has to be reported as its own ending instead.
7093
+ ``discarded`` — the user asked for the test itself to go, which the
7094
+ loop does **after** posting that ending, because the request row
7095
+ cascades with the test it names.
6983
7096
 
6984
7097
  ``recording`` is posted **first**, before anything slow: replaying a
6985
7098
  chain of pre-steps can take a minute, and the page that asked for
@@ -7039,7 +7152,7 @@ def _record_requested(
7039
7152
  runner=runner,
7040
7153
  stream_runner=stream_runner,
7041
7154
  shell_runner=shell_runner,
7042
- ).cancelled
7155
+ )
7043
7156
 
7044
7157
 
7045
7158
  def _edit_requested(
@@ -21,6 +21,7 @@ from verifaied.client import (
21
21
  clear_manual_coverage,
22
22
  create_recorded_test,
23
23
  create_session,
24
+ delete_recorded_test,
24
25
  finalize_session,
25
26
  list_installations,
26
27
  list_recorded_tests,
@@ -1382,6 +1383,50 @@ def test_post_recording_request_status_wraps_a_transport_failure(
1382
1383
  assert "could not reach" in exc.value.message
1383
1384
 
1384
1385
 
1386
+ def test_delete_recorded_test_removes_the_row(httpx_mock: HTTPXMock):
1387
+ """The discard's other half: the machine that did the recording is
1388
+ what removes the row a first recording left behind."""
1389
+ test_id = uuid4()
1390
+ httpx_mock.add_response(
1391
+ method="DELETE",
1392
+ url=f"http://api.test/recorded-tests/{test_id}",
1393
+ status_code=204,
1394
+ )
1395
+ assert (
1396
+ delete_recorded_test(api_url="http://api.test/", token="t", test_id=test_id)
1397
+ is True
1398
+ )
1399
+ assert httpx_mock.get_request().headers["Authorization"] == "Bearer t"
1400
+
1401
+
1402
+ def test_delete_recorded_test_reads_a_404_as_already_gone(httpx_mock: HTTPXMock):
1403
+ """Not an error: the row is gone, which is the state the caller was
1404
+ asking for."""
1405
+ httpx_mock.add_response(
1406
+ method="DELETE", status_code=404, json={"detail": "recorded test not found"}
1407
+ )
1408
+ assert (
1409
+ delete_recorded_test(api_url="http://api.test", token="t", test_id=uuid4())
1410
+ is False
1411
+ )
1412
+
1413
+
1414
+ def test_delete_recorded_test_raises_on_anything_else(httpx_mock: HTTPXMock):
1415
+ httpx_mock.add_response(method="DELETE", status_code=500, json={"detail": "boom"})
1416
+ with pytest.raises(ApiError) as exc:
1417
+ delete_recorded_test(api_url="http://api.test", token="t", test_id=uuid4())
1418
+ assert exc.value.status_code == 500
1419
+ assert exc.value.message == "boom"
1420
+
1421
+
1422
+ def test_delete_recorded_test_wraps_a_transport_failure(httpx_mock: HTTPXMock):
1423
+ httpx_mock.add_exception(httpx.ConnectError("no route"))
1424
+ with pytest.raises(ApiError) as exc:
1425
+ delete_recorded_test(api_url="http://api.test", token="t", test_id=uuid4())
1426
+ assert exc.value.status_code == 0
1427
+ assert "could not reach" in exc.value.message
1428
+
1429
+
1385
1430
  def test_post_recorder_heartbeat_declares_the_kinds_it_can_do(
1386
1431
  httpx_mock: HTTPXMock,
1387
1432
  ):
@@ -1674,6 +1674,7 @@ class Recording:
1674
1674
  rows,
1675
1675
  results,
1676
1676
  outcome,
1677
+ deleted,
1677
1678
  ):
1678
1679
  self.code = code
1679
1680
  self.stream = stream
@@ -1685,9 +1686,12 @@ class Recording:
1685
1686
  self.rows = rows
1686
1687
  # Command results the CLI reported back on the driver's behalf.
1687
1688
  self.results = results
1688
- # What `record_one` answered — the exit code plus the one thing an
1689
- # exit code cannot say, whether the recording was discarded.
1689
+ # What `record_one` answered — the exit code plus the two things an
1690
+ # exit code cannot say: whether the recording was discarded, and
1691
+ # whether the row was asked to go with it.
1690
1692
  self.outcome = outcome
1693
+ # The ids `delete_recorded_test` was called with, in order.
1694
+ self.deleted = deleted
1691
1695
 
1692
1696
 
1693
1697
  def _record(
@@ -1700,6 +1704,8 @@ def _record(
1700
1704
  replay_code: int = 0,
1701
1705
  extra_lines: tuple[str, ...] = (),
1702
1706
  acks: list[dict] | None = None,
1707
+ delete_error: Exception | None = None,
1708
+ delete_found: bool = True,
1703
1709
  **overrides,
1704
1710
  ) -> Recording:
1705
1711
  root = _repo(tmp_path)
@@ -1714,6 +1720,13 @@ def _record(
1714
1720
  queued = list(acks or [])
1715
1721
 
1716
1722
  outcomes: list[RecordOutcome] = []
1723
+ deleted: list[dict] = []
1724
+
1725
+ def _delete(**kw):
1726
+ deleted.append(kw)
1727
+ if delete_error is not None:
1728
+ raise delete_error
1729
+ return delete_found
1717
1730
 
1718
1731
  def _post(**kw):
1719
1732
  posted.append(kw)
@@ -1760,6 +1773,7 @@ def _record(
1760
1773
  side_effect=lambda **kw: results.append(kw),
1761
1774
  ),
1762
1775
  patch("verifaied.testcmd.record_one", side_effect=_capture_outcome),
1776
+ patch("verifaied.testcmd.delete_recorded_test", side_effect=_delete),
1763
1777
  _inline_poster(),
1764
1778
  ):
1765
1779
  code = run_test_record(**kwargs)
@@ -1774,6 +1788,7 @@ def _record(
1774
1788
  rows,
1775
1789
  results,
1776
1790
  outcomes[-1] if outcomes else None,
1791
+ deleted,
1777
1792
  )
1778
1793
 
1779
1794
 
@@ -6095,9 +6110,165 @@ def test_record_discards_whatever_the_driver_exit_looked_like(tmp_path: Path):
6095
6110
  assert result.patched.call_count == 0
6096
6111
 
6097
6112
 
6113
+ DISCARD_COMMAND = {"id": "cmd-dis", "kind": "discard_recording", "payload": {}}
6114
+
6115
+
6116
+ def test_record_forwards_a_discard_the_same_way_it_forwards_a_cancel(tmp_path: Path):
6117
+ """One ending down stdin, not two: the driver knows how to stop, and
6118
+ what the row is worth afterwards is the CLI's question."""
6119
+ row = _test_row(status="draft")
6120
+ result = _record(
6121
+ tmp_path,
6122
+ test=row,
6123
+ rows=[row],
6124
+ events=(
6125
+ {"type": "ready"},
6126
+ {"type": "spec", "text": SPEC_BODY},
6127
+ {"type": "cancelled", "id": "cmd-dis"},
6128
+ {"type": "done", "spec_saved": True},
6129
+ ),
6130
+ acks=[{"commands": [DISCARD_COMMAND]}],
6131
+ )
6132
+ assert result.stream.stdin == [READY_CAPTURE, {"cmd": "cancel", "id": "cmd-dis"}]
6133
+ assert result.results == [
6134
+ {
6135
+ "api_url": "http://api.test",
6136
+ "token": "t",
6137
+ "test_id": UUID(row["id"]),
6138
+ "command_id": "cmd-dis",
6139
+ "status": "done",
6140
+ "result": {"ok": True, "cancelled": True},
6141
+ }
6142
+ ]
6143
+
6144
+
6145
+ def test_record_removes_the_row_when_a_first_recording_is_discarded(tmp_path: Path):
6146
+ """The whole point of the second button: the row exists only because
6147
+ somebody named a flow, so "this was a mistake" takes it with it."""
6148
+ row = _test_row(status="draft")
6149
+ console = _console()
6150
+ result = _record(
6151
+ tmp_path,
6152
+ test=row,
6153
+ rows=[row],
6154
+ console=console,
6155
+ events=(
6156
+ {"type": "ready"},
6157
+ {"type": "spec", "text": SPEC_BODY},
6158
+ {"type": "cancelled", "id": "cmd-dis"},
6159
+ {"type": "done", "spec_saved": True},
6160
+ ),
6161
+ acks=[{"commands": [DISCARD_COMMAND]}],
6162
+ )
6163
+ assert result.code == 0
6164
+ assert result.outcome == RecordOutcome(0, cancelled=True, discarded=True)
6165
+ assert not (result.root / row["spec_path"]).exists()
6166
+ assert result.patched.call_count == 0
6167
+ assert result.replayed.call_count == 0
6168
+ # Nothing on disk, so nothing for the closing flowchart to say.
6169
+ assert result.posted[-1]["live"] is False
6170
+ assert result.posted[-1]["steps"] == []
6171
+ assert [call["test_id"] for call in result.deleted] == [UUID(row["id"])]
6172
+ assert result.deleted[0]["api_url"] == "http://api.test"
6173
+ assert result.deleted[0]["token"] == "t"
6174
+ printed = [str(call.args[0]) for call in console.print.call_args_list]
6175
+ assert (
6176
+ "[yellow]recording discarded[/yellow] — nothing was saved, and "
6177
+ '"Settings/API tokens" is being removed.'
6178
+ ) in printed
6179
+ assert '[dim]"Settings/API tokens" removed from verifAIed.[/dim]' in printed
6180
+
6181
+
6182
+ def test_record_degrades_a_discard_on_a_saved_test_to_a_plain_cancel(tmp_path: Path):
6183
+ """The endpoint refuses this, and the CLI checks again: a re-record
6184
+ throwing away its attempt must never take the spec with it."""
6185
+ row = _test_row(status="ready")
6186
+ root = _repo(tmp_path)
6187
+ _write_spec(root, row, OLD_SPEC_BODY)
6188
+ before = (root / row["spec_path"]).read_bytes()
6189
+ console = _console()
6190
+
6191
+ result = _record(
6192
+ tmp_path,
6193
+ test=row,
6194
+ rows=[row],
6195
+ console=console,
6196
+ events=(
6197
+ {"type": "ready"},
6198
+ {"type": "spec", "text": SPEC_BODY},
6199
+ {"type": "cancelled", "id": "cmd-dis"},
6200
+ {"type": "done", "spec_saved": True},
6201
+ ),
6202
+ acks=[{"commands": [DISCARD_COMMAND]}],
6203
+ )
6204
+
6205
+ assert result.outcome == RecordOutcome(0, cancelled=True, discarded=False)
6206
+ assert result.deleted == []
6207
+ assert (result.root / row["spec_path"]).read_bytes() == before
6208
+ assert (
6209
+ "[yellow]recording cancelled[/yellow] — nothing was saved, so "
6210
+ '"Settings/API tokens" keeps the spec it already had.'
6211
+ ) in [str(call.args[0]) for call in console.print.call_args_list]
6212
+
6213
+
6214
+ def test_record_says_so_when_the_row_was_already_gone(tmp_path: Path):
6215
+ """A 404 is the state the caller asked for, not a failure."""
6216
+ row = _test_row(status="draft")
6217
+ console = _console()
6218
+ result = _record(
6219
+ tmp_path,
6220
+ test=row,
6221
+ rows=[row],
6222
+ console=console,
6223
+ delete_found=False,
6224
+ events=(
6225
+ {"type": "ready"},
6226
+ {"type": "spec", "text": SPEC_BODY},
6227
+ {"type": "cancelled", "id": "cmd-dis"},
6228
+ {"type": "done", "spec_saved": True},
6229
+ ),
6230
+ acks=[{"commands": [DISCARD_COMMAND]}],
6231
+ )
6232
+ assert result.code == 0
6233
+ assert '[dim]"Settings/API tokens" was already gone.[/dim]' in [
6234
+ str(call.args[0]) for call in console.print.call_args_list
6235
+ ]
6236
+
6237
+
6238
+ def test_record_survives_a_delete_it_could_not_land(tmp_path: Path):
6239
+ """The recording is already over and the browser is already closed, so
6240
+ a delete that fails is worth one yellow line and nothing more."""
6241
+ row = _test_row(status="draft")
6242
+ err_console = _console()
6243
+ result = _record(
6244
+ tmp_path,
6245
+ test=row,
6246
+ rows=[row],
6247
+ err_console=err_console,
6248
+ delete_error=ApiError(500, "boom"),
6249
+ events=(
6250
+ {"type": "ready"},
6251
+ {"type": "spec", "text": SPEC_BODY},
6252
+ {"type": "cancelled", "id": "cmd-dis"},
6253
+ {"type": "done", "spec_saved": True},
6254
+ ),
6255
+ acks=[{"commands": [DISCARD_COMMAND]}],
6256
+ )
6257
+ assert result.code == 0
6258
+ assert [str(call.args[0]) for call in err_console.print.call_args_list] == [
6259
+ '[yellow]could not remove "Settings/API tokens"[/yellow] — '
6260
+ "it stays a draft: boom"
6261
+ ]
6262
+
6263
+
6264
+ def test_record_deletes_nothing_when_the_recording_was_saved(tmp_path: Path):
6265
+ result = _record(tmp_path)
6266
+ assert result.deleted == []
6267
+
6268
+
6098
6269
  def test_record_answers_not_cancelled_when_the_recording_was_saved(tmp_path: Path):
6099
6270
  result = _record(tmp_path)
6100
- assert result.outcome == RecordOutcome(0, cancelled=False)
6271
+ assert result.outcome == RecordOutcome(0, cancelled=False, discarded=False)
6101
6272
 
6102
6273
 
6103
6274
  def test_record_ignores_a_command_kind_it_does_not_know(tmp_path: Path):
@@ -6446,6 +6617,8 @@ class Studio:
6446
6617
  amends,
6447
6618
  sleeps,
6448
6619
  err_console,
6620
+ deleted,
6621
+ order,
6449
6622
  ):
6450
6623
  self.code = code
6451
6624
  # Every heartbeat, in order, including the goodbye.
@@ -6461,6 +6634,11 @@ class Studio:
6461
6634
  self.amends = amends
6462
6635
  self.sleeps = sleeps
6463
6636
  self.err_console = err_console
6637
+ # The ids `delete_recorded_test` was called with, in order.
6638
+ self.deleted = deleted
6639
+ # Status posts and deletes interleaved, so "after the status post"
6640
+ # is something a test can actually see.
6641
+ self.order = order
6464
6642
 
6465
6643
 
6466
6644
  def _studio_request(test_id: str, **overrides) -> dict:
@@ -6501,6 +6679,7 @@ def _studio(
6501
6679
  edit: object = None,
6502
6680
  amend: object = None,
6503
6681
  status_error: Exception | None = None,
6682
+ delete_error: Exception | None = None,
6504
6683
  goodbye_error: Exception | None = None,
6505
6684
  sleeps_before_stop: int | None = None,
6506
6685
  branches: list[str] | None = None,
@@ -6515,6 +6694,8 @@ def _studio(
6515
6694
  edits: list[dict] = []
6516
6695
  amends: list[dict] = []
6517
6696
  sleeps: list[float] = []
6697
+ deleted: list[dict] = []
6698
+ order: list[str] = []
6518
6699
  queued = list(acks)
6519
6700
  err_console = _console()
6520
6701
 
@@ -6535,9 +6716,17 @@ def _studio(
6535
6716
 
6536
6717
  def _status(**kw):
6537
6718
  statuses.append(kw)
6719
+ order.append(f"status:{kw['status']}")
6538
6720
  if status_error is not None and kw["status"] == "failed":
6539
6721
  raise status_error
6540
6722
 
6723
+ def _delete(**kw):
6724
+ deleted.append(kw)
6725
+ order.append("delete")
6726
+ if delete_error is not None:
6727
+ raise delete_error
6728
+ return True
6729
+
6541
6730
  def _sleep(seconds):
6542
6731
  sleeps.append(seconds)
6543
6732
  if sleeps_before_stop is not None and len(sleeps) >= sleeps_before_stop:
@@ -6604,10 +6793,21 @@ def _studio(
6604
6793
  patch("verifaied.testcmd.replay_spec", side_effect=_replay_spec),
6605
6794
  patch("verifaied.testcmd.edit_one", side_effect=_edit_one),
6606
6795
  patch("verifaied.testcmd.amend_one", side_effect=_amend_one),
6796
+ patch("verifaied.testcmd.delete_recorded_test", side_effect=_delete),
6607
6797
  ):
6608
6798
  code = run_test_studio(**kwargs)
6609
6799
  return Studio(
6610
- code, beats, statuses, records, replays, edits, amends, sleeps, err_console
6800
+ code,
6801
+ beats,
6802
+ statuses,
6803
+ records,
6804
+ replays,
6805
+ edits,
6806
+ amends,
6807
+ sleeps,
6808
+ err_console,
6809
+ deleted,
6810
+ order,
6611
6811
  )
6612
6812
 
6613
6813
 
@@ -6879,6 +7079,68 @@ def test_studio_reports_a_discarded_recording_as_cancelled(tmp_path: Path):
6879
7079
  assert "error" not in result.statuses[1]
6880
7080
 
6881
7081
 
7082
+ def test_studio_removes_the_row_after_reporting_a_discard(tmp_path: Path):
7083
+ """The order is load-bearing: the request row cascades with the test
7084
+ it names, so deleting first would leave the ending nowhere to land."""
7085
+ row = _test_row(status="draft")
7086
+ request = _studio_request(row["id"])
7087
+
7088
+ result = _studio(
7089
+ tmp_path,
7090
+ acks=[_ack(request)],
7091
+ rows=[row],
7092
+ record=lambda **kw: RecordOutcome(0, cancelled=True, discarded=True),
7093
+ )
7094
+
7095
+ assert result.code == 0
7096
+ assert [s["status"] for s in result.statuses] == ["recording", "cancelled"]
7097
+ assert result.order == ["status:recording", "status:cancelled", "delete"]
7098
+ assert [call["test_id"] for call in result.deleted] == [
7099
+ UUID(request["recorded_test_id"])
7100
+ ]
7101
+ assert result.deleted[0]["api_url"] == "http://api.test"
7102
+
7103
+
7104
+ def test_studio_deletes_nothing_for_a_cancel_that_kept_the_draft(tmp_path: Path):
7105
+ row = _test_row(status="draft")
7106
+ result = _studio(
7107
+ tmp_path,
7108
+ acks=[_ack(_studio_request(row["id"]))],
7109
+ rows=[row],
7110
+ record=lambda **kw: RecordOutcome(0, cancelled=True),
7111
+ )
7112
+ assert [s["status"] for s in result.statuses] == ["recording", "cancelled"]
7113
+ assert result.deleted == []
7114
+
7115
+
7116
+ def test_studio_keeps_going_when_a_discard_could_not_be_removed(tmp_path: Path):
7117
+ """One row that would not delete is not a dead daemon: the next
7118
+ request is still taken."""
7119
+ first = _test_row(status="draft")
7120
+ second = _test_row(status="draft")
7121
+
7122
+ result = _studio(
7123
+ tmp_path,
7124
+ acks=[
7125
+ _ack(_studio_request(first["id"])),
7126
+ _ack(_studio_request(second["id"])),
7127
+ ],
7128
+ rows=[first, second],
7129
+ record=lambda **kw: RecordOutcome(0, cancelled=True, discarded=True),
7130
+ delete_error=ApiError(500, "boom"),
7131
+ )
7132
+
7133
+ assert result.code == 0
7134
+ assert [s["status"] for s in result.statuses] == [
7135
+ "recording",
7136
+ "cancelled",
7137
+ "recording",
7138
+ "cancelled",
7139
+ ]
7140
+ assert len(result.deleted) == 2
7141
+ assert result.err_console.print.called
7142
+
7143
+
6882
7144
  def test_studio_fails_a_request_whose_test_was_deleted(tmp_path: Path):
6883
7145
  result = _studio(
6884
7146
  tmp_path,
@@ -10898,6 +11160,39 @@ def test_a_cancelled_edit_leaves_the_spec_byte_identical(tmp_path: Path):
10898
11160
  assert result.posted[-1]["live"] is False
10899
11161
 
10900
11162
 
11163
+ def test_a_discard_collected_during_an_edit_is_just_a_cancel(tmp_path: Path):
11164
+ """An edit only ever happens on a test that already has a spec, which
11165
+ is exactly the row a discard is fenced off from — so a session that
11166
+ somehow collected one still ends with the saved flow standing."""
11167
+ events = (
11168
+ {"type": "ready"},
11169
+ {"type": "spec", "text": EDIT_TAIL},
11170
+ {"type": "cancelled", "id": "c1"},
11171
+ {"type": "done", "spec_saved": False},
11172
+ )
11173
+ result = _edit(
11174
+ tmp_path,
11175
+ edit_from=1,
11176
+ events=events,
11177
+ acks=[
11178
+ {
11179
+ "commands": [
11180
+ {"id": "c1", "kind": "discard_recording", "payload": {}},
11181
+ ]
11182
+ }
11183
+ ],
11184
+ )
11185
+ assert result.code == 0
11186
+ assert result.spec == EDIT_SPEC
11187
+ assert result.patched.call_count == 0
11188
+ assert result.replayed.call_count == 0
11189
+ assert [step["label"] for step in result.posted[-1]["steps"]] == [
11190
+ "Open http://localhost:3000/settings",
11191
+ "Click 'New token'",
11192
+ "'Created' is visible",
11193
+ ]
11194
+
11195
+
10901
11196
  def test_an_edit_that_recorded_nothing_truncates_to_what_was_kept(
10902
11197
  tmp_path: Path,
10903
11198
  ):
@@ -442,7 +442,7 @@ wheels = [
442
442
 
443
443
  [[package]]
444
444
  name = "verifaied"
445
- version = "0.23.1"
445
+ version = "0.24.0"
446
446
  source = { editable = "." }
447
447
  dependencies = [
448
448
  { name = "httpx" },