"""Phase 36 E2E (Playwright): the summary and the original document are visible **together** in the viewer. Story: ``.agents/user_stories/summary-in-viewer.md`` Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_summary_in_viewer.py -v --no-cov The fixture KB is the story-dedicated ``tests/fixtures/summary_kb/`` directory (the same one the phase-30 suite imports — its machinery is reused here as closely as possible): * ``quadlet/qwen-llamacpp.yaml`` — a non-markdown A9 doc. At import the mock ``lite`` model (``SUMMARY_MODE`` marker, ``tests/e2e/mock_llm.py``) reduces it to a deterministic 24-token digest, stored on ``documents.summary``. The sentinel ``RESE-SUMMARY-SENTINEL-7f3a`` sits on the document's LAST line — **outside** the 24-token digest — so it is a marker for "the original, not the summary". * ``notes/qwen-llamacpp-notes.md`` — a markdown control doc, never summarized (phase 30 scope): the viewer must render it exactly as before, with no Summary panel. This phase only surfaces the stored field: the content endpoint returns ``summary`` (task 01) and the shared ``renderDocument`` core draws the labeled ``.doc-summary`` panel above the content on BOTH surfaces (task 02) — the full-page viewer and the chat/sources modal. The tests assert exactly that contract. Phase 79 supersedes the phase-16 soft rule: the content endpoint is ``require_user``-gated, so the viewer surfaces and the API shape pin run under a signed-in session (the shape itself — ``summary`` for the yaml, ``null`` for the markdown control — is unchanged). """ from __future__ import annotations import asyncio import re from pathlib import Path from threading import Thread from typing import Any from playwright.sync_api import Page, 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 tests.e2e.mock_llm import TOKEN_RE REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "summary_kb" SENTINEL = "RESE-SUMMARY-SENTINEL-7f3a" SOURCE = "summary_kb" YAML_PATH = "quadlet/qwen-llamacpp.yaml" MD_PATH = "notes/qwen-llamacpp-notes.md" #: Encoded viewer URL query values (slashes come out as %2F, same as the #: chips / Sources table links build them). YAML_URL_PATH = "quadlet%2Fqwen-llamacpp.yaml" MD_URL_PATH = "notes%2Fqwen-llamacpp-notes.md" # --- Importer + thread helpers (test_document_summaries.py pattern) --- 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_and_import(mock_llm: int) -> ImportSummary: """Truncate the KB (and query log + steering) and re-import the summary_kb fixtures — yaml summarized, md control not.""" with SessionLocal() as db: db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes")) db.commit() summary = _run_in_thread(_import_fixtures(mock_llm)) assert summary.added == 2 # yaml + md control assert summary.summaries == 1 and summary.summary_errors == 0 assert summary.errors == 0 return summary def _expected_summary(content: str, source: str, path: str) -> str: """The mock lite model's byte-stable digest + the code pointer line. Mirrors ``mock_llm.compose_answer``'s ``SUMMARY_MODE`` branch (first 24 tokens of the document content) plus the summarizer's deterministic ``Source:`` line — no model output is ever trusted. """ digest = " ".join(TOKEN_RE.findall(content.lower())[:24]) return f"This document covers {digest}.\nSource: {source}/{path}" def _summary_lines(source: str, path: str) -> tuple[str, str]: """(digest line, pointer line) of the stored summary for a fixture.""" expected = _expected_summary( (FIXTURES / path).read_text(encoding="utf-8"), source, path ) digest_line, pointer_line = expected.split("\n", 1) return digest_line, pointer_line # --------------------------------------------------------------------------- # 1. Full page: labeled Summary panel ABOVE the original, both visible # --------------------------------------------------------------------------- def test_full_page_shows_summary_and_original_together( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: """The dedicated viewer for a summarized yaml shows the labeled Summary panel (deterministic mock digest) and, below it, the FULL original content — its tail sentinel, which the 24-token digest cannot contain, is rendered in the raw ``
``."""
    _reset_db_and_import(mock_llm)
    page.set_default_timeout(30_000)
    login(page, app_url, next="/")  # phase 79: the viewer content is gated

    digest_line, pointer_line = _summary_lines(SOURCE, YAML_PATH)
    page.goto(f"{app_url}/document.html?source={SOURCE}&path={YAML_URL_PATH}")

    # Title + meta row unchanged by the phase (badge row is untouched).
    expect(page.locator("#doc-title")).to_have_text("qwen-llamacpp")
    expect(page.locator("#doc-meta .doc-source-badge")).to_have_text(SOURCE)
    expect(page.locator("#doc-meta .format-badge")).to_have_text("yaml")
    expect(page.locator("#doc-meta .doc-path")).to_have_text(YAML_PATH)

    # Exactly one labeled Summary section — a real 
# with a visible heading (UI Structure Check: labeled section). panel = page.locator(".doc-summary") expect(panel).to_have_count(1) expect(panel).to_be_visible() expect(panel).to_have_attribute("aria-label", "Summary") expect(panel.locator(".doc-summary-title")).to_have_text("Summary") # The panel carries the DETERMINISTIC digest (byte-stable, no real # LLM) — digest line + code pointer line — and nothing from the raw # body beyond the first 24 tokens ("healthcheck" is deeper in the # file, so the panel cannot be the original content). expect(panel).to_contain_text(digest_line) expect(panel).to_contain_text(pointer_line) expect(panel).not_to_contain_text("healthcheck") # The ORIGINAL is fully visible: the raw yaml in its
, including
    # the tail sentinel that sits outside the digest.
    pre = page.locator("#doc-content pre.doc-raw")
    expect(pre).to_have_count(1)
    expect(pre).to_contain_text(SENTINEL)
    expect(pre).to_contain_text("healthcheck")

    # The panel sits ABOVE the content: first child of #doc-content.
    order = page.evaluate(
        "() => [...document.querySelector('#doc-content').children]"
        ".map((el) => el.className)"
    )
    assert order[0] == "doc-summary", f"panel not first: {order}"
    assert "doc-raw" in order and order.index("doc-raw") > order.index("doc-summary")

    # Theme (phase 08, dark-red rebrand 2026-08-28): panel on the
    # surface colour, title in brand-ink (--brand-ink #fca5a5 on
    # --surface #1a0f0f = 9.0:1 ≥4.5:1), and the panel matches the
    # raw-content width — it does not break the content layout.
    bg = panel.evaluate("el => getComputedStyle(el).backgroundColor")
    assert bg == "rgb(26, 15, 15)", f"panel not on the --surface colour (rebrand #1a0f0f): {bg}"
    title_color = panel.locator(".doc-summary-title").evaluate(
        "el => getComputedStyle(el).color"
    )
    assert title_color == "rgb(252, 165, 165)", f"title not brand-ink: {title_color}"
    panel_box = panel.bounding_box()
    pre_box = pre.bounding_box()
    assert panel_box is not None and pre_box is not None
    assert abs(panel_box["width"] - pre_box["width"]) < 2, (
        f"panel width {panel_box['width']} != raw width {pre_box['width']}"
    )
    assert panel_box["y"] < pre_box["y"], "panel must sit above the content"


# ---------------------------------------------------------------------------
# 2. Modal from the Sources table: same panel + content; "Full page"
#    agrees (the two surfaces cannot drift — one shared renderer)
# ---------------------------------------------------------------------------


def test_modal_shows_panel_and_full_page_agrees(
    page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
    """Admin opens the yaml from the Sources table row: the SAME-PAGE
    modal shows the Summary panel + the sentinel-bearing original; the
    'Full page' escape hatch lands on the dedicated page with the panel
    still there — both surfaces render through one shared core."""
    _reset_db_and_import(mock_llm)
    page.set_default_timeout(30_000)
    login(page, app_url)  # phase 16: the Sources catalog is admin-only

    digest_line, pointer_line = _summary_lines(SOURCE, YAML_PATH)
    row = page.locator("#docs-tbody tr", has_text=YAML_PATH)
    expect(row).to_have_count(1)
    before = len(page.context.pages)
    row.locator("td:nth-child(2) a.doc-link").click()
    assert len(page.context.pages) == before, "row link must not open a new tab"

    # The modal shows the panel + the original content, in order.
    expect(page.locator(".doc-modal")).to_be_visible()
    expect(page.locator("#doc-modal-title")).to_have_text("qwen-llamacpp")
    modal_panel = page.locator("#doc-modal .doc-summary")
    expect(modal_panel).to_have_count(1)
    expect(modal_panel).to_be_visible()
    expect(modal_panel).to_have_attribute("aria-label", "Summary")
    expect(modal_panel).to_contain_text(digest_line)
    expect(modal_panel).to_contain_text(pointer_line)
    modal_pre = page.locator("#doc-modal pre.doc-raw")
    expect(modal_pre).to_have_count(1)
    expect(modal_pre).to_contain_text(SENTINEL)
    order = page.evaluate(
        "() => [...document.querySelector('#doc-modal-content').children]"
        ".map((el) => el.className)"
    )
    assert order[0] == "doc-summary", f"panel not first in the modal: {order}"

    # "Full page" (the #doc-modal-open escape hatch — target=_blank by
    # design) opens the dedicated viewer in a NEW TAB, and the panel is
    # there with the same digest and the same sentinel: the surfaces
    # agree (one shared renderer, no drift).
    with page.expect_popup() as popup_info:
        page.click("#doc-modal-open")
    full_page = popup_info.value
    expect(
        full_page
    ).to_have_url(
        re.compile(
            r"^" + re.escape(f"{app_url}/document.html")
            + r"\?source=summary_kb&path=quadlet%2Fqwen-llamacpp\.yaml$"
        ),
        timeout=30_000,
    )
    full_panel = full_page.locator(".doc-summary")
    expect(full_panel).to_have_count(1)
    expect(full_panel).to_be_visible()
    expect(full_panel).to_contain_text(digest_line)
    expect(full_panel).to_contain_text(pointer_line)
    expect(full_page.locator("#doc-content pre.doc-raw")).to_contain_text(SENTINEL)


# ---------------------------------------------------------------------------
# 3. No-summary control: the markdown doc renders exactly as before
# ---------------------------------------------------------------------------


def test_markdown_doc_has_no_summary_panel(
    page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
    """Control doc (markdown — never summarized): NO .doc-summary
    element on either surface, and the content renders exactly as
    before (first child of the content container is the doc body)."""
    _reset_db_and_import(mock_llm)
    page.set_default_timeout(30_000)
    login(page, app_url, next="/")  # phase 79: the viewer content is gated

    # Full page: no panel, markdown column untouched.
    page.goto(f"{app_url}/document.html?source={SOURCE}&path={MD_URL_PATH}")
    expect(page.locator(".doc-summary")).to_have_count(0)
    expect(page.locator("#doc-content .doc-md")).to_have_count(1)
    expect(page.locator("#doc-content")).to_contain_text(
        "came out of a week of"
    )
    order = page.evaluate(
        "() => [...document.querySelector('#doc-content').children]"
        ".map((el) => el.className)"
    )
    assert order == ["doc-md"], f"markdown doc gained children: {order}"

    # Modal: same story — no panel, .doc-md is the sole content child.
    # (the session is already signed in — the form login above)
    page.goto(f"{app_url}/sources.html")
    row = page.locator("#docs-tbody tr", has_text=MD_PATH)
    expect(row).to_have_count(1)
    row.locator("td:nth-child(2) a.doc-link").click()
    expect(page.locator(".doc-modal")).to_be_visible()
    expect(page.locator("#doc-modal .doc-summary")).to_have_count(0)
    expect(page.locator("#doc-modal .doc-md")).to_have_count(1)
    expect(page.locator("#doc-modal-content")).to_contain_text(
        "came out of a week of"
    )
    order = page.evaluate(
        "() => [...document.querySelector('#doc-modal-content').children]"
        ".map((el) => el.className)"
    )
    assert order == ["doc-md"], f"markdown modal gained children: {order}"


# ---------------------------------------------------------------------------
# 4. API shape (cheap, via the page context's request — signed in since
#    phase 79 gated the endpoint)
# ---------------------------------------------------------------------------


def test_content_api_summary_shape(
    page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
    """``GET /api/documents/content`` carries ``summary`` — the string
    for the summarized yaml, ``null`` for the markdown control. Phase 79
    superseded the phase-16 soft rule: the endpoint is require_user, so
    the pin runs under the form-login session (the shape itself is
    unchanged)."""
    _reset_db_and_import(mock_llm)
    login(page, app_url, next="/")

    digest_line, _ = _summary_lines(SOURCE, YAML_PATH)
    expected_summary = f"{digest_line}\nSource: {SOURCE}/{YAML_PATH}"

    resp = page.context.request.get(
        f"{app_url}/api/documents/content?source={SOURCE}&path={YAML_URL_PATH}"
    )
    assert resp.status == 200
    body = resp.json()
    assert body["source"] == SOURCE
    assert body["path"] == YAML_PATH
    assert body["format"] == "yaml"
    assert isinstance(body["summary"], str)
    assert body["summary"] == expected_summary
    assert SENTINEL in body["content"]  # full original, never just the digest

    resp_md = page.context.request.get(
        f"{app_url}/api/documents/content?source={SOURCE}&path={MD_URL_PATH}"
    )
    assert resp_md.status == 200
    md_body = resp_md.json()
    assert md_body["format"] == "md"
    assert md_body["summary"] is None, "markdown docs never carry a summary"
    assert "came out of a week of" in md_body["content"]