charisma-cli 0.1.2__tar.gz → 0.1.5__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.5}/PKG-INFO +20 -13
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/README.md +19 -12
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/pyproject.toml +1 -1
- charisma_cli-0.1.5/src/charisma_cli/launch_url.py +45 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/main.py +62 -2
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/parser.py +47 -7
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/uploader.py +70 -15
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_integration.py +72 -0
- charisma_cli-0.1.5/tests/test_launch_url.py +37 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_parser.py +118 -3
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_uploader.py +49 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/.gitignore +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/__init__.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/config.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/models.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/retry.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/subprocess_mgr.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/watcher.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/__init__.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/conftest.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_cli.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_config.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_models.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_retry.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_silent_mode.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_subprocess_mgr.py +0 -0
- {charisma_cli-0.1.2 → charisma_cli-0.1.5}/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.5
|
|
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,7 +9,9 @@ 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
|
|
14
|
+
from charisma_cli.parser import parse_environment_properties
|
|
13
15
|
from charisma_cli.subprocess_mgr import SubprocessManager
|
|
14
16
|
from charisma_cli.uploader import Uploader
|
|
15
17
|
from charisma_cli.watcher import ResultsWatcher, classify_file
|
|
@@ -59,6 +61,57 @@ def _open_launch_or_exit(uploader: Uploader, config: Config) -> str:
|
|
|
59
61
|
return launch_id
|
|
60
62
|
|
|
61
63
|
|
|
64
|
+
def _read_launch_variables(config: Config) -> dict[str, str]:
|
|
65
|
+
"""Read Allure environment.properties from the results dir, if present.
|
|
66
|
+
|
|
67
|
+
Returns the parsed key/value variables (empty dict if the file is absent),
|
|
68
|
+
to be attached to the launch at close time.
|
|
69
|
+
"""
|
|
70
|
+
props_path = Path(config.results_dir) / "environment.properties"
|
|
71
|
+
if not props_path.is_file():
|
|
72
|
+
return {}
|
|
73
|
+
return parse_environment_properties(props_path)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _surface_launch_url(uploader: Uploader) -> None:
|
|
77
|
+
"""Print the Charisma launch URL and expose it to GitHub Actions.
|
|
78
|
+
|
|
79
|
+
Constructs the URL from the project UUID returned by the open-launch
|
|
80
|
+
response and the launch ID. Prints it to stdout and, when running under
|
|
81
|
+
GitHub Actions, appends ``launch_url=<url>`` to $GITHUB_OUTPUT.
|
|
82
|
+
Does nothing if no launch was opened or the project UUID is unavailable.
|
|
83
|
+
"""
|
|
84
|
+
launch_id = uploader.launch_id
|
|
85
|
+
project_id = uploader.project_id
|
|
86
|
+
if not launch_id or not project_id:
|
|
87
|
+
return
|
|
88
|
+
url = build_launch_url(project_id, launch_id)
|
|
89
|
+
click.echo(f"Charisma launch: {url}")
|
|
90
|
+
emit_github_output(url)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _warn_if_results_unsent(uploader: Uploader, config: Config) -> None:
|
|
94
|
+
"""Warn loudly when result files exist on disk but none were sent.
|
|
95
|
+
|
|
96
|
+
Turns the previously-silent ``results_sent=0`` failure into a visible
|
|
97
|
+
warning naming the absolute watched path and the on-disk result count, so
|
|
98
|
+
a broken run is not mistaken for success.
|
|
99
|
+
"""
|
|
100
|
+
if uploader.summary.results_sent > 0:
|
|
101
|
+
return
|
|
102
|
+
results_path = Path(config.results_dir).resolve()
|
|
103
|
+
if not results_path.exists():
|
|
104
|
+
return
|
|
105
|
+
result_files = [p for p in results_path.iterdir() if p.name.endswith("-result.json")]
|
|
106
|
+
if result_files:
|
|
107
|
+
click.echo(
|
|
108
|
+
f"Warning: {len(result_files)} result file(s) present in "
|
|
109
|
+
f"{results_path} but results_sent=0 — nothing was uploaded. "
|
|
110
|
+
"Check connectivity/auth or file another issue.",
|
|
111
|
+
err=True,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
62
115
|
def _print_summary(uploader: Uploader) -> None:
|
|
63
116
|
"""Print the upload summary to stdout."""
|
|
64
117
|
summary = uploader.summary
|
|
@@ -106,6 +159,9 @@ def watch(
|
|
|
106
159
|
mgr.spawn(command)
|
|
107
160
|
sys.exit(mgr.wait())
|
|
108
161
|
|
|
162
|
+
# Surface the launch URL (stdout + $GITHUB_OUTPUT) now that the launch is open
|
|
163
|
+
_surface_launch_url(uploader)
|
|
164
|
+
|
|
109
165
|
# Start watcher + uploader consumer
|
|
110
166
|
watcher = ResultsWatcher(config.results_dir, queue, config)
|
|
111
167
|
watcher.start()
|
|
@@ -141,11 +197,12 @@ def watch(
|
|
|
141
197
|
uploader.stop()
|
|
142
198
|
|
|
143
199
|
uploader.ensure_client()
|
|
144
|
-
uploader.close_launch()
|
|
200
|
+
uploader.close_launch(variables=_read_launch_variables(config))
|
|
145
201
|
if uploader._client is not None:
|
|
146
202
|
uploader._client.close()
|
|
147
203
|
uploader._client = None
|
|
148
204
|
|
|
205
|
+
_warn_if_results_unsent(uploader, config)
|
|
149
206
|
_print_summary(uploader)
|
|
150
207
|
sys.exit(exit_code)
|
|
151
208
|
|
|
@@ -181,6 +238,9 @@ def upload(
|
|
|
181
238
|
click.echo("Warning: Failed to open launch. No results uploaded.", err=True)
|
|
182
239
|
sys.exit(0)
|
|
183
240
|
|
|
241
|
+
# Surface the launch URL (stdout + $GITHUB_OUTPUT) now that the launch is open
|
|
242
|
+
_surface_launch_url(uploader)
|
|
243
|
+
|
|
184
244
|
# Scan existing results directory
|
|
185
245
|
results_path = Path(config.results_dir)
|
|
186
246
|
if not results_path.exists():
|
|
@@ -200,7 +260,7 @@ def upload(
|
|
|
200
260
|
|
|
201
261
|
# Close launch
|
|
202
262
|
uploader.ensure_client()
|
|
203
|
-
uploader.close_launch()
|
|
263
|
+
uploader.close_launch(variables=_read_launch_variables(config))
|
|
204
264
|
uploader.stop()
|
|
205
265
|
|
|
206
266
|
_print_summary(uploader)
|
|
@@ -54,15 +54,18 @@ def parse_result_file(path: Path) -> CharismaResult | None:
|
|
|
54
54
|
logger.warning("Result file is not a JSON object: %s", path)
|
|
55
55
|
return None
|
|
56
56
|
|
|
57
|
-
#
|
|
57
|
+
# uuid is the only strictly required field. start/stop are optional:
|
|
58
|
+
# skipped tests (and some setup/teardown-only results) never execute, so
|
|
59
|
+
# Allure omits `stop` (and sometimes `start`). Requiring them here silently
|
|
60
|
+
# dropped skipped results before they were ever sent to Charisma.
|
|
58
61
|
uuid = data.get("uuid")
|
|
62
|
+
if uuid is None:
|
|
63
|
+
logger.warning("Result file missing required field (uuid): %s", path)
|
|
64
|
+
return None
|
|
65
|
+
|
|
59
66
|
start = data.get("start")
|
|
60
67
|
stop = data.get("stop")
|
|
61
68
|
|
|
62
|
-
if uuid is None or start is None or stop is None:
|
|
63
|
-
logger.warning("Result file missing required fields (uuid/start/stop): %s", path)
|
|
64
|
-
return None
|
|
65
|
-
|
|
66
69
|
# testId: prefer historyId, fallback to fullName
|
|
67
70
|
history_id = data.get("historyId")
|
|
68
71
|
full_name = data.get("fullName")
|
|
@@ -72,8 +75,9 @@ def parse_result_file(path: Path) -> CharismaResult | None:
|
|
|
72
75
|
logger.warning("Result file missing both historyId and fullName: %s", path)
|
|
73
76
|
return None
|
|
74
77
|
|
|
75
|
-
# Compute duration
|
|
76
|
-
|
|
78
|
+
# Compute duration only when both timestamps are present; otherwise 0
|
|
79
|
+
# (e.g. skipped tests that never ran).
|
|
80
|
+
duration_ms = (stop - start) if (start is not None and stop is not None) else 0
|
|
77
81
|
|
|
78
82
|
# Status mapping
|
|
79
83
|
status = data.get("status", "")
|
|
@@ -145,6 +149,42 @@ def parse_container_file(path: Path) -> ContainerData | None:
|
|
|
145
149
|
)
|
|
146
150
|
|
|
147
151
|
|
|
152
|
+
def parse_environment_properties(path: Path) -> dict[str, str]:
|
|
153
|
+
"""Parse an Allure ``environment.properties`` file into a key/value dict.
|
|
154
|
+
|
|
155
|
+
The file is a standard Java-style properties file: ``key=value`` per line,
|
|
156
|
+
with ``#`` / ``!`` comment lines and blank lines ignored. Values may contain
|
|
157
|
+
``=`` (only the first ``=`` splits key from value). These become the
|
|
158
|
+
launch-level "Variables" shown in Charisma.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
path: Path to the environment.properties file.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Dict of variable name → value (both stripped). Empty dict if the file
|
|
165
|
+
is missing, unreadable, or contains no valid entries.
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
text = path.read_text(encoding="utf-8")
|
|
169
|
+
except (OSError, FileNotFoundError):
|
|
170
|
+
logger.warning("Cannot read environment.properties: %s", path)
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
variables: dict[str, str] = {}
|
|
174
|
+
for line in text.splitlines():
|
|
175
|
+
stripped = line.strip()
|
|
176
|
+
if not stripped or stripped.startswith("#") or stripped.startswith("!"):
|
|
177
|
+
continue
|
|
178
|
+
if "=" not in stripped:
|
|
179
|
+
continue
|
|
180
|
+
key, _, value = stripped.partition("=")
|
|
181
|
+
key = key.strip()
|
|
182
|
+
if key:
|
|
183
|
+
variables[key] = value.strip()
|
|
184
|
+
|
|
185
|
+
return variables
|
|
186
|
+
|
|
187
|
+
|
|
148
188
|
def extract_attachment_refs(result_data: dict, result_uuid: str) -> list[AttachmentRef]:
|
|
149
189
|
"""Extract attachment references from an Allure result data dict.
|
|
150
190
|
|
|
@@ -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))
|
|
@@ -165,15 +206,24 @@ class Uploader:
|
|
|
165
206
|
|
|
166
207
|
return None
|
|
167
208
|
|
|
168
|
-
def close_launch(self) -> None:
|
|
169
|
-
"""Close the streaming launch via the API.
|
|
209
|
+
def close_launch(self, variables: dict[str, str] | None = None) -> None:
|
|
210
|
+
"""Close the streaming launch via the API.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
variables: Optional launch-level key/value metadata (parsed from
|
|
214
|
+
Allure environment.properties) to persist on the launch.
|
|
215
|
+
"""
|
|
170
216
|
self.ensure_client()
|
|
171
217
|
if self._launch_id is None:
|
|
172
218
|
return
|
|
173
219
|
|
|
220
|
+
body = {"variables": variables} if variables else None
|
|
221
|
+
|
|
174
222
|
for attempt in range(MAX_RETRIES + 1):
|
|
175
223
|
try:
|
|
176
|
-
response = self._client.post(
|
|
224
|
+
response = self._client.post(
|
|
225
|
+
f"/api/v1/launches/{self._launch_id}/close", json=body
|
|
226
|
+
)
|
|
177
227
|
if response.status_code in (200, 201, 204):
|
|
178
228
|
return
|
|
179
229
|
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
@@ -411,13 +461,18 @@ class Uploader:
|
|
|
411
461
|
self._flush_batch()
|
|
412
462
|
|
|
413
463
|
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
|
|
464
|
+
"""Send the current batch to the API.
|
|
417
465
|
|
|
418
|
-
batch
|
|
419
|
-
|
|
420
|
-
|
|
466
|
+
Thread-safe: the batch may be flushed by the consumer thread (on 50-item
|
|
467
|
+
or 2s-timeout triggers) or by drain() on the main thread. The lock ensures
|
|
468
|
+
the swap-and-clear is atomic so a batch is never sent twice or lost.
|
|
469
|
+
"""
|
|
470
|
+
with self._batch_lock:
|
|
471
|
+
if not self._batch or not self._launch_id:
|
|
472
|
+
return
|
|
473
|
+
batch = self._batch[:]
|
|
474
|
+
self._batch = []
|
|
475
|
+
self._batch_start = 0.0
|
|
421
476
|
|
|
422
477
|
success = self._send_batch(batch)
|
|
423
478
|
if success:
|
|
@@ -170,3 +170,75 @@ class TestUploadCommand:
|
|
|
170
170
|
|
|
171
171
|
assert result.exit_code == 0
|
|
172
172
|
assert "results_sent" in result.output or "Summary" in result.output or result.exit_code == 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TestReportingFixesEndToEnd:
|
|
176
|
+
"""E2E: skipped tests are sent, and environment.properties → close variables."""
|
|
177
|
+
|
|
178
|
+
@respx.mock
|
|
179
|
+
def test_skipped_result_and_variables_are_sent(self, tmp_path: Path, monkeypatch) -> None:
|
|
180
|
+
"""A skipped result (no 'stop') is uploaded, and environment.properties
|
|
181
|
+
is sent as `variables` in the close-launch body."""
|
|
182
|
+
results_dir = tmp_path / "allure-results"
|
|
183
|
+
results_dir.mkdir()
|
|
184
|
+
|
|
185
|
+
# Skipped result — no "stop" (Allure omits it for tests that never ran)
|
|
186
|
+
(results_dir / "skip-result.json").write_text(json.dumps({
|
|
187
|
+
"uuid": "skip-1",
|
|
188
|
+
"historyId": "hist_skip",
|
|
189
|
+
"fullName": "tests.test_x.test_skipped",
|
|
190
|
+
"name": "test_skipped",
|
|
191
|
+
"status": "skipped",
|
|
192
|
+
"statusDetails": {"message": "conditional skip"},
|
|
193
|
+
"start": 1000,
|
|
194
|
+
"labels": [],
|
|
195
|
+
"parameters": [],
|
|
196
|
+
}))
|
|
197
|
+
|
|
198
|
+
# Allure environment.properties → launch variables
|
|
199
|
+
(results_dir / "environment.properties").write_text(
|
|
200
|
+
"environment_name=dv1\naws_region=us-west-2\nworkers_number=8\n"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
respx.post("https://charisma.test/api/v1/launches").mock(
|
|
204
|
+
return_value=httpx.Response(
|
|
205
|
+
201, json={"launchId": "launch-x", "projectId": "proj-uuid"}
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
results_route = respx.post(
|
|
209
|
+
"https://charisma.test/api/v1/launches/launch-x/results"
|
|
210
|
+
).mock(return_value=httpx.Response(200, json={"accepted": 1}))
|
|
211
|
+
close_route = respx.post(
|
|
212
|
+
"https://charisma.test/api/v1/launches/launch-x/close"
|
|
213
|
+
).mock(return_value=httpx.Response(200, json={}))
|
|
214
|
+
|
|
215
|
+
monkeypatch.setenv("CHARISMA_ENDPOINT", "https://charisma.test")
|
|
216
|
+
monkeypatch.setenv("CHARISMA_TOKEN", "tok")
|
|
217
|
+
|
|
218
|
+
runner = CliRunner()
|
|
219
|
+
result = runner.invoke(cli, [
|
|
220
|
+
"watch",
|
|
221
|
+
"--project", "p",
|
|
222
|
+
"--results", str(results_dir),
|
|
223
|
+
"--", sys.executable, "-c", "import sys; sys.exit(0)",
|
|
224
|
+
])
|
|
225
|
+
|
|
226
|
+
assert result.exit_code == 0
|
|
227
|
+
|
|
228
|
+
# The skipped result was sent (not dropped)
|
|
229
|
+
assert results_route.called
|
|
230
|
+
sent_results = json.loads(results_route.calls[0].request.content)["results"]
|
|
231
|
+
outcomes = {r["testId"]: r["outcome"] for r in sent_results}
|
|
232
|
+
assert outcomes.get("hist_skip") == "skipped"
|
|
233
|
+
# duration defaulted to 0 for the missing stop
|
|
234
|
+
skip_payload = next(r for r in sent_results if r["testId"] == "hist_skip")
|
|
235
|
+
assert skip_payload["duration_ms"] == 0
|
|
236
|
+
|
|
237
|
+
# environment.properties sent as variables on close
|
|
238
|
+
assert close_route.called
|
|
239
|
+
close_body = json.loads(close_route.calls[0].request.content)
|
|
240
|
+
assert close_body["variables"] == {
|
|
241
|
+
"environment_name": "dv1",
|
|
242
|
+
"aws_region": "us-west-2",
|
|
243
|
+
"workers_number": "8",
|
|
244
|
+
}
|
|
@@ -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")
|
|
@@ -216,8 +216,12 @@ class TestParseResultFile:
|
|
|
216
216
|
|
|
217
217
|
assert result is None
|
|
218
218
|
|
|
219
|
-
def
|
|
220
|
-
"""Result without timing fields
|
|
219
|
+
def test_missing_start_stop_parses_with_zero_duration(self, tmp_path: Path) -> None:
|
|
220
|
+
"""Result without timing fields parses with duration 0 (not dropped).
|
|
221
|
+
|
|
222
|
+
Previously this returned None, which silently dropped skipped tests
|
|
223
|
+
(Allure omits 'stop' for tests that never executed).
|
|
224
|
+
"""
|
|
221
225
|
data = {
|
|
222
226
|
"uuid": "no-time",
|
|
223
227
|
"historyId": "hist",
|
|
@@ -227,7 +231,10 @@ class TestParseResultFile:
|
|
|
227
231
|
filepath = self._write_json(tmp_path, data)
|
|
228
232
|
result = parse_result_file(filepath)
|
|
229
233
|
|
|
230
|
-
assert result is None
|
|
234
|
+
assert result is not None
|
|
235
|
+
assert result.duration_ms == 0
|
|
236
|
+
assert result.started_at is None
|
|
237
|
+
assert result.ended_at is None
|
|
231
238
|
|
|
232
239
|
def test_nonexistent_file_returns_none(self, tmp_path: Path) -> None:
|
|
233
240
|
"""File that doesn't exist returns None gracefully."""
|
|
@@ -534,3 +541,111 @@ class TestExtractAttachmentRefs:
|
|
|
534
541
|
|
|
535
542
|
assert len(refs) == 1
|
|
536
543
|
assert refs[0].name == "abc-screenshot.png"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class TestSkippedResultParsing:
|
|
547
|
+
"""Regression: skipped tests (no 'stop', sometimes no 'start') must parse.
|
|
548
|
+
|
|
549
|
+
Allure omits 'stop' for tests that never executed (skipped). The parser
|
|
550
|
+
previously required both start and stop and returned None, silently
|
|
551
|
+
dropping skipped results so they never reached Charisma.
|
|
552
|
+
"""
|
|
553
|
+
|
|
554
|
+
def test_skipped_without_stop_parses(self, tmp_path: Path) -> None:
|
|
555
|
+
"""A skipped result with 'start' but no 'stop' parses with duration 0."""
|
|
556
|
+
filepath = tmp_path / "skip-result.json"
|
|
557
|
+
filepath.write_text(json.dumps({
|
|
558
|
+
"uuid": "skip-uuid",
|
|
559
|
+
"historyId": "hist-skip",
|
|
560
|
+
"fullName": "tests.test_mod.test_skipped",
|
|
561
|
+
"name": "test_skipped",
|
|
562
|
+
"status": "skipped",
|
|
563
|
+
"statusDetails": {"message": "conditional skip"},
|
|
564
|
+
"start": 1000,
|
|
565
|
+
# no "stop"
|
|
566
|
+
"labels": [],
|
|
567
|
+
"parameters": [],
|
|
568
|
+
}))
|
|
569
|
+
|
|
570
|
+
result = parse_result_file(filepath)
|
|
571
|
+
|
|
572
|
+
assert result is not None
|
|
573
|
+
assert result.outcome == "skipped"
|
|
574
|
+
assert result.duration_ms == 0
|
|
575
|
+
assert result.testId == "hist-skip"
|
|
576
|
+
assert result.error_message == "conditional skip"
|
|
577
|
+
|
|
578
|
+
def test_skipped_without_start_or_stop_parses(self, tmp_path: Path) -> None:
|
|
579
|
+
"""A skipped result with neither start nor stop still parses."""
|
|
580
|
+
filepath = tmp_path / "skip2-result.json"
|
|
581
|
+
filepath.write_text(json.dumps({
|
|
582
|
+
"uuid": "skip2-uuid",
|
|
583
|
+
"fullName": "tests.test_mod.test_skipped2",
|
|
584
|
+
"status": "skipped",
|
|
585
|
+
"labels": [],
|
|
586
|
+
"parameters": [],
|
|
587
|
+
}))
|
|
588
|
+
|
|
589
|
+
result = parse_result_file(filepath)
|
|
590
|
+
|
|
591
|
+
assert result is not None
|
|
592
|
+
assert result.outcome == "skipped"
|
|
593
|
+
assert result.duration_ms == 0
|
|
594
|
+
assert result.started_at is None
|
|
595
|
+
assert result.ended_at is None
|
|
596
|
+
|
|
597
|
+
def test_missing_uuid_still_returns_none(self, tmp_path: Path) -> None:
|
|
598
|
+
"""uuid remains strictly required — its absence returns None."""
|
|
599
|
+
filepath = tmp_path / "nouuid-result.json"
|
|
600
|
+
filepath.write_text(json.dumps({
|
|
601
|
+
"fullName": "tests.test_mod.test_x",
|
|
602
|
+
"status": "passed",
|
|
603
|
+
"start": 1000,
|
|
604
|
+
"stop": 2000,
|
|
605
|
+
}))
|
|
606
|
+
|
|
607
|
+
assert parse_result_file(filepath) is None
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
class TestParseEnvironmentProperties:
|
|
611
|
+
"""parse_environment_properties reads Allure environment.properties files."""
|
|
612
|
+
|
|
613
|
+
def test_parses_key_value_pairs(self, tmp_path: Path) -> None:
|
|
614
|
+
from charisma_cli.parser import parse_environment_properties
|
|
615
|
+
|
|
616
|
+
f = tmp_path / "environment.properties"
|
|
617
|
+
f.write_text(
|
|
618
|
+
"environment_name=dv1\n"
|
|
619
|
+
"aws_region=us-west-2\n"
|
|
620
|
+
"workers_number=8\n"
|
|
621
|
+
)
|
|
622
|
+
result = parse_environment_properties(f)
|
|
623
|
+
assert result == {
|
|
624
|
+
"environment_name": "dv1",
|
|
625
|
+
"aws_region": "us-west-2",
|
|
626
|
+
"workers_number": "8",
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
def test_ignores_comments_and_blank_lines(self, tmp_path: Path) -> None:
|
|
630
|
+
from charisma_cli.parser import parse_environment_properties
|
|
631
|
+
|
|
632
|
+
f = tmp_path / "environment.properties"
|
|
633
|
+
f.write_text(
|
|
634
|
+
"# a comment\n"
|
|
635
|
+
"! another comment\n"
|
|
636
|
+
"\n"
|
|
637
|
+
"key=value\n"
|
|
638
|
+
)
|
|
639
|
+
assert parse_environment_properties(f) == {"key": "value"}
|
|
640
|
+
|
|
641
|
+
def test_value_with_equals_sign_keeps_remainder(self, tmp_path: Path) -> None:
|
|
642
|
+
from charisma_cli.parser import parse_environment_properties
|
|
643
|
+
|
|
644
|
+
f = tmp_path / "environment.properties"
|
|
645
|
+
f.write_text("url=https://x.com/a?b=c\n")
|
|
646
|
+
assert parse_environment_properties(f) == {"url": "https://x.com/a?b=c"}
|
|
647
|
+
|
|
648
|
+
def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
|
|
649
|
+
from charisma_cli.parser import parse_environment_properties
|
|
650
|
+
|
|
651
|
+
assert parse_environment_properties(tmp_path / "nope.properties") == {}
|
|
@@ -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
|