"""Unit: the shared ``errorDetailText`` serializer (phase 164, task 02). Behavior-pins the exported helper under node (the ``_run_node`` house pattern — the function is pure, so its extracted source runs standalone) across the full shape matrix, led by the EXACT live FastAPI 422 body of 2026-10-07: a pydantic list-of-objects ``detail`` must serialize to the ``msg`` verbatim — never ``"[object Object]"``, never the rejected ``input`` text (the entries carry the full conversation — it must never reach the DOM). Source pins: the two chat call sites now read through the helper, the helper never ``JSON.stringify``-s anything, and the SEVEN existing guarded detail-readers (tuning.js, theme.js, document.js, git-sources.js, doc-edit.js, app.js's tuning form and its image-upload guard) stay byte-identical — no opportunistic refactor. """ from __future__ import annotations import json import re import shutil import subprocess from pathlib import Path import pytest FRONTEND = Path(__file__).resolve().parents[2] / "frontend" ASSETS = FRONTEND / "assets" APP_JS = ASSETS / "app.js" CHAT_PY = Path(__file__).resolve().parents[2] / "app" / "api" / "chat.py" HAVE_NODE = shutil.which("node") is not None def _js() -> str: return APP_JS.read_text(encoding="utf-8") def _asset(name: str) -> str: return (ASSETS / name).read_text(encoding="utf-8") def _run_node(script: str) -> str: proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60) assert proc.returncode == 0, f"node failed: {proc.stderr}" return proc.stdout def _fn(js: str, name: str) -> str: """The full source of ``export function name(…) {…}`` via balanced-brace counting (the test_frontend_* convention — the ``export`` prefix still matches the ``function name(`` search).""" start = js.find(f"function {name}(") assert start != -1, f"{name}() must exist in app.js" depth = 0 for i in range(js.find("{", start), len(js)): if js[i] == "{": depth += 1 elif js[i] == "}": depth -= 1 if depth == 0: return js[start : i + 1] raise AssertionError(f"unbalanced braces in {name}()") #: Sentinel serialized to the JS literal ``undefined`` (json has none). class _Undef: pass UNDEFINED = _Undef() def _lit(v: object) -> str: if isinstance(v, _Undef): return "undefined" return json.dumps(v) def _serialize(cases: list[tuple[str, object, object]]) -> dict[str, str]: """Run the REAL exported ``errorDetailText`` under node over ``[(name, value, fallback), …]`` and return ``{name: result}``.""" script = ( _fn(_js(), "errorDetailText") + "\nconst out = [];\n" + "".join( f"out.push([{_lit(name)}, errorDetailText({_lit(value)}, {_lit(fallback)})]);\n" for name, value, fallback in cases ) + "for (const row of out) console.log(JSON.stringify(row));\n" ) rows = (json.loads(line) for line in _run_node(script).splitlines() if line) return {row[0]: row[1] for row in rows} #: The EXACT live 422 body (phase 164 overview): the follow-up turn of #: a >32k-char answer, rejected by the old HistoryTurn.text cap. The #: `input` is the whole rejected conversation — here a marked stand-in #: that must NEVER appear in the serialized result. LIVE_422_DETAIL = [ { "type": "string_too_long", "loc": ["body", "history", 5, "text"], "msg": "String should have at most 600000 characters", "input": f"CANARY-INPUT {'x' * 4000}", } ] FB = "FALLBACK-LINE" # --------------------------------------------------------------------------- # Behavior — the shape matrix under node (the exported, REAL function) # --------------------------------------------------------------------------- @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_live_422_list_detail_serializes_the_msg_only() -> None: """The EXACT live 422 body: the entry's ``msg`` verbatim, and the result carries NO ``input`` text and no ``"[object Object]"`` — the 2026-10-07 failure is unrepresentable (locked A2).""" got = _serialize([("live422", LIVE_422_DETAIL, FB)]) assert got["live422"] == "String should have at most 600000 characters" assert "CANARY" not in got["live422"], "the rejected input must never serialize" assert "[object Object]" not in got["live422"] @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_array_entries_join_with_semicolon_space() -> None: """Multi-entry arrays join their usable texts with ``"; "``: several object ``msg``s, and mixed object + plain-string entries (the generalization of the seven guarded copies).""" got = _serialize( [ ( "multi_obj", [{"msg": "first problem"}, {"msg": "second problem"}], FB, ), ("mixed", [{"msg": "obj msg"}, "plain string"], FB), ("plain_only", ["a", "b"], FB), ("number_entry", [42], FB), ] ) assert got["multi_obj"] == "first problem; second problem" assert got["mixed"] == "obj msg; plain string" assert got["plain_only"] == "a; b" assert got["number_entry"] == "42" @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_empty_array_entries_drop_and_all_empty_falls_back() -> None: """Entries with neither a usable ``msg`` nor a non-empty string drop (objects without msg, empty msgs, nullish, empty strings); an all-empty array — and the bare ``[]`` — fall back.""" got = _serialize( [ ("drop_and_keep", [{"nomsg": 1}, {"msg": ""}, None, "", {"msg": "kept"}], FB), ("all_empty", [{"nomsg": 1}, None, ""], FB), ("bare_empty", [], FB), ] ) assert got["drop_and_keep"] == "kept" assert got["all_empty"] == FB assert got["bare_empty"] == FB @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_object_detail_msg_or_fallback() -> None: """A plain object with a non-empty ``msg`` serializes it verbatim; without one — even carrying a huge rejected ``input`` — it falls back (never stringified).""" got = _serialize( [ ("obj_msg", {"type": "string_too_long", "msg": "only the msg"}, FB), ("obj_nomsg", {"input": "CANARY " * 500}, FB), ("obj_empty_msg", {"msg": ""}, FB), ] ) assert got["obj_msg"] == "only the msg" assert got["obj_nomsg"] == FB assert got["obj_empty_msg"] == FB @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_string_verbatim_incl_server_fixed_copy_otherwise_fallback() -> None: """A non-empty string serializes VERBATIM — the pinned server-built frame copies ("Question too long…", "The chat model dropped the connection…") must reach the user byte-identical. ``""``, null, undefined, a number, and a boolean all fall back.""" server_copy_1 = "Question too long — trim it and re-ask." server_copy_2 = "The chat model dropped the connection — try again?" # The server-side half of the pin: these ARE the ChatErrorEvent copies. chat_py = CHAT_PY.read_text(encoding="utf-8") assert f'detail="{server_copy_1}"' in chat_py assert f'detail="{server_copy_2}"' in chat_py got = _serialize( [ ("server_copy_1", server_copy_1, FB), ("server_copy_2", server_copy_2, FB), ("plain", "some string", FB), ("empty", "", FB), ("null", None, FB), ("undefined", UNDEFINED, FB), ("number", 42, FB), ("boolean", True, FB), ] ) assert got["server_copy_1"] == server_copy_1 assert got["server_copy_2"] == server_copy_2 assert got["plain"] == "some string" for name in ("empty", "null", "undefined", "number", "boolean"): assert got[name] == FB, name @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_no_shape_yields_object_object() -> None: """TOTALITY sweep: across every shape the helper can meet, no result is ever ``"[object Object]"`` (or contains it) — the string is unrepresentable at the serializer level.""" shapes = [ LIVE_422_DETAIL, [{"type": "x", "loc": [1], "msg": "m", "input": "i"}] * 3, [{"a": {"b": 1}}], {"deep": {"nested": [1, 2]}}, [[{"msg": "nested"}]], "s", 0, None, UNDEFINED, ] got = _serialize([(f"s{i}", v, FB) for i, v in enumerate(shapes)]) for name, res in got.items(): assert isinstance(res, str) and res, name assert "[object Object]" not in res, name # --------------------------------------------------------------------------- # Source pins — the export, the two call sites, the untouched copies # --------------------------------------------------------------------------- def test_error_detail_text_is_exported_for_unit_pinning() -> None: """The helper is an EXPORTED function (the TURN_TIMEOUT_MS / THINKING_NEAR_BOTTOM_PX export-for-unit-pin idiom), declared exactly once.""" js = _js() assert re.search(r"^export function errorDetailText\(value, fallback\) \{", js, re.M), ( "errorDetailText must be an exported top-level function" ) assert js.count("function errorDetailText(") == 1 def test_helper_never_stringifies_never_reads_input_loc_ctx() -> None: """LOCKED A2 inside the helper: NO ``JSON.stringify`` anywhere in its source, and the words ``input``/``loc``/``ctx`` never appear — only ``msg`` and plain strings serialize (the docstring names the rule but the FUNCTION body is what this pins).""" fn = _fn(_js(), "errorDetailText") assert "JSON.stringify" not in fn for forbidden in ("input", "loc", "ctx"): assert re.search(rf"\b{forbidden}\b", fn) is None, ( f"the helper must never touch `{forbidden}` (the 422 entries " "carry the full rejected conversation)" ) # The docstring above the helper names the live failure + the rule. js = _js() doc = js[: js.find("function errorDetailText(")][-2500:] assert "2026-10-07" in doc and "[object Object]" in doc assert "input" in doc, "the docstring names the input-leak rule" def test_prestream_call_site_reads_through_the_helper() -> None: """The pre-stream ``!res.ok || !res.body`` handler: the unguarded ``if (body.detail) detail = body.detail`` is GONE, the assignment reads ``errorDetailText(body.detail, …)`` with the per-status string as the fallback, and the try-around-res.json + non-JSON catch keep their shape.""" js = _js() assert "if (body.detail) detail = body.detail;" not in js site = js.index('if (!res.ok || !res.body) {') block_end = js.index("await readSSE(", site) block = js[site:block_end] assert "let detail = `Brain's API answered with HTTP ${res.status}.`;" in block assert "detail = errorDetailText(body.detail, detail);" in block, ( "the per-status default is the serializer's fallback" ) assert "} catch { /* non-JSON error body */ }" in block # Phase 164 task 03 decorates THIS error (bubbleText + hint) before # the throw — the thrown message stays the serialized detail. assert "const err = new Error(detail);" in block assert "throw err;" in block # The fallback is exactly the status line the bubble must show for # a non-JSON 500 (phase 164 E2E case b). assert "Brain's API answered with HTTP" in block def test_sse_error_frame_call_site_reads_through_the_helper() -> None: """The SSE ``error``-frame throw: ``new Error(errorDetailText(ev.detail, "Something went wrong on my side."))`` — and the phase-114 ``err.hint`` line stays byte-identical right after it.""" js = _js() assert ( 'const err = new Error(errorDetailText(ev.detail, "Something went wrong on my side."));' in js ) site = js.index('const err = new Error(errorDetailText(ev.detail,') tail = js[site : site + 200] assert tail.splitlines()[1].strip() == "err.hint = ev.hint;", ( "the err.hint channel is untouched" ) assert 'new Error(ev.detail ||' not in js, "the old unguarded read is gone" #: One guarded 422-reader block per existing site (the seven copies, #: D2 "stay byte-identical — no opportunistic refactor"): the exact #: idiom lines, file → (array-msg line, string line). GUARDED_COPIES: dict[str, tuple[str, str]] = { "tuning.js": ( " if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {", ' if (typeof data.detail === "string" && data.detail) return data.detail;', ), "theme.js": ( " if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {", ' if (typeof data.detail === "string" && data.detail) return data.detail;', ), "document.js": ( " if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {", ' if (typeof data.detail === "string" && data.detail) return data.detail;', ), "git-sources.js": ( " if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {", ' if (typeof data.detail === "string" && data.detail) return data.detail;', ), "doc-edit.js": ( " if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {", ' if (typeof data.detail === "string" && data.detail) return data.detail;', ), } @pytest.mark.parametrize("name", sorted(GUARDED_COPIES)) def test_existing_guarded_readers_are_byte_identical(name: str) -> None: """The five external guarded readers keep their EXACT lines (indented, verbatim) — the phase touches only the two unguarded chat paths.""" src = _asset(name) array_line, string_line = GUARDED_COPIES[name] assert array_line in src, f"{name} lost its guarded array-msg line" assert string_line in src, f"{name} lost its guarded string line" def test_app_js_guarded_copies_are_untouched() -> None: """app.js's own guarded copies — the tuning-form reader (the three-branch idiom) and the image-upload string guard — stay byte-identical, and the tuning form was NOT opportunistically rewritten onto the new helper (exactly one Array-guard block in app.js: the tuning form).""" js = _js() guard = "if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {" assert js.count(guard) == 1 assert "detail = String(data.detail[0].msg);" in js assert "} else if (typeof data.detail === \"string\" && data.detail) {" in js assert 'if (typeof body?.detail === "string") detail = body.detail;' in js