charisma-cli 0.2.1__tar.gz → 0.2.2__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.1 → charisma_cli-0.2.2}/.gitignore +4 -1
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/PKG-INFO +1 -1
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/pyproject.toml +1 -1
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/__init__.py +1 -1
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/models.py +26 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/uploader.py +112 -28
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_uploader.py +304 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/README.md +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/config.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/launch_url.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/main.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/parser.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/retry.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/subprocess_mgr.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/watcher.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/__init__.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/conftest.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_cli.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_config.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_integration.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_launch_url.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_models.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_parser.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_retry.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_silent_mode.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_subprocess_mgr.py +0 -0
- {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_watcher.py +0 -0
|
@@ -55,7 +55,8 @@ dist/
|
|
|
55
55
|
|
|
56
56
|
# Python cache
|
|
57
57
|
*.pyc
|
|
58
|
-
|
|
58
|
+
*.pyc.*
|
|
59
|
+
**/__pycache__/
|
|
59
60
|
|
|
60
61
|
# Python virtual environments
|
|
61
62
|
.venv/
|
|
@@ -133,3 +134,5 @@ backend/coverage.json
|
|
|
133
134
|
*/__pycache__/*
|
|
134
135
|
allure-results/*
|
|
135
136
|
allure-results-live/
|
|
137
|
+
*tasks.meta.json
|
|
138
|
+
*.code-workspace
|
|
@@ -4,6 +4,32 @@ from dataclasses import dataclass, field
|
|
|
4
4
|
from enum import IntEnum
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Any
|
|
7
|
+
from uuid import NAMESPACE_URL, uuid5
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def backend_result_id(launch_id: str, test_id: str) -> str:
|
|
11
|
+
"""Derive the result identity the Charisma backend stores and validates against.
|
|
12
|
+
|
|
13
|
+
The backend computes ``test_results.id`` as
|
|
14
|
+
``uuid5(NAMESPACE_URL, f"{launch_id}/{test_id}")`` (see backend
|
|
15
|
+
``models/testresults.py`` ``to_canonical``). Attachment uploads are validated
|
|
16
|
+
against that stored id, so the CLI MUST send the same value as ``resultId`` —
|
|
17
|
+
not the raw Allure ``uuid``, which the backend discards. This function is the
|
|
18
|
+
single CLI-side mirror of that contract; keep the namespace, separator, and
|
|
19
|
+
field order byte-identical to the backend.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
launch_id: The open launch UUID returned by the backend.
|
|
23
|
+
test_id: The result's test identity — the SAME value the CLI sends as
|
|
24
|
+
``test_id`` in the results batch. This is the Allure ``historyId``,
|
|
25
|
+
falling back to ``fullName`` when ``historyId`` is absent (see
|
|
26
|
+
``parser.parse_result_file``). Matching the backend requires only
|
|
27
|
+
that both sides key off this same value, whichever it is.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The deterministic result id string matching the backend's stored id.
|
|
31
|
+
"""
|
|
32
|
+
return str(uuid5(NAMESPACE_URL, f"{launch_id}/{test_id}"))
|
|
7
33
|
|
|
8
34
|
|
|
9
35
|
class FileCategory(IntEnum):
|
|
@@ -17,6 +17,7 @@ from charisma_cli.models import (
|
|
|
17
17
|
FileCategory,
|
|
18
18
|
FileEvent,
|
|
19
19
|
UploadSummary,
|
|
20
|
+
backend_result_id,
|
|
20
21
|
)
|
|
21
22
|
from charisma_cli.parser import (
|
|
22
23
|
extract_attachment_refs,
|
|
@@ -74,8 +75,15 @@ class Uploader:
|
|
|
74
75
|
self._batch_start: float = 0.0
|
|
75
76
|
self._pending_containers: list[ContainerData] = []
|
|
76
77
|
self._known_result_uuids: set[str] = set()
|
|
77
|
-
# Map source filename →
|
|
78
|
+
# Map source filename → owning result's Allure uuid (join key from
|
|
79
|
+
# Allure's own structure: results own attachments and have a uuid;
|
|
80
|
+
# containers reference their children by uuid).
|
|
78
81
|
self._attachment_result_map: dict[str, str] = {}
|
|
82
|
+
# Map Allure result uuid → historyId. The backend keys stored results by
|
|
83
|
+
# uuid5(launch_id/historyId), NOT the Allure uuid, so an attachment's
|
|
84
|
+
# owning uuid must be translated to its historyId to compute the
|
|
85
|
+
# resultId the backend will accept. Populated as result files are parsed.
|
|
86
|
+
self._result_uuid_to_history: dict[str, str] = {}
|
|
79
87
|
# Attachments dequeued before their owning result/container was seen.
|
|
80
88
|
# In watch mode the PriorityQueue orders items co-present at dequeue time
|
|
81
89
|
# but gives no global temporal guarantee: an attachment can be processed
|
|
@@ -328,6 +336,13 @@ class Uploader:
|
|
|
328
336
|
# Track UUID for container association
|
|
329
337
|
if result.uuid:
|
|
330
338
|
self._known_result_uuids.add(result.uuid)
|
|
339
|
+
# Record uuid → historyId so attachments (which resolve to their
|
|
340
|
+
# owning Allure uuid) can be uploaded with the historyId-based id the
|
|
341
|
+
# backend actually stores. A newly-learned mapping may unblock
|
|
342
|
+
# attachments deferred because their owner's historyId wasn't known.
|
|
343
|
+
if result.testId:
|
|
344
|
+
self._result_uuid_to_history[result.uuid] = result.testId
|
|
345
|
+
self._retry_pending_attachments()
|
|
331
346
|
|
|
332
347
|
# Build API payload (snake_case wire format — shared TestResultInput contract)
|
|
333
348
|
payload: dict[str, Any] = {
|
|
@@ -463,13 +478,27 @@ class Uploader:
|
|
|
463
478
|
self._summary.attachments_skipped += 1
|
|
464
479
|
return
|
|
465
480
|
|
|
466
|
-
|
|
467
|
-
if not
|
|
468
|
-
# Owner not known yet — defer and retry when a
|
|
481
|
+
history_id = self._resolve_history_id(path.name)
|
|
482
|
+
if not history_id:
|
|
483
|
+
# Owner (or its historyId) not known yet — defer and retry when a
|
|
484
|
+
# result registers the source→uuid and uuid→historyId mappings.
|
|
469
485
|
self._pending_attachments.append(path)
|
|
470
486
|
return
|
|
471
487
|
|
|
472
|
-
self._upload_attachment(path,
|
|
488
|
+
self._upload_attachment(path, history_id)
|
|
489
|
+
|
|
490
|
+
def _resolve_history_id(self, source_name: str) -> str:
|
|
491
|
+
"""Resolve an attachment's source filename to its owning result's historyId.
|
|
492
|
+
|
|
493
|
+
Two hops: source filename → owning Allure result uuid → historyId. Both
|
|
494
|
+
are learned from result/container files, which may arrive after the
|
|
495
|
+
attachment (watch-mode ordering). Returns "" when either hop is missing,
|
|
496
|
+
signalling the caller to defer rather than skip.
|
|
497
|
+
"""
|
|
498
|
+
result_uuid = self._attachment_result_map.get(source_name, "")
|
|
499
|
+
if not result_uuid:
|
|
500
|
+
return ""
|
|
501
|
+
return self._result_uuid_to_history.get(result_uuid, "")
|
|
473
502
|
|
|
474
503
|
def _retry_pending_attachments(self) -> None:
|
|
475
504
|
"""Upload any buffered attachments whose owner mapping is now known.
|
|
@@ -482,9 +511,9 @@ class Uploader:
|
|
|
482
511
|
return
|
|
483
512
|
still_pending: list[Path] = []
|
|
484
513
|
for path in self._pending_attachments:
|
|
485
|
-
|
|
486
|
-
if
|
|
487
|
-
self._upload_attachment(path,
|
|
514
|
+
history_id = self._resolve_history_id(path.name)
|
|
515
|
+
if history_id:
|
|
516
|
+
self._upload_attachment(path, history_id)
|
|
488
517
|
else:
|
|
489
518
|
still_pending.append(path)
|
|
490
519
|
self._pending_attachments = still_pending
|
|
@@ -499,32 +528,87 @@ class Uploader:
|
|
|
499
528
|
self._retry_pending_attachments()
|
|
500
529
|
for path in self._pending_attachments:
|
|
501
530
|
self._summary.attachments_skipped += 1
|
|
502
|
-
logger.
|
|
531
|
+
logger.warning(
|
|
532
|
+
"Skipping attachment with no matching result: %s", path.name
|
|
533
|
+
)
|
|
503
534
|
self._pending_attachments = []
|
|
504
535
|
|
|
505
|
-
def _upload_attachment(self, path: Path,
|
|
506
|
-
"""POST a single attachment to the Charisma attachment API.
|
|
536
|
+
def _upload_attachment(self, path: Path, history_id: str) -> None:
|
|
537
|
+
"""POST a single attachment to the Charisma attachment API.
|
|
538
|
+
|
|
539
|
+
``resultId`` is the backend-derived id (uuid5 of launch/historyId), the
|
|
540
|
+
same value the backend stores as ``test_results.id``. Sending the raw
|
|
541
|
+
Allure uuid here was the attachments-skipped bug: the backend validates
|
|
542
|
+
the resultId against the stored id and returns 404 on a mismatch.
|
|
543
|
+
|
|
544
|
+
Transient failures (5xx, 429, connect/timeout) are retried with the same
|
|
545
|
+
exponential backoff as result batches — attachments were previously the
|
|
546
|
+
only backend call with no retry, so a momentary blip permanently dropped
|
|
547
|
+
an attachment a single retry would have delivered. On terminal failure the
|
|
548
|
+
attachment is counted skipped and logged at WARNING (not DEBUG): a silent
|
|
549
|
+
DEBUG-only skip is exactly what hid the original bug.
|
|
550
|
+
"""
|
|
551
|
+
# Fail fast rather than fabricate a wrong id: the backend derives the
|
|
552
|
+
# stored result id from launch_id, so an empty launch_id computes a
|
|
553
|
+
# well-formed but wrong id that always 404s. Never send that.
|
|
554
|
+
if not self._launch_id:
|
|
555
|
+
self._summary.attachments_skipped += 1
|
|
556
|
+
logger.warning(
|
|
557
|
+
"Cannot upload attachment %s: no open launch", path.name
|
|
558
|
+
)
|
|
559
|
+
return
|
|
560
|
+
|
|
507
561
|
try:
|
|
508
562
|
content = path.read_bytes()
|
|
509
|
-
|
|
510
|
-
|
|
563
|
+
except OSError as e:
|
|
564
|
+
self._summary.attachments_skipped += 1
|
|
565
|
+
logger.warning("Cannot read attachment %s: %s", path.name, e)
|
|
566
|
+
return
|
|
511
567
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
self.
|
|
519
|
-
|
|
568
|
+
result_id = backend_result_id(self._launch_id, history_id)
|
|
569
|
+
files = {"file": (path.name, content)}
|
|
570
|
+
data = {"name": path.name, "resultId": result_id}
|
|
571
|
+
|
|
572
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
573
|
+
try:
|
|
574
|
+
response = self._client.post(
|
|
575
|
+
f"/api/v1/launches/{self._launch_id}/attachments",
|
|
576
|
+
files=files,
|
|
577
|
+
data=data,
|
|
578
|
+
)
|
|
579
|
+
if response.status_code == 201:
|
|
580
|
+
self._summary.attachments_sent += 1
|
|
581
|
+
return
|
|
582
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
583
|
+
time.sleep(exponential_backoff(attempt))
|
|
584
|
+
continue
|
|
585
|
+
# Non-retryable status (e.g. 404) or retries exhausted.
|
|
520
586
|
self._summary.attachments_skipped += 1
|
|
521
|
-
logger.
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
587
|
+
logger.warning(
|
|
588
|
+
"Attachment upload failed: HTTP %d for %s",
|
|
589
|
+
response.status_code,
|
|
590
|
+
path.name,
|
|
591
|
+
)
|
|
592
|
+
return
|
|
593
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
594
|
+
if attempt < MAX_RETRIES:
|
|
595
|
+
time.sleep(exponential_backoff(attempt))
|
|
596
|
+
continue
|
|
597
|
+
self._summary.attachments_skipped += 1
|
|
598
|
+
logger.warning(
|
|
599
|
+
"Attachment upload failed after %d retries: %s for %s",
|
|
600
|
+
MAX_RETRIES,
|
|
601
|
+
e,
|
|
602
|
+
path.name,
|
|
603
|
+
)
|
|
604
|
+
return
|
|
605
|
+
except Exception:
|
|
606
|
+
# Non-network error (e.g. malformed client state) — not retryable.
|
|
607
|
+
self._summary.attachments_skipped += 1
|
|
608
|
+
logger.warning(
|
|
609
|
+
"Attachment upload unexpected error for %s", path.name, exc_info=True
|
|
610
|
+
)
|
|
611
|
+
return
|
|
528
612
|
|
|
529
613
|
def _try_associate_containers(self) -> None:
|
|
530
614
|
"""Try to associate buffered containers with known results."""
|
|
@@ -520,3 +520,307 @@ class TestUploaderDrainFlushesBatch:
|
|
|
520
520
|
assert results_route.called
|
|
521
521
|
|
|
522
522
|
uploader.stop()
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class TestAttachmentResultIdContract:
|
|
526
|
+
"""The attachment `resultId` must match the backend's stored test_results.id.
|
|
527
|
+
|
|
528
|
+
Regression for the `attachments_sent=0, attachments_skipped=N` production bug.
|
|
529
|
+
The backend derives test_results.id as uuid5(NAMESPACE_URL, f"{launch_id}/{test_id}")
|
|
530
|
+
where test_id is the Allure historyId (see backend models/testresults.py). The CLI
|
|
531
|
+
previously sent the raw Allure `uuid` as resultId, which never matches that stored
|
|
532
|
+
id, so the backend returned 404 RESULT_NOT_FOUND and every attachment was skipped.
|
|
533
|
+
|
|
534
|
+
These tests pin the on-the-wire resultId to the backend contract so the mismatch
|
|
535
|
+
cannot silently return.
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
def _write_json(self, path: Path, data: dict) -> None:
|
|
539
|
+
import json
|
|
540
|
+
path.write_text(json.dumps(data), encoding="utf-8")
|
|
541
|
+
|
|
542
|
+
@staticmethod
|
|
543
|
+
def _expected_result_id(launch_id: str, history_id: str) -> str:
|
|
544
|
+
from uuid import NAMESPACE_URL, uuid5
|
|
545
|
+
return str(uuid5(NAMESPACE_URL, f"{launch_id}/{history_id}"))
|
|
546
|
+
|
|
547
|
+
@staticmethod
|
|
548
|
+
def _sent_result_id(route) -> str:
|
|
549
|
+
"""Extract the resultId form field from the recorded multipart request."""
|
|
550
|
+
request = route.calls[0].request
|
|
551
|
+
body = request.content.decode("utf-8", errors="replace")
|
|
552
|
+
# multipart body contains: name="resultId"\r\n\r\n<value>\r\n
|
|
553
|
+
marker = 'name="resultId"'
|
|
554
|
+
idx = body.find(marker)
|
|
555
|
+
assert idx != -1, f"resultId field not found in request body:\n{body}"
|
|
556
|
+
after = body[idx + len(marker):]
|
|
557
|
+
# skip the two CRLFs separating headers from the value
|
|
558
|
+
value = after.split("\r\n\r\n", 1)[1]
|
|
559
|
+
return value.split("\r\n", 1)[0]
|
|
560
|
+
|
|
561
|
+
@respx.mock
|
|
562
|
+
def test_top_level_attachment_result_id_matches_backend(self, tmp_path: Path) -> None:
|
|
563
|
+
"""A top-level attachment sends resultId = uuid5(launch/historyId), not the Allure uuid.
|
|
564
|
+
|
|
565
|
+
Mirrors the real repro: the result's file uuid differs from its historyId,
|
|
566
|
+
and attachments are declared at the result top level.
|
|
567
|
+
"""
|
|
568
|
+
config = _make_config()
|
|
569
|
+
queue: PriorityQueue = PriorityQueue()
|
|
570
|
+
launch_id = "launch-1"
|
|
571
|
+
history_id = "601485468354fb8daff78d8e009a0714"
|
|
572
|
+
allure_uuid = "f7451cdd-7ad3-48fa-bb88-61b65e408645"
|
|
573
|
+
|
|
574
|
+
respx.post(f"https://charisma.test/api/v1/launches/{launch_id}/results").mock(
|
|
575
|
+
return_value=httpx.Response(200, json={"accepted": 1})
|
|
576
|
+
)
|
|
577
|
+
attach_route = respx.post(
|
|
578
|
+
f"https://charisma.test/api/v1/launches/{launch_id}/attachments"
|
|
579
|
+
).mock(return_value=httpx.Response(201, json={"id": "att-1"}))
|
|
580
|
+
|
|
581
|
+
result_file = tmp_path / "09d4-result.json"
|
|
582
|
+
self._write_json(result_file, {
|
|
583
|
+
"uuid": allure_uuid,
|
|
584
|
+
"historyId": history_id,
|
|
585
|
+
"fullName": "tests.emea.dsp.test_x",
|
|
586
|
+
"status": "broken",
|
|
587
|
+
"start": 1000,
|
|
588
|
+
"stop": 2000,
|
|
589
|
+
"attachments": [
|
|
590
|
+
{"source": "shot.png", "name": "screenshot", "type": "image/png"}
|
|
591
|
+
],
|
|
592
|
+
})
|
|
593
|
+
attach_file = tmp_path / "shot.png"
|
|
594
|
+
attach_file.write_bytes(b"\x89PNG fake")
|
|
595
|
+
|
|
596
|
+
uploader = Uploader(config, queue)
|
|
597
|
+
uploader._launch_id = launch_id
|
|
598
|
+
queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
|
|
599
|
+
queue.put(FileEvent(path=attach_file, category=FileCategory.ATTACHMENT))
|
|
600
|
+
|
|
601
|
+
uploader.start()
|
|
602
|
+
uploader.drain(timeout=5.0)
|
|
603
|
+
uploader.stop()
|
|
604
|
+
|
|
605
|
+
assert uploader.summary.attachments_sent == 1
|
|
606
|
+
assert uploader.summary.attachments_skipped == 0
|
|
607
|
+
assert attach_route.called
|
|
608
|
+
|
|
609
|
+
sent = self._sent_result_id(attach_route)
|
|
610
|
+
expected = self._expected_result_id(launch_id, history_id)
|
|
611
|
+
assert sent == expected, (
|
|
612
|
+
f"resultId sent={sent} but backend stores id={expected} "
|
|
613
|
+
f"(uuid5 of launch/historyId). Sending the Allure uuid ({allure_uuid}) "
|
|
614
|
+
"is the attachments-skipped bug."
|
|
615
|
+
)
|
|
616
|
+
assert sent != allure_uuid
|
|
617
|
+
|
|
618
|
+
@respx.mock
|
|
619
|
+
def test_fixture_attachment_result_id_matches_backend(self, tmp_path: Path) -> None:
|
|
620
|
+
"""A container/fixture attachment resolves to its child's historyId-based id."""
|
|
621
|
+
config = _make_config()
|
|
622
|
+
queue: PriorityQueue = PriorityQueue()
|
|
623
|
+
launch_id = "launch-1"
|
|
624
|
+
child_history = "hist-child-777"
|
|
625
|
+
child_uuid = "res1-uuid"
|
|
626
|
+
|
|
627
|
+
respx.post(f"https://charisma.test/api/v1/launches/{launch_id}/results").mock(
|
|
628
|
+
return_value=httpx.Response(200, json={"accepted": 1})
|
|
629
|
+
)
|
|
630
|
+
attach_route = respx.post(
|
|
631
|
+
f"https://charisma.test/api/v1/launches/{launch_id}/attachments"
|
|
632
|
+
).mock(return_value=httpx.Response(201, json={"id": "att-2"}))
|
|
633
|
+
|
|
634
|
+
result_file = tmp_path / "res1-result.json"
|
|
635
|
+
self._write_json(result_file, {
|
|
636
|
+
"uuid": child_uuid,
|
|
637
|
+
"historyId": child_history,
|
|
638
|
+
"fullName": "tests.test_fix",
|
|
639
|
+
"status": "passed",
|
|
640
|
+
"start": 1000,
|
|
641
|
+
"stop": 2000,
|
|
642
|
+
})
|
|
643
|
+
container_file = tmp_path / "c1-container.json"
|
|
644
|
+
self._write_json(container_file, {
|
|
645
|
+
"uuid": "c1",
|
|
646
|
+
"name": "fixture",
|
|
647
|
+
"children": [child_uuid],
|
|
648
|
+
"befores": [
|
|
649
|
+
{"name": "setup", "attachments": [
|
|
650
|
+
{"source": "setup.log", "name": "setup log", "type": "text/plain"}
|
|
651
|
+
]}
|
|
652
|
+
],
|
|
653
|
+
"afters": [],
|
|
654
|
+
})
|
|
655
|
+
attach_file = tmp_path / "setup.log"
|
|
656
|
+
attach_file.write_text("fixture output")
|
|
657
|
+
|
|
658
|
+
uploader = Uploader(config, queue)
|
|
659
|
+
uploader._launch_id = launch_id
|
|
660
|
+
queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
|
|
661
|
+
queue.put(FileEvent(path=container_file, category=FileCategory.CONTAINER))
|
|
662
|
+
queue.put(FileEvent(path=attach_file, category=FileCategory.ATTACHMENT))
|
|
663
|
+
|
|
664
|
+
uploader.start()
|
|
665
|
+
uploader.drain(timeout=5.0)
|
|
666
|
+
uploader.stop()
|
|
667
|
+
|
|
668
|
+
assert uploader.summary.attachments_sent == 1
|
|
669
|
+
assert uploader.summary.attachments_skipped == 0
|
|
670
|
+
|
|
671
|
+
sent = self._sent_result_id(attach_route)
|
|
672
|
+
expected = self._expected_result_id(launch_id, child_history)
|
|
673
|
+
assert sent == expected, (
|
|
674
|
+
f"fixture attachment resultId sent={sent} but backend stores {expected}"
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
class TestAttachmentUploadResilience:
|
|
679
|
+
"""Attachment uploads must be retried on transient failures and fail visibly.
|
|
680
|
+
|
|
681
|
+
Review findings: attachment uploads were the only backend call with no retry
|
|
682
|
+
(unlike _send_batch and launch open/close), and every failure/skip was logged
|
|
683
|
+
at DEBUG — below the CLI's effective WARNING floor — so failures were invisible,
|
|
684
|
+
recreating the very 'attachments silently skipped' bug this work targets.
|
|
685
|
+
"""
|
|
686
|
+
|
|
687
|
+
def _write_json(self, path: Path, data: dict) -> None:
|
|
688
|
+
import json
|
|
689
|
+
path.write_text(json.dumps(data), encoding="utf-8")
|
|
690
|
+
|
|
691
|
+
def _result_with_attachment(self, tmp_path: Path) -> tuple[Path, Path]:
|
|
692
|
+
result_file = tmp_path / "r1-result.json"
|
|
693
|
+
self._write_json(result_file, {
|
|
694
|
+
"uuid": "r1",
|
|
695
|
+
"historyId": "hist-r1",
|
|
696
|
+
"fullName": "tests.test_it",
|
|
697
|
+
"status": "passed",
|
|
698
|
+
"start": 1000,
|
|
699
|
+
"stop": 2000,
|
|
700
|
+
"attachments": [
|
|
701
|
+
{"source": "shot.png", "name": "shot", "type": "image/png"}
|
|
702
|
+
],
|
|
703
|
+
})
|
|
704
|
+
attach_file = tmp_path / "shot.png"
|
|
705
|
+
attach_file.write_bytes(b"\x89PNG fake")
|
|
706
|
+
return result_file, attach_file
|
|
707
|
+
|
|
708
|
+
@respx.mock
|
|
709
|
+
def test_attachment_retries_transient_5xx_then_succeeds(self, tmp_path: Path, monkeypatch) -> None:
|
|
710
|
+
"""A 503 followed by a 201 must retry and ultimately count as sent."""
|
|
711
|
+
# Neutralize backoff sleeps so the test is fast.
|
|
712
|
+
monkeypatch.setattr("charisma_cli.uploader.exponential_backoff", lambda attempt: 0.0)
|
|
713
|
+
|
|
714
|
+
config = _make_config()
|
|
715
|
+
queue: PriorityQueue = PriorityQueue()
|
|
716
|
+
|
|
717
|
+
respx.post("https://charisma.test/api/v1/launches/launch-1/results").mock(
|
|
718
|
+
return_value=httpx.Response(200, json={"accepted": 1})
|
|
719
|
+
)
|
|
720
|
+
attach_route = respx.post(
|
|
721
|
+
"https://charisma.test/api/v1/launches/launch-1/attachments"
|
|
722
|
+
).mock(side_effect=[
|
|
723
|
+
httpx.Response(503),
|
|
724
|
+
httpx.Response(201, json={"id": "att-1"}),
|
|
725
|
+
])
|
|
726
|
+
|
|
727
|
+
result_file, attach_file = self._result_with_attachment(tmp_path)
|
|
728
|
+
|
|
729
|
+
uploader = Uploader(config, queue)
|
|
730
|
+
uploader._launch_id = "launch-1"
|
|
731
|
+
uploader.ensure_client()
|
|
732
|
+
uploader._handle_result(result_file)
|
|
733
|
+
uploader._handle_attachment(attach_file)
|
|
734
|
+
|
|
735
|
+
assert uploader.summary.attachments_sent == 1
|
|
736
|
+
assert uploader.summary.attachments_skipped == 0
|
|
737
|
+
assert attach_route.call_count == 2 # retried once
|
|
738
|
+
|
|
739
|
+
uploader._client.close()
|
|
740
|
+
|
|
741
|
+
@respx.mock
|
|
742
|
+
def test_attachment_gives_up_after_max_retries_and_warns(self, tmp_path: Path, monkeypatch, caplog) -> None:
|
|
743
|
+
"""Persistent 5xx exhausts retries, counts skipped once, and logs at WARNING."""
|
|
744
|
+
import logging
|
|
745
|
+
monkeypatch.setattr("charisma_cli.uploader.exponential_backoff", lambda attempt: 0.0)
|
|
746
|
+
|
|
747
|
+
config = _make_config()
|
|
748
|
+
queue: PriorityQueue = PriorityQueue()
|
|
749
|
+
|
|
750
|
+
respx.post("https://charisma.test/api/v1/launches/launch-1/results").mock(
|
|
751
|
+
return_value=httpx.Response(200, json={"accepted": 1})
|
|
752
|
+
)
|
|
753
|
+
attach_route = respx.post(
|
|
754
|
+
"https://charisma.test/api/v1/launches/launch-1/attachments"
|
|
755
|
+
).mock(return_value=httpx.Response(503))
|
|
756
|
+
|
|
757
|
+
result_file, attach_file = self._result_with_attachment(tmp_path)
|
|
758
|
+
|
|
759
|
+
uploader = Uploader(config, queue)
|
|
760
|
+
uploader._launch_id = "launch-1"
|
|
761
|
+
uploader.ensure_client()
|
|
762
|
+
uploader._handle_result(result_file)
|
|
763
|
+
|
|
764
|
+
with caplog.at_level(logging.WARNING, logger="charisma_cli.uploader"):
|
|
765
|
+
uploader._handle_attachment(attach_file)
|
|
766
|
+
|
|
767
|
+
assert uploader.summary.attachments_sent == 0
|
|
768
|
+
assert uploader.summary.attachments_skipped == 1 # counted exactly once
|
|
769
|
+
# MAX_RETRIES(3) + initial = 4 attempts
|
|
770
|
+
assert attach_route.call_count == 4
|
|
771
|
+
assert any(r.levelno >= logging.WARNING for r in caplog.records), (
|
|
772
|
+
"a persistent attachment upload failure must be visible at WARNING+, not DEBUG"
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
uploader._client.close()
|
|
776
|
+
|
|
777
|
+
@respx.mock
|
|
778
|
+
def test_attachment_non_retryable_404_warns_and_does_not_retry(self, tmp_path: Path, caplog) -> None:
|
|
779
|
+
"""A 404 (non-retryable) is skipped once, logged at WARNING, not retried."""
|
|
780
|
+
import logging
|
|
781
|
+
config = _make_config()
|
|
782
|
+
queue: PriorityQueue = PriorityQueue()
|
|
783
|
+
|
|
784
|
+
respx.post("https://charisma.test/api/v1/launches/launch-1/results").mock(
|
|
785
|
+
return_value=httpx.Response(200, json={"accepted": 1})
|
|
786
|
+
)
|
|
787
|
+
attach_route = respx.post(
|
|
788
|
+
"https://charisma.test/api/v1/launches/launch-1/attachments"
|
|
789
|
+
).mock(return_value=httpx.Response(404, json={"error": "RESULT_NOT_FOUND"}))
|
|
790
|
+
|
|
791
|
+
result_file, attach_file = self._result_with_attachment(tmp_path)
|
|
792
|
+
|
|
793
|
+
uploader = Uploader(config, queue)
|
|
794
|
+
uploader._launch_id = "launch-1"
|
|
795
|
+
uploader.ensure_client()
|
|
796
|
+
uploader._handle_result(result_file)
|
|
797
|
+
|
|
798
|
+
with caplog.at_level(logging.WARNING, logger="charisma_cli.uploader"):
|
|
799
|
+
uploader._handle_attachment(attach_file)
|
|
800
|
+
|
|
801
|
+
assert uploader.summary.attachments_skipped == 1
|
|
802
|
+
assert attach_route.call_count == 1 # NOT retried
|
|
803
|
+
assert any(r.levelno >= logging.WARNING for r in caplog.records)
|
|
804
|
+
|
|
805
|
+
uploader._client.close()
|
|
806
|
+
|
|
807
|
+
def test_upload_attachment_without_launch_id_fails_fast(self, tmp_path: Path) -> None:
|
|
808
|
+
"""_upload_attachment must not fabricate an id from a missing launch_id.
|
|
809
|
+
|
|
810
|
+
The backend derives test_results.id from launch_id/historyId; computing it
|
|
811
|
+
from an empty launch_id yields a well-formed-but-wrong id that 404s. Guard
|
|
812
|
+
instead of masking None with 'or ""'.
|
|
813
|
+
"""
|
|
814
|
+
config = _make_config()
|
|
815
|
+
queue: PriorityQueue = PriorityQueue()
|
|
816
|
+
|
|
817
|
+
attach_file = tmp_path / "shot.png"
|
|
818
|
+
attach_file.write_bytes(b"\x89PNG fake")
|
|
819
|
+
|
|
820
|
+
uploader = Uploader(config, queue)
|
|
821
|
+
uploader._launch_id = None # no launch
|
|
822
|
+
|
|
823
|
+
# Directly invoking the upload with no launch must count skipped, not POST.
|
|
824
|
+
uploader._upload_attachment(attach_file, "hist-r1")
|
|
825
|
+
assert uploader.summary.attachments_sent == 0
|
|
826
|
+
assert uploader.summary.attachments_skipped == 1
|
|
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
|
|
File without changes
|