verifaied 0.33.0.dev83__tar.gz → 0.34.0.dev84__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 (85) hide show
  1. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/PKG-INFO +28 -7
  2. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/README.md +27 -6
  3. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/pyproject.toml +1 -1
  4. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/cli.py +18 -1
  5. verifaied-0.34.0.dev84/src/verifaied/finishing.py +177 -0
  6. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/record.py +7 -2
  7. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/replay_harvest.ts +334 -20
  8. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/repo_config.py +33 -5
  9. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/testcmd.py +676 -69
  10. verifaied-0.34.0.dev84/src/verifaied/video_render.js +260 -0
  11. verifaied-0.34.0.dev84/src/verifaied/video_render.py +980 -0
  12. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/conftest.py +12 -1
  13. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_browser_settings_parity.py +2 -0
  14. verifaied-0.34.0.dev84/tests/test_finishing.py +191 -0
  15. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_record.py +21 -0
  16. verifaied-0.34.0.dev84/tests/test_render_parity.py +67 -0
  17. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_replay_harvest_runner.py +8 -0
  18. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_repo_config.py +1 -0
  19. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_testcmd.py +831 -12
  20. verifaied-0.34.0.dev84/tests/test_video_render.py +677 -0
  21. verifaied-0.34.0.dev84/tests/test_video_render_runner.py +323 -0
  22. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/uv.lock +1 -1
  23. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/.gitignore +0 -0
  24. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/LICENSE +0 -0
  25. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/__init__.py +0 -0
  26. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/__main__.py +0 -0
  27. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/agent_mcp.py +0 -0
  28. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/agent_steps.py +0 -0
  29. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/analyzer.py +0 -0
  30. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/audit.py +0 -0
  31. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/client.py +0 -0
  32. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/collect_browser.js +0 -0
  33. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/collect_browser.py +0 -0
  34. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/config.py +0 -0
  35. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/failure_text.py +0 -0
  36. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/instrument.py +0 -0
  37. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/instrument_snippets.py +0 -0
  38. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/instrumenter.js +0 -0
  39. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/instrumenter.py +0 -0
  40. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/lanes.py +0 -0
  41. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/prompts.py +0 -0
  42. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/proxy.py +0 -0
  43. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/recorder_driver.js +0 -0
  44. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/recorder_observer.js +0 -0
  45. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/recorder_picker.js +0 -0
  46. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/recorder_refs.js +0 -0
  47. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/recorder_spec.js +0 -0
  48. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/run_reporter.js +0 -0
  49. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/runner_dir.py +0 -0
  50. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/skill/SKILL.md +0 -0
  51. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/skill/reference/commands.md +0 -0
  52. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/skillcmd.py +0 -0
  53. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/source_index.py +0 -0
  54. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/stepcheck.py +0 -0
  55. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/subprocess_util.py +0 -0
  56. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/suite_report.py +0 -0
  57. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/uploader.py +0 -0
  58. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/varcmd.py +0 -0
  59. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/__init__.py +0 -0
  60. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_agent_mcp.py +0 -0
  61. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_audit.py +0 -0
  62. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_caption_parity.py +0 -0
  63. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_check.py +0 -0
  64. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_check_done.py +0 -0
  65. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_clear_manual.py +0 -0
  66. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_cli.py +0 -0
  67. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_client.py +0 -0
  68. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_collect_browser.py +0 -0
  69. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_config.py +0 -0
  70. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_console_label_parity.py +0 -0
  71. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_instrument.py +0 -0
  72. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_instrument_detect.py +0 -0
  73. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_instrument_snippets.py +0 -0
  74. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_instrumenter.py +0 -0
  75. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_lanes.py +0 -0
  76. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_pause_parity.py +0 -0
  77. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_prompt_parity.py +0 -0
  78. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_proxy.py +0 -0
  79. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_skill_parity.py +0 -0
  80. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_skillcmd.py +0 -0
  81. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_source_index.py +0 -0
  82. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_stepcheck_parity.py +0 -0
  83. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_suite_report.py +0 -0
  84. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_uploader.py +0 -0
  85. {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_varcmd.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: verifaied
3
- Version: 0.33.0.dev83
3
+ Version: 0.34.0.dev84
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
@@ -669,10 +669,28 @@ await test.step("The token appears in the list", async () => { await expect(page
669
669
  ```
670
670
 
671
671
  A held check with no caption is titled `Hold 2s`; a step with neither is
672
- written bare, exactly as before. Holds wait in every replay except one
673
- run with `--video off` (or `replay_pauses = false`), which skips them
674
- with the Pause steps; captions are drawn unless `replay_captions = false`
675
- or the video is off. Neither counts against the replay's time budget.
672
+ written bare, exactly as before. A run with `--video off` (or
673
+ `replay_pauses = false`) skips every hold with the Pause steps; captions
674
+ are drawn unless `replay_captions = false` or the video is off.
675
+
676
+ ### Headless runs go at full speed
677
+
678
+ All of that pacing — the cursor, the marks, the typing, the captions,
679
+ the holds — is time a browser spends waiting, and nobody watches a
680
+ headless run. So a headless replay (`test run`, a batch queued from the
681
+ web, CI) runs at full speed and records where each action landed and how
682
+ long each check asked to be held; the paced video is rendered afterwards
683
+ from the run's own frames, following every setting above. While it is
684
+ rendered, in the background, the next test is already running.
685
+
686
+ A run you watch in a window is always paced live. So is a flow the
687
+ renderer cannot reproduce — one that opens a popup or a second page,
688
+ resizes its own window, or acts on the page without a locator — and the
689
+ run says so in one line. If a render fails after the run, the plain video
690
+ is kept and labelled unpaced. Pause steps always wait, because the page
691
+ may change during one. `--no-render` on `test run`, or
692
+ `replay_render = false` in the file or on the Settings page, paces every
693
+ replay live instead.
676
694
 
677
695
  ### Running another test inside this one
678
696
 
@@ -1152,7 +1170,8 @@ not available, so use `--video off` there, or set every replay key to
1152
1170
  its off value (`replay_show_actions_ms = 0`, `replay_cursor_ms = 0`,
1153
1171
  `replay_type_delay_ms = 0`, `replay_pauses = false`,
1154
1172
  `replay_captions = false` and `replay_video_step_banner = false`) — that
1155
- writes exactly the config the CLI wrote before any of it existed. Every
1173
+ writes exactly the config the CLI wrote before any of it existed, and
1174
+ leaves nothing for a render to pace. Every
1156
1175
  one of those keys can also be set once for the repository on its
1157
1176
  Settings page, which the CLI reads at the start of each recording and
1158
1177
  replay and lays over the file.
@@ -1198,7 +1217,9 @@ either),
1198
1217
  `--description <text>`, `--pre "<Feature>/<Name>"` (repeatable;
1199
1218
  replaces the stored chain — omit it to keep what the test already has),
1200
1219
  and `--no-replay` to skip the replay. `run` takes `--all` or `--folder
1201
- "<folder path>"` in place of a test name, `--video fast|watch|off`, and
1220
+ "<folder path>"` in place of a test name, `--video fast|watch|off`,
1221
+ `--no-render` (pace each replay live rather than rendering its video
1222
+ afterwards), and
1202
1223
  `--retries <n>` (default 0): a test whose replay **failed** is reset and
1203
1224
  replayed again, up to `n` more times, and the summary says `passed on
1204
1225
  retry 1 of 2` when a later attempt passes. Retries are for infrastructure
@@ -623,10 +623,28 @@ await test.step("The token appears in the list", async () => { await expect(page
623
623
  ```
624
624
 
625
625
  A held check with no caption is titled `Hold 2s`; a step with neither is
626
- written bare, exactly as before. Holds wait in every replay except one
627
- run with `--video off` (or `replay_pauses = false`), which skips them
628
- with the Pause steps; captions are drawn unless `replay_captions = false`
629
- or the video is off. Neither counts against the replay's time budget.
626
+ written bare, exactly as before. A run with `--video off` (or
627
+ `replay_pauses = false`) skips every hold with the Pause steps; captions
628
+ are drawn unless `replay_captions = false` or the video is off.
629
+
630
+ ### Headless runs go at full speed
631
+
632
+ All of that pacing — the cursor, the marks, the typing, the captions,
633
+ the holds — is time a browser spends waiting, and nobody watches a
634
+ headless run. So a headless replay (`test run`, a batch queued from the
635
+ web, CI) runs at full speed and records where each action landed and how
636
+ long each check asked to be held; the paced video is rendered afterwards
637
+ from the run's own frames, following every setting above. While it is
638
+ rendered, in the background, the next test is already running.
639
+
640
+ A run you watch in a window is always paced live. So is a flow the
641
+ renderer cannot reproduce — one that opens a popup or a second page,
642
+ resizes its own window, or acts on the page without a locator — and the
643
+ run says so in one line. If a render fails after the run, the plain video
644
+ is kept and labelled unpaced. Pause steps always wait, because the page
645
+ may change during one. `--no-render` on `test run`, or
646
+ `replay_render = false` in the file or on the Settings page, paces every
647
+ replay live instead.
630
648
 
631
649
  ### Running another test inside this one
632
650
 
@@ -1106,7 +1124,8 @@ not available, so use `--video off` there, or set every replay key to
1106
1124
  its off value (`replay_show_actions_ms = 0`, `replay_cursor_ms = 0`,
1107
1125
  `replay_type_delay_ms = 0`, `replay_pauses = false`,
1108
1126
  `replay_captions = false` and `replay_video_step_banner = false`) — that
1109
- writes exactly the config the CLI wrote before any of it existed. Every
1127
+ writes exactly the config the CLI wrote before any of it existed, and
1128
+ leaves nothing for a render to pace. Every
1110
1129
  one of those keys can also be set once for the repository on its
1111
1130
  Settings page, which the CLI reads at the start of each recording and
1112
1131
  replay and lays over the file.
@@ -1152,7 +1171,9 @@ either),
1152
1171
  `--description <text>`, `--pre "<Feature>/<Name>"` (repeatable;
1153
1172
  replaces the stored chain — omit it to keep what the test already has),
1154
1173
  and `--no-replay` to skip the replay. `run` takes `--all` or `--folder
1155
- "<folder path>"` in place of a test name, `--video fast|watch|off`, and
1174
+ "<folder path>"` in place of a test name, `--video fast|watch|off`,
1175
+ `--no-render` (pace each replay live rather than rendering its video
1176
+ afterwards), and
1156
1177
  `--retries <n>` (default 0): a test whose replay **failed** is reset and
1157
1178
  replayed again, up to `n` more times, and the summary says `passed on
1158
1179
  retry 1 of 2` when a later attempt passes. Retries are for infrastructure
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "verifaied"
3
- version = "0.33.0.dev83"
3
+ version = "0.34.0.dev84"
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"
@@ -2053,7 +2053,19 @@ def test_run_command(
2053
2053
  "`watch` lingers on every action; `off` records without any of "
2054
2054
  "it and skips the pauses the flow asks for. Overrides "
2055
2055
  "replay_show_actions_ms / replay_slow_mo_ms / replay_cursor_ms / "
2056
- "replay_type_delay_ms / replay_pauses for this run."
2056
+ "replay_type_delay_ms / replay_pauses for this run. The run "
2057
+ "itself goes at full speed and the paced video is rendered "
2058
+ "afterwards, unless --no-render."
2059
+ ),
2060
+ ),
2061
+ no_render: bool = typer.Option(
2062
+ False,
2063
+ "--no-render",
2064
+ help=(
2065
+ "Pace each replay live, slowing the browser down so its own "
2066
+ "recording is the paced video, instead of running at full speed "
2067
+ "and rendering the paced video afterwards. Same as "
2068
+ "replay_render = false for this run."
2057
2069
  ),
2058
2070
  ),
2059
2071
  retries: int = typer.Option(
@@ -2112,6 +2124,10 @@ def test_run_command(
2112
2124
  of the app with its own reset. Only this flag turns it on: the
2113
2125
  Settings page's "Tests run at once" is for the studio.
2114
2126
 
2127
+ Each replay runs at full speed and its paced video — cursor, marks,
2128
+ typing, captions and holds — is rendered afterwards, in the background
2129
+ while the next test runs. ``--no-render`` paces each one live instead.
2130
+
2115
2131
  Exit codes:
2116
2132
 
2117
2133
  0 every test replayed cleanly
@@ -2145,6 +2161,7 @@ def test_run_command(
2145
2161
  video=video.value if video is not None else None,
2146
2162
  retries=retries,
2147
2163
  parallel=parallel,
2164
+ no_render=no_render,
2148
2165
  )
2149
2166
  except (RecordedTestError, UploaderError) as e:
2150
2167
  err_console.print(f"[red]VerifAIed error[/red]: {e}")
@@ -0,0 +1,177 @@
1
+ """Finishing a replay in the background while the next one runs.
2
+
3
+ A fast replay is over the moment its runner exits: the verdict is known,
4
+ the coverage has been flushed and the collector has let go of its port.
5
+ What is left — rendering the paced video, uploading the evidence and
6
+ closing the session with the verdict — touches nothing the next replay
7
+ uses. It reads the files in the run's own temp directory and talks to the
8
+ API; it never touches the app, its database or a spec. So it can run
9
+ here, beside the next test, instead of in front of it.
10
+
11
+ A :class:`FinishPool` runs those jobs on as many threads as the run has
12
+ lanes, and holds at most twice that many outstanding: every lane can hand
13
+ one job over and start its next test while another job is still
14
+ rendering, and a lane whose job cannot be taken waits — which bounds the
15
+ renders running at once by the lanes, and the temp directories waiting
16
+ by twice that, so a long batch never fills a disk with videos nobody has
17
+ rendered yet. A studio, whose lanes change with what the repository asks
18
+ for, resizes it as they do.
19
+
20
+ Every job is wrapped whole: whatever goes wrong in a render or an upload
21
+ is printed with the test's name, and the session is still closed with the
22
+ run's verdict, because a session nobody closes reads as a run still going.
23
+ Ctrl-C stops the renders and closes what is left with its verdict and no
24
+ video — the user asked to leave, and every verdict is already known.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import queue
30
+ import threading
31
+ from collections.abc import Callable
32
+ from dataclasses import dataclass
33
+ from typing import Protocol
34
+ from uuid import UUID
35
+
36
+ from rich.console import Console
37
+ from rich.markup import escape
38
+
39
+
40
+ class Stop(Protocol):
41
+ """What a job is handed to be stopped by (``RunStop`` in testcmd)."""
42
+
43
+ def is_set(self) -> bool: ...
44
+
45
+ def interrupt(self, message: str) -> None: ...
46
+
47
+
48
+ # A job's whole work: given the stop it may be ended by and whether the
49
+ # pool was interrupted (no video then, only the verdict), it finishes the
50
+ # replay and answers its final exit code and what became of its video.
51
+ FinishWork = Callable[[Stop, bool], "tuple[int, str]"]
52
+
53
+
54
+ @dataclass
55
+ class FinishJob:
56
+ """One replay whose finishing was handed over."""
57
+
58
+ label: str
59
+ code: int
60
+ work: FinishWork
61
+ stop: Stop
62
+ # The studio request this replay answers, which its beats must keep
63
+ # naming until the job is done — or None for a terminal run.
64
+ request_id: UUID | None = None
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class FinishResult:
69
+ label: str
70
+ code: int
71
+ # "paced", "unpaced: <why>", "live" or "none".
72
+ video: str = "none"
73
+
74
+
75
+ class FinishPool:
76
+ """A few threads finishing replays; see the module docstring."""
77
+
78
+ def __init__(self, workers: int, *, err_console: Console) -> None:
79
+ self._limit = max(1, workers)
80
+ self._err_console = err_console
81
+ self._queue: queue.Queue[FinishJob | None] = queue.Queue()
82
+ self._lock = threading.Lock()
83
+ self._changed = threading.Condition(self._lock)
84
+ self._outstanding: list[FinishJob] = []
85
+ self._active = 0
86
+ self._results: list[FinishResult] = []
87
+ self._threads: list[threading.Thread] = []
88
+ self._interrupted = False
89
+
90
+ # --- what the run asks ---------------------------------------------------
91
+
92
+ def has_room(self) -> bool:
93
+ """Whether a job could be handed over now without waiting."""
94
+ with self._lock:
95
+ return len(self._outstanding) < 2 * self._limit
96
+
97
+ def in_flight_ids(self) -> list[UUID]:
98
+ """The studio requests whose replays are still being finished."""
99
+ with self._lock:
100
+ return [job.request_id for job in self._outstanding if job.request_id]
101
+
102
+ def worst(self) -> int:
103
+ with self._lock:
104
+ return max((result.code for result in self._results), default=0)
105
+
106
+ def results(self) -> list[FinishResult]:
107
+ with self._lock:
108
+ return list(self._results)
109
+
110
+ def resize(self, workers: int) -> None:
111
+ """Run at most ``workers`` jobs at once, and hold twice that."""
112
+ with self._changed:
113
+ self._limit = max(1, workers)
114
+ self._changed.notify_all()
115
+
116
+ # --- handing work over ---------------------------------------------------
117
+
118
+ def submit(self, job: FinishJob) -> None:
119
+ """Queue ``job``, waiting while the backlog is full."""
120
+ with self._changed:
121
+ while len(self._outstanding) >= 2 * self._limit:
122
+ self._changed.wait()
123
+ self._outstanding.append(job)
124
+ if len(self._threads) < len(self._outstanding):
125
+ thread = threading.Thread(target=self._work, daemon=True)
126
+ self._threads.append(thread)
127
+ thread.start()
128
+ self._queue.put(job)
129
+
130
+ def interrupt(self, message: str) -> None:
131
+ """Stop every render and finish what is left without its video."""
132
+ with self._changed:
133
+ self._interrupted = True
134
+ jobs = list(self._outstanding)
135
+ self._changed.notify_all()
136
+ for job in jobs:
137
+ job.stop.interrupt(message)
138
+
139
+ def drain(self) -> list[FinishResult]:
140
+ """Wait for every job handed over, and stop the threads."""
141
+ with self._lock:
142
+ threads = list(self._threads)
143
+ for _ in threads:
144
+ self._queue.put(None)
145
+ for thread in threads:
146
+ thread.join()
147
+ with self._lock:
148
+ self._threads = []
149
+ return self.results()
150
+
151
+ # --- the workers ---------------------------------------------------------
152
+
153
+ def _work(self) -> None:
154
+ while True:
155
+ job = self._queue.get()
156
+ if job is None:
157
+ return
158
+ with self._changed:
159
+ # Never more renders at once than the lanes, unless the user
160
+ # is leaving — then everything left only closes its session.
161
+ while self._active >= self._limit and not self._interrupted:
162
+ self._changed.wait()
163
+ self._active += 1
164
+ interrupted = self._interrupted
165
+ try:
166
+ code, video = job.work(job.stop, interrupted)
167
+ except Exception as e: # noqa: BLE001 — a finisher never dies silently
168
+ self._err_console.print(
169
+ f"[red]could not finish[/red] {escape(job.label)}: "
170
+ f"{escape(str(e) or repr(e))}"
171
+ )
172
+ code, video = (2 if job.code == 0 else job.code), "none"
173
+ with self._changed:
174
+ self._active -= 1
175
+ self._results.append(FinishResult(job.label, code, video))
176
+ self._outstanding.remove(job)
177
+ self._changed.notify_all()
@@ -31,7 +31,7 @@ import subprocess
31
31
  import tempfile
32
32
  import threading
33
33
  import time
34
- from collections.abc import Callable
34
+ from collections.abc import Callable, Mapping
35
35
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
36
36
  from pathlib import Path
37
37
  from typing import Any
@@ -500,6 +500,7 @@ def upload_artifacts(
500
500
  results_root: Path,
501
501
  console: Console,
502
502
  err_console: Console,
503
+ labels: Mapping[Path, str] | None = None,
503
504
  ) -> int:
504
505
  """Attach each harvested file to the session; returns how many landed.
505
506
 
@@ -507,6 +508,10 @@ def upload_artifacts(
507
508
  is a bonus on top of the coverage the session already uploaded, and
508
509
  losing a run's exit code over a failed screenshot upload would be an
509
510
  absurd trade.
511
+
512
+ ``labels`` names a file whose label is not its place under
513
+ ``results_root`` — a paced video rendered elsewhere, standing in for
514
+ the run's own, or a video kept unpaced and saying so.
510
515
  """
511
516
  uploaded = 0
512
517
  for kind, path in items:
@@ -525,7 +530,7 @@ def upload_artifacts(
525
530
  session_id=session_id,
526
531
  path=path,
527
532
  kind=kind,
528
- label=artifact_label(path, results_root),
533
+ label=(labels or {}).get(path) or artifact_label(path, results_root),
529
534
  )
530
535
  except ApiError as e:
531
536
  err_console.print(