charisma-cli 0.1.4__tar.gz → 0.1.6__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.4 → charisma_cli-0.1.6}/PKG-INFO +1 -1
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/pyproject.toml +1 -1
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/main.py +23 -6
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/parser.py +47 -7
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/uploader.py +12 -3
- charisma_cli-0.1.6/src/charisma_cli/watcher.py +323 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_integration.py +72 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_parser.py +118 -3
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_watcher.py +238 -4
- charisma_cli-0.1.4/src/charisma_cli/watcher.py +0 -223
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/.gitignore +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/README.md +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/__init__.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/config.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/launch_url.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/models.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/retry.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/subprocess_mgr.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/__init__.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/conftest.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_cli.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_config.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_launch_url.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_models.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_retry.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_silent_mode.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_subprocess_mgr.py +0 -0
- {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_uploader.py +0 -0
|
@@ -11,6 +11,7 @@ from charisma_cli import __version__
|
|
|
11
11
|
from charisma_cli.config import Config
|
|
12
12
|
from charisma_cli.launch_url import build_launch_url, emit_github_output
|
|
13
13
|
from charisma_cli.models import FileEvent
|
|
14
|
+
from charisma_cli.parser import parse_environment_properties
|
|
14
15
|
from charisma_cli.subprocess_mgr import SubprocessManager
|
|
15
16
|
from charisma_cli.uploader import Uploader
|
|
16
17
|
from charisma_cli.watcher import ResultsWatcher, classify_file
|
|
@@ -60,6 +61,18 @@ def _open_launch_or_exit(uploader: Uploader, config: Config) -> str:
|
|
|
60
61
|
return launch_id
|
|
61
62
|
|
|
62
63
|
|
|
64
|
+
def _read_launch_variables(config: Config) -> dict[str, str]:
|
|
65
|
+
"""Read Allure environment.properties from the results dir, if present.
|
|
66
|
+
|
|
67
|
+
Returns the parsed key/value variables (empty dict if the file is absent),
|
|
68
|
+
to be attached to the launch at close time.
|
|
69
|
+
"""
|
|
70
|
+
props_path = Path(config.results_dir) / "environment.properties"
|
|
71
|
+
if not props_path.is_file():
|
|
72
|
+
return {}
|
|
73
|
+
return parse_environment_properties(props_path)
|
|
74
|
+
|
|
75
|
+
|
|
63
76
|
def _surface_launch_url(uploader: Uploader) -> None:
|
|
64
77
|
"""Print the Charisma launch URL and expose it to GitHub Actions.
|
|
65
78
|
|
|
@@ -172,11 +185,15 @@ def watch(
|
|
|
172
185
|
mgr.spawn(command)
|
|
173
186
|
exit_code = mgr.wait()
|
|
174
187
|
|
|
175
|
-
# After subprocess exits (crash or normal): flush debouncing files
|
|
176
|
-
#
|
|
177
|
-
#
|
|
188
|
+
# After subprocess exits (crash or normal): flush debouncing files, then
|
|
189
|
+
# keep scanning until the results directory settles. allure-pytest writes
|
|
190
|
+
# its result files in a burst during test-process teardown; under high
|
|
191
|
+
# xdist parallelism that burst can land AFTER the subprocess exits, so a
|
|
192
|
+
# single scan taken here races the OS flush and captures nothing (the
|
|
193
|
+
# watch results_sent=0 bug). settle_scan reconciles until the directory is
|
|
194
|
+
# quiet, capturing the whole burst before we drain.
|
|
178
195
|
watcher.flush_pending()
|
|
179
|
-
watcher.
|
|
196
|
+
watcher.settle_scan()
|
|
180
197
|
|
|
181
198
|
# Drain and close — stop watcher AFTER drain so all files are processed
|
|
182
199
|
uploader.drain(timeout=config.drain_timeout)
|
|
@@ -184,7 +201,7 @@ def watch(
|
|
|
184
201
|
uploader.stop()
|
|
185
202
|
|
|
186
203
|
uploader.ensure_client()
|
|
187
|
-
uploader.close_launch()
|
|
204
|
+
uploader.close_launch(variables=_read_launch_variables(config))
|
|
188
205
|
if uploader._client is not None:
|
|
189
206
|
uploader._client.close()
|
|
190
207
|
uploader._client = None
|
|
@@ -247,7 +264,7 @@ def upload(
|
|
|
247
264
|
|
|
248
265
|
# Close launch
|
|
249
266
|
uploader.ensure_client()
|
|
250
|
-
uploader.close_launch()
|
|
267
|
+
uploader.close_launch(variables=_read_launch_variables(config))
|
|
251
268
|
uploader.stop()
|
|
252
269
|
|
|
253
270
|
_print_summary(uploader)
|
|
@@ -54,15 +54,18 @@ def parse_result_file(path: Path) -> CharismaResult | None:
|
|
|
54
54
|
logger.warning("Result file is not a JSON object: %s", path)
|
|
55
55
|
return None
|
|
56
56
|
|
|
57
|
-
#
|
|
57
|
+
# uuid is the only strictly required field. start/stop are optional:
|
|
58
|
+
# skipped tests (and some setup/teardown-only results) never execute, so
|
|
59
|
+
# Allure omits `stop` (and sometimes `start`). Requiring them here silently
|
|
60
|
+
# dropped skipped results before they were ever sent to Charisma.
|
|
58
61
|
uuid = data.get("uuid")
|
|
62
|
+
if uuid is None:
|
|
63
|
+
logger.warning("Result file missing required field (uuid): %s", path)
|
|
64
|
+
return None
|
|
65
|
+
|
|
59
66
|
start = data.get("start")
|
|
60
67
|
stop = data.get("stop")
|
|
61
68
|
|
|
62
|
-
if uuid is None or start is None or stop is None:
|
|
63
|
-
logger.warning("Result file missing required fields (uuid/start/stop): %s", path)
|
|
64
|
-
return None
|
|
65
|
-
|
|
66
69
|
# testId: prefer historyId, fallback to fullName
|
|
67
70
|
history_id = data.get("historyId")
|
|
68
71
|
full_name = data.get("fullName")
|
|
@@ -72,8 +75,9 @@ def parse_result_file(path: Path) -> CharismaResult | None:
|
|
|
72
75
|
logger.warning("Result file missing both historyId and fullName: %s", path)
|
|
73
76
|
return None
|
|
74
77
|
|
|
75
|
-
# Compute duration
|
|
76
|
-
|
|
78
|
+
# Compute duration only when both timestamps are present; otherwise 0
|
|
79
|
+
# (e.g. skipped tests that never ran).
|
|
80
|
+
duration_ms = (stop - start) if (start is not None and stop is not None) else 0
|
|
77
81
|
|
|
78
82
|
# Status mapping
|
|
79
83
|
status = data.get("status", "")
|
|
@@ -145,6 +149,42 @@ def parse_container_file(path: Path) -> ContainerData | None:
|
|
|
145
149
|
)
|
|
146
150
|
|
|
147
151
|
|
|
152
|
+
def parse_environment_properties(path: Path) -> dict[str, str]:
|
|
153
|
+
"""Parse an Allure ``environment.properties`` file into a key/value dict.
|
|
154
|
+
|
|
155
|
+
The file is a standard Java-style properties file: ``key=value`` per line,
|
|
156
|
+
with ``#`` / ``!`` comment lines and blank lines ignored. Values may contain
|
|
157
|
+
``=`` (only the first ``=`` splits key from value). These become the
|
|
158
|
+
launch-level "Variables" shown in Charisma.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
path: Path to the environment.properties file.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Dict of variable name → value (both stripped). Empty dict if the file
|
|
165
|
+
is missing, unreadable, or contains no valid entries.
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
text = path.read_text(encoding="utf-8")
|
|
169
|
+
except (OSError, FileNotFoundError):
|
|
170
|
+
logger.warning("Cannot read environment.properties: %s", path)
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
variables: dict[str, str] = {}
|
|
174
|
+
for line in text.splitlines():
|
|
175
|
+
stripped = line.strip()
|
|
176
|
+
if not stripped or stripped.startswith("#") or stripped.startswith("!"):
|
|
177
|
+
continue
|
|
178
|
+
if "=" not in stripped:
|
|
179
|
+
continue
|
|
180
|
+
key, _, value = stripped.partition("=")
|
|
181
|
+
key = key.strip()
|
|
182
|
+
if key:
|
|
183
|
+
variables[key] = value.strip()
|
|
184
|
+
|
|
185
|
+
return variables
|
|
186
|
+
|
|
187
|
+
|
|
148
188
|
def extract_attachment_refs(result_data: dict, result_uuid: str) -> list[AttachmentRef]:
|
|
149
189
|
"""Extract attachment references from an Allure result data dict.
|
|
150
190
|
|
|
@@ -206,15 +206,24 @@ class Uploader:
|
|
|
206
206
|
|
|
207
207
|
return None
|
|
208
208
|
|
|
209
|
-
def close_launch(self) -> None:
|
|
210
|
-
"""Close the streaming launch via the API.
|
|
209
|
+
def close_launch(self, variables: dict[str, str] | None = None) -> None:
|
|
210
|
+
"""Close the streaming launch via the API.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
variables: Optional launch-level key/value metadata (parsed from
|
|
214
|
+
Allure environment.properties) to persist on the launch.
|
|
215
|
+
"""
|
|
211
216
|
self.ensure_client()
|
|
212
217
|
if self._launch_id is None:
|
|
213
218
|
return
|
|
214
219
|
|
|
220
|
+
body = {"variables": variables} if variables else None
|
|
221
|
+
|
|
215
222
|
for attempt in range(MAX_RETRIES + 1):
|
|
216
223
|
try:
|
|
217
|
-
response = self._client.post(
|
|
224
|
+
response = self._client.post(
|
|
225
|
+
f"/api/v1/launches/{self._launch_id}/close", json=body
|
|
226
|
+
)
|
|
218
227
|
if response.status_code in (200, 201, 204):
|
|
219
228
|
return
|
|
220
229
|
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Watchdog observer, stability debounce, and file classification for allure-results."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from queue import PriorityQueue
|
|
7
|
+
from threading import Lock, Timer
|
|
8
|
+
|
|
9
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
10
|
+
from watchdog.observers import Observer
|
|
11
|
+
|
|
12
|
+
from charisma_cli.config import Config
|
|
13
|
+
from charisma_cli.models import FileCategory, FileEvent
|
|
14
|
+
|
|
15
|
+
_TWO_MB = 2 * 1024 * 1024
|
|
16
|
+
_STABILITY_SECONDS = 0.5
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _SeenSet:
|
|
20
|
+
"""Thread-safe set of absolute paths already enqueued.
|
|
21
|
+
|
|
22
|
+
Shared by every producer (live observer, flush_pending, final_scan,
|
|
23
|
+
settle_scan) so each file is enqueued exactly once. ``add`` returns True
|
|
24
|
+
only the first time a path is seen, letting callers gate the enqueue.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._paths: set[str] = set()
|
|
29
|
+
self._lock = Lock()
|
|
30
|
+
|
|
31
|
+
def add(self, path: str) -> bool:
|
|
32
|
+
"""Record a path. Returns True if newly added, False if already present."""
|
|
33
|
+
with self._lock:
|
|
34
|
+
if path in self._paths:
|
|
35
|
+
return False
|
|
36
|
+
self._paths.add(path)
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
def __contains__(self, path: str) -> bool:
|
|
40
|
+
with self._lock:
|
|
41
|
+
return path in self._paths
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def classify_file(filename: str) -> FileCategory | None:
|
|
45
|
+
"""Classify a filename into a FileCategory or None if excluded.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
filename: The base filename (not full path).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
FileCategory.RESULT for *-result.json,
|
|
52
|
+
FileCategory.CONTAINER for *-container.json,
|
|
53
|
+
FileCategory.ATTACHMENT for all other valid files,
|
|
54
|
+
None for excluded files (dot-prefix or .tmp suffix).
|
|
55
|
+
"""
|
|
56
|
+
if filename.startswith("."):
|
|
57
|
+
return None
|
|
58
|
+
if filename.endswith(".tmp"):
|
|
59
|
+
return None
|
|
60
|
+
if filename.endswith("-result.json"):
|
|
61
|
+
return FileCategory.RESULT
|
|
62
|
+
if filename.endswith("-container.json"):
|
|
63
|
+
return FileCategory.CONTAINER
|
|
64
|
+
return FileCategory.ATTACHMENT
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def should_skip(path: Path, config: Config) -> bool:
|
|
68
|
+
"""Check whether a file should be skipped based on size.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
path: Path to the file on disk.
|
|
72
|
+
config: Resolved CLI configuration.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
True if the file exceeds 2MB and skip_too_big is enabled.
|
|
76
|
+
"""
|
|
77
|
+
if not config.skip_too_big:
|
|
78
|
+
return False
|
|
79
|
+
return path.stat().st_size > _TWO_MB
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _enqueue_once(
|
|
83
|
+
queue: "PriorityQueue[FileEvent]",
|
|
84
|
+
config: Config,
|
|
85
|
+
enqueued: "_SeenSet",
|
|
86
|
+
path: Path,
|
|
87
|
+
category: FileCategory,
|
|
88
|
+
) -> bool:
|
|
89
|
+
"""Enqueue a file exactly once, guarding all filesystem access.
|
|
90
|
+
|
|
91
|
+
Single owner of the enqueue-decision pipeline shared by every producer
|
|
92
|
+
(the live observer's debounce handler, flush_pending, and the scans):
|
|
93
|
+
resolve the path, apply the size-skip guard, gate on the shared seen-set,
|
|
94
|
+
then put the event. Keeping this in one place ensures the exactly-once
|
|
95
|
+
invariant (skip BEFORE marking seen, one canonical resolved-path key)
|
|
96
|
+
stays consistent across all callers.
|
|
97
|
+
|
|
98
|
+
All filesystem calls (``resolve``, ``stat`` via ``should_skip``) are guarded:
|
|
99
|
+
the scans run on the main thread during teardown, so an unhandled OSError
|
|
100
|
+
here would abort launch-close and the summary. On any filesystem error the
|
|
101
|
+
file is skipped rather than raised.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
True if a FileEvent was enqueued, False if skipped (too big, already
|
|
105
|
+
seen, or unreadable).
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
if should_skip(path, config):
|
|
109
|
+
return False
|
|
110
|
+
abs_path = str(path.resolve())
|
|
111
|
+
except (OSError, ValueError):
|
|
112
|
+
# File vanished, permission/symlink error, or unresolvable path —
|
|
113
|
+
# skip it rather than crash the caller (main-thread teardown).
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
if not enqueued.add(abs_path):
|
|
117
|
+
return False
|
|
118
|
+
queue.put(FileEvent(path=path, category=category))
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class _StabilityHandler(FileSystemEventHandler):
|
|
123
|
+
"""Debounces filesystem events, enqueuing files after 500ms of stability."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, queue: "PriorityQueue[FileEvent]", config: Config, enqueued: "_SeenSet") -> None:
|
|
126
|
+
super().__init__()
|
|
127
|
+
self._queue = queue
|
|
128
|
+
self._config = config
|
|
129
|
+
self._timers: dict[str, Timer] = {}
|
|
130
|
+
# Shared across the live observer, flush_pending, and the scans so a
|
|
131
|
+
# given file is enqueued exactly once no matter which producer sees it
|
|
132
|
+
# first. Without this, the end-of-run settle_scan re-enqueues files the
|
|
133
|
+
# live observer already delivered, doubling results_sent.
|
|
134
|
+
self._enqueued = enqueued
|
|
135
|
+
|
|
136
|
+
def on_created(self, event: FileSystemEvent) -> None:
|
|
137
|
+
"""Handle file creation events."""
|
|
138
|
+
if not event.is_directory:
|
|
139
|
+
self._handle_event(event.src_path)
|
|
140
|
+
|
|
141
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
142
|
+
"""Handle file modification events."""
|
|
143
|
+
if not event.is_directory:
|
|
144
|
+
self._handle_event(event.src_path)
|
|
145
|
+
|
|
146
|
+
def _handle_event(self, src_path: str) -> None:
|
|
147
|
+
"""Reset the stability timer for a file path."""
|
|
148
|
+
path = Path(src_path)
|
|
149
|
+
filename = path.name
|
|
150
|
+
|
|
151
|
+
category = classify_file(filename)
|
|
152
|
+
if category is None:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# Cancel existing timer for this path
|
|
156
|
+
existing = self._timers.get(src_path)
|
|
157
|
+
if existing is not None:
|
|
158
|
+
existing.cancel()
|
|
159
|
+
|
|
160
|
+
# Set a new timer that enqueues after stability period
|
|
161
|
+
timer = Timer(_STABILITY_SECONDS, self._enqueue, args=(path, category))
|
|
162
|
+
timer.daemon = True
|
|
163
|
+
self._timers[src_path] = timer
|
|
164
|
+
timer.start()
|
|
165
|
+
|
|
166
|
+
def _enqueue(self, path: Path, category: FileCategory) -> None:
|
|
167
|
+
"""Enqueue a file event after stability check passes."""
|
|
168
|
+
# Remove from timer dict
|
|
169
|
+
self._timers.pop(str(path), None)
|
|
170
|
+
_enqueue_once(self._queue, self._config, self._enqueued, path, category)
|
|
171
|
+
|
|
172
|
+
def cancel_all(self) -> None:
|
|
173
|
+
"""Cancel all pending timers."""
|
|
174
|
+
for timer in self._timers.values():
|
|
175
|
+
timer.cancel()
|
|
176
|
+
self._timers.clear()
|
|
177
|
+
|
|
178
|
+
def flush_pending(self) -> None:
|
|
179
|
+
"""Immediately enqueue all files currently in debounce windows.
|
|
180
|
+
|
|
181
|
+
Called after subprocess exit to ensure no result files are lost due
|
|
182
|
+
to pending debounce timers. Cancels all timers and enqueues their
|
|
183
|
+
associated files directly.
|
|
184
|
+
"""
|
|
185
|
+
# Snapshot and clear timers atomically
|
|
186
|
+
pending = dict(self._timers)
|
|
187
|
+
self._timers.clear()
|
|
188
|
+
|
|
189
|
+
for src_path, timer in pending.items():
|
|
190
|
+
timer.cancel()
|
|
191
|
+
path = Path(src_path)
|
|
192
|
+
|
|
193
|
+
category = classify_file(path.name)
|
|
194
|
+
if category is None:
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
_enqueue_once(self._queue, self._config, self._enqueued, path, category)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class ResultsWatcher:
|
|
201
|
+
"""Watches the allure-results directory for new files using watchdog.
|
|
202
|
+
|
|
203
|
+
Creates the directory if it doesn't exist, observes file creation/modification,
|
|
204
|
+
debounces events for 500ms of stability, then enqueues FileEvent objects
|
|
205
|
+
into the provided PriorityQueue.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(self, results_dir: str, queue: "PriorityQueue[FileEvent]", config: Config) -> None:
|
|
209
|
+
self._results_dir = results_dir
|
|
210
|
+
self._queue = queue
|
|
211
|
+
self._config = config
|
|
212
|
+
# One shared seen-set gates every producer (live observer, flush_pending,
|
|
213
|
+
# final_scan, settle_scan) so each file is enqueued exactly once.
|
|
214
|
+
self._enqueued = _SeenSet()
|
|
215
|
+
self._handler = _StabilityHandler(queue, config, self._enqueued)
|
|
216
|
+
self._observer = Observer()
|
|
217
|
+
|
|
218
|
+
def start(self) -> None:
|
|
219
|
+
"""Create the results directory and start observing for file events."""
|
|
220
|
+
os.makedirs(self._results_dir, exist_ok=True)
|
|
221
|
+
self._observer.schedule(self._handler, self._results_dir, recursive=False)
|
|
222
|
+
self._observer.start()
|
|
223
|
+
|
|
224
|
+
def stop(self) -> None:
|
|
225
|
+
"""Stop the observer and cancel all pending stability timers."""
|
|
226
|
+
self._observer.stop()
|
|
227
|
+
self._observer.join()
|
|
228
|
+
self._handler.cancel_all()
|
|
229
|
+
|
|
230
|
+
def flush_pending(self) -> None:
|
|
231
|
+
"""Flush all files currently in debounce windows into the queue.
|
|
232
|
+
|
|
233
|
+
Should be called BEFORE stop() when you want to capture all pending
|
|
234
|
+
files rather than discard them.
|
|
235
|
+
"""
|
|
236
|
+
self._handler.flush_pending()
|
|
237
|
+
|
|
238
|
+
def final_scan(self) -> int:
|
|
239
|
+
"""Scan the results directory and enqueue any files not already processed.
|
|
240
|
+
|
|
241
|
+
Performs a one-time sweep of all files in the results directory,
|
|
242
|
+
enqueuing any that pass classification and size checks. This catches
|
|
243
|
+
files that the filesystem watcher may have missed entirely (e.g., written
|
|
244
|
+
between watcher setup and observation start, or during high-throughput
|
|
245
|
+
bursts that overwhelm OS event buffers). Files already enqueued by any
|
|
246
|
+
producer are skipped via the shared seen-set.
|
|
247
|
+
|
|
248
|
+
Runs on the main thread during teardown, so all filesystem access is
|
|
249
|
+
guarded: a transient error listing or reading a file skips that file
|
|
250
|
+
rather than aborting the scan (and the launch-close that follows it).
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Number of new files enqueued by this scan.
|
|
254
|
+
"""
|
|
255
|
+
results_path = Path(self._results_dir)
|
|
256
|
+
try:
|
|
257
|
+
entries = list(results_path.iterdir())
|
|
258
|
+
except (OSError, FileNotFoundError):
|
|
259
|
+
# Directory missing or unreadable — nothing to enqueue this pass.
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
enqueued = 0
|
|
263
|
+
for filepath in entries:
|
|
264
|
+
try:
|
|
265
|
+
if not filepath.is_file():
|
|
266
|
+
continue
|
|
267
|
+
except OSError:
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
category = classify_file(filepath.name)
|
|
271
|
+
if category is None:
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
if _enqueue_once(self._queue, self._config, self._enqueued, filepath, category):
|
|
275
|
+
enqueued += 1
|
|
276
|
+
|
|
277
|
+
return enqueued
|
|
278
|
+
|
|
279
|
+
def settle_scan(
|
|
280
|
+
self,
|
|
281
|
+
quiet_period: float = 1.0,
|
|
282
|
+
max_wait: float = 15.0,
|
|
283
|
+
poll_interval: float = 0.25,
|
|
284
|
+
) -> int:
|
|
285
|
+
"""Repeatedly scan until the results directory stops producing new files.
|
|
286
|
+
|
|
287
|
+
Under high parallelism (e.g. pytest-xdist with many workers), allure
|
|
288
|
+
writes its result files in a burst during the test process's teardown.
|
|
289
|
+
A single ``final_scan()`` taken the instant the subprocess exits can run
|
|
290
|
+
before the OS has finished flushing that burst to disk, capturing zero
|
|
291
|
+
or a partial set — the ``watch results_sent=0`` bug. This method keeps
|
|
292
|
+
scanning until no new files have appeared for ``quiet_period`` seconds
|
|
293
|
+
(the directory has "settled"), or until ``max_wait`` seconds elapse.
|
|
294
|
+
|
|
295
|
+
Idempotent with ``final_scan()``: both share the watcher's internal
|
|
296
|
+
``_enqueued`` set, so files already enqueued are never enqueued again.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
quiet_period: Seconds with no new files before the directory is
|
|
300
|
+
considered settled.
|
|
301
|
+
max_wait: Absolute cap on total wait time, so a directory that keeps
|
|
302
|
+
changing can't block teardown forever.
|
|
303
|
+
poll_interval: Delay between scans.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Total number of new files enqueued across all iterations.
|
|
307
|
+
"""
|
|
308
|
+
total = 0
|
|
309
|
+
deadline = time.monotonic() + max_wait
|
|
310
|
+
last_new_at = time.monotonic()
|
|
311
|
+
|
|
312
|
+
while time.monotonic() < deadline:
|
|
313
|
+
new_count = self.final_scan()
|
|
314
|
+
total += new_count
|
|
315
|
+
now = time.monotonic()
|
|
316
|
+
if new_count > 0:
|
|
317
|
+
last_new_at = now
|
|
318
|
+
elif now - last_new_at >= quiet_period:
|
|
319
|
+
# No new files for a full quiet period — directory has settled.
|
|
320
|
+
break
|
|
321
|
+
time.sleep(poll_interval)
|
|
322
|
+
|
|
323
|
+
return total
|
|
@@ -170,3 +170,75 @@ class TestUploadCommand:
|
|
|
170
170
|
|
|
171
171
|
assert result.exit_code == 0
|
|
172
172
|
assert "results_sent" in result.output or "Summary" in result.output or result.exit_code == 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TestReportingFixesEndToEnd:
|
|
176
|
+
"""E2E: skipped tests are sent, and environment.properties → close variables."""
|
|
177
|
+
|
|
178
|
+
@respx.mock
|
|
179
|
+
def test_skipped_result_and_variables_are_sent(self, tmp_path: Path, monkeypatch) -> None:
|
|
180
|
+
"""A skipped result (no 'stop') is uploaded, and environment.properties
|
|
181
|
+
is sent as `variables` in the close-launch body."""
|
|
182
|
+
results_dir = tmp_path / "allure-results"
|
|
183
|
+
results_dir.mkdir()
|
|
184
|
+
|
|
185
|
+
# Skipped result — no "stop" (Allure omits it for tests that never ran)
|
|
186
|
+
(results_dir / "skip-result.json").write_text(json.dumps({
|
|
187
|
+
"uuid": "skip-1",
|
|
188
|
+
"historyId": "hist_skip",
|
|
189
|
+
"fullName": "tests.test_x.test_skipped",
|
|
190
|
+
"name": "test_skipped",
|
|
191
|
+
"status": "skipped",
|
|
192
|
+
"statusDetails": {"message": "conditional skip"},
|
|
193
|
+
"start": 1000,
|
|
194
|
+
"labels": [],
|
|
195
|
+
"parameters": [],
|
|
196
|
+
}))
|
|
197
|
+
|
|
198
|
+
# Allure environment.properties → launch variables
|
|
199
|
+
(results_dir / "environment.properties").write_text(
|
|
200
|
+
"environment_name=dv1\naws_region=us-west-2\nworkers_number=8\n"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
respx.post("https://charisma.test/api/v1/launches").mock(
|
|
204
|
+
return_value=httpx.Response(
|
|
205
|
+
201, json={"launchId": "launch-x", "projectId": "proj-uuid"}
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
results_route = respx.post(
|
|
209
|
+
"https://charisma.test/api/v1/launches/launch-x/results"
|
|
210
|
+
).mock(return_value=httpx.Response(200, json={"accepted": 1}))
|
|
211
|
+
close_route = respx.post(
|
|
212
|
+
"https://charisma.test/api/v1/launches/launch-x/close"
|
|
213
|
+
).mock(return_value=httpx.Response(200, json={}))
|
|
214
|
+
|
|
215
|
+
monkeypatch.setenv("CHARISMA_ENDPOINT", "https://charisma.test")
|
|
216
|
+
monkeypatch.setenv("CHARISMA_TOKEN", "tok")
|
|
217
|
+
|
|
218
|
+
runner = CliRunner()
|
|
219
|
+
result = runner.invoke(cli, [
|
|
220
|
+
"watch",
|
|
221
|
+
"--project", "p",
|
|
222
|
+
"--results", str(results_dir),
|
|
223
|
+
"--", sys.executable, "-c", "import sys; sys.exit(0)",
|
|
224
|
+
])
|
|
225
|
+
|
|
226
|
+
assert result.exit_code == 0
|
|
227
|
+
|
|
228
|
+
# The skipped result was sent (not dropped)
|
|
229
|
+
assert results_route.called
|
|
230
|
+
sent_results = json.loads(results_route.calls[0].request.content)["results"]
|
|
231
|
+
outcomes = {r["testId"]: r["outcome"] for r in sent_results}
|
|
232
|
+
assert outcomes.get("hist_skip") == "skipped"
|
|
233
|
+
# duration defaulted to 0 for the missing stop
|
|
234
|
+
skip_payload = next(r for r in sent_results if r["testId"] == "hist_skip")
|
|
235
|
+
assert skip_payload["duration_ms"] == 0
|
|
236
|
+
|
|
237
|
+
# environment.properties sent as variables on close
|
|
238
|
+
assert close_route.called
|
|
239
|
+
close_body = json.loads(close_route.calls[0].request.content)
|
|
240
|
+
assert close_body["variables"] == {
|
|
241
|
+
"environment_name": "dv1",
|
|
242
|
+
"aws_region": "us-west-2",
|
|
243
|
+
"workers_number": "8",
|
|
244
|
+
}
|
|
@@ -216,8 +216,12 @@ class TestParseResultFile:
|
|
|
216
216
|
|
|
217
217
|
assert result is None
|
|
218
218
|
|
|
219
|
-
def
|
|
220
|
-
"""Result without timing fields
|
|
219
|
+
def test_missing_start_stop_parses_with_zero_duration(self, tmp_path: Path) -> None:
|
|
220
|
+
"""Result without timing fields parses with duration 0 (not dropped).
|
|
221
|
+
|
|
222
|
+
Previously this returned None, which silently dropped skipped tests
|
|
223
|
+
(Allure omits 'stop' for tests that never executed).
|
|
224
|
+
"""
|
|
221
225
|
data = {
|
|
222
226
|
"uuid": "no-time",
|
|
223
227
|
"historyId": "hist",
|
|
@@ -227,7 +231,10 @@ class TestParseResultFile:
|
|
|
227
231
|
filepath = self._write_json(tmp_path, data)
|
|
228
232
|
result = parse_result_file(filepath)
|
|
229
233
|
|
|
230
|
-
assert result is None
|
|
234
|
+
assert result is not None
|
|
235
|
+
assert result.duration_ms == 0
|
|
236
|
+
assert result.started_at is None
|
|
237
|
+
assert result.ended_at is None
|
|
231
238
|
|
|
232
239
|
def test_nonexistent_file_returns_none(self, tmp_path: Path) -> None:
|
|
233
240
|
"""File that doesn't exist returns None gracefully."""
|
|
@@ -534,3 +541,111 @@ class TestExtractAttachmentRefs:
|
|
|
534
541
|
|
|
535
542
|
assert len(refs) == 1
|
|
536
543
|
assert refs[0].name == "abc-screenshot.png"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class TestSkippedResultParsing:
|
|
547
|
+
"""Regression: skipped tests (no 'stop', sometimes no 'start') must parse.
|
|
548
|
+
|
|
549
|
+
Allure omits 'stop' for tests that never executed (skipped). The parser
|
|
550
|
+
previously required both start and stop and returned None, silently
|
|
551
|
+
dropping skipped results so they never reached Charisma.
|
|
552
|
+
"""
|
|
553
|
+
|
|
554
|
+
def test_skipped_without_stop_parses(self, tmp_path: Path) -> None:
|
|
555
|
+
"""A skipped result with 'start' but no 'stop' parses with duration 0."""
|
|
556
|
+
filepath = tmp_path / "skip-result.json"
|
|
557
|
+
filepath.write_text(json.dumps({
|
|
558
|
+
"uuid": "skip-uuid",
|
|
559
|
+
"historyId": "hist-skip",
|
|
560
|
+
"fullName": "tests.test_mod.test_skipped",
|
|
561
|
+
"name": "test_skipped",
|
|
562
|
+
"status": "skipped",
|
|
563
|
+
"statusDetails": {"message": "conditional skip"},
|
|
564
|
+
"start": 1000,
|
|
565
|
+
# no "stop"
|
|
566
|
+
"labels": [],
|
|
567
|
+
"parameters": [],
|
|
568
|
+
}))
|
|
569
|
+
|
|
570
|
+
result = parse_result_file(filepath)
|
|
571
|
+
|
|
572
|
+
assert result is not None
|
|
573
|
+
assert result.outcome == "skipped"
|
|
574
|
+
assert result.duration_ms == 0
|
|
575
|
+
assert result.testId == "hist-skip"
|
|
576
|
+
assert result.error_message == "conditional skip"
|
|
577
|
+
|
|
578
|
+
def test_skipped_without_start_or_stop_parses(self, tmp_path: Path) -> None:
|
|
579
|
+
"""A skipped result with neither start nor stop still parses."""
|
|
580
|
+
filepath = tmp_path / "skip2-result.json"
|
|
581
|
+
filepath.write_text(json.dumps({
|
|
582
|
+
"uuid": "skip2-uuid",
|
|
583
|
+
"fullName": "tests.test_mod.test_skipped2",
|
|
584
|
+
"status": "skipped",
|
|
585
|
+
"labels": [],
|
|
586
|
+
"parameters": [],
|
|
587
|
+
}))
|
|
588
|
+
|
|
589
|
+
result = parse_result_file(filepath)
|
|
590
|
+
|
|
591
|
+
assert result is not None
|
|
592
|
+
assert result.outcome == "skipped"
|
|
593
|
+
assert result.duration_ms == 0
|
|
594
|
+
assert result.started_at is None
|
|
595
|
+
assert result.ended_at is None
|
|
596
|
+
|
|
597
|
+
def test_missing_uuid_still_returns_none(self, tmp_path: Path) -> None:
|
|
598
|
+
"""uuid remains strictly required — its absence returns None."""
|
|
599
|
+
filepath = tmp_path / "nouuid-result.json"
|
|
600
|
+
filepath.write_text(json.dumps({
|
|
601
|
+
"fullName": "tests.test_mod.test_x",
|
|
602
|
+
"status": "passed",
|
|
603
|
+
"start": 1000,
|
|
604
|
+
"stop": 2000,
|
|
605
|
+
}))
|
|
606
|
+
|
|
607
|
+
assert parse_result_file(filepath) is None
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
class TestParseEnvironmentProperties:
|
|
611
|
+
"""parse_environment_properties reads Allure environment.properties files."""
|
|
612
|
+
|
|
613
|
+
def test_parses_key_value_pairs(self, tmp_path: Path) -> None:
|
|
614
|
+
from charisma_cli.parser import parse_environment_properties
|
|
615
|
+
|
|
616
|
+
f = tmp_path / "environment.properties"
|
|
617
|
+
f.write_text(
|
|
618
|
+
"environment_name=dv1\n"
|
|
619
|
+
"aws_region=us-west-2\n"
|
|
620
|
+
"workers_number=8\n"
|
|
621
|
+
)
|
|
622
|
+
result = parse_environment_properties(f)
|
|
623
|
+
assert result == {
|
|
624
|
+
"environment_name": "dv1",
|
|
625
|
+
"aws_region": "us-west-2",
|
|
626
|
+
"workers_number": "8",
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
def test_ignores_comments_and_blank_lines(self, tmp_path: Path) -> None:
|
|
630
|
+
from charisma_cli.parser import parse_environment_properties
|
|
631
|
+
|
|
632
|
+
f = tmp_path / "environment.properties"
|
|
633
|
+
f.write_text(
|
|
634
|
+
"# a comment\n"
|
|
635
|
+
"! another comment\n"
|
|
636
|
+
"\n"
|
|
637
|
+
"key=value\n"
|
|
638
|
+
)
|
|
639
|
+
assert parse_environment_properties(f) == {"key": "value"}
|
|
640
|
+
|
|
641
|
+
def test_value_with_equals_sign_keeps_remainder(self, tmp_path: Path) -> None:
|
|
642
|
+
from charisma_cli.parser import parse_environment_properties
|
|
643
|
+
|
|
644
|
+
f = tmp_path / "environment.properties"
|
|
645
|
+
f.write_text("url=https://x.com/a?b=c\n")
|
|
646
|
+
assert parse_environment_properties(f) == {"url": "https://x.com/a?b=c"}
|
|
647
|
+
|
|
648
|
+
def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
|
|
649
|
+
from charisma_cli.parser import parse_environment_properties
|
|
650
|
+
|
|
651
|
+
assert parse_environment_properties(tmp_path / "nope.properties") == {}
|
|
@@ -520,8 +520,12 @@ class TestFinalScan:
|
|
|
520
520
|
event = queue.get_nowait()
|
|
521
521
|
assert event.path.name == "valid-result.json"
|
|
522
522
|
|
|
523
|
-
def
|
|
524
|
-
"""final_scan skips files
|
|
523
|
+
def test_skips_files_already_enqueued(self, tmp_path: Path) -> None:
|
|
524
|
+
"""final_scan skips files already enqueued by a prior producer.
|
|
525
|
+
|
|
526
|
+
Dedup is via the watcher's shared seen-set: a file the live observer
|
|
527
|
+
already claimed is not re-enqueued by a subsequent scan.
|
|
528
|
+
"""
|
|
525
529
|
results_dir = tmp_path / "allure-results"
|
|
526
530
|
results_dir.mkdir()
|
|
527
531
|
|
|
@@ -534,8 +538,11 @@ class TestFinalScan:
|
|
|
534
538
|
config = _make_config()
|
|
535
539
|
watcher = ResultsWatcher(str(results_dir), queue, config)
|
|
536
540
|
|
|
537
|
-
|
|
538
|
-
|
|
541
|
+
# Simulate file_a already enqueued via the live-observer path.
|
|
542
|
+
watcher._handler._enqueue(file_a, FileCategory.RESULT)
|
|
543
|
+
queue.get_nowait() # drain the observer's enqueue
|
|
544
|
+
|
|
545
|
+
count = watcher.final_scan()
|
|
539
546
|
|
|
540
547
|
assert count == 1
|
|
541
548
|
event = queue.get_nowait()
|
|
@@ -569,3 +576,230 @@ class TestFinalScan:
|
|
|
569
576
|
|
|
570
577
|
assert count == 0
|
|
571
578
|
assert queue.empty()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
# settle_scan() tests — the fix for the `watch results_sent=0` bug under
|
|
583
|
+
# high pytest-xdist parallelism (burst of result files written at teardown).
|
|
584
|
+
# ---------------------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class TestSettleScan:
|
|
588
|
+
"""settle_scan rescans until the results directory stops changing.
|
|
589
|
+
|
|
590
|
+
A single final_scan() taken the instant the test subprocess exits can run
|
|
591
|
+
before the OS finishes flushing allure's end-of-session burst, capturing
|
|
592
|
+
nothing (the results_sent=0 bug). settle_scan keeps scanning until the
|
|
593
|
+
directory is quiet, so the whole burst is captured regardless of how many
|
|
594
|
+
files or workers produced it.
|
|
595
|
+
"""
|
|
596
|
+
|
|
597
|
+
def test_captures_files_present_before_scan(self, tmp_path: Path) -> None:
|
|
598
|
+
"""Files already on disk are enqueued by the first settle_scan pass."""
|
|
599
|
+
results_dir = tmp_path / "allure-results"
|
|
600
|
+
results_dir.mkdir()
|
|
601
|
+
for i in range(5):
|
|
602
|
+
(results_dir / f"r{i}-result.json").write_text("{}")
|
|
603
|
+
|
|
604
|
+
queue: PriorityQueue = PriorityQueue()
|
|
605
|
+
watcher = ResultsWatcher(str(results_dir), queue, _make_config())
|
|
606
|
+
|
|
607
|
+
enqueued = watcher.settle_scan(quiet_period=0.3, max_wait=3.0, poll_interval=0.1)
|
|
608
|
+
|
|
609
|
+
assert enqueued == 5
|
|
610
|
+
assert queue.qsize() == 5
|
|
611
|
+
|
|
612
|
+
def test_captures_single_result_flushed_after_first_scan(self, tmp_path: Path) -> None:
|
|
613
|
+
"""Single-test case: one result file flushed AFTER the initial scan.
|
|
614
|
+
|
|
615
|
+
Reproduces the observed 1-test / 1-worker `results_sent=0` failure —
|
|
616
|
+
allure-pytest flushes its one result file during process teardown, which
|
|
617
|
+
surfaces to the filesystem just after the subprocess exits. A single
|
|
618
|
+
final_scan taken at that instant captures 0; settle_scan waits and
|
|
619
|
+
captures the 1 late file. This is the N=1 boundary of the burst race.
|
|
620
|
+
"""
|
|
621
|
+
import threading
|
|
622
|
+
|
|
623
|
+
results_dir = tmp_path / "allure-results"
|
|
624
|
+
results_dir.mkdir()
|
|
625
|
+
|
|
626
|
+
queue: PriorityQueue = PriorityQueue()
|
|
627
|
+
watcher = ResultsWatcher(str(results_dir), queue, _make_config())
|
|
628
|
+
|
|
629
|
+
def write_single_late() -> None:
|
|
630
|
+
time.sleep(0.3)
|
|
631
|
+
(results_dir / "only-result.json").write_text("{}")
|
|
632
|
+
|
|
633
|
+
writer = threading.Thread(target=write_single_late)
|
|
634
|
+
writer.start()
|
|
635
|
+
|
|
636
|
+
# Directory is empty when the first scan runs (the old bug: captures 0).
|
|
637
|
+
first = watcher.final_scan()
|
|
638
|
+
assert first == 0
|
|
639
|
+
|
|
640
|
+
settled = watcher.settle_scan(quiet_period=0.5, max_wait=10.0, poll_interval=0.1)
|
|
641
|
+
writer.join()
|
|
642
|
+
|
|
643
|
+
assert first + settled == 1
|
|
644
|
+
assert queue.qsize() == 1
|
|
645
|
+
|
|
646
|
+
def test_captures_burst_written_after_first_scan(self, tmp_path: Path) -> None:
|
|
647
|
+
"""The core fix: files that appear AFTER the initial scan are still captured.
|
|
648
|
+
|
|
649
|
+
Reproduces the teardown-burst race — a single final_scan would have
|
|
650
|
+
captured only the one baseline file; settle_scan waits for the rest.
|
|
651
|
+
"""
|
|
652
|
+
import threading
|
|
653
|
+
|
|
654
|
+
results_dir = tmp_path / "allure-results"
|
|
655
|
+
results_dir.mkdir()
|
|
656
|
+
(results_dir / "baseline-result.json").write_text("{}")
|
|
657
|
+
|
|
658
|
+
queue: PriorityQueue = PriorityQueue()
|
|
659
|
+
watcher = ResultsWatcher(str(results_dir), queue, _make_config())
|
|
660
|
+
|
|
661
|
+
def write_burst() -> None:
|
|
662
|
+
time.sleep(0.2)
|
|
663
|
+
for i in range(30):
|
|
664
|
+
(results_dir / f"burst{i}-result.json").write_text("{}")
|
|
665
|
+
time.sleep(0.02)
|
|
666
|
+
|
|
667
|
+
writer = threading.Thread(target=write_burst)
|
|
668
|
+
writer.start()
|
|
669
|
+
|
|
670
|
+
# A single scan would see only the baseline file.
|
|
671
|
+
first = watcher.final_scan()
|
|
672
|
+
assert first == 1
|
|
673
|
+
|
|
674
|
+
# settle_scan reconciles the burst that lands afterward.
|
|
675
|
+
settled = watcher.settle_scan(quiet_period=0.5, max_wait=10.0, poll_interval=0.1)
|
|
676
|
+
writer.join()
|
|
677
|
+
|
|
678
|
+
assert first + settled == 31
|
|
679
|
+
assert queue.qsize() == 31
|
|
680
|
+
|
|
681
|
+
def test_no_duplicate_enqueue_across_scans(self, tmp_path: Path) -> None:
|
|
682
|
+
"""Repeated scans never enqueue the same file twice (shared seen-set)."""
|
|
683
|
+
results_dir = tmp_path / "allure-results"
|
|
684
|
+
results_dir.mkdir()
|
|
685
|
+
for i in range(4):
|
|
686
|
+
(results_dir / f"r{i}-result.json").write_text("{}")
|
|
687
|
+
|
|
688
|
+
queue: PriorityQueue = PriorityQueue()
|
|
689
|
+
watcher = ResultsWatcher(str(results_dir), queue, _make_config())
|
|
690
|
+
|
|
691
|
+
first = watcher.settle_scan(quiet_period=0.3, max_wait=3.0, poll_interval=0.1)
|
|
692
|
+
second = watcher.settle_scan(quiet_period=0.3, max_wait=3.0, poll_interval=0.1)
|
|
693
|
+
|
|
694
|
+
assert first == 4
|
|
695
|
+
assert second == 0 # nothing new — no double-enqueue
|
|
696
|
+
assert queue.qsize() == 4
|
|
697
|
+
|
|
698
|
+
def test_observer_enqueue_and_settle_scan_dedup(self, tmp_path: Path) -> None:
|
|
699
|
+
"""Files the live observer already enqueued are not re-enqueued by settle_scan.
|
|
700
|
+
|
|
701
|
+
The live-observer path (_StabilityHandler._enqueue) and settle_scan share
|
|
702
|
+
the watcher's seen-set. Without this, settle_scan would double-count files
|
|
703
|
+
the observer already delivered — the results_sent doubling regression.
|
|
704
|
+
"""
|
|
705
|
+
results_dir = tmp_path / "allure-results"
|
|
706
|
+
results_dir.mkdir()
|
|
707
|
+
files = []
|
|
708
|
+
for i in range(10):
|
|
709
|
+
f = results_dir / f"r{i}-result.json"
|
|
710
|
+
f.write_text("{}")
|
|
711
|
+
files.append(f)
|
|
712
|
+
|
|
713
|
+
queue: PriorityQueue = PriorityQueue()
|
|
714
|
+
watcher = ResultsWatcher(str(results_dir), queue, _make_config())
|
|
715
|
+
|
|
716
|
+
# Simulate the live observer having already enqueued the first 4 files
|
|
717
|
+
# by driving the handler's enqueue path directly.
|
|
718
|
+
for f in files[:4]:
|
|
719
|
+
watcher._handler._enqueue(f, FileCategory.RESULT)
|
|
720
|
+
assert queue.qsize() == 4
|
|
721
|
+
|
|
722
|
+
# settle_scan should add only the remaining 6, not re-add the first 4.
|
|
723
|
+
added = watcher.settle_scan(quiet_period=0.3, max_wait=3.0, poll_interval=0.1)
|
|
724
|
+
|
|
725
|
+
assert added == 6
|
|
726
|
+
assert queue.qsize() == 10 # 10 unique, zero duplicates
|
|
727
|
+
|
|
728
|
+
seen: set[str] = set()
|
|
729
|
+
while not queue.empty():
|
|
730
|
+
ev: FileEvent = queue.get_nowait()
|
|
731
|
+
key = str(ev.path.resolve())
|
|
732
|
+
assert key not in seen, "duplicate file enqueued"
|
|
733
|
+
seen.add(key)
|
|
734
|
+
assert len(seen) == 10
|
|
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."""
|
|
738
|
+
results_dir = tmp_path / "allure-results"
|
|
739
|
+
results_dir.mkdir()
|
|
740
|
+
|
|
741
|
+
queue: PriorityQueue = PriorityQueue()
|
|
742
|
+
watcher = ResultsWatcher(str(results_dir), queue, _make_config())
|
|
743
|
+
|
|
744
|
+
start = time.monotonic()
|
|
745
|
+
enqueued = watcher.settle_scan(quiet_period=0.3, max_wait=5.0, poll_interval=0.1)
|
|
746
|
+
elapsed = time.monotonic() - start
|
|
747
|
+
|
|
748
|
+
assert enqueued == 0
|
|
749
|
+
assert queue.empty()
|
|
750
|
+
assert elapsed < 4.0 # returned on the quiet-period, not the max_wait cap
|
|
751
|
+
|
|
752
|
+
def test_respects_max_wait_when_directory_never_settles(self, tmp_path: Path) -> None:
|
|
753
|
+
"""max_wait caps total time even if files keep arriving continuously.
|
|
754
|
+
|
|
755
|
+
This documents the one bounded caveat: a directory that never goes quiet
|
|
756
|
+
stops at max_wait rather than blocking teardown forever.
|
|
757
|
+
"""
|
|
758
|
+
import threading
|
|
759
|
+
|
|
760
|
+
results_dir = tmp_path / "allure-results"
|
|
761
|
+
results_dir.mkdir()
|
|
762
|
+
|
|
763
|
+
queue: PriorityQueue = PriorityQueue()
|
|
764
|
+
watcher = ResultsWatcher(str(results_dir), queue, _make_config())
|
|
765
|
+
|
|
766
|
+
stop = threading.Event()
|
|
767
|
+
|
|
768
|
+
def write_forever() -> None:
|
|
769
|
+
i = 0
|
|
770
|
+
while not stop.is_set():
|
|
771
|
+
(results_dir / f"c{i}-result.json").write_text("{}")
|
|
772
|
+
i += 1
|
|
773
|
+
time.sleep(0.05)
|
|
774
|
+
|
|
775
|
+
writer = threading.Thread(target=write_forever)
|
|
776
|
+
writer.start()
|
|
777
|
+
try:
|
|
778
|
+
start = time.monotonic()
|
|
779
|
+
watcher.settle_scan(quiet_period=0.5, max_wait=1.5, poll_interval=0.1)
|
|
780
|
+
elapsed = time.monotonic() - start
|
|
781
|
+
finally:
|
|
782
|
+
stop.set()
|
|
783
|
+
writer.join()
|
|
784
|
+
|
|
785
|
+
# Should stop at ~max_wait, not run indefinitely.
|
|
786
|
+
assert 1.5 <= elapsed < 4.0
|
|
787
|
+
|
|
788
|
+
def test_scales_to_large_burst(self, tmp_path: Path) -> None:
|
|
789
|
+
"""A large number of files (simulating many workers/tests) is fully captured.
|
|
790
|
+
|
|
791
|
+
Correctness is independent of file count — settle_scan keeps scanning
|
|
792
|
+
until quiet, so 500 files enqueue exactly once each.
|
|
793
|
+
"""
|
|
794
|
+
results_dir = tmp_path / "allure-results"
|
|
795
|
+
results_dir.mkdir()
|
|
796
|
+
for i in range(500):
|
|
797
|
+
(results_dir / f"r{i}-result.json").write_text("{}")
|
|
798
|
+
|
|
799
|
+
queue: PriorityQueue = PriorityQueue()
|
|
800
|
+
watcher = ResultsWatcher(str(results_dir), queue, _make_config())
|
|
801
|
+
|
|
802
|
+
enqueued = watcher.settle_scan(quiet_period=0.3, max_wait=10.0, poll_interval=0.1)
|
|
803
|
+
|
|
804
|
+
assert enqueued == 500
|
|
805
|
+
assert queue.qsize() == 500
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
"""Watchdog observer, stability debounce, and file classification for allure-results."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from queue import PriorityQueue
|
|
6
|
-
from threading import Timer
|
|
7
|
-
|
|
8
|
-
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
9
|
-
from watchdog.observers import Observer
|
|
10
|
-
|
|
11
|
-
from charisma_cli.config import Config
|
|
12
|
-
from charisma_cli.models import FileCategory, FileEvent
|
|
13
|
-
|
|
14
|
-
_TWO_MB = 2 * 1024 * 1024
|
|
15
|
-
_STABILITY_SECONDS = 0.5
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def classify_file(filename: str) -> FileCategory | None:
|
|
19
|
-
"""Classify a filename into a FileCategory or None if excluded.
|
|
20
|
-
|
|
21
|
-
Args:
|
|
22
|
-
filename: The base filename (not full path).
|
|
23
|
-
|
|
24
|
-
Returns:
|
|
25
|
-
FileCategory.RESULT for *-result.json,
|
|
26
|
-
FileCategory.CONTAINER for *-container.json,
|
|
27
|
-
FileCategory.ATTACHMENT for all other valid files,
|
|
28
|
-
None for excluded files (dot-prefix or .tmp suffix).
|
|
29
|
-
"""
|
|
30
|
-
if filename.startswith("."):
|
|
31
|
-
return None
|
|
32
|
-
if filename.endswith(".tmp"):
|
|
33
|
-
return None
|
|
34
|
-
if filename.endswith("-result.json"):
|
|
35
|
-
return FileCategory.RESULT
|
|
36
|
-
if filename.endswith("-container.json"):
|
|
37
|
-
return FileCategory.CONTAINER
|
|
38
|
-
return FileCategory.ATTACHMENT
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def should_skip(path: Path, config: Config) -> bool:
|
|
42
|
-
"""Check whether a file should be skipped based on size.
|
|
43
|
-
|
|
44
|
-
Args:
|
|
45
|
-
path: Path to the file on disk.
|
|
46
|
-
config: Resolved CLI configuration.
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
True if the file exceeds 2MB and skip_too_big is enabled.
|
|
50
|
-
"""
|
|
51
|
-
if not config.skip_too_big:
|
|
52
|
-
return False
|
|
53
|
-
return path.stat().st_size > _TWO_MB
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class _StabilityHandler(FileSystemEventHandler):
|
|
57
|
-
"""Debounces filesystem events, enqueuing files after 500ms of stability."""
|
|
58
|
-
|
|
59
|
-
def __init__(self, queue: PriorityQueue, config: Config) -> None:
|
|
60
|
-
super().__init__()
|
|
61
|
-
self._queue = queue
|
|
62
|
-
self._config = config
|
|
63
|
-
self._timers: dict[str, Timer] = {}
|
|
64
|
-
|
|
65
|
-
def on_created(self, event: FileSystemEvent) -> None:
|
|
66
|
-
"""Handle file creation events."""
|
|
67
|
-
if not event.is_directory:
|
|
68
|
-
self._handle_event(event.src_path)
|
|
69
|
-
|
|
70
|
-
def on_modified(self, event: FileSystemEvent) -> None:
|
|
71
|
-
"""Handle file modification events."""
|
|
72
|
-
if not event.is_directory:
|
|
73
|
-
self._handle_event(event.src_path)
|
|
74
|
-
|
|
75
|
-
def _handle_event(self, src_path: str) -> None:
|
|
76
|
-
"""Reset the stability timer for a file path."""
|
|
77
|
-
path = Path(src_path)
|
|
78
|
-
filename = path.name
|
|
79
|
-
|
|
80
|
-
category = classify_file(filename)
|
|
81
|
-
if category is None:
|
|
82
|
-
return
|
|
83
|
-
|
|
84
|
-
# Cancel existing timer for this path
|
|
85
|
-
existing = self._timers.get(src_path)
|
|
86
|
-
if existing is not None:
|
|
87
|
-
existing.cancel()
|
|
88
|
-
|
|
89
|
-
# Set a new timer that enqueues after stability period
|
|
90
|
-
timer = Timer(_STABILITY_SECONDS, self._enqueue, args=(path, category))
|
|
91
|
-
timer.daemon = True
|
|
92
|
-
self._timers[src_path] = timer
|
|
93
|
-
timer.start()
|
|
94
|
-
|
|
95
|
-
def _enqueue(self, path: Path, category: FileCategory) -> None:
|
|
96
|
-
"""Enqueue a file event after stability check passes."""
|
|
97
|
-
# Remove from timer dict
|
|
98
|
-
self._timers.pop(str(path), None)
|
|
99
|
-
|
|
100
|
-
# Check file still exists and size at enqueue time
|
|
101
|
-
try:
|
|
102
|
-
if should_skip(path, self._config):
|
|
103
|
-
return
|
|
104
|
-
except (OSError, FileNotFoundError):
|
|
105
|
-
return # File was deleted between debounce and enqueue
|
|
106
|
-
|
|
107
|
-
self._queue.put(FileEvent(path=path, category=category))
|
|
108
|
-
|
|
109
|
-
def cancel_all(self) -> None:
|
|
110
|
-
"""Cancel all pending timers."""
|
|
111
|
-
for timer in self._timers.values():
|
|
112
|
-
timer.cancel()
|
|
113
|
-
self._timers.clear()
|
|
114
|
-
|
|
115
|
-
def flush_pending(self) -> None:
|
|
116
|
-
"""Immediately enqueue all files currently in debounce windows.
|
|
117
|
-
|
|
118
|
-
Called after subprocess exit to ensure no result files are lost due
|
|
119
|
-
to pending debounce timers. Cancels all timers and enqueues their
|
|
120
|
-
associated files directly.
|
|
121
|
-
"""
|
|
122
|
-
# Snapshot and clear timers atomically
|
|
123
|
-
pending = dict(self._timers)
|
|
124
|
-
self._timers.clear()
|
|
125
|
-
|
|
126
|
-
for src_path, timer in pending.items():
|
|
127
|
-
timer.cancel()
|
|
128
|
-
path = Path(src_path)
|
|
129
|
-
filename = path.name
|
|
130
|
-
|
|
131
|
-
category = classify_file(filename)
|
|
132
|
-
if category is None:
|
|
133
|
-
continue
|
|
134
|
-
|
|
135
|
-
try:
|
|
136
|
-
if should_skip(path, self._config):
|
|
137
|
-
continue
|
|
138
|
-
except (OSError, FileNotFoundError):
|
|
139
|
-
continue
|
|
140
|
-
|
|
141
|
-
self._queue.put(FileEvent(path=path, category=category))
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
class ResultsWatcher:
|
|
145
|
-
"""Watches the allure-results directory for new files using watchdog.
|
|
146
|
-
|
|
147
|
-
Creates the directory if it doesn't exist, observes file creation/modification,
|
|
148
|
-
debounces events for 500ms of stability, then enqueues FileEvent objects
|
|
149
|
-
into the provided PriorityQueue.
|
|
150
|
-
"""
|
|
151
|
-
|
|
152
|
-
def __init__(self, results_dir: str, queue: PriorityQueue, config: Config) -> None:
|
|
153
|
-
self._results_dir = results_dir
|
|
154
|
-
self._queue = queue
|
|
155
|
-
self._config = config
|
|
156
|
-
self._handler = _StabilityHandler(queue, config)
|
|
157
|
-
self._observer = Observer()
|
|
158
|
-
|
|
159
|
-
def start(self) -> None:
|
|
160
|
-
"""Create the results directory and start observing for file events."""
|
|
161
|
-
os.makedirs(self._results_dir, exist_ok=True)
|
|
162
|
-
self._observer.schedule(self._handler, self._results_dir, recursive=False)
|
|
163
|
-
self._observer.start()
|
|
164
|
-
|
|
165
|
-
def stop(self) -> None:
|
|
166
|
-
"""Stop the observer and cancel all pending stability timers."""
|
|
167
|
-
self._observer.stop()
|
|
168
|
-
self._observer.join()
|
|
169
|
-
self._handler.cancel_all()
|
|
170
|
-
|
|
171
|
-
def flush_pending(self) -> None:
|
|
172
|
-
"""Flush all files currently in debounce windows into the queue.
|
|
173
|
-
|
|
174
|
-
Should be called BEFORE stop() when you want to capture all pending
|
|
175
|
-
files rather than discard them.
|
|
176
|
-
"""
|
|
177
|
-
self._handler.flush_pending()
|
|
178
|
-
|
|
179
|
-
def final_scan(self, already_seen: set[str] | None = None) -> int:
|
|
180
|
-
"""Scan the results directory and enqueue any files not already processed.
|
|
181
|
-
|
|
182
|
-
Performs a one-time sweep of all files in the results directory,
|
|
183
|
-
enqueuing any that pass classification and size checks. This catches
|
|
184
|
-
files that the filesystem watcher may have missed entirely (e.g., written
|
|
185
|
-
between watcher setup and observation start, or during high-throughput
|
|
186
|
-
bursts that overwhelm OS event buffers).
|
|
187
|
-
|
|
188
|
-
Args:
|
|
189
|
-
already_seen: Optional set of absolute path strings that were
|
|
190
|
-
already enqueued. Files in this set are skipped.
|
|
191
|
-
|
|
192
|
-
Returns:
|
|
193
|
-
Number of new files enqueued by this scan.
|
|
194
|
-
"""
|
|
195
|
-
results_path = Path(self._results_dir)
|
|
196
|
-
if not results_path.exists():
|
|
197
|
-
return 0
|
|
198
|
-
|
|
199
|
-
enqueued = 0
|
|
200
|
-
seen = already_seen or set()
|
|
201
|
-
|
|
202
|
-
for filepath in results_path.iterdir():
|
|
203
|
-
if not filepath.is_file():
|
|
204
|
-
continue
|
|
205
|
-
|
|
206
|
-
abs_path = str(filepath.resolve())
|
|
207
|
-
if abs_path in seen:
|
|
208
|
-
continue
|
|
209
|
-
|
|
210
|
-
category = classify_file(filepath.name)
|
|
211
|
-
if category is None:
|
|
212
|
-
continue
|
|
213
|
-
|
|
214
|
-
try:
|
|
215
|
-
if should_skip(filepath, self._config):
|
|
216
|
-
continue
|
|
217
|
-
except (OSError, FileNotFoundError):
|
|
218
|
-
continue
|
|
219
|
-
|
|
220
|
-
self._queue.put(FileEvent(path=filepath, category=category))
|
|
221
|
-
enqueued += 1
|
|
222
|
-
|
|
223
|
-
return enqueued
|
|
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
|