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.
Files changed (27) hide show
  1. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/PKG-INFO +20 -13
  2. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/README.md +19 -12
  3. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/pyproject.toml +1 -1
  4. charisma_cli-0.1.5/src/charisma_cli/launch_url.py +45 -0
  5. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/main.py +62 -2
  6. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/parser.py +47 -7
  7. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/uploader.py +70 -15
  8. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_integration.py +72 -0
  9. charisma_cli-0.1.5/tests/test_launch_url.py +37 -0
  10. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_parser.py +118 -3
  11. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_uploader.py +49 -0
  12. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/.gitignore +0 -0
  13. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/__init__.py +0 -0
  14. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/config.py +0 -0
  15. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/models.py +0 -0
  16. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/retry.py +0 -0
  17. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/subprocess_mgr.py +0 -0
  18. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/src/charisma_cli/watcher.py +0 -0
  19. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/__init__.py +0 -0
  20. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/conftest.py +0 -0
  21. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_cli.py +0 -0
  22. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_config.py +0 -0
  23. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_models.py +0 -0
  24. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_retry.py +0 -0
  25. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_silent_mode.py +0 -0
  26. {charisma_cli-0.1.2 → charisma_cli-0.1.5}/tests/test_subprocess_mgr.py +0 -0
  27. {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.2
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
+ ```
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "charisma-cli"
8
- version = "0.1.2"
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"
@@ -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
- # 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
 
@@ -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 for queue to empty or timeout to expire.
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
- while not self._queue.empty() and time.monotonic() < deadline:
124
- time.sleep(0.1)
125
- if not self._queue.empty():
126
- logger.warning("Drain timeout expired with %d items remaining in queue", self._queue.qsize())
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(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
+ )
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 = self._batch[:]
419
- self._batch = []
420
- self._batch_start = 0.0
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 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") == {}
@@ -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