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.
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/PKG-INFO +28 -7
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/README.md +27 -6
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/pyproject.toml +1 -1
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/cli.py +18 -1
- verifaied-0.34.0.dev84/src/verifaied/finishing.py +177 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/record.py +7 -2
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/replay_harvest.ts +334 -20
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/repo_config.py +33 -5
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/testcmd.py +676 -69
- verifaied-0.34.0.dev84/src/verifaied/video_render.js +260 -0
- verifaied-0.34.0.dev84/src/verifaied/video_render.py +980 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/conftest.py +12 -1
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_browser_settings_parity.py +2 -0
- verifaied-0.34.0.dev84/tests/test_finishing.py +191 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_record.py +21 -0
- verifaied-0.34.0.dev84/tests/test_render_parity.py +67 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_replay_harvest_runner.py +8 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_repo_config.py +1 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_testcmd.py +831 -12
- verifaied-0.34.0.dev84/tests/test_video_render.py +677 -0
- verifaied-0.34.0.dev84/tests/test_video_render_runner.py +323 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/uv.lock +1 -1
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/.gitignore +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/LICENSE +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/__init__.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/__main__.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/agent_mcp.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/agent_steps.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/analyzer.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/audit.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/client.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/collect_browser.js +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/collect_browser.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/config.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/failure_text.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/instrument.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/instrument_snippets.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/instrumenter.js +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/instrumenter.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/lanes.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/prompts.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/proxy.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/recorder_driver.js +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/recorder_observer.js +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/recorder_picker.js +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/recorder_refs.js +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/recorder_spec.js +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/run_reporter.js +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/runner_dir.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/skill/SKILL.md +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/skill/reference/commands.md +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/skillcmd.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/source_index.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/stepcheck.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/subprocess_util.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/suite_report.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/uploader.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/src/verifaied/varcmd.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/__init__.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_agent_mcp.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_audit.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_caption_parity.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_check.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_check_done.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_clear_manual.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_cli.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_client.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_collect_browser.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_config.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_console_label_parity.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_instrument.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_instrument_detect.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_instrument_snippets.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_instrumenter.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_lanes.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_pause_parity.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_prompt_parity.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_proxy.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_skill_parity.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_skillcmd.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_source_index.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_stepcheck_parity.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_suite_report.py +0 -0
- {verifaied-0.33.0.dev83 → verifaied-0.34.0.dev84}/tests/test_uploader.py +0 -0
- {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.
|
|
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.
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
|
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`,
|
|
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.
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
|
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`,
|
|
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
|
|
@@ -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(
|