charisma-cli 0.1.6__tar.gz → 0.2.1__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.6 → charisma_cli-0.2.1}/.gitignore +1 -0
  2. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/PKG-INFO +1 -1
  3. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/pyproject.toml +1 -1
  4. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/__init__.py +1 -1
  5. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/main.py +20 -9
  6. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/models.py +23 -0
  7. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/parser.py +71 -15
  8. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/uploader.py +112 -20
  9. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/watcher.py +27 -2
  10. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_integration.py +132 -2
  11. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_parser.py +213 -0
  12. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_uploader.py +189 -0
  13. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_watcher.py +25 -6
  14. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/README.md +0 -0
  15. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/config.py +0 -0
  16. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/launch_url.py +0 -0
  17. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/retry.py +0 -0
  18. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/subprocess_mgr.py +0 -0
  19. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/__init__.py +0 -0
  20. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/conftest.py +0 -0
  21. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_cli.py +0 -0
  22. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_config.py +0 -0
  23. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_launch_url.py +0 -0
  24. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_models.py +0 -0
  25. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_retry.py +0 -0
  26. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_silent_mode.py +0 -0
  27. {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_subprocess_mgr.py +0 -0
@@ -132,3 +132,4 @@ ltm/snapshots/*
132
132
  backend/coverage.json
133
133
  */__pycache__/*
134
134
  allure-results/*
135
+ allure-results-live/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: charisma-cli
3
- Version: 0.1.6
3
+ Version: 0.2.1
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.6"
8
+ version = "0.2.1"
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"
@@ -1,3 +1,3 @@
1
1
  """charisma-cli: Stream allure results to Charisma in real time."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.1"
@@ -16,6 +16,15 @@ from charisma_cli.subprocess_mgr import SubprocessManager
16
16
  from charisma_cli.uploader import Uploader
17
17
  from charisma_cli.watcher import ResultsWatcher, classify_file
18
18
 
19
+ # Post-subprocess settle window for the watch command. allure-pytest under heavy
20
+ # xdist can finish flushing its result files to disk seconds after the test
21
+ # subprocess exits; settle_scan keeps polling until the directory is quiet for
22
+ # SETTLE_QUIET_PERIOD or SETTLE_MAX_WAIT elapses. The window is deliberately
23
+ # wider than settle_scan's own defaults (1s/15s) because a real CI burst can
24
+ # land well past 15s — a too-short window is the watch results_sent=0 bug.
25
+ SETTLE_QUIET_PERIOD = 2.0
26
+ SETTLE_MAX_WAIT = 60.0
27
+
19
28
 
20
29
  @click.group()
21
30
  @click.version_option(version=__version__, prog_name="charismactl")
@@ -185,17 +194,19 @@ def watch(
185
194
  mgr.spawn(command)
186
195
  exit_code = mgr.wait()
187
196
 
188
- # After subprocess exits (crash or normal): flush debouncing files, then
189
- # keep scanning until the results directory settles. allure-pytest writes
190
- # its result files in a burst during test-process teardown; under high
191
- # xdist parallelism that burst can land AFTER the subprocess exits, so a
192
- # single scan taken here races the OS flush and captures nothing (the
193
- # watch results_sent=0 bug). settle_scan reconciles until the directory is
194
- # quiet, capturing the whole burst before we drain.
197
+ # Reconcile the results directory after the subprocess exits. allure-pytest
198
+ # writes its result files in a burst during test-process teardown; under
199
+ # heavy xdist parallelism that burst can finish flushing to disk seconds
200
+ # after the subprocess returns. flush_pending drains any files still in the
201
+ # 500ms debounce window; settle_scan then polls the directory until it is
202
+ # quiet, capturing the whole late burst before we drain. Without a wide
203
+ # enough window the queue is drained empty and nothing is sent (the watch
204
+ # results_sent=0 bug). The shared seen-set keeps every file exactly-once.
195
205
  watcher.flush_pending()
196
- watcher.settle_scan()
206
+ watcher.settle_scan(quiet_period=SETTLE_QUIET_PERIOD, max_wait=SETTLE_MAX_WAIT)
197
207
 
198
- # Drain and close — stop watcher AFTER drain so all files are processed
208
+ # Drain and close — stop the watcher AFTER drain so the consumer's final
209
+ # queue flush still sees anything the observer enqueued during drain.
199
210
  uploader.drain(timeout=config.drain_timeout)
200
211
  watcher.stop()
201
212
  uploader.stop()
@@ -3,6 +3,7 @@
3
3
  from dataclasses import dataclass, field
4
4
  from enum import IntEnum
5
5
  from pathlib import Path
6
+ from typing import Any
6
7
 
7
8
 
8
9
  class FileCategory(IntEnum):
@@ -52,6 +53,28 @@ class AttachmentRef:
52
53
  mime_type: str # content-type
53
54
  result_uuid: str # owning result UUID
54
55
 
56
+ @classmethod
57
+ def from_allure(cls, attachment: dict[str, Any], result_uuid: str) -> "AttachmentRef | None":
58
+ """Build an AttachmentRef from a single Allure attachment entry.
59
+
60
+ Args:
61
+ attachment: An Allure attachment dict (``source``, optional ``name``
62
+ and ``type``).
63
+ result_uuid: The UUID of the owning result.
64
+
65
+ Returns:
66
+ An AttachmentRef, or None if the attachment has no usable ``source``.
67
+ """
68
+ source = attachment.get("source")
69
+ if not source:
70
+ return None
71
+ return cls(
72
+ source=source,
73
+ name=attachment.get("name", source),
74
+ mime_type=attachment.get("type", "application/octet-stream"),
75
+ result_uuid=result_uuid,
76
+ )
77
+
55
78
 
56
79
  @dataclass
57
80
  class ContainerData:
@@ -7,6 +7,7 @@ for streaming to the Charisma ingestion API.
7
7
  import json
8
8
  import logging
9
9
  from pathlib import Path
10
+ from typing import Any
10
11
 
11
12
  from charisma_cli.models import AttachmentRef, CharismaResult, ContainerData
12
13
 
@@ -185,31 +186,86 @@ def parse_environment_properties(path: Path) -> dict[str, str]:
185
186
  return variables
186
187
 
187
188
 
188
- def extract_attachment_refs(result_data: dict, result_uuid: str) -> list[AttachmentRef]:
189
+ def _collect_step_attachments(steps: list[dict[str, Any]], result_uuid: str) -> list[AttachmentRef]:
190
+ """Recursively collect attachment refs from a list of Allure steps.
191
+
192
+ Allure nests attachments inside steps, and steps inside steps. A single
193
+ ``result_data["attachments"]`` read only sees attachments added directly to
194
+ the test body — the minority case. Screenshots and logs captured inside a
195
+ step (the common case) live in ``step["attachments"]`` at arbitrary depth.
196
+
197
+ Args:
198
+ steps: A list of Allure step dicts (may be empty or contain nested steps).
199
+ result_uuid: The UUID of the owning result, applied to every ref.
200
+
201
+ Returns:
202
+ Flat list of AttachmentRef for attachments found at every step depth.
203
+ """
204
+ refs: list[AttachmentRef] = []
205
+ for step in steps:
206
+ if not isinstance(step, dict):
207
+ continue
208
+ for attachment in step.get("attachments") or []:
209
+ ref = AttachmentRef.from_allure(attachment, result_uuid)
210
+ if ref is not None:
211
+ refs.append(ref)
212
+ refs.extend(_collect_step_attachments(step.get("steps") or [], result_uuid))
213
+ return refs
214
+
215
+
216
+ def extract_attachment_refs(result_data: dict[str, Any], result_uuid: str) -> list[AttachmentRef]:
189
217
  """Extract attachment references from an Allure result data dict.
190
218
 
219
+ Collects attachments from the result's top-level ``attachments`` array AND
220
+ from every step (recursively through nested sub-steps), since Allure stores
221
+ most attachments inside steps rather than at the top level.
222
+
191
223
  Args:
192
224
  result_data: The raw parsed Allure result JSON dict.
193
225
  result_uuid: The UUID of the owning result (passed as argument,
194
226
  not taken from result_data).
195
227
 
196
228
  Returns:
197
- List of AttachmentRef objects for each attachment in the result.
229
+ List of AttachmentRef objects for each attachment in the result,
230
+ including those nested inside steps.
198
231
  """
199
- attachments = result_data.get("attachments")
200
- if not attachments:
232
+ refs: list[AttachmentRef] = []
233
+ for attachment in result_data.get("attachments") or []:
234
+ ref = AttachmentRef.from_allure(attachment, result_uuid)
235
+ if ref is not None:
236
+ refs.append(ref)
237
+
238
+ refs.extend(_collect_step_attachments(result_data.get("steps") or [], result_uuid))
239
+ return refs
240
+
241
+
242
+ def extract_container_attachment_refs(container: ContainerData) -> list[AttachmentRef]:
243
+ """Extract fixture attachment references from an Allure container.
244
+
245
+ Setup/teardown attachments (logs and screenshots captured in fixtures) live
246
+ in the container's ``befores``/``afters`` fixture steps, which nest ``steps``
247
+ and ``attachments`` just like result steps. These attachments belong to the
248
+ container's child results; without this extraction they are never registered
249
+ and every fixture attachment is skipped on upload.
250
+
251
+ A container may own multiple children (suite/class/session-scoped fixtures
252
+ run once for many tests). A fixture attachment applies to every test the
253
+ fixture served, so each attachment is bound to ALL of the container's
254
+ children — one AttachmentRef per (attachment, child) pair. Containers with
255
+ no children cannot bind their attachments and yield an empty list.
256
+
257
+ Args:
258
+ container: The parsed container holding fixture steps and child UUIDs.
259
+
260
+ Returns:
261
+ List of AttachmentRef for fixture attachments, one per child result.
262
+ Empty if there are no children.
263
+ """
264
+ if not container.children:
201
265
  return []
202
266
 
267
+ fixture_steps = list(container.befores) + list(container.afters)
203
268
  refs: list[AttachmentRef] = []
204
- for attachment in attachments:
205
- source = attachment["source"]
206
- refs.append(
207
- AttachmentRef(
208
- source=source,
209
- name=attachment.get("name", source),
210
- mime_type=attachment.get("type", "application/octet-stream"),
211
- result_uuid=result_uuid,
212
- )
213
- )
214
-
269
+ for child_uuid in container.children:
270
+ refs.extend(_collect_step_attachments(fixture_steps, child_uuid))
215
271
  return refs
@@ -12,12 +12,18 @@ import httpx
12
12
 
13
13
  from charisma_cli.config import Config
14
14
  from charisma_cli.models import (
15
+ AttachmentRef,
15
16
  ContainerData,
16
17
  FileCategory,
17
18
  FileEvent,
18
19
  UploadSummary,
19
20
  )
20
- from charisma_cli.parser import extract_attachment_refs, parse_container_file, parse_result_file
21
+ from charisma_cli.parser import (
22
+ extract_attachment_refs,
23
+ extract_container_attachment_refs,
24
+ parse_container_file,
25
+ parse_result_file,
26
+ )
21
27
  from charisma_cli.retry import MAX_RETRIES, RETRYABLE_STATUS_CODES, exponential_backoff
22
28
 
23
29
  logger = logging.getLogger(__name__)
@@ -26,6 +32,11 @@ _BATCH_SIZE = 50
26
32
  _BATCH_TIMEOUT_SECONDS = 2.0
27
33
  _CONTAINER_ASSOCIATION_TIMEOUT = 60.0
28
34
 
35
+ # Sentinel expectedTests for CLI launches: the total count is unknown at open
36
+ # time (directory-watch streaming), so we send the backend maximum. This keeps
37
+ # the launch in 'receiving' across all appends; close_launch() finalizes it.
38
+ _UNKNOWN_EXPECTED_TESTS = 1_000_000
39
+
29
40
 
30
41
  def _epoch_ms_to_iso(epoch_ms: int) -> str:
31
42
  """Convert epoch milliseconds to ISO 8601 UTC string."""
@@ -65,6 +76,13 @@ class Uploader:
65
76
  self._known_result_uuids: set[str] = set()
66
77
  # Map source filename → result_uuid for attachment uploads
67
78
  self._attachment_result_map: dict[str, str] = {}
79
+ # Attachments dequeued before their owning result/container was seen.
80
+ # In watch mode the PriorityQueue orders items co-present at dequeue time
81
+ # but gives no global temporal guarantee: an attachment can be processed
82
+ # before the result that registers its source→uuid mapping. These are
83
+ # held here and retried as mappings arrive; unresolved ones are counted
84
+ # as skipped at flush time.
85
+ self._pending_attachments: list[Path] = []
68
86
 
69
87
  @property
70
88
  def summary(self) -> UploadSummary:
@@ -173,9 +191,14 @@ class Uploader:
173
191
  """
174
192
  self.ensure_client()
175
193
 
194
+ # The CLI streams from a watched directory and does not know the total
195
+ # test count at open time. Send a high sentinel so the launch stays
196
+ # 'receiving' through every append; finalization is driven by
197
+ # close_launch() at the end of the run (or the stale-launch finalizer as
198
+ # a backstop), never by the append counter reaching expectedTests.
176
199
  payload: dict[str, Any] = {
177
200
  "projectAlias": self._config.project,
178
- "expectedTests": 1000,
201
+ "expectedTests": _UNKNOWN_EXPECTED_TESTS,
179
202
  }
180
203
  if self._config.build_id:
181
204
  payload["buildId"] = self._config.build_id
@@ -306,16 +329,16 @@ class Uploader:
306
329
  if result.uuid:
307
330
  self._known_result_uuids.add(result.uuid)
308
331
 
309
- # Build API payload
332
+ # Build API payload (snake_case wire format — shared TestResultInput contract)
310
333
  payload: dict[str, Any] = {
311
- "testId": result.testId,
334
+ "test_id": result.testId,
312
335
  "outcome": result.outcome,
313
336
  "duration_ms": result.duration_ms,
314
337
  }
315
338
  if result.name:
316
339
  payload["name"] = result.name
317
340
  if result.full_name:
318
- payload["fullName"] = result.full_name
341
+ payload["full_name"] = result.full_name
319
342
  if result.error_message:
320
343
  payload["error_message"] = result.error_message
321
344
  if result.stack_trace:
@@ -344,10 +367,11 @@ class Uploader:
344
367
  if result.ended_at is not None:
345
368
  payload["ended_at"] = _epoch_ms_to_iso(result.ended_at)
346
369
 
347
- # Serialize Allure steps and links into labels dict (per design field mapping)
370
+ # Serialize Allure steps into the first-class `steps` array (StepInput
371
+ # wire shape). Previously packed as a JSON string in labels["steps"] —
372
+ # dropped in favor of the shared TestResultInput contract.
348
373
  steps = raw_data.get("steps")
349
374
  if steps:
350
- # Normalize Allure step format to match the schema _parse_steps expects
351
375
  normalized_steps = []
352
376
  for step in steps:
353
377
  status_details = step.get("statusDetails") or {}
@@ -359,17 +383,16 @@ class Uploader:
359
383
  "duration_ms": (stop - start) if (start and stop) else None,
360
384
  "error_message": status_details.get("message"),
361
385
  })
362
- labels_d = payload.get("labels", {})
363
- labels_d["steps"] = json.dumps(normalized_steps)
364
- payload["labels"] = labels_d
386
+ payload["steps"] = normalized_steps
365
387
 
388
+ # Links and description are label-like metadata not modeled by StepInput;
389
+ # keep them in labels (values are strings, matching labels: dict[str, str]).
366
390
  links = raw_data.get("links")
367
391
  if links:
368
392
  labels_d = payload.get("labels", {})
369
393
  labels_d["links"] = json.dumps(links)
370
394
  payload["labels"] = labels_d
371
395
 
372
- # Add description to labels if present
373
396
  description = raw_data.get("description") or raw_data.get("descriptionHtml")
374
397
  if description:
375
398
  labels_d = payload.get("labels", {})
@@ -386,37 +409,101 @@ class Uploader:
386
409
 
387
410
  # Extract attachment refs and register them for upload with correct result_uuid
388
411
  try:
389
- refs = extract_attachment_refs(raw_data, result.uuid or "")
390
- for ref in refs:
391
- # Register source → result_uuid mapping so _handle_attachment knows the owner
392
- self._attachment_result_map[ref.source] = ref.result_uuid
412
+ self._register_attachment_refs(extract_attachment_refs(raw_data, result.uuid or ""))
393
413
  except Exception:
394
414
  logger.debug("Failed to extract attachment refs from %s", path, exc_info=True)
395
415
 
396
416
  def _handle_container(self, path: Path) -> None:
397
- """Parse a container file and buffer for deferred association."""
417
+ """Parse a container file and buffer for deferred association.
418
+
419
+ Also registers any fixture attachments (setup/teardown logs and
420
+ screenshots nested in befores/afters) so they upload with the correct
421
+ owning result. Without this, fixture attachments have no source→result
422
+ mapping and are skipped.
423
+ """
398
424
  container = parse_container_file(path)
399
425
  if container is None:
400
426
  return
401
427
 
428
+ try:
429
+ self._register_attachment_refs(extract_container_attachment_refs(container))
430
+ except Exception:
431
+ logger.debug("Failed to extract fixture attachment refs from %s", path, exc_info=True)
432
+
402
433
  container.received_at = time.monotonic()
403
434
  self._pending_containers.append(container)
404
435
  self._try_associate_containers()
405
436
 
437
+ def _register_attachment_refs(self, refs: list[AttachmentRef]) -> None:
438
+ """Register source→result_uuid mappings so _handle_attachment finds owners.
439
+
440
+ Thread affinity: ``_attachment_result_map`` is written here (from both
441
+ _handle_result and _handle_container) and read by _handle_attachment.
442
+ All three run on the single consumer thread (_run), so no lock is needed;
443
+ this single-consumer invariant is what keeps the map consistent.
444
+ """
445
+ if not refs:
446
+ return
447
+ for ref in refs:
448
+ self._attachment_result_map[ref.source] = ref.result_uuid
449
+ # A newly-registered mapping may unblock attachments that were dequeued
450
+ # before their owning result/container arrived (watch-mode ordering).
451
+ self._retry_pending_attachments()
452
+
406
453
  def _handle_attachment(self, path: Path) -> None:
407
- """Upload an attachment file to the Charisma attachment API."""
454
+ """Upload an attachment, or defer it if its owning result isn't known yet.
455
+
456
+ The owning result/container may not have been processed at the moment
457
+ this attachment is dequeued (watch mode streams files in arrival order,
458
+ not owner-first). When the mapping is missing, the attachment is buffered
459
+ in ``_pending_attachments`` and retried once a mapping arrives, rather
460
+ than being immediately counted as skipped.
461
+ """
408
462
  if not self._launch_id or not path.exists():
409
463
  self._summary.attachments_skipped += 1
410
464
  return
411
465
 
412
- # Look up the result_uuid for this attachment
413
466
  result_uuid = self._attachment_result_map.get(path.name, "")
414
467
  if not result_uuid:
415
- # Attachment not referenced by any result skip
468
+ # Owner not known yet defer and retry when a mapping is registered.
469
+ self._pending_attachments.append(path)
470
+ return
471
+
472
+ self._upload_attachment(path, result_uuid)
473
+
474
+ def _retry_pending_attachments(self) -> None:
475
+ """Upload any buffered attachments whose owner mapping is now known.
476
+
477
+ Attachments still without a mapping remain pending; they are only
478
+ counted as skipped by _flush_pending_attachments() at flush time, once
479
+ no further mappings can arrive.
480
+ """
481
+ if not self._pending_attachments:
482
+ return
483
+ still_pending: list[Path] = []
484
+ for path in self._pending_attachments:
485
+ result_uuid = self._attachment_result_map.get(path.name, "")
486
+ if result_uuid:
487
+ self._upload_attachment(path, result_uuid)
488
+ else:
489
+ still_pending.append(path)
490
+ self._pending_attachments = still_pending
491
+
492
+ def _flush_pending_attachments(self) -> None:
493
+ """Resolve remaining deferred attachments; count the truly orphaned as skipped.
494
+
495
+ Called at end-of-run when no further result/container mappings can
496
+ arrive. Any attachment still without an owner is genuinely unreferenced
497
+ and is counted as skipped.
498
+ """
499
+ self._retry_pending_attachments()
500
+ for path in self._pending_attachments:
416
501
  self._summary.attachments_skipped += 1
417
502
  logger.debug("Skipping attachment with no result reference: %s", path.name)
418
- return
503
+ self._pending_attachments = []
419
504
 
505
+ def _upload_attachment(self, path: Path, result_uuid: str) -> None:
506
+ """POST a single attachment to the Charisma attachment API."""
420
507
  try:
421
508
  content = path.read_bytes()
422
509
  files = {"file": (path.name, content)}
@@ -539,5 +626,10 @@ class Uploader:
539
626
  if self._batch:
540
627
  self._flush_batch()
541
628
 
629
+ # All queued events are processed and no further mappings can arrive:
630
+ # resolve any deferred attachments now, counting the truly orphaned as
631
+ # skipped. Runs before container finalization for symmetry with results.
632
+ self._flush_pending_attachments()
633
+
542
634
  self._summary.containers_sent += len(self._pending_containers)
543
635
  self._pending_containers.clear()
@@ -1,5 +1,6 @@
1
1
  """Watchdog observer, stability debounce, and file classification for allure-results."""
2
2
 
3
+ import logging
3
4
  import os
4
5
  import time
5
6
  from pathlib import Path
@@ -12,6 +13,8 @@ from watchdog.observers import Observer
12
13
  from charisma_cli.config import Config
13
14
  from charisma_cli.models import FileCategory, FileEvent
14
15
 
16
+ logger = logging.getLogger(__name__)
17
+
15
18
  _TWO_MB = 2 * 1024 * 1024
16
19
  _STABILITY_SECONDS = 0.5
17
20
 
@@ -306,8 +309,10 @@ class ResultsWatcher:
306
309
  Total number of new files enqueued across all iterations.
307
310
  """
308
311
  total = 0
309
- deadline = time.monotonic() + max_wait
310
- last_new_at = time.monotonic()
312
+ started_at = time.monotonic()
313
+ deadline = started_at + max_wait
314
+ last_new_at = started_at
315
+ settled = False
311
316
 
312
317
  while time.monotonic() < deadline:
313
318
  new_count = self.final_scan()
@@ -317,7 +322,27 @@ class ResultsWatcher:
317
322
  last_new_at = now
318
323
  elif now - last_new_at >= quiet_period:
319
324
  # No new files for a full quiet period — directory has settled.
325
+ settled = True
320
326
  break
321
327
  time.sleep(poll_interval)
322
328
 
329
+ elapsed = time.monotonic() - started_at
330
+ if settled:
331
+ logger.info(
332
+ "Results directory settled after %.1fs (%d file(s) captured during settle)",
333
+ elapsed,
334
+ total,
335
+ )
336
+ else:
337
+ # Exited on max_wait, not quiescence: the directory was still
338
+ # producing files when we gave up. Surface this so an operator can
339
+ # see teardown burned the full window rather than settling — a
340
+ # signal that max_wait may be too short for this workload.
341
+ logger.warning(
342
+ "settle_scan hit max_wait (%.0fs) before the results directory "
343
+ "settled; captured %d file(s). Some late results may be unsent.",
344
+ max_wait,
345
+ total,
346
+ )
347
+
323
348
  return total
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import sys
5
+ import threading
5
6
  from pathlib import Path
6
7
 
7
8
  import httpx
@@ -228,10 +229,10 @@ class TestReportingFixesEndToEnd:
228
229
  # The skipped result was sent (not dropped)
229
230
  assert results_route.called
230
231
  sent_results = json.loads(results_route.calls[0].request.content)["results"]
231
- outcomes = {r["testId"]: r["outcome"] for r in sent_results}
232
+ outcomes = {r["test_id"]: r["outcome"] for r in sent_results}
232
233
  assert outcomes.get("hist_skip") == "skipped"
233
234
  # duration defaulted to 0 for the missing stop
234
- skip_payload = next(r for r in sent_results if r["testId"] == "hist_skip")
235
+ skip_payload = next(r for r in sent_results if r["test_id"] == "hist_skip")
235
236
  assert skip_payload["duration_ms"] == 0
236
237
 
237
238
  # environment.properties sent as variables on close
@@ -242,3 +243,132 @@ class TestReportingFixesEndToEnd:
242
243
  "aws_region": "us-west-2",
243
244
  "workers_number": "8",
244
245
  }
246
+
247
+
248
+ class TestSettleScanCapturesLateTeardownBurst:
249
+ """Regression for the `watch results_sent=0` bug — isolates the true fix.
250
+
251
+ The one behavioral property that separates the old teardown from the new is
252
+ settle_scan's max_wait window. allure-pytest under heavy xdist can flush its
253
+ result files to disk SEVERAL SECONDS after the subprocess exits. The old
254
+ teardown used settle_scan()'s default max_wait=15.0; the new watch teardown
255
+ widens it to 60.0. A file that first lands on disk AFTER the window closes is
256
+ never enqueued and never sent — that is the production failure.
257
+
258
+ This test drives ResultsWatcher directly (not the full CLI) with scaled-down
259
+ timings so it is fast and deterministic: a result file is written by a
260
+ background timer at t=0.3s after settle_scan starts. With a SHORT window
261
+ (mimicking the old code) settle_scan gives up before the file lands and
262
+ enqueues nothing. With a LONGER window (mimicking the new code) it is still
263
+ polling when the file lands and enqueues it. No reliance on OS event buffers
264
+ or thread scheduling — only on-disk appearance time vs the window.
265
+ """
266
+
267
+ def _write_result_after(self, results_dir: Path, delay: float) -> threading.Timer:
268
+ """Schedule a valid Allure result file to appear after `delay` seconds."""
269
+
270
+ def _write() -> None:
271
+ (results_dir / "late-result.json").write_text(json.dumps({
272
+ "uuid": "late-1",
273
+ "historyId": "hist_late",
274
+ "fullName": "tests.test_late.test_case",
275
+ "name": "test_case",
276
+ "status": "passed",
277
+ "start": 1000,
278
+ "stop": 2000,
279
+ "labels": [],
280
+ "parameters": [],
281
+ "attachments": [],
282
+ }))
283
+
284
+ timer = threading.Timer(delay, _write)
285
+ timer.daemon = True
286
+ timer.start()
287
+ return timer
288
+
289
+ def test_short_window_misses_late_file_long_window_captures_it(
290
+ self, tmp_path: Path, default_config
291
+ ) -> None:
292
+ """settle_scan with a window shorter than the file's appearance misses it;
293
+ a window longer than the appearance captures it. This is exactly the
294
+ old (max_wait=15) vs new (max_wait=60) separation, scaled to sub-second."""
295
+ from queue import PriorityQueue
296
+
297
+ from charisma_cli.models import FileEvent
298
+ from charisma_cli.watcher import ResultsWatcher
299
+
300
+ # --- OLD behavior: window closes BEFORE the file lands (t=0.3s) ---
301
+ old_dir = tmp_path / "old"
302
+ old_dir.mkdir()
303
+ old_queue: PriorityQueue[FileEvent] = PriorityQueue()
304
+ old_watcher = ResultsWatcher(str(old_dir), old_queue, default_config)
305
+ t1 = self._write_result_after(old_dir, delay=0.3)
306
+ # Do not start the observer — isolate the scan behavior from OS events.
307
+ old_captured = old_watcher.settle_scan(quiet_period=0.1, max_wait=0.2)
308
+ t1.join()
309
+ assert old_captured == 0, "short window should give up before the late file lands"
310
+ assert old_queue.empty()
311
+
312
+ # --- NEW behavior: window stays open PAST the file landing (t=0.3s) ---
313
+ new_dir = tmp_path / "new"
314
+ new_dir.mkdir()
315
+ new_queue: PriorityQueue[FileEvent] = PriorityQueue()
316
+ new_watcher = ResultsWatcher(str(new_dir), new_queue, default_config)
317
+ t2 = self._write_result_after(new_dir, delay=0.3)
318
+ new_captured = new_watcher.settle_scan(quiet_period=0.3, max_wait=2.0)
319
+ t2.join()
320
+ assert new_captured == 1, "long window should still be polling when the late file lands"
321
+ assert new_queue.qsize() == 1
322
+
323
+
324
+ class TestWatchStreamsPreexistingResult:
325
+ """Sanity E2E: watch streams a result already on disk through the launch API.
326
+
327
+ This does NOT isolate the old/new fix (any on-disk file is caught by both).
328
+ It guards the happy path: watch opens a launch, sends the result, closes,
329
+ and reports results_sent in the summary.
330
+ """
331
+
332
+ @respx.mock
333
+ def test_watch_sends_result_and_reports_summary(self, tmp_path: Path, monkeypatch) -> None:
334
+ results_dir = tmp_path / "allure-results"
335
+ results_dir.mkdir()
336
+ (results_dir / "pre-result.json").write_text(json.dumps({
337
+ "uuid": "pre-1",
338
+ "historyId": "hist_pre",
339
+ "fullName": "tests.test_pre.test_case",
340
+ "name": "test_case",
341
+ "status": "passed",
342
+ "start": 1000,
343
+ "stop": 2000,
344
+ "labels": [],
345
+ "parameters": [],
346
+ "attachments": [],
347
+ }))
348
+
349
+ respx.post("https://charisma.test/api/v1/launches").mock(
350
+ return_value=httpx.Response(201, json={"launchId": "launch-pre", "projectId": "proj-uuid"})
351
+ )
352
+ results_route = respx.post(
353
+ "https://charisma.test/api/v1/launches/launch-pre/results"
354
+ ).mock(return_value=httpx.Response(200, json={"accepted": 1}))
355
+ respx.post("https://charisma.test/api/v1/launches/launch-pre/close").mock(
356
+ return_value=httpx.Response(200, json={})
357
+ )
358
+
359
+ monkeypatch.setenv("CHARISMA_ENDPOINT", "https://charisma.test")
360
+ monkeypatch.setenv("CHARISMA_TOKEN", "pre-token")
361
+
362
+ runner = CliRunner()
363
+ result = runner.invoke(cli, [
364
+ "watch",
365
+ "--project", "pre-project",
366
+ "--results", str(results_dir),
367
+ "--", sys.executable, "-c", "import sys; sys.exit(0)",
368
+ ])
369
+
370
+ assert result.exit_code == 0
371
+ assert results_route.called
372
+ sent_results = json.loads(results_route.calls[0].request.content)["results"]
373
+ assert any(r["test_id"] == "hist_pre" for r in sent_results)
374
+ assert "results_sent=1" in result.output
@@ -13,6 +13,7 @@ from pathlib import Path
13
13
  from charisma_cli.models import AttachmentRef, CharismaResult, ContainerData
14
14
  from charisma_cli.parser import (
15
15
  extract_attachment_refs,
16
+ extract_container_attachment_refs,
16
17
  map_allure_status,
17
18
  parse_container_file,
18
19
  parse_result_file,
@@ -542,6 +543,218 @@ class TestExtractAttachmentRefs:
542
543
  assert len(refs) == 1
543
544
  assert refs[0].name == "abc-screenshot.png"
544
545
 
546
+ def test_attachment_nested_in_step(self) -> None:
547
+ """Attachment inside a step is extracted (the common Allure layout)."""
548
+ data = {
549
+ "uuid": "r7",
550
+ "attachments": [],
551
+ "steps": [
552
+ {
553
+ "name": "click login",
554
+ "status": "passed",
555
+ "attachments": [
556
+ {"source": "step-shot.png", "name": "shot", "type": "image/png"}
557
+ ],
558
+ }
559
+ ],
560
+ }
561
+ refs = extract_attachment_refs(data, result_uuid="r7")
562
+
563
+ assert len(refs) == 1
564
+ assert refs[0].source == "step-shot.png"
565
+ assert refs[0].result_uuid == "r7"
566
+
567
+ def test_attachment_nested_in_substep(self) -> None:
568
+ """Attachment in a sub-step (steps within steps) is extracted recursively."""
569
+ data = {
570
+ "uuid": "r8",
571
+ "steps": [
572
+ {
573
+ "name": "outer",
574
+ "steps": [
575
+ {
576
+ "name": "inner",
577
+ "attachments": [
578
+ {"source": "deep.log", "name": "log", "type": "text/plain"}
579
+ ],
580
+ }
581
+ ],
582
+ }
583
+ ],
584
+ }
585
+ refs = extract_attachment_refs(data, result_uuid="r8")
586
+
587
+ assert len(refs) == 1
588
+ assert refs[0].source == "deep.log"
589
+ assert refs[0].result_uuid == "r8"
590
+
591
+ def test_top_level_and_step_attachments_combined(self) -> None:
592
+ """Top-level and step-nested attachments are both collected."""
593
+ data = {
594
+ "uuid": "r9",
595
+ "attachments": [
596
+ {"source": "top.png", "name": "top", "type": "image/png"}
597
+ ],
598
+ "steps": [
599
+ {
600
+ "name": "s1",
601
+ "attachments": [
602
+ {"source": "s1.png", "name": "s1", "type": "image/png"}
603
+ ],
604
+ "steps": [
605
+ {
606
+ "name": "s1a",
607
+ "attachments": [
608
+ {"source": "s1a.png", "name": "s1a", "type": "image/png"}
609
+ ],
610
+ }
611
+ ],
612
+ },
613
+ {
614
+ "name": "s2",
615
+ "attachments": [
616
+ {"source": "s2.log", "name": "s2", "type": "text/plain"}
617
+ ],
618
+ },
619
+ ],
620
+ }
621
+ refs = extract_attachment_refs(data, result_uuid="r9")
622
+
623
+ sources = {ref.source for ref in refs}
624
+ assert sources == {"top.png", "s1.png", "s1a.png", "s2.log"}
625
+ assert all(ref.result_uuid == "r9" for ref in refs)
626
+
627
+ def test_steps_none_is_safe(self) -> None:
628
+ """A result with steps=None does not raise and returns top-level only."""
629
+ data = {
630
+ "uuid": "r10",
631
+ "attachments": [
632
+ {"source": "only-top.png", "name": "top", "type": "image/png"}
633
+ ],
634
+ "steps": None,
635
+ }
636
+ refs = extract_attachment_refs(data, result_uuid="r10")
637
+
638
+ assert len(refs) == 1
639
+ assert refs[0].source == "only-top.png"
640
+
641
+
642
+ class TestExtractContainerAttachmentRefs:
643
+ """extract_container_attachment_refs pulls fixture attachments from a container.
644
+
645
+ Allure stores setup/teardown attachments (logs, screenshots taken in
646
+ fixtures) inside the container's ``befores``/``afters`` fixture steps, which
647
+ themselves nest ``steps`` and ``attachments``. These belong to the
648
+ container's child results and were never uploaded before.
649
+ """
650
+
651
+ def test_no_fixture_attachments_returns_empty(self) -> None:
652
+ container = ContainerData(
653
+ uuid="c1",
654
+ name="fix",
655
+ children=["res-1"],
656
+ befores=[{"name": "setup", "status": "passed"}],
657
+ afters=[],
658
+ )
659
+ refs = extract_container_attachment_refs(container)
660
+ assert refs == []
661
+
662
+ def test_before_attachment_bound_to_child(self) -> None:
663
+ """An attachment in a before-fixture is bound to the container's child result."""
664
+ container = ContainerData(
665
+ uuid="c2",
666
+ name="fix",
667
+ children=["res-1"],
668
+ befores=[
669
+ {
670
+ "name": "setup_db",
671
+ "attachments": [
672
+ {"source": "setup.log", "name": "setup log", "type": "text/plain"}
673
+ ],
674
+ }
675
+ ],
676
+ afters=[],
677
+ )
678
+ refs = extract_container_attachment_refs(container)
679
+
680
+ assert len(refs) == 1
681
+ assert refs[0].source == "setup.log"
682
+ assert refs[0].result_uuid == "res-1"
683
+
684
+ def test_after_attachment_nested_in_substep(self) -> None:
685
+ """An attachment nested in an after-fixture sub-step is extracted recursively."""
686
+ container = ContainerData(
687
+ uuid="c3",
688
+ name="fix",
689
+ children=["res-9"],
690
+ befores=[],
691
+ afters=[
692
+ {
693
+ "name": "teardown",
694
+ "steps": [
695
+ {
696
+ "name": "capture screenshot",
697
+ "attachments": [
698
+ {"source": "final.png", "name": "final", "type": "image/png"}
699
+ ],
700
+ }
701
+ ],
702
+ }
703
+ ],
704
+ )
705
+ refs = extract_container_attachment_refs(container)
706
+
707
+ assert len(refs) == 1
708
+ assert refs[0].source == "final.png"
709
+ assert refs[0].result_uuid == "res-9"
710
+
711
+ def test_no_children_yields_no_refs(self) -> None:
712
+ """A container with fixture attachments but no children cannot bind them."""
713
+ container = ContainerData(
714
+ uuid="c4",
715
+ name="orphan",
716
+ children=[],
717
+ befores=[
718
+ {
719
+ "name": "setup",
720
+ "attachments": [
721
+ {"source": "orphan.log", "name": "log", "type": "text/plain"}
722
+ ],
723
+ }
724
+ ],
725
+ afters=[],
726
+ )
727
+ refs = extract_container_attachment_refs(container)
728
+ assert refs == []
729
+
730
+ def test_multi_child_fixture_attachment_bound_to_every_child(self) -> None:
731
+ """Suite/session-scoped fixture: attachment binds to ALL children, not just first.
732
+
733
+ Allure emits a single container with many children for class/suite/session
734
+ fixtures. A fixture attachment applies to every test the fixture served, so
735
+ binding it only to children[0] would leave the other tests without it.
736
+ """
737
+ container = ContainerData(
738
+ uuid="c5",
739
+ name="session fixture",
740
+ children=["res-a", "res-b", "res-c"],
741
+ befores=[
742
+ {
743
+ "name": "session_setup",
744
+ "attachments": [
745
+ {"source": "session.log", "name": "session log", "type": "text/plain"}
746
+ ],
747
+ }
748
+ ],
749
+ afters=[],
750
+ )
751
+ refs = extract_container_attachment_refs(container)
752
+
753
+ assert len(refs) == 3
754
+ owners = {ref.result_uuid for ref in refs}
755
+ assert owners == {"res-a", "res-b", "res-c"}
756
+ assert all(ref.source == "session.log" for ref in refs)
757
+
545
758
 
546
759
  class TestSkippedResultParsing:
547
760
  """Regression: skipped tests (no 'stop', sometimes no 'start') must parse.
@@ -267,6 +267,195 @@ class TestUploaderExceptionIsolation:
267
267
  assert True
268
268
 
269
269
 
270
+ class TestUploaderAttachments:
271
+ """Attachments nested in steps and fixtures are registered and uploaded.
272
+
273
+ Regression for the `charismactl upload attachments_sent=0` bug: attachments
274
+ live inside result steps and container fixture steps, not at the result top
275
+ level. The uploader must register every source→result mapping so
276
+ _handle_attachment can upload them instead of skipping.
277
+ """
278
+
279
+ def _write_json(self, path: Path, data: dict) -> None:
280
+ import json
281
+ path.write_text(json.dumps(data), encoding="utf-8")
282
+
283
+ def _drive(self, uploader: Uploader, queue: PriorityQueue) -> None:
284
+ uploader.start()
285
+ uploader.drain(timeout=5.0)
286
+ uploader.stop()
287
+
288
+ @respx.mock
289
+ def test_step_nested_attachment_is_uploaded(self, tmp_path: Path) -> None:
290
+ """An attachment inside a result step is uploaded, not skipped."""
291
+ config = _make_config()
292
+ queue: PriorityQueue = PriorityQueue()
293
+
294
+ respx.post("https://charisma.test/api/v1/launches/launch-1/results").mock(
295
+ return_value=httpx.Response(200, json={"accepted": 1})
296
+ )
297
+ attach_route = respx.post(
298
+ "https://charisma.test/api/v1/launches/launch-1/attachments"
299
+ ).mock(return_value=httpx.Response(201, json={"id": "att-1"}))
300
+
301
+ result_file = tmp_path / "r1-result.json"
302
+ self._write_json(result_file, {
303
+ "uuid": "r1",
304
+ "historyId": "hist-r1",
305
+ "fullName": "tests.test_it",
306
+ "status": "passed",
307
+ "start": 1000,
308
+ "stop": 2000,
309
+ "attachments": [],
310
+ "steps": [
311
+ {"name": "click", "attachments": [
312
+ {"source": "shot.png", "name": "shot", "type": "image/png"}
313
+ ]}
314
+ ],
315
+ })
316
+ attach_file = tmp_path / "shot.png"
317
+ attach_file.write_bytes(b"\x89PNG fake")
318
+
319
+ uploader = Uploader(config, queue)
320
+ uploader._launch_id = "launch-1"
321
+ queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
322
+ queue.put(FileEvent(path=attach_file, category=FileCategory.ATTACHMENT))
323
+
324
+ self._drive(uploader, queue)
325
+
326
+ assert uploader.summary.attachments_sent == 1
327
+ assert uploader.summary.attachments_skipped == 0
328
+ assert attach_route.called
329
+
330
+ @respx.mock
331
+ def test_attachment_before_result_is_deferred_then_uploaded(self, tmp_path: Path) -> None:
332
+ """An attachment processed BEFORE its owning result is not lost.
333
+
334
+ Watch-mode ordering race: the PriorityQueue only orders items co-present
335
+ at dequeue time, so an attachment can be handled before the result that
336
+ registers its source→uuid mapping. The attachment must be deferred and
337
+ uploaded once the result arrives — not counted as skipped on first sight.
338
+ """
339
+ config = _make_config()
340
+ queue: PriorityQueue = PriorityQueue()
341
+
342
+ respx.post("https://charisma.test/api/v1/launches/launch-1/results").mock(
343
+ return_value=httpx.Response(200, json={"accepted": 1})
344
+ )
345
+ attach_route = respx.post(
346
+ "https://charisma.test/api/v1/launches/launch-1/attachments"
347
+ ).mock(return_value=httpx.Response(201, json={"id": "att-3"}))
348
+
349
+ result_file = tmp_path / "late-result.json"
350
+ self._write_json(result_file, {
351
+ "uuid": "lr1",
352
+ "historyId": "hist-lr1",
353
+ "fullName": "tests.test_race",
354
+ "status": "passed",
355
+ "start": 1000,
356
+ "stop": 2000,
357
+ "steps": [
358
+ {"name": "act", "attachments": [
359
+ {"source": "race.png", "name": "race", "type": "image/png"}
360
+ ]}
361
+ ],
362
+ })
363
+ attach_file = tmp_path / "race.png"
364
+ attach_file.write_bytes(b"\x89PNG fake")
365
+
366
+ uploader = Uploader(config, queue)
367
+ uploader._launch_id = "launch-1"
368
+
369
+ # Drive handlers in the adversarial order: attachment FIRST (no mapping
370
+ # yet → deferred), then the result (registers mapping → retry uploads it).
371
+ uploader.ensure_client()
372
+ uploader._handle_attachment(attach_file)
373
+ assert uploader.summary.attachments_sent == 0
374
+ assert uploader.summary.attachments_skipped == 0 # deferred, not skipped
375
+
376
+ uploader._handle_result(result_file)
377
+
378
+ assert uploader.summary.attachments_sent == 1
379
+ assert uploader.summary.attachments_skipped == 0
380
+ assert attach_route.called
381
+
382
+ uploader._client.close()
383
+
384
+ @respx.mock
385
+ def test_orphan_attachment_counted_skipped_at_flush(self, tmp_path: Path) -> None:
386
+ """An attachment with no owning result is counted skipped once at flush."""
387
+ config = _make_config()
388
+ queue: PriorityQueue = PriorityQueue()
389
+
390
+ uploader = Uploader(config, queue)
391
+ uploader._launch_id = "launch-1"
392
+ uploader.ensure_client()
393
+
394
+ orphan = tmp_path / "orphan.png"
395
+ orphan.write_bytes(b"\x89PNG")
396
+
397
+ uploader._handle_attachment(orphan)
398
+ assert uploader.summary.attachments_skipped == 0 # deferred first
399
+
400
+ # No result/container ever registers it → flush counts it skipped once.
401
+ uploader._flush_pending_attachments()
402
+ assert uploader.summary.attachments_skipped == 1
403
+ # Idempotent: a second flush must not double-count.
404
+ uploader._flush_pending_attachments()
405
+ assert uploader.summary.attachments_skipped == 1
406
+
407
+ uploader._client.close()
408
+
409
+ @respx.mock
410
+ def test_fixture_attachment_is_uploaded(self, tmp_path: Path) -> None:
411
+ """An attachment inside a container's before-fixture is uploaded."""
412
+ config = _make_config()
413
+ queue: PriorityQueue = PriorityQueue()
414
+
415
+ respx.post("https://charisma.test/api/v1/launches/launch-1/results").mock(
416
+ return_value=httpx.Response(200, json={"accepted": 1})
417
+ )
418
+ attach_route = respx.post(
419
+ "https://charisma.test/api/v1/launches/launch-1/attachments"
420
+ ).mock(return_value=httpx.Response(201, json={"id": "att-2"}))
421
+
422
+ result_file = tmp_path / "res1-result.json"
423
+ self._write_json(result_file, {
424
+ "uuid": "res1",
425
+ "historyId": "hist-res1",
426
+ "fullName": "tests.test_fix",
427
+ "status": "passed",
428
+ "start": 1000,
429
+ "stop": 2000,
430
+ })
431
+ container_file = tmp_path / "c1-container.json"
432
+ self._write_json(container_file, {
433
+ "uuid": "c1",
434
+ "name": "fixture",
435
+ "children": ["res1"],
436
+ "befores": [
437
+ {"name": "setup", "attachments": [
438
+ {"source": "setup.log", "name": "setup log", "type": "text/plain"}
439
+ ]}
440
+ ],
441
+ "afters": [],
442
+ })
443
+ attach_file = tmp_path / "setup.log"
444
+ attach_file.write_text("fixture output")
445
+
446
+ uploader = Uploader(config, queue)
447
+ uploader._launch_id = "launch-1"
448
+ queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
449
+ queue.put(FileEvent(path=container_file, category=FileCategory.CONTAINER))
450
+ queue.put(FileEvent(path=attach_file, category=FileCategory.ATTACHMENT))
451
+
452
+ self._drive(uploader, queue)
453
+
454
+ assert uploader.summary.attachments_sent == 1
455
+ assert uploader.summary.attachments_skipped == 0
456
+ assert attach_route.called
457
+
458
+
270
459
  class TestUploaderSummary:
271
460
  """Uploader.summary returns accurate UploadSummary."""
272
461
 
@@ -733,8 +733,14 @@ class TestSettleScan:
733
733
  seen.add(key)
734
734
  assert len(seen) == 10
735
735
 
736
- def test_settles_quickly_when_directory_empty(self, tmp_path: Path) -> None:
737
- """An empty directory settles after one quiet period and enqueues nothing."""
736
+ def test_settles_quickly_when_directory_empty(self, tmp_path: Path, caplog) -> None:
737
+ """An empty directory settles after one quiet period and enqueues nothing.
738
+
739
+ Also asserts the settle path logs at INFO (not WARNING) — settling
740
+ cleanly is the normal case and must not raise a max_wait warning.
741
+ """
742
+ import logging
743
+
738
744
  results_dir = tmp_path / "allure-results"
739
745
  results_dir.mkdir()
740
746
 
@@ -742,19 +748,26 @@ class TestSettleScan:
742
748
  watcher = ResultsWatcher(str(results_dir), queue, _make_config())
743
749
 
744
750
  start = time.monotonic()
745
- enqueued = watcher.settle_scan(quiet_period=0.3, max_wait=5.0, poll_interval=0.1)
751
+ with caplog.at_level(logging.INFO, logger="charisma_cli.watcher"):
752
+ enqueued = watcher.settle_scan(quiet_period=0.3, max_wait=5.0, poll_interval=0.1)
746
753
  elapsed = time.monotonic() - start
747
754
 
748
755
  assert enqueued == 0
749
756
  assert queue.empty()
750
757
  assert elapsed < 4.0 # returned on the quiet-period, not the max_wait cap
758
+ # Clean settle → INFO log, never a max_wait WARNING.
759
+ assert any(r.levelno == logging.INFO for r in caplog.records)
760
+ assert not any(r.levelno == logging.WARNING for r in caplog.records)
751
761
 
752
- def test_respects_max_wait_when_directory_never_settles(self, tmp_path: Path) -> None:
762
+ def test_respects_max_wait_when_directory_never_settles(self, tmp_path: Path, caplog) -> None:
753
763
  """max_wait caps total time even if files keep arriving continuously.
754
764
 
755
765
  This documents the one bounded caveat: a directory that never goes quiet
756
- stops at max_wait rather than blocking teardown forever.
766
+ stops at max_wait rather than blocking teardown forever. Also asserts a
767
+ WARNING is emitted so the up-to-max_wait teardown is observable rather
768
+ than silent.
757
769
  """
770
+ import logging
758
771
  import threading
759
772
 
760
773
  results_dir = tmp_path / "allure-results"
@@ -776,7 +789,8 @@ class TestSettleScan:
776
789
  writer.start()
777
790
  try:
778
791
  start = time.monotonic()
779
- watcher.settle_scan(quiet_period=0.5, max_wait=1.5, poll_interval=0.1)
792
+ with caplog.at_level(logging.WARNING, logger="charisma_cli.watcher"):
793
+ watcher.settle_scan(quiet_period=0.5, max_wait=1.5, poll_interval=0.1)
780
794
  elapsed = time.monotonic() - start
781
795
  finally:
782
796
  stop.set()
@@ -784,6 +798,11 @@ class TestSettleScan:
784
798
 
785
799
  # Should stop at ~max_wait, not run indefinitely.
786
800
  assert 1.5 <= elapsed < 4.0
801
+ # Hitting max_wait must surface a WARNING (observability, not silent).
802
+ assert any(
803
+ r.levelno == logging.WARNING and "max_wait" in r.getMessage()
804
+ for r in caplog.records
805
+ )
787
806
 
788
807
  def test_scales_to_large_burst(self, tmp_path: Path) -> None:
789
808
  """A large number of files (simulating many workers/tests) is fully captured.
File without changes