"""Phase 77 E2E (Playwright): navbar re-shows refresh the view's data — the story suite for the phase-77 refresh hook + the History refresh button. Source: TODO.md L3 — "Clicking navbar icons should refresh the relevant page. For example, clicking 'history' doesn't load new history until I refresh. The history page should also have a refresh button." Since the phase-76 shell, a view's data was fetched exactly once, at mount (mount-once, hide-forever) — a History view opened at 10:00 still showed 10:00's data at 10:30. Phase 77 makes every USER-INITIATED re-show of an already-mounted view re-fetch its list: the router dispatches ``bor:view-refresh`` on the view's section (a switch back onto it, a re-click of its own nav link — NO pushState — or back/ forward onto it; the first show and boot never), the four data views (History, RAG, Sources, Tuning) listen and re-run their loads, and the History view gains an explicit Refresh button in its page-head (the owner's second half of L3). The Chat view is deliberately excluded (the in-flight stream and local conversation persist — the phase-76 contract; the unchanged ``test_nav_switch_keeps_stream.py`` suite is the additional control). Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov The "freshness" proof (the house pattern from the phase overview): create new backing data via the API AFTER a view has loaded, nav back (or re-click / press Refresh), assert the new row — with the phase-76 canonical same-document sentinel (a ``window`` global set before the nav clicks is still readable after — no document load). Test → story mapping (Playwright Mapping Rule): 1. ``test_reshow_refetches_history`` — THE L3 REPRO: saved chat A visible on History; chat B created via the API while Chat is up; Chat → History: row B appears (replaced list — no duplicates), the window sentinel survives every click (same document). 2. ``test_active_view_reclick_refetches`` — a re-click of the ACTIVE view's own nav link re-fetches (today a no-op): chat C created via the API while History is up; click History AGAIN → C appears, the URL is still /history.html and ``history.length`` is unchanged (no pushState). 3. ``test_history_refresh_button`` — the explicit Refresh control (TODO.md L3's second half): chat D created via the API; click #history-refresh → D appears, #history-status announces "Saved chats refreshed.", the button is disabled while the request is in flight (the house hold-the-request pattern — deterministic, not a race) and re-enabled after (accessible name + keyboard focus pinned too). 4. ``test_popstate_refetches_history`` — back/forward onto an already-mounted view re-fetches: Chat → History (loads) → Chat → create E via the API → ``page.go_back()`` (popstate) → History → E present. 5. ``test_all_four_data_views_refetch_on_reshow`` — one assertion per data view (History, RAG, Sources, Tuning): the GET to the view's list endpoint is exactly +1 on a re-show (the request-log pattern from task 02) — the first show (the mount) is the baseline. 6. ``test_stream_surval_control`` — the phase-76 CONTRACT control with the hook in place: send a question (the mock LLM's ~8 s stream) → mid-stream nav to RAG → back to Chat → the FULL answer completes and the turn settles (one query_log row). The unchanged ``test_nav_switch_keeps_stream.py`` run in isolation is the additional control. """ from __future__ import annotations import asyncio import re import time from collections.abc import Callable from pathlib import Path from threading import Thread from typing import Any from urllib.parse import urlsplit import httpx from playwright.sync_api import Locator, Page, Request, expect from sqlalchemy import text from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient from e2e.auth_helpers import login from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" #: The phase-11 on-topic long-answer phrasing (house pattern, #: test_nav_switch_keeps_stream.py): the honesty gate is HIGH and the #: ~900-word answer streams for ~8–9 s — the guaranteed mid-stream #: window. LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer" #: The typing indicator is itself a .msg.brain — exclude its bubble. ANSWER = ".msg.brain .bubble:not(.typing)" #: Distinct saved-chat titles per test (the DB is truncated at each #: test's start, so fixed titles stay unambiguous within a test). TITLE_A = "Phase77 re-show A" TITLE_B = "Phase77 re-show B" TITLE_C = "Phase77 re-click C" TITLE_D = "Phase77 refresh button D" TITLE_E = "Phase77 popstate E" # The four data views (task 02's refresh joiners): the nav link, the # view's URL (the pushState target), and the list endpoint the view's # load hits (the request-log assertion's target — the sync/upload # pollers hit OTHER paths and only run while a job is in flight). DATA_VIEWS: tuple[tuple[str, str, str], ...] = ( ("#nav-history", "/history.html", "/api/chats"), # Phase 97: the RAG view's list endpoint is the drill-down tree # (the flat /api/docs is the unchanged API surface, no longer the # view's fetch). Phase 167: the load carries depth=1 (the payload # diet) — the per-level drill fetches /api/docs/tree/node, which # these no-drill tests never trigger. ("#nav-sources", "/sources.html", "/api/docs/tree?depth=1"), ("#nav-git-sources", "/git-sources.html", "/api/git-sources"), ("#nav-tuning", "/tuning.html", "/api/steering"), ) # --------------------------------------------------------------------------- # DB reset + API helpers (the house pattern from test_nav_switch_keeps_stream) # --------------------------------------------------------------------------- async def _import_fixtures(mock_port: int) -> ImportSummary: kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] return await import_sources([FIXTURES], LLMClient(settings)) def _run_in_thread(coro: Any) -> Any: """Run a coroutine on a worker thread. Playwright's sync API keeps an asyncio loop running on the test thread, so ``asyncio.run`` cannot be called directly from a test body. """ box: dict[str, Any] = {} def runner() -> None: try: box["value"] = asyncio.run(coro) except BaseException as e: # noqa: BLE001 — re-raised on the test thread box["error"] = e t = Thread(target=runner) t.start() t.join() if "error" in box: raise box["error"] return box["value"] def _reset_db(mock_port: int) -> ImportSummary: """House reset + the prompt-shaping tables: steering notes and the KB overview would otherwise append deterministic suffixes to every mock answer and break the exact-text assertions; saved_chats is cleared too (each test creates its own rows via the API).""" with SessionLocal() as db: db.execute( text( "TRUNCATE chunks, documents, query_log, " "steering_notes, kb_overview, saved_chats" ) ) db.commit() return _run_in_thread(_import_fixtures(mock_port)) def _query_log_count() -> int: """The settled-row count over the whole (truncated) log — the house pattern: a row finalizes ONLY when the LLM finished AND the persistence succeeded, so 0 = cancelled, 1 = settled.""" with SessionLocal() as db: return db.execute(text("SELECT count(*) FROM query_log")).scalar_one() def _admin_cookies(page: Page) -> dict[str, str]: """The signed session cookies the browser holds after a form login — the test's API side sees exactly what the signed-in browser sees.""" return {c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c} def _create_chat(app_url: str, cookies: dict[str, str], title: str) -> dict[str, Any]: """POST /api/chats (the public save surface) — the API-side mutation the freshness proofs hinge on: the row is created while the view is NOT showing, so only a re-fetch can surface it.""" r = httpx.post( f"{app_url}/api/chats", timeout=10, cookies=cookies, json={ "title": title, "messages": [ {"who": "user", "text": f"Saved via the API for the phase-77 suite: {title}"}, {"who": "brain", "text": "Saved."}, ], }, ) assert r.status_code == 201, f"POST /api/chats for {title!r}: {r.status_code} {r.text}" return r.json() def _row(page: Page, title: str) -> Locator: """The saved chat's row (the Title cell's Open link text is the title — textContent only, so an exact-text locator is stable).""" return page.locator("#history-tbody a.history-title-link", has_text=title) def _wait_until(page: Page, pred: Callable[[], bool], timeout: float = 15.0) -> None: """Poll ``pred`` until it holds — the tick is ``page.wait_for_timeout`` (NOT a raw ``time.sleep``): the sync API delivers ``page.on`` events only while a Playwright call is in flight, so the request log the predicate reads is drained on every tick (a raw sleep starves the event queue).""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: if pred(): return page.wait_for_timeout(50) raise AssertionError("timeout waiting for the condition") def _get_logger(page: Page) -> list[str]: """Every GET the browser issues (the request-log pattern from task 02 — the re-fetch proof where a cheap row-delta is not available). POST/PUT (auto-saves, form posts) are deliberately not logged: the assertion is about the view's LIST fetch.""" seen: list[str] = [] def on_request(req: Request) -> None: if req.method == "GET": seen.append(req.url) page.on("request", on_request) return seen # --------------------------------------------------------------------------- # Shared stream helpers (mirrored from test_nav_switch_keeps_stream.py — # the phase-76 suite must stay unchanged; this file is the story gate) # --------------------------------------------------------------------------- def _no_error_banner(page: Page) -> None: expect(page.locator('[role="alert"]:visible')).to_have_count(0) def _ask_long(page: Page) -> None: page.fill("#message-input", LONG_QUESTION) page.click("#send-btn") expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION) def _wait_streaming(page: Page, answer: Locator) -> Locator: """Wait until answer text is visibly streaming (a few delta frames rendered — the mid-stream moment, well inside the ~8–9 s stream).""" answer.wait_for(state="visible", timeout=30_000) partial = "" deadline = time.monotonic() + 15 while time.monotonic() < deadline: partial = answer.inner_text() if len(partial.split()) >= 8: break time.sleep(0.05) assert len(partial.split()) >= 8, "no answer deltas before the view switch" # In flight at the switch: the button IS the enabled Stop control. expect(page.locator("#send-label")).to_have_text("Stop") expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop")) return answer def _wait_done(page: Page, answer: Locator) -> str: """Wait for the ``done`` settle: the Send button is back and the bubble carries the unique final line — no error banner on the way.""" expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000) expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop")) expect(answer).to_contain_text(LONG_ANSWER_END, timeout=30_000) _no_error_banner(page) return answer.inner_text() def _assert_full_answer(text: str) -> None: """The bubble carries the FULL mock answer — every one of the 40 numbered steps plus the unique final line (a truncated stream would be missing its tail).""" for i in range(1, LONG_ANSWER_LINES + 1): assert f"Step {i}: configure node-{i}" in text, f"step {i} missing from the answer" assert LONG_ANSWER_END in text # --------------------------------------------------------------------------- # 1. THE L3 REPRO: a switch back onto History re-fetches — the new row # appears (list replaced, no duplicates), same document throughout # --------------------------------------------------------------------------- def test_reshow_refetches_history(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None: """Sign in → create saved chat A via POST /api/chats → nav to History (row A visible) → create chat B via the API while Chat is up → nav to Chat → nav back to History: row B is visible (the re-show re-fetched — before phase 77 the 10:00 list stayed), row A appears EXACTLY ONCE (the re-load replaces the list, never duplicates it), and the window sentinel set before the nav clicks is still readable after (the phase-76 canonical no-document-load proof).""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") expect(page.locator("#nav-history")).to_be_visible() cookies = _admin_cookies(page) _create_chat(app_url, cookies, TITLE_A) page.click("#nav-history") expect(page).to_have_url(app_url + "/history.html") expect(_row(page, TITLE_A)).to_be_visible(timeout=15_000) # THE REPRO: B is created while History is NOT showing… page.evaluate("() => { window.__shell_boot = 'phase77'; }") _create_chat(app_url, cookies, TITLE_B) page.click('a.nav-link[href="/"]') expect(page).to_have_url(app_url + "/") # …and the switch back re-fetches: B is visible without a reload. page.click("#nav-history") expect(page).to_have_url(app_url + "/history.html") expect(_row(page, TITLE_B)).to_be_visible(timeout=15_000) # Replaced list, never duplicated: each title EXACTLY ONCE. expect(_row(page, TITLE_A)).to_have_count(1) expect(_row(page, TITLE_B)).to_have_count(1) # Same document through every click (a real navigation would wipe # the window sentinel). assert page.evaluate("() => window.__shell_boot") == "phase77", ( "a real navigation would have wiped the window sentinel — " "the switches must be same-document" ) # --------------------------------------------------------------------------- # 2. A re-click of the ACTIVE view's own nav link re-fetches (no-op # before phase 77) — NO pushState (the URL is already the path) # --------------------------------------------------------------------------- def test_active_view_reclick_refetches( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: """With History visible (rows A, B): create C via the API; click the History nav link AGAIN (the active view's own link) → C appears. The URL is still /history.html and history.length is UNCHANGED — the re-click dispatches the refresh event instead of a bare return, and it must not pushState (the URL already IS this view's path).""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") cookies = _admin_cookies(page) _create_chat(app_url, cookies, TITLE_A) _create_chat(app_url, cookies, TITLE_B) page.click("#nav-history") expect(page).to_have_url(app_url + "/history.html") expect(_row(page, TITLE_B)).to_be_visible(timeout=15_000) length_before = page.evaluate("() => window.history.length") _create_chat(app_url, cookies, TITLE_C) page.click("#nav-history") # re-click the ACTIVE link expect(page).to_have_url(app_url + "/history.html") expect(_row(page, TITLE_C)).to_be_visible(timeout=15_000) expect(_row(page, TITLE_A)).to_have_count(1) length_after = page.evaluate("() => window.history.length") assert length_after == length_before, ( "the active re-click must NOT pushState — the URL is already " f"this view's path ({length_before} → {length_after})" ) # --------------------------------------------------------------------------- # 3. The explicit Refresh button (TODO.md L3's second half): the # visible, keyboard-reachable control with its in-flight lifecycle # --------------------------------------------------------------------------- def test_history_refresh_button(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None: """The Refresh control (TODO.md L3: "The history page should also have a refresh button."): visible for admin in the page-head, keyboard-reachable (Tab-able, focusable, accessible name), and the in-flight lifecycle — create D via the API; click #history-refresh → D appears, #history-status announces "Saved chats refreshed.", the button is disabled DURING the in-flight request (asserted deterministically: the request is held in the browser via page.route — the house pattern from test_archive_upload_sources) and re-enabled after.""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") cookies = _admin_cookies(page) _create_chat(app_url, cookies, TITLE_A) page.click("#nav-history") expect(page).to_have_url(app_url + "/history.html") expect(_row(page, TITLE_A)).to_be_visible(timeout=15_000) btn = page.locator("#history-refresh") expect(btn).to_be_visible() expect(btn).to_be_enabled() # WCAG 2.1 AA basics: the accessible name (icon-only below 640px — # the aria-label carries it in both) + keyboard-reachable. assert btn.get_attribute("aria-label") == "Refresh saved chats" btn.focus() active = page.evaluate("() => document.activeElement && document.activeElement.id") assert active == "history-refresh", "the Refresh button must be keyboard-focusable" # Hold the refresh's GET in the browser so the in-flight state is # observable deterministically instead of racing the fast # localhost round-trip (the house pattern): the flag is armed only # around the button's click. hold = {"on": False} def handle(route: Any) -> None: if hold["on"]: time.sleep(1.0) route.continue_() page.route("**/api/chats", handle) _create_chat(app_url, cookies, TITLE_D) hold["on"] = True btn.click() # In flight (§7.4): the button is disabled — no double-fire. expect(btn).to_be_disabled() hold["on"] = False # Settled: D is visible (the re-fetch replaced the list), the live # region carries the exact success line, the button is re-enabled. expect(_row(page, TITLE_D)).to_be_visible(timeout=15_000) expect(_row(page, TITLE_A)).to_have_count(1) expect(page.locator("#history-status")).to_have_text("Saved chats refreshed.") expect(btn).to_be_enabled() # --------------------------------------------------------------------------- # 4. Back/forward (popstate) onto an already-mounted view re-fetches # --------------------------------------------------------------------------- def test_popstate_refetches_history( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: """Chat → History (the mount's own load) → back to Chat → create E via the API → ``page.go_back()`` (popstate onto the already-mounted History view) → E is present: the popstate path flows through switchTo, so it inherits the wasMounted gating — a re-show dispatches bor:view-refresh, the first show never did (E was not visible on the original show).""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") cookies = _admin_cookies(page) page.click("#nav-history") # Chat → History (mount + first load) expect(page).to_have_url(app_url + "/history.html") expect(page.locator("#history-table-wrap")).to_be_visible(timeout=15_000) page.click('a.nav-link[href="/"]') # → Chat (pushState) expect(page).to_have_url(app_url + "/") _create_chat(app_url, cookies, TITLE_E) page.go_back() # popstate → /history.html (the already-mounted view) expect(page).to_have_url(app_url + "/history.html") expect(page.locator("#view-history")).not_to_be_hidden() expect(_row(page, TITLE_E)).to_be_visible(timeout=15_000) # --------------------------------------------------------------------------- # 5. All four data views re-fetch on a re-show (the request-log pattern # from task 02 — one assertion per view) # --------------------------------------------------------------------------- def test_all_four_data_views_refetch_on_reshow( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: """One assertion per data view (History, RAG, Sources, Tuning): after the first show (the mount — its load is the baseline), a switch back onto the view issues EXACTLY ONE more GET to the view's list endpoint — the re-fetch the phase is about. The windowed counts (before/after the re-show click) make the assertion immune to the boot-time fetches (the header's steering panel loads once at shell boot) and to the sync/upload pollers (other paths, and only while a job is in flight). For History the row-delta rides along: a chat created before the re-show appears after it.""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") expect(page.locator("#nav-sources")).to_be_visible() # admin: every link revealed expect(page.locator("#nav-history")).to_be_visible() expect(page.locator("#nav-git-sources")).to_be_visible() expect(page.locator("#nav-tuning")).to_be_visible() cookies = _admin_cookies(page) log = _get_logger(page) def count(endpoint: str) -> int: """Requests to ONE endpoint: exact path, plus every query pair the pin names. Phase 175 paginated the History list (``/api/chats?limit=25&offset=0``, ``history.js``), so the plain ``endswith`` this suite used to count with stopped seeing the view's own fetch altogether — the mount baseline never arrived and the loop timed out on the FIRST view.""" path, _, query = endpoint.partition("?") wanted = [pair for pair in query.split("&") if pair] total = 0 for url in log: parts = urlsplit(url) if parts.path != path: continue params = parts.query.split("&") if parts.query else [] if all(pair in params for pair in wanted): total += 1 return total for nav_sel, view_path, endpoint in DATA_VIEWS: # First show (the mount) — wait until the mount's own load # lands in the log (the baseline). page.click(nav_sel) expect(page).to_have_url(app_url + view_path) _wait_until(page, lambda ep=endpoint: count(ep) >= 1) before = count(endpoint) # History: create the row-delta's backing data while hidden. if endpoint == "/api/chats": _create_chat(app_url, cookies, "Phase77 four-view row") # Back to Chat, then a re-show of the view: the refresh event # must issue exactly one more list GET. page.click('a.nav-link[href="/"]') expect(page).to_have_url(app_url + "/") page.click(nav_sel) expect(page).to_have_url(app_url + view_path) _wait_until(page, lambda b=before, ep=endpoint: count(ep) >= b + 1) assert count(endpoint) == before + 1, ( f"{nav_sel}: a re-show must re-fetch exactly once " f"({before} → {count(endpoint)} GETs to {endpoint})" ) if endpoint == "/api/chats": expect(_row(page, "Phase77 four-view row")).to_be_visible(timeout=15_000) # --------------------------------------------------------------------------- # 6. The phase-76 CONTRACT control: with the refresh hook in place, an # in-flight stream still survives a mid-stream nav switch # --------------------------------------------------------------------------- def test_stream_survival_control(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None: """The phase-76 contract holds with the hook in place: send a question (the mock LLM's ~8 s stream) → mid-stream nav to RAG (a re-show-capable, LISTENING view — the hook is live) → back to Chat → the FULL answer completes and the turn settles (one query_log row). The Chat view never listens for bor:view-refresh (the negative unit pin) — its in-flight SSE reader and local conversation persist through every switch. The unchanged tests/e2e/test_nav_switch_keeps_stream.py run in isolation is the additional control.""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") expect(page.locator("#nav-sources")).to_be_visible() _ask_long(page) answer = _wait_streaming(page, page.locator(ANSWER)) # THE SWITCH (mid-stream): the window sentinel set BEFORE the click # is still readable AFTER it — same document. page.evaluate("() => { window.__shell_boot = 'phase77'; }") page.click("#nav-sources") expect(page).to_have_url(app_url + "/sources.html") assert page.evaluate("() => window.__shell_boot") == "phase77", ( "a real navigation would have wiped the window sentinel" ) # The RAG view actually showed (the source rows are listed — phase # 97: the top level lists the sources; the file table is per-level # and hidden at the top) and the chat view is hidden (the stream # fills it in the background — that persistence IS the phase-76 fix). expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000) expect(page.locator("#view-chat")).to_be_hidden() # Stay on RAG while the stream keeps running, then return to Chat. page.wait_for_timeout(2000) page.click('a.nav-link[href="/"]') expect(page).to_have_url(app_url + "/") assert page.evaluate("() => window.__shell_boot") == "phase77" # The answer COMPLETED — the FULL mock answer, no error banner — # and the turn SETTLED (one query_log row; a cancelled turn would # leave none). done_text = _wait_done(page, answer) _assert_full_answer(done_text) assert _query_log_count() == 1, "the completed turn must finalize exactly one settled row" # --------------------------------------------------------------------------- # 7. Sanity: the API-side rows the suite creates carry the bor.chat.v1 # record shape (guards _create_chat against a schema drift that # would silently change what History renders) # --------------------------------------------------------------------------- def test_api_created_chats_carry_the_bor_chat_v1_shape( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: """The suite's API-side mutation helper (POST /api/chats) produces rows in the exact bor.chat.v1 record shape the History table and the ?chat= restore path render: who/text messages, the message count, and no stray keys (the schema's extra=forbid rejects them at the boundary with a 422 — a 201 here proves the shape) — and History renders the row (the Open link's text IS the title).""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") cookies = _admin_cookies(page) created = _create_chat(app_url, cookies, "Phase77 shape check") r = httpx.get(f"{app_url}/api/chats/{created['id']}", timeout=10, cookies=cookies) assert r.status_code == 200 body = r.json() assert body["title"] == "Phase77 shape check" assert [m["who"] for m in body["messages"]] == ["user", "brain"] assert body["message_count"] == 2 # The bor.chat.v1 record shape: who/text present; only the schema's # optional keys may accompany them (explicit nulls are preserved — # the plain model_dump round-trips byte-identical). ``related`` is # the phase-113 related-doc tier the UI persists with grounded # brain records (accepted by the ChatMessage schema — its earlier # absence 422'd the done-time auto-save, phase-118 verification fix). allowed = {"who", "text", "sources", "related", "deflected", "suggestions", "thinking", "tools", "stopped", "failed", "error", "budget_exhausted"} assert all({"who", "text"} <= set(m) <= allowed for m in body["messages"]) # The History row renders it (the Open link's text IS the title). page.click("#nav-history") expect(page).to_have_url(app_url + "/history.html") expect(_row(page, "Phase77 shape check")).to_be_visible(timeout=15_000)