"""Every request the web UI makes has a deadline (rule 156). A source-inspection guard in the unit lane — there is no frontend test runner, and this is a property of the source rather than of a rendered result, so reading the source is the honest way to check it. WHY. `fetch`'s default is to wait as long as the browser will. That is not a long timeout, it is the absence of one, and there is no state a surface can render for "pending forever" that is not a lie — the spinner that never resolves is indistinguishable from work still in progress. Rule 156 names `fetch` specifically: When a library's default is "wait indefinitely" — `fetch`, most HTTP clients, a bare `await` on a stream — supplying the deadline is part of using it, not a hardening pass for later. Before this guard, no request in the app carried one. """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src" CLIENT = FRONTEND / "api" / "client.ts" def _call_text(src: str, start: int) -> str: """The source of one `fetch(...)` call, from its open paren to its close. Naive paren balancing. Adequate because every call site here passes an object literal, and a construct complex enough to defeat it is one worth looking at by hand anyway. """ depth = 0 for i in range(start, len(src)): if src[i] == "(": depth += 1 elif src[i] == ")": depth -= 1 if depth == 0: return src[start:i + 1] return src[start:] def _fetch_calls() -> list[tuple[Path, str]]: calls: list[tuple[Path, str]] = [] for path in list(FRONTEND.rglob("*.ts")) + list(FRONTEND.rglob("*.vue")): src = path.read_text() for m in re.finditer(r"\bfetch\(", src): calls.append((path, _call_text(src, m.end() - 1))) return calls def test_every_fetch_passes_a_signal(): """No bare `fetch` anywhere in the frontend. Stated on the SIGNAL rather than on a timeout value, because the two legitimate shapes here produce different values and only share this: an ordinary call takes the client's default, a stream bounds its CONNECT and then deliberately runs unbounded, and a bulk transfer passes minutes. What they must all do is pass something. """ naked = [ f"{path.relative_to(FRONTEND)}: {call[:70]}" for path, call in _fetch_calls() if "signal:" not in call ] assert not naked, ( "these fetch calls carry no AbortSignal, so they wait forever " "(rule 156):\n " + "\n ".join(naked) ) def test_the_client_applies_its_deadline_by_default(): """The specific regression that would silently undo this. An earlier pass (#3329) made `timeoutMs` OPT-IN and used it at exactly one call site, which left ~330 others waiting forever while the mechanism looked present. Reverting to that shape would not fail the guard above — every call would still reach `fetch` through `request()` — so the default is pinned here separately. `??` is the operative character: `opts?.timeoutMs || DEFAULT` would treat an explicit 0 as "use the default", and `opts?.timeoutMs` alone would reinstate the opt-in bug. """ src = CLIENT.read_text() assert "opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS" in src, ( "request() must fall back to DEFAULT_TIMEOUT_MS — without it the " "deadline is opt-in again and almost nothing opts in" ) def test_a_timeout_arrives_as_the_error_shape_callers_already_handle(): """Rule 156's second half: expiry surfaces as a NAMED failure. A raw `DOMException: TimeoutError` reaches `apiErrorMessage(e, fallback)` as an object with no `body`, so every catch site in the app would print its generic fallback and the timeout would be invisible in exactly the situation it exists to expose. Rethrowing as `ApiError` is what makes the other ~330 call sites report it without being edited. """ src = CLIENT.read_text() assert 'e.name === "TimeoutError"' in src, ( "request() must recognise a timeout specifically" ) assert "new ApiError(CLIENT_TIMEOUT_STATUS" in src, ( "a timeout must be rethrown as ApiError so apiErrorMessage can read it" ) def test_a_deliberate_cancellation_is_not_reported_as_a_timeout(): """Only `TimeoutError` is converted, never `AbortError`. A caller that cancelled its own request — a superseded search, a closed stream — must not have that surfaced to the user as a server failure. The guard is that the conversion is gated on the name, which the assertion above already pins; this states the intent so the gate is not "simplified" into catching every abort. """ src = CLIENT.read_text() convert = src[src.index("async function request<"):] convert = convert[:convert.index("\n}")] assert "AbortError" not in convert, ( "request() must not convert AbortError — a deliberate cancellation is " "not a timeout" )