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.
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/PKG-INFO +1 -1
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/pyproject.toml +1 -1
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/__init__.py +1 -1
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/models.py +23 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/parser.py +71 -15
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/uploader.py +92 -10
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_parser.py +213 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_uploader.py +189 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/.gitignore +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/README.md +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/config.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/launch_url.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/main.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/retry.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/subprocess_mgr.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/src/charisma_cli/watcher.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/__init__.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/conftest.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_cli.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_config.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_integration.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_launch_url.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_models.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_retry.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_silent_mode.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_subprocess_mgr.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.1}/tests/test_watcher.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|