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.
Files changed (27) hide show
  1. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/PKG-INFO +1 -1
  2. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/pyproject.toml +1 -1
  3. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/main.py +15 -2
  4. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/parser.py +47 -7
  5. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/uploader.py +12 -3
  6. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_integration.py +72 -0
  7. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_parser.py +118 -3
  8. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/.gitignore +0 -0
  9. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/README.md +0 -0
  10. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/__init__.py +0 -0
  11. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/config.py +0 -0
  12. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/launch_url.py +0 -0
  13. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/models.py +0 -0
  14. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/retry.py +0 -0
  15. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/subprocess_mgr.py +0 -0
  16. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/src/charisma_cli/watcher.py +0 -0
  17. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/__init__.py +0 -0
  18. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/conftest.py +0 -0
  19. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_cli.py +0 -0
  20. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_config.py +0 -0
  21. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_launch_url.py +0 -0
  22. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_models.py +0 -0
  23. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_retry.py +0 -0
  24. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_silent_mode.py +0 -0
  25. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_subprocess_mgr.py +0 -0
  26. {charisma_cli-0.1.4 → charisma_cli-0.1.5}/tests/test_uploader.py +0 -0
  27. {charisma_cli-0.1.4 → 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.4
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
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "charisma-cli"
8
- version = "0.1.4"
8
+ version = "0.1.5"
9
9
  description = "CLI tool that watches allure-results and streams test results + attachments to Charisma."
10
10
  readme = "README.md"
11
11
  license = "MIT"
@@ -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
- # Required fields
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
- duration_ms = stop - start
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(f"/api/v1/launches/{self._launch_id}/close")
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 test_missing_start_stop_returns_none(self, tmp_path: Path) -> None:
220
- """Result without timing fields cannot compute duration → returns None."""
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