Skip to content

Commit 7995ed5

Browse files
feat: keep ha_manage_radio reads available in read-only mode (#1699)
* feat: keep ha_manage_radio reads available in read-only mode ha_manage_radio is a mixed read/write tool (reads: diagnostics, network_status, ping; writes: commission/add, remove, reinterview, firmware, fabric/credential/ channel/network changes) but it was not in READ_ONLY_EXEMPT_TOOLS. So read-only mode treated it as a plain write tool: hidden from the catalog and every call blocked — including the reads. Its 'ping' active probe has no pure-read duplicate elsewhere, so it became unreachable in read-only mode entirely. Add a _radio_write predicate that allows diagnostics/network_status/ping and blocks every other action, and register ha_manage_radio in the exempt table — matching ha_manage_energy_prefs / ha_manage_pipeline. Writes now return the structured READ_ONLY_MODE error before the handler runs; reads stay callable. Tests: new parametrized test_manage_radio + all four schema-drift manifests (exempt set, module map, inspected args, gated/read partition); the e2e read-only suite gains ha_manage_radio in the still-listed set plus read-allowed / write-blocked cases. The settings-UI exempt-list test compares against the live constant, so it adapts automatically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(radio): allow cluster_read + list_datasets reads in read-only mode The exemption predicate only allowed diagnostics/network_status/ping, but zigbee cluster_read (zha/devices/clusters/attributes/value) and thread list_datasets (thread/list_datasets) are pure non-mutating reads with no pure-read duplicate elsewhere in the catalog — so read-only mode made them unreachable, the exact harm the exemption exists to prevent. Add both to the allow-list and document why the two read-ish-but-not actions stay blocked: zigbee network_backup creates a backup artifact + key material (mirrors ha_manage_backup's blocked snapshot create) and thread discover_routers kicks off a long-running mDNS scan. Unit test gains the two new read cases plus the two intentionally-blocked cases. Found by the pr-review-toolkit code-reviewer + pr-test-analyzer, verified against source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): fail-fast on error-spam + cover read-only on the inaddon backend Two e2e-infra improvements folded into the read-only PR (both hit while working its CI): 1. Fail-fast on a doomed run. A Supervisor add-on-update flake made all 997 inaddon tests ERROR at setup (0 passed/failed) yet the run ground on 11m39s. Add a pytest_runtest_logreport hook in the SHARED e2e conftest (covers every e2e lane) that aborts via pytest.exit after 50 consecutive setup/teardown errors with zero call-phase pass/fail in between. Resets on any real pass or fail, so genuine failures still run through in full; this is NOT --maxfail. Validated under -n2 xdist: aborts at 50/150 in ~2s; a healthy run with real failures does not trip it. 2. Read-only coverage on the inaddon backend. The read-only e2e suite skipped inaddon (it only knows how to inject READ_ONLY_MODE into a fresh in-process server). Add test_inaddon_read_only_mode_blocks_radio_writes: enable read_only_mode via the add-on's own settings API (merges into the Supervisor options the production way), self-restart ONLY the add-on, then assert ha_manage_radio reads work and writes return READ_ONLY_MODE. Marked run_last (new marker + ordering in pytest_collection_modifyitems) so it is the last scope dispatched; each xdist worker owns an isolated add-on, so leaving read-only on cannot affect another test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): bump container/haos skip ceilings for the inaddon read-only test test_inaddon_read_only_mode_blocks_radio_writes is @inaddon_only, so it skips on the container and external-haos lanes — pushing the container skip count to 66 (ceiling was 65). Bump container 65->66 and haos 32->33; haos_inaddon is unchanged since the test runs there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(test): make inaddon read-only test self-contained (restore read-only off) run_last did not work: --dist loadscope groups tests by module, so moving one item to the end can't make the test the last thing on its xdist worker. The test enabled read_only_mode on the shared add-on and left it on, cascading READ_ONLY_MODE into ~298 later write tests on the same worker. Make it self-contained instead: enable read-only + restart, verify ha_manage_radio read works / write blocked, then in a finally restore read-only OFF + restart and poll ha_get_overview until it is confirmed off before returning. xdist runs a worker's tests serially and each worker owns an isolated add-on, so bracketing read-only around this one test is safe at any position. Drop the run_last marker + the (ineffective) ordering hook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(test): address pr-review-toolkit round-2 findings on #1699 Four confirmed findings from the customized toolkit re-run: 1. (important) The inaddon read-only test self-restarts the dev add-on, which drops the SHARED session mcp_client connection (test_supervisor_inaddon.py documents this kills mcp_client for later tests on the worker). Add mcp_client to the test and warm it back up in the finally so the next module loadscope schedules on this worker gets a live session, not a stale one. 2. The fail-fast hook had no unit coverage. Extract the streak logic into tests/src/doomed_run.py::DoomedRunDetector and unit-test it in tests/src/unit/test_doomed_run.py (abort threshold, reset-on-real-pass/fail, rerun/skip exclusions). 3. The fail-fast comment claimed controller-only / global-across-workers; pytest_runtest_logreport fires per-process under xdist, each with its own detector. Correct the comment (the abort still fires from whichever process hits the threshold first). 4. _await_read_only confirmed the OFF state via the ABSENCE of read_only_mode in ha_get_overview, which a degraded payload could also fake. Switch to a positive catalog signal from a real list_tools response (ha_call_service is hidden iff read-only is on). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3504936 commit 7995ed5

7 files changed

Lines changed: 367 additions & 4 deletions

File tree

src/ha_mcp/read_only.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,37 @@ def _custom_tool_write(args: dict[str, Any]) -> str | None:
131131
return "sandbox code execution"
132132

133133

134+
def _radio_write(args: dict[str, Any]) -> str | None:
135+
action = args.get("action")
136+
# Reads (allowed): per-node diagnostics, the integration/network summary,
137+
# the active reachability probe, a single Zigbee cluster-attribute read, and
138+
# the Thread dataset listing. Everything else is a write — commission/add,
139+
# remove, reinterview, firmware, fabric/credential/channel/network changes,
140+
# plus the two actions that LOOK read-ish but are not: zigbee network_backup
141+
# (creates a backup artifact + key material, like ha_manage_backup's blocked
142+
# snapshot create) and thread discover_routers (kicks off a long-running
143+
# mDNS scan). A missing action fails closed.
144+
if action in (
145+
"diagnostics",
146+
"network_status",
147+
"ping",
148+
"cluster_read",
149+
"list_datasets",
150+
):
151+
return None
152+
return f"action={action!r}"
153+
154+
134155
# Mixed read/write tools whose read surface has no pure-read duplicate
135156
# (verified per tool: ha_get_addon cannot proxy-read addon-internal
136157
# APIs; energy prefs and assist pipelines are reachable only through
137158
# these tools; edit-backup listing exists nowhere else; the saved-tools
138-
# cache is only listable here). Everything NOT in this table and not
139-
# ``readOnlyHint=True`` is hidden and blocked outright.
159+
# cache is only listable here; ha_manage_radio's 'ping' probe, 'cluster_read'
160+
# and 'list_datasets' have no pure-read duplicate elsewhere, while its
161+
# 'diagnostics'/'network_status' reads mirror ha_get_device /
162+
# ha_get_system_health but stay reachable here mid-management). Everything
163+
# NOT in this table and not ``readOnlyHint=True`` is hidden and blocked
164+
# outright.
140165
#
141166
# ``MANDATORY_TOOLS`` (settings_ui/__init__.py) intentionally needs no special
142167
# case here: every mandatory tool is either ``readOnlyHint=True`` or
@@ -168,6 +193,13 @@ def _custom_tool_write(args: dict[str, Any]) -> str | None:
168193
_custom_tool_write,
169194
"listing saved tools (list_saved=True)",
170195
),
196+
"ha_manage_radio": ReadOnlyExemption(
197+
_radio_write,
198+
"node diagnostics ('diagnostics'), the integration/network summary "
199+
"('network_status'), the active reachability probe ('ping'), a Zigbee "
200+
"cluster-attribute read ('cluster_read'), and the Thread dataset "
201+
"listing ('list_datasets')",
202+
),
171203
}
172204

173205

tests/src/doomed_run.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Doomed-run detector for the e2e fail-fast hook.
2+
3+
Extracted from ``tests/src/e2e/conftest.py`` so the streak logic is unit-testable
4+
without importing the heavy e2e conftest. See
5+
``tests/src/e2e/conftest.py::pytest_runtest_logreport`` for the wiring and
6+
``tests/src/unit/test_doomed_run.py`` for the tests.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
DOOMED_RUN_ERROR_STREAK = 50
12+
13+
14+
class DoomedRunDetector:
15+
"""Counts CONSECUTIVE setup/teardown errors with zero call-phase pass/fail
16+
between them.
17+
18+
``record(when, outcome)`` returns ``True`` once the streak reaches
19+
``threshold`` — the signal that the run is producing nothing but errors and
20+
should be aborted. A genuine call-phase ``passed``/``failed`` resets the
21+
streak, so an isolated flaky-setup test never trips it. pytest-rerunfailures'
22+
intermediate ``rerun`` outcome is ignored (neither resets nor increments), as
23+
is ``skipped`` and a non-failing setup/teardown.
24+
"""
25+
26+
def __init__(self, threshold: int = DOOMED_RUN_ERROR_STREAK) -> None:
27+
self.threshold = threshold
28+
self.streak = 0
29+
30+
def record(self, when: str, outcome: str) -> bool:
31+
# A real test body ran -> the run is alive; reset.
32+
if when == "call" and outcome in ("passed", "failed"):
33+
self.streak = 0
34+
return False
35+
# A setup/teardown error (an "error", not a "fail"). ``== "failed"``
36+
# excludes the "rerun" outcome, which must not extend a doomed streak.
37+
if when in ("setup", "teardown") and outcome == "failed":
38+
self.streak += 1
39+
return self.streak >= self.threshold
40+
return False

tests/src/e2e/basic/test_backend_dispatch_smoke.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@
8080
# Baselines are the observed skip counts as of 2026-05-22 (container=46,
8181
# haos=14, haos_inaddon=39 from the prose above), plus this PR's new
8282
# marker-gated skips, plus a 5-9 growth buffer.
83-
"container": 65, # was 62; +3 self-update-notice inaddon tests (@inaddon_only, skip here)
84-
"haos": 32, # was 29; +3 self-update-notice inaddon tests (@inaddon_only, skip here)
83+
"container": 66, # was 65; +1 inaddon read-only test (@inaddon_only, skips here)
84+
"haos": 33, # was 32; +1 inaddon read-only test (@inaddon_only, skips here)
8585
"haos_inaddon": 58, # was 55; +3 self-update notice tests (TestSelfUpdateNoticeSurfacedInTools, @external_only)
8686
}
8787

tests/src/e2e/conftest.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
4545
sys.path.insert(0, str(Path(__file__).parent.parent)) # tests/src/ for haos_runtime
4646

47+
from doomed_run import DoomedRunDetector
4748
from fastmcp import Client
4849
from haos_runtime import (
4950
HA_MCP_DEV_ADDON_SLUG,
@@ -168,6 +169,36 @@ def pytest_collection_modifyitems(config, items):
168169
item.add_marker(skip_external_only)
169170

170171

172+
# Fail fast on a doomed run, on EVERY e2e lane (this conftest is shared by the
173+
# testcontainer / external-HAOS / inaddon suites, so the hook guards all three).
174+
# A Supervisor add-on-update flake did exactly this on PR #1699: all 997 inaddon
175+
# tests ERRORed at setup (0 passed, 0 failed) while the run ground on 11m39s
176+
# producing nothing but errors. DoomedRunDetector aborts once it sees 50
177+
# consecutive setup/teardown errors with zero call-phase pass/fail between them;
178+
# a genuine pass/fail resets the streak, so real failures still run through in
179+
# full (this is NOT --maxfail). The detector logic is unit-tested in
180+
# tests/src/unit/test_doomed_run.py.
181+
#
182+
# Under xdist, pytest_runtest_logreport fires in EACH process — the controller
183+
# (which receives every worker's reports) AND each worker for its own tests — so
184+
# every process keeps its own module-global detector; the streak is per-process,
185+
# not global. That is fine: a doomed run errors on every process, so whichever
186+
# reaches 50 first calls pytest.exit and ends the session (validated under -n2:
187+
# a 150-test all-error run aborts at 50 in ~2s).
188+
_doomed_detector = DoomedRunDetector()
189+
190+
191+
def pytest_runtest_logreport(report):
192+
if _doomed_detector.record(report.when, report.outcome):
193+
pytest.exit(
194+
f"Aborting: {_doomed_detector.streak} consecutive setup/teardown "
195+
f"errors with no test passing or failing — the run is doomed by a "
196+
f"systemic setup failure (e.g. add-on/container setup). Failing fast "
197+
f"instead of grinding through the suite.",
198+
returncode=1,
199+
)
200+
201+
171202
def pytest_sessionfinish(session, exitstatus):
172203
"""xdist worker hook: hand collected timings up to the master.
173204

tests/src/e2e/policy/test_readonly_mode.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ async def test_write_tools_hidden_exempt_and_read_tools_listed(readonly_mcp):
147147
"ha_manage_backup",
148148
"ha_manage_pipeline",
149149
"ha_manage_energy_prefs",
150+
"ha_manage_radio",
150151
):
151152
assert kept in names, f"{kept} should stay listed"
152153

@@ -237,6 +238,32 @@ async def test_exempt_tool_write_action_blocked(readonly_mcp):
237238
assert "list" in body["error"]["message"], body
238239

239240

241+
@pytest.mark.asyncio
242+
async def test_radio_read_action_works(readonly_mcp):
243+
"""ha_manage_radio is exempt: a read action (network_status) stays
244+
callable in read-only mode. Z-Wave JS is not configured on the test
245+
container, so this also exercises the graceful integration-absent read
246+
path (available=False but success=True)."""
247+
client, _server = readonly_mcp
248+
result = await client.call_tool(
249+
"ha_manage_radio", {"radio": "zwave", "action": "network_status"}
250+
)
251+
body = parse_mcp_result(result)
252+
assert body.get("success") is True, body
253+
254+
255+
@pytest.mark.asyncio
256+
async def test_radio_write_action_blocked(readonly_mcp):
257+
"""A ha_manage_radio write action (zwave 'add') is blocked with the
258+
structured READ_ONLY_MODE error before the handler runs."""
259+
client, _server = readonly_mcp
260+
body = await _expect_read_only_blocked(
261+
client, "ha_manage_radio", {"radio": "zwave", "action": "add"}
262+
)
263+
assert body["tool_name"] == "ha_manage_radio", body
264+
assert body["blocked_operation"], body
265+
266+
240267
@pytest.mark.asyncio
241268
async def test_proxy_dispatched_write_blocked_with_tool_search(readonly_toolsearch_mcp):
242269
"""ha_call_write_tool re-dispatches through the middleware chain, so
@@ -399,3 +426,127 @@ async def test_code_mode_tool_read_works_and_execution_blocked(readonly_codemode
399426
{"code": "1 + 1", "justification": "read-only mode test"},
400427
)
401428
assert body["tool_name"] == "ha_manage_custom_tool", body
429+
430+
431+
@pytest.mark.inaddon_only
432+
@pytest.mark.asyncio
433+
async def test_inaddon_read_only_mode_blocks_radio_writes(
434+
ha_container_with_fresh_config, mcp_client
435+
):
436+
"""Read-only mode on the REAL inaddon add-on (every test above skips inaddon
437+
via ``_build_readonly_server``, which can only inject ``READ_ONLY_MODE`` into
438+
a fresh in-process server).
439+
440+
Enables read_only_mode through the add-on's OWN settings API — which merges it
441+
into the Supervisor add-on options the production way (a bare options POST is
442+
full-replacement and would drop required keys) — self-restarts ONLY the add-on
443+
(~10s; Home Assistant is untouched) so it boots with ``READ_ONLY_MODE=true``,
444+
asserts ha_manage_radio's read action still works while a write is blocked with
445+
the structured READ_ONLY_MODE error, then RESTORES read-only to off.
446+
447+
xdist runs a worker's tests serially and each worker owns an isolated add-on
448+
(``_haos_worker_setup``), so bracketing read-only around just this test and
449+
restoring it in ``finally`` is safe at any position. (Ordering tricks do NOT
450+
help — ``--dist loadscope`` groups tests by module, so a single "run last"
451+
marker can't make this the last thing on its worker.) The two dev-add-on
452+
restarts also drop the SHARED session ``mcp_client`` connection
453+
(test_supervisor_inaddon.py documents that restarting the dev add-on kills
454+
mcp_client for later tests), so the ``finally`` also warms that client back up
455+
so whatever module loadscope hands this worker next gets a live session.
456+
"""
457+
import asyncio
458+
import time
459+
460+
import httpx
461+
from fastmcp.client.transports import StreamableHttpTransport
462+
from haos_runtime import HA_MCP_TEST_SECRET_PATH, wait_for_addon_mcp_ready
463+
464+
container_info = ha_container_with_fresh_config
465+
addon_mcp_url = container_info.get("addon_mcp_url")
466+
assert addon_mcp_url, "inaddon backend should expose addon_mcp_url"
467+
# The settings UI is mounted at the secret-path root (see TestSettingsUiRestartReal).
468+
base = addon_mcp_url.split("/mcp", 1)[0]
469+
settings = f"{base}{HA_MCP_TEST_SECRET_PATH}/api/settings"
470+
_transient = (AssertionError, TimeoutError, OSError, httpx.HTTPError, RuntimeError)
471+
472+
async def _set_read_only(enabled: bool) -> None:
473+
"""POST the flag (handler merges into Supervisor options) + self-restart
474+
the add-on. Empty restart body -> target='self', which the handler
475+
schedules in the background so this 200 flushes before the bounce."""
476+
async with httpx.AsyncClient(timeout=30.0) as http:
477+
resp = await http.post(
478+
f"{settings}/features", json={"flags": {"read_only_mode": enabled}}
479+
)
480+
assert resp.status_code == 200, (
481+
f"set read_only_mode={enabled}: {resp.status_code} {resp.text[:300]!r}"
482+
)
483+
resp = await http.post(f"{settings}/restart", json={})
484+
assert resp.status_code == 200, (
485+
f"restart add-on: {resp.status_code} {resp.text[:300]!r}"
486+
)
487+
488+
async def _await_read_only(expected: bool) -> None:
489+
"""Poll, reconnecting each round, until the add-on's CATALOG reflects
490+
read_only_mode == expected — i.e. the restart actually took effect. In
491+
read-only mode the catalog filter hides write tools, so ``ha_call_service``
492+
is absent iff read-only is on. Keying on a positive signal from a real
493+
``list_tools`` response (rather than the absence of an overview key, which
494+
a degraded payload could also fake) ensures we only confirm against a
495+
healthy, fully-booted add-on."""
496+
deadline = time.monotonic() + 180.0
497+
last: object = None
498+
while time.monotonic() < deadline:
499+
try:
500+
url = wait_for_addon_mcp_ready(timeout=30.0)
501+
async with Client(StreamableHttpTransport(url=url)) as mcp:
502+
names = {t.name for t in await mcp.list_tools()}
503+
if ("ha_call_service" not in names) is expected:
504+
return
505+
last = len(names)
506+
except _transient as err:
507+
last = err
508+
await asyncio.sleep(3)
509+
raise AssertionError(
510+
f"read-only catalog did not become {expected} within 180s (last={last!r})"
511+
)
512+
513+
try:
514+
await _set_read_only(True)
515+
await _await_read_only(True)
516+
url = wait_for_addon_mcp_ready(timeout=30.0)
517+
async with Client(StreamableHttpTransport(url=url)) as mcp:
518+
result = await mcp.call_tool(
519+
"ha_manage_radio", {"radio": "zwave", "action": "network_status"}
520+
)
521+
assert parse_mcp_result(result).get("success") is True, result
522+
blocked = await _expect_read_only_blocked(
523+
mcp, "ha_manage_radio", {"radio": "zwave", "action": "add"}
524+
)
525+
assert blocked.get("tool_name") == "ha_manage_radio", blocked
526+
finally:
527+
# Restore read-only OFF: this worker's remaining tests share the add-on
528+
# and need writes, so a leaked read-only would cascade READ_ONLY_MODE into
529+
# all of them. Retry the whole set+restart until it's confirmed off.
530+
restore_deadline = time.monotonic() + 300.0
531+
while True:
532+
try:
533+
await _set_read_only(False)
534+
await _await_read_only(False)
535+
break
536+
except _transient:
537+
if time.monotonic() >= restore_deadline:
538+
raise
539+
await asyncio.sleep(3)
540+
# The dev-add-on restarts above dropped the SHARED session mcp_client's
541+
# connection. Warm it back up so the next test loadscope schedules on this
542+
# worker gets a live session rather than a stale one (a read tool is enough
543+
# to force re-establishment; retry while the add-on finishes coming up).
544+
warm_deadline = time.monotonic() + 120.0
545+
while True:
546+
try:
547+
await mcp_client.call_tool("ha_get_overview", {})
548+
break
549+
except _transient:
550+
if time.monotonic() >= warm_deadline:
551+
raise
552+
await asyncio.sleep(3)

tests/src/unit/test_doomed_run.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Unit tests for the e2e fail-fast ``DoomedRunDetector``.
2+
3+
Covers the abort threshold, the reset-on-real-pass/fail semantics, and the
4+
rerun/skip exclusions — the branching the e2e hook relies on (which is otherwise
5+
only exercised incidentally on real e2e runs).
6+
"""
7+
8+
from tests.src.doomed_run import DoomedRunDetector
9+
10+
11+
def test_call_pass_resets_streak():
12+
d = DoomedRunDetector(threshold=5)
13+
for _ in range(4):
14+
assert d.record("setup", "failed") is False
15+
assert d.streak == 4
16+
assert d.record("call", "passed") is False
17+
assert d.streak == 0
18+
19+
20+
def test_call_fail_resets_streak():
21+
d = DoomedRunDetector(threshold=5)
22+
for _ in range(4):
23+
d.record("setup", "failed")
24+
assert d.record("call", "failed") is False
25+
assert d.streak == 0
26+
27+
28+
def test_rerun_outcome_neither_resets_nor_increments():
29+
d = DoomedRunDetector(threshold=5)
30+
d.record("setup", "failed")
31+
d.record("setup", "failed")
32+
assert d.streak == 2
33+
assert d.record("setup", "rerun") is False # pytest-rerunfailures
34+
assert d.record("call", "rerun") is False
35+
assert d.streak == 2
36+
37+
38+
def test_aborts_exactly_at_threshold():
39+
d = DoomedRunDetector(threshold=50)
40+
for _ in range(49):
41+
assert d.record("setup", "failed") is False
42+
assert d.record("setup", "failed") is True
43+
assert d.streak == 50
44+
45+
46+
def test_interleaved_pass_prevents_abort():
47+
d = DoomedRunDetector(threshold=5)
48+
for _ in range(10):
49+
for _ in range(4):
50+
assert d.record("setup", "failed") is False
51+
assert d.record("call", "passed") is False
52+
assert d.streak == 0
53+
54+
55+
def test_teardown_error_counts_toward_streak():
56+
d = DoomedRunDetector(threshold=2)
57+
assert d.record("teardown", "failed") is False
58+
assert d.record("teardown", "failed") is True
59+
60+
61+
def test_skip_and_passing_setup_do_not_increment():
62+
d = DoomedRunDetector(threshold=2)
63+
assert d.record("setup", "skipped") is False
64+
assert d.record("setup", "passed") is False
65+
assert d.streak == 0
66+
67+
68+
def test_default_threshold_is_50():
69+
assert DoomedRunDetector().threshold == 50

0 commit comments

Comments
 (0)