charisma-cli 0.1.6__tar.gz → 0.2.0__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.0}/.gitignore +1 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/PKG-INFO +1 -1
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/pyproject.toml +1 -1
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/__init__.py +1 -1
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/main.py +20 -9
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/uploader.py +20 -10
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/watcher.py +27 -2
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_integration.py +132 -2
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_watcher.py +25 -6
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/README.md +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/config.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/launch_url.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/models.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/parser.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/retry.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/subprocess_mgr.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/__init__.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/conftest.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_cli.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_config.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_launch_url.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_models.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_parser.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_retry.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_silent_mode.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_subprocess_mgr.py +0 -0
- {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_uploader.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()
|
|
@@ -26,6 +26,11 @@ _BATCH_SIZE = 50
|
|
|
26
26
|
_BATCH_TIMEOUT_SECONDS = 2.0
|
|
27
27
|
_CONTAINER_ASSOCIATION_TIMEOUT = 60.0
|
|
28
28
|
|
|
29
|
+
# Sentinel expectedTests for CLI launches: the total count is unknown at open
|
|
30
|
+
# time (directory-watch streaming), so we send the backend maximum. This keeps
|
|
31
|
+
# the launch in 'receiving' across all appends; close_launch() finalizes it.
|
|
32
|
+
_UNKNOWN_EXPECTED_TESTS = 1_000_000
|
|
33
|
+
|
|
29
34
|
|
|
30
35
|
def _epoch_ms_to_iso(epoch_ms: int) -> str:
|
|
31
36
|
"""Convert epoch milliseconds to ISO 8601 UTC string."""
|
|
@@ -173,9 +178,14 @@ class Uploader:
|
|
|
173
178
|
"""
|
|
174
179
|
self.ensure_client()
|
|
175
180
|
|
|
181
|
+
# The CLI streams from a watched directory and does not know the total
|
|
182
|
+
# test count at open time. Send a high sentinel so the launch stays
|
|
183
|
+
# 'receiving' through every append; finalization is driven by
|
|
184
|
+
# close_launch() at the end of the run (or the stale-launch finalizer as
|
|
185
|
+
# a backstop), never by the append counter reaching expectedTests.
|
|
176
186
|
payload: dict[str, Any] = {
|
|
177
187
|
"projectAlias": self._config.project,
|
|
178
|
-
"expectedTests":
|
|
188
|
+
"expectedTests": _UNKNOWN_EXPECTED_TESTS,
|
|
179
189
|
}
|
|
180
190
|
if self._config.build_id:
|
|
181
191
|
payload["buildId"] = self._config.build_id
|
|
@@ -306,16 +316,16 @@ class Uploader:
|
|
|
306
316
|
if result.uuid:
|
|
307
317
|
self._known_result_uuids.add(result.uuid)
|
|
308
318
|
|
|
309
|
-
# Build API payload
|
|
319
|
+
# Build API payload (snake_case wire format — shared TestResultInput contract)
|
|
310
320
|
payload: dict[str, Any] = {
|
|
311
|
-
"
|
|
321
|
+
"test_id": result.testId,
|
|
312
322
|
"outcome": result.outcome,
|
|
313
323
|
"duration_ms": result.duration_ms,
|
|
314
324
|
}
|
|
315
325
|
if result.name:
|
|
316
326
|
payload["name"] = result.name
|
|
317
327
|
if result.full_name:
|
|
318
|
-
payload["
|
|
328
|
+
payload["full_name"] = result.full_name
|
|
319
329
|
if result.error_message:
|
|
320
330
|
payload["error_message"] = result.error_message
|
|
321
331
|
if result.stack_trace:
|
|
@@ -344,10 +354,11 @@ class Uploader:
|
|
|
344
354
|
if result.ended_at is not None:
|
|
345
355
|
payload["ended_at"] = _epoch_ms_to_iso(result.ended_at)
|
|
346
356
|
|
|
347
|
-
# Serialize Allure steps
|
|
357
|
+
# Serialize Allure steps into the first-class `steps` array (StepInput
|
|
358
|
+
# wire shape). Previously packed as a JSON string in labels["steps"] —
|
|
359
|
+
# dropped in favor of the shared TestResultInput contract.
|
|
348
360
|
steps = raw_data.get("steps")
|
|
349
361
|
if steps:
|
|
350
|
-
# Normalize Allure step format to match the schema _parse_steps expects
|
|
351
362
|
normalized_steps = []
|
|
352
363
|
for step in steps:
|
|
353
364
|
status_details = step.get("statusDetails") or {}
|
|
@@ -359,17 +370,16 @@ class Uploader:
|
|
|
359
370
|
"duration_ms": (stop - start) if (start and stop) else None,
|
|
360
371
|
"error_message": status_details.get("message"),
|
|
361
372
|
})
|
|
362
|
-
|
|
363
|
-
labels_d["steps"] = json.dumps(normalized_steps)
|
|
364
|
-
payload["labels"] = labels_d
|
|
373
|
+
payload["steps"] = normalized_steps
|
|
365
374
|
|
|
375
|
+
# Links and description are label-like metadata not modeled by StepInput;
|
|
376
|
+
# keep them in labels (values are strings, matching labels: dict[str, str]).
|
|
366
377
|
links = raw_data.get("links")
|
|
367
378
|
if links:
|
|
368
379
|
labels_d = payload.get("labels", {})
|
|
369
380
|
labels_d["links"] = json.dumps(links)
|
|
370
381
|
payload["labels"] = labels_d
|
|
371
382
|
|
|
372
|
-
# Add description to labels if present
|
|
373
383
|
description = raw_data.get("description") or raw_data.get("descriptionHtml")
|
|
374
384
|
if description:
|
|
375
385
|
labels_d = payload.get("labels", {})
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|