charisma-cli 0.2.0__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.2.0 → charisma_cli-0.2.1}/PKG-INFO +1 -1
  2. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/pyproject.toml +1 -1
  3. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/__init__.py +1 -1
  4. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/models.py +23 -0
  5. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/parser.py +71 -15
  6. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/uploader.py +92 -10
  7. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_parser.py +213 -0
  8. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_uploader.py +189 -0
  9. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/.gitignore +0 -0
  10. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/README.md +0 -0
  11. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/config.py +0 -0
  12. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/launch_url.py +0 -0
  13. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/main.py +0 -0
  14. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/retry.py +0 -0
  15. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/subprocess_mgr.py +0 -0
  16. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/watcher.py +0 -0
  17. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/__init__.py +0 -0
  18. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/conftest.py +0 -0
  19. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_cli.py +0 -0
  20. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_config.py +0 -0
  21. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_integration.py +0 -0
  22. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_launch_url.py +0 -0
  23. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_models.py +0 -0
  24. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_retry.py +0 -0
  25. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_silent_mode.py +0 -0
  26. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_subprocess_mgr.py +0 -0
  27. {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_watcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: charisma-cli
3
- Version: 0.2.0
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.2.0"
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.6"
3
+ __version__ = "0.2.1"
@@ -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__)
@@ -70,6 +76,13 @@ class Uploader:
70
76
  self._known_result_uuids: set[str] = set()
71
77
  # Map source filename → result_uuid for attachment uploads
72
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] = []
73
86
 
74
87
  @property
75
88
  def summary(self) -> UploadSummary:
@@ -396,37 +409,101 @@ class Uploader:
396
409
 
397
410
  # Extract attachment refs and register them for upload with correct result_uuid
398
411
  try:
399
- refs = extract_attachment_refs(raw_data, result.uuid or "")
400
- for ref in refs:
401
- # Register source → result_uuid mapping so _handle_attachment knows the owner
402
- self._attachment_result_map[ref.source] = ref.result_uuid
412
+ self._register_attachment_refs(extract_attachment_refs(raw_data, result.uuid or ""))
403
413
  except Exception:
404
414
  logger.debug("Failed to extract attachment refs from %s", path, exc_info=True)
405
415
 
406
416
  def _handle_container(self, path: Path) -> None:
407
- """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
+ """
408
424
  container = parse_container_file(path)
409
425
  if container is None:
410
426
  return
411
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
+
412
433
  container.received_at = time.monotonic()
413
434
  self._pending_containers.append(container)
414
435
  self._try_associate_containers()
415
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
+
416
453
  def _handle_attachment(self, path: Path) -> None:
417
- """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
+ """
418
462
  if not self._launch_id or not path.exists():
419
463
  self._summary.attachments_skipped += 1
420
464
  return
421
465
 
422
- # Look up the result_uuid for this attachment
423
466
  result_uuid = self._attachment_result_map.get(path.name, "")
424
467
  if not result_uuid:
425
- # 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:
426
501
  self._summary.attachments_skipped += 1
427
502
  logger.debug("Skipping attachment with no result reference: %s", path.name)
428
- return
503
+ self._pending_attachments = []
429
504
 
505
+ def _upload_attachment(self, path: Path, result_uuid: str) -> None:
506
+ """POST a single attachment to the Charisma attachment API."""
430
507
  try:
431
508
  content = path.read_bytes()
432
509
  files = {"file": (path.name, content)}
@@ -549,5 +626,10 @@ class Uploader:
549
626
  if self._batch:
550
627
  self._flush_batch()
551
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
+
552
634
  self._summary.containers_sent += len(self._pending_containers)
553
635
  self._pending_containers.clear()
@@ -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
 
File without changes
File without changes