diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c4cc057 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,135 @@ +"""Browser-test failure artefacts: a Playwright trace kept for every browser +test that FAILS, captured from the run that failed. + +Why this exists: the browser tests flake under FULL-SUITE load only — three +different tests have each failed once, every one passes in isolation, and a +narrowed repro that passes is the trap (operator, 2026-09-23: "let him diagnose +it properly"). Pass/fail counts cannot say why; a trace — screenshots, DOM +snapshots, console and network per action — can. + +OPT-IN, because tracing is not free and the harness is part of the number: +with it on, every page does more work, so the suite's timing (the very thing +under suspicion) moves. Default runs are untouched. + + BOOTH_TRACE=1 .venv/bin/python -m pytest -q # screenshots + DOM snapshots + BOOTH_TRACE=light .venv/bin/python -m pytest -q # actions + network only + +LIGHT exists because the full mode perturbs the thing it watches: 8 traced +full-suite runs went 8/8 green while untraced runs on the same tree went red. +Network and action records are nearly free, and a goto that never reaches +"networkidle" is answered by the network record alone — which request never +finished. + +Traces land in $BOOTH_TRACE_DIR (default: /booth-test-traces//), +named after the test; open one with `playwright show-trace `. The +terminal summary lists every trace kept. +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +import time +from pathlib import Path + +import pytest + +TRACE_MODE = os.environ.get("BOOTH_TRACE", "") +TRACE = TRACE_MODE in ("1", "light") +_KEPT: list[Path] = [] + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + rep = outcome.get_result() + setattr(item, "rep_" + rep.when, rep) + + +def _trace_dir() -> Path: + root = os.environ.get("BOOTH_TRACE_DIR") or os.path.join(tempfile.gettempdir(), "booth-test-traces") + d = Path(root) / time.strftime("%Y%m%d-%H%M%S", time.localtime(_RUN_STARTED)) + d.mkdir(parents=True, exist_ok=True) + return d + + +_RUN_STARTED = time.time() + + +@pytest.fixture(autouse=True) +def _trace_browser_tests(request): + """Wrap the module's `browser` so every context it opens is traced. + + A test usually closes its page BEFORE asserting (it collects, closes, then + checks), and a closed context can no longer write its trace — so each + context's trace is written at close time, to a scratch file, and only moved + to the kept set if the test then fails. A page from `browser.new_page` owns + its context, as Playwright's own does: closing the page closes it.""" + if not TRACE or "browser" not in request.fixturenames: + yield + return + browser = request.getfixturevalue("browser") + scratch = Path(tempfile.mkdtemp(prefix="booth-trace-")) + written: list[Path] = [] + opened: list = [] + real_new_context = browser.new_context + + def stop(ctx, n=[0]): + if getattr(ctx, "_booth_traced", False): + ctx._booth_traced = False + n[0] += 1 + path = scratch / f"{n[0]}.zip" + try: + ctx.tracing.stop(path=str(path)) + written.append(path) + except Exception: # noqa: BLE001 - a lost trace must not fail the test + pass + + def new_context(*args, **kwargs): + ctx = real_new_context(*args, **kwargs) + heavy = TRACE_MODE == "1" + ctx.tracing.start(screenshots=heavy, snapshots=heavy) + ctx._booth_traced = True + real_close = ctx.close + + def close(*a, **k): + stop(ctx) + return real_close(*a, **k) + + ctx.close = close + opened.append(ctx) + return ctx + + def new_page(*args, **kwargs): + ctx = new_context(*args, **kwargs) + page = ctx.new_page() + page.close = lambda *a, **k: ctx.close() + return page + + browser.new_context, browser.new_page = new_context, new_page + try: + yield + finally: + del browser.new_context, browser.new_page + for ctx in opened: + stop(ctx) + try: + ctx.close() + except Exception: # noqa: BLE001 + pass + rep = getattr(request.node, "rep_call", None) + if rep is not None and rep.failed and written: + dest = _trace_dir() + for i, path in enumerate(written, 1): + kept = dest / f"{request.node.name}-{i}.zip" + shutil.move(str(path), kept) + _KEPT.append(kept) + shutil.rmtree(scratch, ignore_errors=True) + + +def pytest_terminal_summary(terminalreporter): + if _KEPT: + terminalreporter.section("browser traces kept for failed tests") + for p in _KEPT: + terminalreporter.write_line(str(p))