charisma-cli 0.1.2__tar.gz → 0.1.4__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.
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/PKG-INFO +20 -13
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/README.md +19 -12
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/pyproject.toml +1 -1
- charisma_cli-0.1.4/src/charisma_cli/launch_url.py +45 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/main.py +47 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/uploader.py +58 -12
- charisma_cli-0.1.4/tests/test_launch_url.py +37 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_uploader.py +49 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/.gitignore +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/__init__.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/config.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/models.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/parser.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/retry.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/subprocess_mgr.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/watcher.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/__init__.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/conftest.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_cli.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_config.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_integration.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_models.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_parser.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_retry.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_silent_mode.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_subprocess_mgr.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_watcher.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: charisma-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: CLI tool that watches allure-results and streams test results + attachments to Charisma.
|
|
5
5
|
Author: Charisma Team
|
|
6
6
|
License-Expression: MIT
|
|
@@ -41,18 +41,6 @@ For local development from a checkout of the repo:
|
|
|
41
41
|
pip install -e packages/charisma-cli
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
<details>
|
|
45
|
-
<summary>Alternative: install from the GitHub Packages registry (PyOCI)</summary>
|
|
46
|
-
|
|
47
|
-
Also published to GitHub Packages via [PyOCI](https://pyoci.com) (requires a GitHub token with `read:packages`) — useful for a build not yet on PyPI:
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
pip install charisma-cli \
|
|
51
|
-
--index-url "https://__token__:$GITHUB_TOKEN@pyoci.com/ghcr.io/align-QCOE/"
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
</details>
|
|
55
|
-
|
|
56
44
|
## Publishing (maintainers)
|
|
57
45
|
|
|
58
46
|
Releases publish to public PyPI automatically from the merge-queue workflow using a **PyPI API token**.
|
|
@@ -96,3 +84,22 @@ charismactl watch --results allure-results -- uv run pytest -n 4 tests/
|
|
|
96
84
|
| `--project ID` | Project alias (overrides env var) |
|
|
97
85
|
| `--silent` | Don't fail pipeline if upload errors occur |
|
|
98
86
|
| `--skip-too-big` | Skip result files larger than 2MB |
|
|
87
|
+
|
|
88
|
+
## Launch URL in CI
|
|
89
|
+
|
|
90
|
+
`charismactl` prints the Charisma launch URL (`Charisma launch: <url>`) and, when running
|
|
91
|
+
in GitHub Actions, writes it as a step output named `launch_url`. Attach it to the job
|
|
92
|
+
summary or a Teams message:
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
- name: Run tests and stream to Charisma
|
|
96
|
+
id: charisma
|
|
97
|
+
run: charismactl watch --project my-project -- pytest tests/
|
|
98
|
+
|
|
99
|
+
- name: Add Charisma link to summary
|
|
100
|
+
if: always()
|
|
101
|
+
run: echo "[View Charisma launch](${{ steps.charisma.outputs.launch_url }})" >> "$GITHUB_STEP_SUMMARY"
|
|
102
|
+
|
|
103
|
+
# The same value can feed a Teams-notification step:
|
|
104
|
+
# text: "Results: ${{ steps.charisma.outputs.launch_url }}"
|
|
105
|
+
```
|
|
@@ -16,18 +16,6 @@ For local development from a checkout of the repo:
|
|
|
16
16
|
pip install -e packages/charisma-cli
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
<details>
|
|
20
|
-
<summary>Alternative: install from the GitHub Packages registry (PyOCI)</summary>
|
|
21
|
-
|
|
22
|
-
Also published to GitHub Packages via [PyOCI](https://pyoci.com) (requires a GitHub token with `read:packages`) — useful for a build not yet on PyPI:
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
pip install charisma-cli \
|
|
26
|
-
--index-url "https://__token__:$GITHUB_TOKEN@pyoci.com/ghcr.io/align-QCOE/"
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
</details>
|
|
30
|
-
|
|
31
19
|
## Publishing (maintainers)
|
|
32
20
|
|
|
33
21
|
Releases publish to public PyPI automatically from the merge-queue workflow using a **PyPI API token**.
|
|
@@ -71,3 +59,22 @@ charismactl watch --results allure-results -- uv run pytest -n 4 tests/
|
|
|
71
59
|
| `--project ID` | Project alias (overrides env var) |
|
|
72
60
|
| `--silent` | Don't fail pipeline if upload errors occur |
|
|
73
61
|
| `--skip-too-big` | Skip result files larger than 2MB |
|
|
62
|
+
|
|
63
|
+
## Launch URL in CI
|
|
64
|
+
|
|
65
|
+
`charismactl` prints the Charisma launch URL (`Charisma launch: <url>`) and, when running
|
|
66
|
+
in GitHub Actions, writes it as a step output named `launch_url`. Attach it to the job
|
|
67
|
+
summary or a Teams message:
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
- name: Run tests and stream to Charisma
|
|
71
|
+
id: charisma
|
|
72
|
+
run: charismactl watch --project my-project -- pytest tests/
|
|
73
|
+
|
|
74
|
+
- name: Add Charisma link to summary
|
|
75
|
+
if: always()
|
|
76
|
+
run: echo "[View Charisma launch](${{ steps.charisma.outputs.launch_url }})" >> "$GITHUB_STEP_SUMMARY"
|
|
77
|
+
|
|
78
|
+
# The same value can feed a Teams-notification step:
|
|
79
|
+
# text: "Results: ${{ steps.charisma.outputs.launch_url }}"
|
|
80
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Construct and surface the Charisma launch URL for CI consumption."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
# The Charisma web UI base is always the same, unlike the region-dependent API
|
|
9
|
+
# ingestion endpoint. Hardcoded here rather than exposed as user config.
|
|
10
|
+
WEB_BASE_URL = "https://charisma.aligntech.com"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_launch_url(project_id: str, launch_id: str) -> str:
|
|
14
|
+
"""Build the Charisma web URL for a launch.
|
|
15
|
+
|
|
16
|
+
Mirrors the frontend route ``/project/{projectId}/launches/{launchId}``.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
project_id: Project UUID (from the open-launch API response).
|
|
20
|
+
launch_id: Launch UUID.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The fully-qualified launch URL.
|
|
24
|
+
"""
|
|
25
|
+
return f"{WEB_BASE_URL}/project/{project_id}/launches/{launch_id}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def emit_github_output(launch_url: str) -> None:
|
|
29
|
+
"""Append ``launch_url=<url>`` to the GitHub Actions step output file.
|
|
30
|
+
|
|
31
|
+
No-op when not running under GitHub Actions (``GITHUB_OUTPUT`` unset).
|
|
32
|
+
Failures are logged and swallowed — surfacing the URL must never affect
|
|
33
|
+
the exit code or upload outcome.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
launch_url: The launch URL to expose as the ``launch_url`` step output.
|
|
37
|
+
"""
|
|
38
|
+
github_output = os.getenv("GITHUB_OUTPUT")
|
|
39
|
+
if not github_output:
|
|
40
|
+
return
|
|
41
|
+
try:
|
|
42
|
+
with open(github_output, "a", encoding="utf-8") as fh:
|
|
43
|
+
fh.write(f"launch_url={launch_url}\n")
|
|
44
|
+
except OSError as e:
|
|
45
|
+
logger.warning("Could not write launch_url to GITHUB_OUTPUT: %s", e)
|
|
@@ -9,6 +9,7 @@ import click
|
|
|
9
9
|
|
|
10
10
|
from charisma_cli import __version__
|
|
11
11
|
from charisma_cli.config import Config
|
|
12
|
+
from charisma_cli.launch_url import build_launch_url, emit_github_output
|
|
12
13
|
from charisma_cli.models import FileEvent
|
|
13
14
|
from charisma_cli.subprocess_mgr import SubprocessManager
|
|
14
15
|
from charisma_cli.uploader import Uploader
|
|
@@ -59,6 +60,45 @@ def _open_launch_or_exit(uploader: Uploader, config: Config) -> str:
|
|
|
59
60
|
return launch_id
|
|
60
61
|
|
|
61
62
|
|
|
63
|
+
def _surface_launch_url(uploader: Uploader) -> None:
|
|
64
|
+
"""Print the Charisma launch URL and expose it to GitHub Actions.
|
|
65
|
+
|
|
66
|
+
Constructs the URL from the project UUID returned by the open-launch
|
|
67
|
+
response and the launch ID. Prints it to stdout and, when running under
|
|
68
|
+
GitHub Actions, appends ``launch_url=<url>`` to $GITHUB_OUTPUT.
|
|
69
|
+
Does nothing if no launch was opened or the project UUID is unavailable.
|
|
70
|
+
"""
|
|
71
|
+
launch_id = uploader.launch_id
|
|
72
|
+
project_id = uploader.project_id
|
|
73
|
+
if not launch_id or not project_id:
|
|
74
|
+
return
|
|
75
|
+
url = build_launch_url(project_id, launch_id)
|
|
76
|
+
click.echo(f"Charisma launch: {url}")
|
|
77
|
+
emit_github_output(url)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _warn_if_results_unsent(uploader: Uploader, config: Config) -> None:
|
|
81
|
+
"""Warn loudly when result files exist on disk but none were sent.
|
|
82
|
+
|
|
83
|
+
Turns the previously-silent ``results_sent=0`` failure into a visible
|
|
84
|
+
warning naming the absolute watched path and the on-disk result count, so
|
|
85
|
+
a broken run is not mistaken for success.
|
|
86
|
+
"""
|
|
87
|
+
if uploader.summary.results_sent > 0:
|
|
88
|
+
return
|
|
89
|
+
results_path = Path(config.results_dir).resolve()
|
|
90
|
+
if not results_path.exists():
|
|
91
|
+
return
|
|
92
|
+
result_files = [p for p in results_path.iterdir() if p.name.endswith("-result.json")]
|
|
93
|
+
if result_files:
|
|
94
|
+
click.echo(
|
|
95
|
+
f"Warning: {len(result_files)} result file(s) present in "
|
|
96
|
+
f"{results_path} but results_sent=0 — nothing was uploaded. "
|
|
97
|
+
"Check connectivity/auth or file another issue.",
|
|
98
|
+
err=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
62
102
|
def _print_summary(uploader: Uploader) -> None:
|
|
63
103
|
"""Print the upload summary to stdout."""
|
|
64
104
|
summary = uploader.summary
|
|
@@ -106,6 +146,9 @@ def watch(
|
|
|
106
146
|
mgr.spawn(command)
|
|
107
147
|
sys.exit(mgr.wait())
|
|
108
148
|
|
|
149
|
+
# Surface the launch URL (stdout + $GITHUB_OUTPUT) now that the launch is open
|
|
150
|
+
_surface_launch_url(uploader)
|
|
151
|
+
|
|
109
152
|
# Start watcher + uploader consumer
|
|
110
153
|
watcher = ResultsWatcher(config.results_dir, queue, config)
|
|
111
154
|
watcher.start()
|
|
@@ -146,6 +189,7 @@ def watch(
|
|
|
146
189
|
uploader._client.close()
|
|
147
190
|
uploader._client = None
|
|
148
191
|
|
|
192
|
+
_warn_if_results_unsent(uploader, config)
|
|
149
193
|
_print_summary(uploader)
|
|
150
194
|
sys.exit(exit_code)
|
|
151
195
|
|
|
@@ -181,6 +225,9 @@ def upload(
|
|
|
181
225
|
click.echo("Warning: Failed to open launch. No results uploaded.", err=True)
|
|
182
226
|
sys.exit(0)
|
|
183
227
|
|
|
228
|
+
# Surface the launch URL (stdout + $GITHUB_OUTPUT) now that the launch is open
|
|
229
|
+
_surface_launch_url(uploader)
|
|
230
|
+
|
|
184
231
|
# Scan existing results directory
|
|
185
232
|
results_path = Path(config.results_dir)
|
|
186
233
|
if not results_path.exists():
|
|
@@ -51,10 +51,12 @@ class Uploader:
|
|
|
51
51
|
self._queue = queue
|
|
52
52
|
self._summary = UploadSummary()
|
|
53
53
|
self._launch_id: str | None = None
|
|
54
|
+
self._project_id: str | None = None
|
|
54
55
|
self._disabled = False
|
|
55
56
|
self._auth_failed = False
|
|
56
57
|
self._stop_event = threading.Event()
|
|
57
58
|
self._drain_event = threading.Event()
|
|
59
|
+
self._batch_lock = threading.Lock()
|
|
58
60
|
self._thread: threading.Thread | None = None
|
|
59
61
|
self._client: httpx.Client | None = None
|
|
60
62
|
self._batch: list[dict[str, Any]] = []
|
|
@@ -69,6 +71,16 @@ class Uploader:
|
|
|
69
71
|
"""Return current upload statistics."""
|
|
70
72
|
return self._summary
|
|
71
73
|
|
|
74
|
+
@property
|
|
75
|
+
def launch_id(self) -> str | None:
|
|
76
|
+
"""Return the opened launch ID, or None if no launch was opened."""
|
|
77
|
+
return self._launch_id
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def project_id(self) -> str | None:
|
|
81
|
+
"""Return the project UUID from the open-launch response, if available."""
|
|
82
|
+
return self._project_id
|
|
83
|
+
|
|
72
84
|
def configure_logging(self) -> None:
|
|
73
85
|
"""Configure logging based on silent mode.
|
|
74
86
|
|
|
@@ -113,17 +125,45 @@ class Uploader:
|
|
|
113
125
|
self._client = None
|
|
114
126
|
|
|
115
127
|
def drain(self, timeout: float) -> None:
|
|
116
|
-
"""Signal drain and wait
|
|
128
|
+
"""Signal drain and wait until all consumed items are flushed/sent.
|
|
129
|
+
|
|
130
|
+
Waits for BOTH the queue to empty AND the in-memory batch to be flushed.
|
|
131
|
+
Waiting on queue emptiness alone is insufficient: the consumer moves a
|
|
132
|
+
result from the queue into ``self._batch`` (queue becomes empty) but only
|
|
133
|
+
flushes at 50 items or the 2s batch timeout. For short runs (e.g. a single
|
|
134
|
+
result enqueued by final_scan after the subprocess exits), that left the
|
|
135
|
+
result unsent — the ``watch results_sent=0`` bug. Once the queue is empty,
|
|
136
|
+
this forces a final batch flush so pending results are sent before return.
|
|
117
137
|
|
|
118
138
|
Args:
|
|
119
|
-
timeout: Maximum seconds to wait for the queue to drain.
|
|
139
|
+
timeout: Maximum seconds to wait for the queue+batch to drain.
|
|
120
140
|
"""
|
|
121
141
|
self._drain_event.set()
|
|
122
142
|
deadline = time.monotonic() + timeout
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
143
|
+
|
|
144
|
+
# Phase 1: wait for the queue to empty AND for the consumer to finish
|
|
145
|
+
# processing every dequeued item. Checking queue.empty() alone is racy:
|
|
146
|
+
# an item that has been dequeued but not yet appended to the batch is in
|
|
147
|
+
# neither the queue nor the batch. unfinished_tasks (decremented by the
|
|
148
|
+
# consumer's task_done()) closes that window.
|
|
149
|
+
while (
|
|
150
|
+
(not self._queue.empty() or self._queue.unfinished_tasks > 0)
|
|
151
|
+
and time.monotonic() < deadline
|
|
152
|
+
):
|
|
153
|
+
time.sleep(0.05)
|
|
154
|
+
|
|
155
|
+
# Phase 2: the consumer is idle and everything is now in the batch (or
|
|
156
|
+
# already sent). Force out any partial batch that hasn't hit the 50-item
|
|
157
|
+
# or 2s trigger, so drain() honors its contract: everything consumed has
|
|
158
|
+
# been sent when it returns.
|
|
159
|
+
self._flush_batch()
|
|
160
|
+
|
|
161
|
+
if not self._queue.empty() or self._queue.unfinished_tasks > 0 or self._batch:
|
|
162
|
+
logger.warning(
|
|
163
|
+
"Drain timeout expired with %d queued and %d batched items remaining",
|
|
164
|
+
self._queue.qsize(),
|
|
165
|
+
len(self._batch),
|
|
166
|
+
)
|
|
127
167
|
|
|
128
168
|
def open_launch(self) -> str | None:
|
|
129
169
|
"""Open a streaming launch via the API.
|
|
@@ -150,6 +190,7 @@ class Uploader:
|
|
|
150
190
|
if response.status_code == 201:
|
|
151
191
|
data = response.json()
|
|
152
192
|
self._launch_id = data["launchId"]
|
|
193
|
+
self._project_id = data.get("projectId")
|
|
153
194
|
return self._launch_id
|
|
154
195
|
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
155
196
|
time.sleep(exponential_backoff(attempt))
|
|
@@ -411,13 +452,18 @@ class Uploader:
|
|
|
411
452
|
self._flush_batch()
|
|
412
453
|
|
|
413
454
|
def _flush_batch(self) -> None:
|
|
414
|
-
"""Send the current batch to the API.
|
|
415
|
-
if not self._batch or not self._launch_id:
|
|
416
|
-
return
|
|
455
|
+
"""Send the current batch to the API.
|
|
417
456
|
|
|
418
|
-
batch
|
|
419
|
-
|
|
420
|
-
|
|
457
|
+
Thread-safe: the batch may be flushed by the consumer thread (on 50-item
|
|
458
|
+
or 2s-timeout triggers) or by drain() on the main thread. The lock ensures
|
|
459
|
+
the swap-and-clear is atomic so a batch is never sent twice or lost.
|
|
460
|
+
"""
|
|
461
|
+
with self._batch_lock:
|
|
462
|
+
if not self._batch or not self._launch_id:
|
|
463
|
+
return
|
|
464
|
+
batch = self._batch[:]
|
|
465
|
+
self._batch = []
|
|
466
|
+
self._batch_start = 0.0
|
|
421
467
|
|
|
422
468
|
success = self._send_batch(batch)
|
|
423
469
|
if success:
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Unit tests for charisma_cli.launch_url: URL construction + GitHub output emission."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from charisma_cli.launch_url import build_launch_url, emit_github_output
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestBuildLaunchUrl:
|
|
9
|
+
"""build_launch_url mirrors the frontend /project/{id}/launches/{id} route."""
|
|
10
|
+
|
|
11
|
+
def test_basic_url(self) -> None:
|
|
12
|
+
url = build_launch_url("proj-uuid", "launch-uuid")
|
|
13
|
+
assert url == "https://charisma.aligntech.com/project/proj-uuid/launches/launch-uuid"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestEmitGithubOutput:
|
|
17
|
+
"""emit_github_output appends launch_url only when GITHUB_OUTPUT is set."""
|
|
18
|
+
|
|
19
|
+
def test_writes_when_github_output_set(self, tmp_path: Path, monkeypatch) -> None:
|
|
20
|
+
out = tmp_path / "gh_output"
|
|
21
|
+
out.write_text("") # simulate existing GITHUB_OUTPUT file
|
|
22
|
+
monkeypatch.setenv("GITHUB_OUTPUT", str(out))
|
|
23
|
+
|
|
24
|
+
emit_github_output("https://charisma.aligntech.com/project/p/launches/l")
|
|
25
|
+
|
|
26
|
+
content = out.read_text()
|
|
27
|
+
assert "launch_url=https://charisma.aligntech.com/project/p/launches/l\n" in content
|
|
28
|
+
|
|
29
|
+
def test_noop_when_github_output_unset(self, monkeypatch) -> None:
|
|
30
|
+
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
|
31
|
+
# Should simply return without raising
|
|
32
|
+
emit_github_output("https://charisma.aligntech.com/project/p/launches/l")
|
|
33
|
+
|
|
34
|
+
def test_bad_path_does_not_raise(self, monkeypatch) -> None:
|
|
35
|
+
# Point at an unwritable/nonexistent directory — must be swallowed
|
|
36
|
+
monkeypatch.setenv("GITHUB_OUTPUT", "/nonexistent-dir-xyz/output")
|
|
37
|
+
emit_github_output("https://charisma.aligntech.com/project/p/launches/l")
|
|
@@ -282,3 +282,52 @@ class TestUploaderSummary:
|
|
|
282
282
|
assert summary.attachments_sent == 0
|
|
283
283
|
assert summary.attachments_skipped == 0
|
|
284
284
|
assert summary.containers_sent == 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class TestUploaderDrainFlushesBatch:
|
|
288
|
+
"""Regression: drain() must flush the pending sub-batch, not just empty the queue.
|
|
289
|
+
|
|
290
|
+
Reproduces the `charismactl watch --> results_sent=0` bug: a short run enqueues
|
|
291
|
+
a single result after the subprocess exits (via final_scan). The consumer moves
|
|
292
|
+
it from the queue into the in-memory batch, which empties the queue immediately.
|
|
293
|
+
drain() only waits on queue emptiness, so it returns before the 2s batch-timeout
|
|
294
|
+
flush fires — leaving the result unsent. This mirrors the real CI failure where
|
|
295
|
+
`upload` (which flushes on stop) sent the result but `watch` reported 0.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
@respx.mock
|
|
299
|
+
def test_drain_flushes_single_pending_result(self, tmp_path: Path) -> None:
|
|
300
|
+
"""A single result enqueued just before drain must be SENT by drain().
|
|
301
|
+
|
|
302
|
+
Fails on the buggy implementation (result stuck in batch, results_sent=0);
|
|
303
|
+
passes once drain() forces a batch flush and waits for it.
|
|
304
|
+
"""
|
|
305
|
+
config = _make_config()
|
|
306
|
+
queue: PriorityQueue = PriorityQueue()
|
|
307
|
+
|
|
308
|
+
results_route = respx.post(
|
|
309
|
+
"https://charisma.test/api/v1/launches/launch-1/results"
|
|
310
|
+
).mock(return_value=httpx.Response(200, json={"accepted": 1}))
|
|
311
|
+
|
|
312
|
+
uploader = Uploader(config, queue)
|
|
313
|
+
uploader._launch_id = "launch-1"
|
|
314
|
+
|
|
315
|
+
uploader.start()
|
|
316
|
+
|
|
317
|
+
# Simulate final_scan enqueueing one result AFTER the subprocess exits,
|
|
318
|
+
# then draining — the real watch-mode sequence.
|
|
319
|
+
filepath = _make_result_file(tmp_path, "late-result.json")
|
|
320
|
+
queue.put(FileEvent(path=filepath, category=FileCategory.RESULT))
|
|
321
|
+
|
|
322
|
+
uploader.drain(timeout=5.0)
|
|
323
|
+
|
|
324
|
+
# Contract: after drain returns, the result must already be sent —
|
|
325
|
+
# not left sitting in the batch waiting on stop().
|
|
326
|
+
assert uploader.summary.results_sent == 1, (
|
|
327
|
+
"drain() returned but the pending result was not flushed/sent "
|
|
328
|
+
f"(results_sent={uploader.summary.results_sent}); "
|
|
329
|
+
"batch still held the result — this is the watch results_sent=0 bug"
|
|
330
|
+
)
|
|
331
|
+
assert results_route.called
|
|
332
|
+
|
|
333
|
+
uploader.stop()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|