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.
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/.gitignore +1 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/PKG-INFO +1 -1
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/pyproject.toml +1 -1
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/__init__.py +1 -1
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/main.py +20 -9
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/models.py +23 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/parser.py +71 -15
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/uploader.py +112 -20
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/watcher.py +27 -2
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_integration.py +132 -2
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_parser.py +213 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_uploader.py +189 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_watcher.py +25 -6
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/README.md +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/config.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/launch_url.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/retry.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/src/charisma_cli/subprocess_mgr.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/__init__.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/conftest.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_cli.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_config.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_launch_url.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_models.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_retry.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_silent_mode.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.1}/tests/test_subprocess_mgr.py +0 -0
|
@@ -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
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
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
|
|
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
|
|
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__)
|
|
@@ -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":
|
|
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
|
-
"
|
|
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["
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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["
|
|
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["
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|