charisma-cli 0.1.4__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.4 → charisma_cli-0.1.5}/PKG-INFO +1 -1
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/pyproject.toml +1 -1
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/main.py +15 -2
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/parser.py +47 -7
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/uploader.py +12 -3
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_integration.py +72 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_parser.py +118 -3
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/.gitignore +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/README.md +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/__init__.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/config.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/launch_url.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/models.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/retry.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/subprocess_mgr.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/watcher.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/__init__.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/conftest.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_cli.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_config.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_launch_url.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_models.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_retry.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_silent_mode.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_subprocess_mgr.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_uploader.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_watcher.py +0 -0
|
@@ -11,6 +11,7 @@ from charisma_cli import __version__
|
|
|
11
11
|
from charisma_cli.config import Config
|
|
12
12
|
from charisma_cli.launch_url import build_launch_url, emit_github_output
|
|
13
13
|
from charisma_cli.models import FileEvent
|
|
14
|
+
from charisma_cli.parser import parse_environment_properties
|
|
14
15
|
from charisma_cli.subprocess_mgr import SubprocessManager
|
|
15
16
|
from charisma_cli.uploader import Uploader
|
|
16
17
|
from charisma_cli.watcher import ResultsWatcher, classify_file
|
|
@@ -60,6 +61,18 @@ def _open_launch_or_exit(uploader: Uploader, config: Config) -> str:
|
|
|
60
61
|
return launch_id
|
|
61
62
|
|
|
62
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
|
+
|
|
63
76
|
def _surface_launch_url(uploader: Uploader) -> None:
|
|
64
77
|
"""Print the Charisma launch URL and expose it to GitHub Actions.
|
|
65
78
|
|
|
@@ -184,7 +197,7 @@ def watch(
|
|
|
184
197
|
uploader.stop()
|
|
185
198
|
|
|
186
199
|
uploader.ensure_client()
|
|
187
|
-
uploader.close_launch()
|
|
200
|
+
uploader.close_launch(variables=_read_launch_variables(config))
|
|
188
201
|
if uploader._client is not None:
|
|
189
202
|
uploader._client.close()
|
|
190
203
|
uploader._client = None
|
|
@@ -247,7 +260,7 @@ def upload(
|
|
|
247
260
|
|
|
248
261
|
# Close launch
|
|
249
262
|
uploader.ensure_client()
|
|
250
|
-
uploader.close_launch()
|
|
263
|
+
uploader.close_launch(variables=_read_launch_variables(config))
|
|
251
264
|
uploader.stop()
|
|
252
265
|
|
|
253
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
|
|
|
@@ -206,15 +206,24 @@ class Uploader:
|
|
|
206
206
|
|
|
207
207
|
return None
|
|
208
208
|
|
|
209
|
-
def close_launch(self) -> None:
|
|
210
|
-
"""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
|
+
"""
|
|
211
216
|
self.ensure_client()
|
|
212
217
|
if self._launch_id is None:
|
|
213
218
|
return
|
|
214
219
|
|
|
220
|
+
body = {"variables": variables} if variables else None
|
|
221
|
+
|
|
215
222
|
for attempt in range(MAX_RETRIES + 1):
|
|
216
223
|
try:
|
|
217
|
-
response = self._client.post(
|
|
224
|
+
response = self._client.post(
|
|
225
|
+
f"/api/v1/launches/{self._launch_id}/close", json=body
|
|
226
|
+
)
|
|
218
227
|
if response.status_code in (200, 201, 204):
|
|
219
228
|
return
|
|
220
229
|
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
@@ -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
|
+
}
|
|
@@ -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") == {}
|
|
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
|
|
File without changes
|