Files
brain-of-reese/tests/e2e/test_git_source_links.py
T
ducoterra 32a3ec4f5e
Build and Push Containers / build-and-push-app (push) Successful in 2m23s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 145_git_source_links
**Phase 145 final verification pass — all green, no code changes needed**

- **Verified (no defects found):** resolver `origin_for_source` + `_url_basename` (scp/`.git`/trailing-slash, sanitize, ambiguity WARNING); `origin:`/`branch:` lines in all four read shapes via shared `_origin_lines`; HIGH `<tools>` origin rule (verbatim + only-byte-change pins); `DocContent.origin_url/origin_branch` (absent-never-null serializer); `renderDocument` "Original" anchor (copyable verbatim URL, `_blank`/`noopener`, branch in title); mock scripted origin-link flow (disjoint trigger — grep-verified vs all other E2E files)
- **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → 3402 passed, `app/` coverage **99%** (>90% gate); `uv run pytest tests/e2e/test_git_source_links.py -v --no-cov` → **4 passed** in isolation; `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
- **Completion criteria:** (1) git read results carry origin/branch, local byte-identical — ✅ (unit pins + E2E control); (2) HIGH `<tools>` rule pinned, LOW byte-identical — ✅; (3) modal AND /document.html show Original link, branch in label when pinned, nothing for local — ✅; (4) full suite green + coverage + E2E + lint/types — ✅; (5) no completed-phase behavior change (read header line 1, mock prefix, done frame/SourceRef untouched, LOW sha) — ✅
- **Extra regression check:** re-ran shared-mock E2E suites `test_read_line_ranges.py` (2 passed) and `test_document_viewer.py` (8 passed) — no cross-suite regressions from the `mock_llm.py` addition
- **Documentation:** README.md reviewed in full (env table ⊇ `.env.example`, pages/routes, config defaults, scripts, 17-color palette, 300 s guard, 2 s sync poll, phase-145 provenance section matches implementation) and AGENTS.md (72rem container, no-CDN test, smoke test, gitignore, gates) — **verified consistent, no changes needed**
- **Notable:** phase-116 screenshot PNG was regenerated during this phase's E2E runs (byte re-encode only, visually identical, correct no-anchor local-source meta row) — left in tree for the harness commit
- **Next pending phase:** `146_search_tool_scope` (then 147, 148, 149)
2026-10-01 11:13:43 -04:00

586 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 145 task 04 E2E (Playwright, mock-only): the git-source-links'
dedicated story suite — the owner's scenario, end to end.
The browser-visible proof (TODO.md L7: "there always needs to be a
link to the original git source if possible … point to the skills
repo in the answer so I can go download the skills"): a question
about a git-sourced document produces (1) an answer that contains
the repository link (the model-side teaching, through the real read
result that carries the ``origin:`` line) and (2) a document modal
whose meta row carries the "Original" external anchor. A
local-sourced document produces neither (the byte-identity guard).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_git_source_links.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the real
``turbo`` does whatever it does with the tools, while this story's
gate is the deterministic SCRIPTED flow in ``tests/e2e/mock_llm.py``
(the mock is the only E2E lens on the LLM's context):
* ``ORIGIN_LINK_TRIGGER`` — a user message containing ``original
source link`` **and** the system prompt carrying the HIGH
prompt's ``<tools>`` section. The question carries its own
scripted call after the colon — ``original source link: read
source/path`` (the question-carried-call convention,
``_READ_CAP_CALL_RE``). Request 1 is the scripted ``read``;
request 2 (the read result present) is the deterministic FINAL
answer — the repository URL VERBATIM, parsed from the read
result's ``origin:`` line BY THE MOCK (it cannot fabricate it —
the real server's resolver put the line in the result, so the
URL in the answer is the server-side half's assertion) —
followed by the house scripted-turn echo (the read result
VERBATIM — the rendered output is the lens on the
``origin:``/``branch:`` lines' presence; the local-source
control's read result carries none, and the mock answers with
its deterministic no-origin sentence instead).
KB fixture — TWO local directories (the house per-module import —
the ``test_document_viewer.py`` importer-in-a-thread pattern), each
holding ONE readable markdown document:
* ``skills/phased-execution.md`` — the owner's scenario document
(the phased execution process for pi.dev). The source's name is
``skills`` — it MATCHES the seeded git row's URL basename
(``https://example.com/reese/skills`` → ``skills``: the path
component, trailing ``/`` stripped, no ``.git`` suffix to strip,
last segment — the resolver's name mapping). The fixture document
is locally imported, but the mapping is name-based — which is the
point of the seam;
* ``control/notes.md`` — a SECOND plain local source with NO git
row: the byte-identity control (its read result and its modal
meta row must be exactly the pre-phase shapes).
The git row is seeded in the suite's OWN DB session (a direct
``SessionLocal`` write, per test — ``branch=None`` for tests 1–2,
``branch="main"`` for the pinned-branch test, and ABSENT for the
control test) — never through the API, and never during the import
(a ``kind='git'`` row present at import time would be cloned —
``example.com`` is not a git host; the resolver reads the table
LIVE at read/content time, so seeding after the import is the
whole trick).
The app under test is the conftest session app (mock LLM + the
mock-calibrated gate: threshold 0.30 + lexical floor 0.15): both
questions share the subject words with their own fixture document
(the mock's token-overlap distribution), so every scripted turn
stays GROUNDED (the ``<tools>`` section is present) and the read
document is the turn's cited chip (phase 119 A1 — the done frame's
``sources`` is the READ docs).
Test → observable mapping (Playwright Mapping Rule; the story is
the owner's TODO item — one Playwright file per story, A16):
1. ``test_answer_carries_origin_link`` — the ``original source
link`` question on the git-matched source's document → the
rendered tool output for the ``read`` (the mock's verbatim echo
in the answer — the house scripted-turn lens) carries
``origin: https://example.com/reese/skills`` (the server put
the line in the result the model saw) and the final ``done``
answer contains the repository URL (the scripted echo — the
model-side contract, deterministic).
2. ``test_modal_shows_original_anchor`` — same setup → click the
source chip → ``#doc-modal`` rendered → the meta row contains
the ``.doc-origin`` anchor with ``href`` = the URL,
``target="_blank"``, ``rel="noopener"``, ``textContent`` = the
URL verbatim (and NO branch in the text — ``branch=None``: the
``title`` is the bare URL).
3. ``test_pinned_branch_in_title`` — the ``branch="main"`` row →
the read result's surface also carries ``branch: main``, the
done answer still carries the repository URL, and the same
anchor's ``title`` is
``https://example.com/reese/skills — branch: main`` (the text
stays the bare URL — the copyable-link contract).
4. ``test_local_source_has_no_origin`` — the control source's
document (no git rows at all — the pre-phase world) → the
read's rendered tool output carries NO ``origin:`` line (the
byte-identical result shape — the mock's no-origin sentence
answers) and the modal's meta row has NO ``.doc-origin``
anchor (the pre-phase badge row).
"""
from __future__ import annotations
import asyncio
import json
import time
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import delete, func, select, text
from app.config import Settings
from app.db import SessionLocal
from app.models import Document, GitSource
from app.rag.importer import import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from e2e.mock_llm import ORIGIN_LINK_ANSWER, ORIGIN_LINK_NO_ORIGIN
REPO = Path(__file__).resolve().parents[2]
SOURCE = "skills" # the local directory's basename = the source name
SKILLS_REL = "phased-execution.md"
SKILLS_SP = f"{SOURCE}/{SKILLS_REL}" # the combined read identity
CONTROL = "control" # the plain local control source (NO git row)
CONTROL_REL = "notes.md"
CONTROL_SP = f"{CONTROL}/{CONTROL_REL}"
#: The seeded git row's public URL (no credential — the public form;
#: the phase-121 sanitizer is a byte-identical no-op for it). Its
#: URL basename — ``skills`` — MATCHES the fixture source's name
#: (the resolver's name mapping — the seam).
ORIGIN_URL = "https://example.com/reese/skills"
PINNED_BRANCH = "main"
SKILLS_DOC = """# Phased execution for pi.dev
How does the phased execution process work for pi.dev? It runs one
task at a time: each task file executes in a separate pi subprocess,
and the harness manages phase and task files, retries, and final
validation.
## Task files
Every task file is a unit of work. The pipeline runs the todo phases
in numeric order, and a completed phase moves to the complete
directory with its own test suite green.
## Validation
A phase is complete only when the unit and integration tests pass,
the coverage gate is green, and the Playwright story suite passes
in isolation. The harness then moves the task file and commits the
phase atomically.
"""
CONTROL_DOC = """# Control notes
This is a plain local directory source. It is walked directly by
the importer and never cloned from a repository. The control note
exists so the byte-identity guard has a document: local documents
read, render, and cite exactly as before, with nothing new in the
read result or the meta row.
"""
# The suite's own content pins — the mock's echo assertion keys off
# the read result's ``origin:``/``branch:`` lines, so neither
# fixture may contain a line with either prefix (a fixture defect,
# not a mock defect — see the mock's ``_origin_link_url``).
assert "origin:" not in SKILLS_DOC and "branch:" not in SKILLS_DOC
assert "origin:" not in CONTROL_DOC and "branch:" not in CONTROL_DOC
# The scripted questions — the mock's ``ORIGIN_LINK_TRIGGER`` + the
# question-carried call (``_ORIGIN_LINK_CALL_RE``) + the subject
# words (the mock-calibrated gate: both questions stay GROUNDED on
# their own fixture document).
Q_SKILLS = (
f"Original source link: read {SKILLS_SP} — "
"how does the phased execution process work for pi.dev?"
)
Q_CONTROL = (
f"Original source link: read {CONTROL_SP} — "
"what does the control note say about plain local sources?"
)
#: The final answer the mock scripts for the git-matched document
#: (the ``ORIGIN_LINK_ANSWER`` constant with the seeded URL — the
#: model-side contract, imported so the mock can never drift).
ANSWER_SENTENCE = ORIGIN_LINK_ANSWER.format(url=ORIGIN_URL)
# --------------------------------------------------------------------------
# Fixtures
# --------------------------------------------------------------------------
@pytest.fixture(scope="module")
def kb_dirs(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]:
"""The story's two local-directory sources (host temp dirs — the
app server runs on the same host, so the paths are visible to
it): the ``skills`` source (its basename MATCHES the seeded git
row's URL basename — the resolver's name mapping) and the
``control`` source (no git row — the byte-identity control)."""
skills = tmp_path_factory.mktemp("bor_origin_skills") / SOURCE
skills.mkdir()
(skills / SKILLS_REL).write_text(SKILLS_DOC, encoding="utf-8")
control = tmp_path_factory.mktemp("bor_origin_control") / CONTROL
control.mkdir()
(control / CONTROL_REL).write_text(CONTROL_DOC, encoding="utf-8")
return skills, control
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread (the
``test_document_viewer`` pattern — Playwright's sync API keeps
an asyncio loop on the test thread, so ``asyncio.run`` cannot
run directly in a test body)."""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised in the test
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
async def _import_sources(roots: list[Path], mock_port: int) -> None:
"""The house per-module import (the ``test_document_viewer``
pattern): the REAL importer against the fixture roots under the
deterministic mock embeddings — the mock LLM supplies the
embeds, and the owner's local ``.env`` is NOT read (the
``_env_file=None`` isolation)."""
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]
await import_sources(roots, LLMClient(settings))
def _truncate_all() -> None:
"""Fresh registry + KB (the E2E isolation pattern): the E2E
suites share one Postgres, so a leftover git_sources row or
document would pollute the resolver and the retrieval the
scripted turns run against (the two-source KB's gate margins
are pinned against EXACTLY these two documents)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources, folder_summaries"
)
)
db.commit()
@pytest.fixture(scope="module")
def seeded_kb(mock_llm: int, kb_dirs: tuple[Path, Path]) -> None:
"""The story's precondition: the two-source KB (one readable
markdown doc each) imported under the deterministic mock, the
stored content byte-pinned. The git row is NOT part of the
import (a git row present at import time would be cloned — the
row is seeded in the suite's own DB session per test, AFTER
the import; the resolver reads the table live at
read/content time)."""
_truncate_all()
_run_in_thread(_import_sources(list(kb_dirs), mock_llm))
with SessionLocal() as db:
docs = {
(d.source, d.path): d for d in db.scalars(select(Document)).all()
}
assert set(docs) == {(SOURCE, SKILLS_REL), (CONTROL, CONTROL_REL)}, (
docs.keys()
)
assert docs[(SOURCE, SKILLS_REL)].content == SKILLS_DOC
assert docs[(CONTROL, CONTROL_REL)].content == CONTROL_DOC
# The import ran with NO git rows (the local-only KB): the
# seeded row lands after, per test.
assert db.scalar(select(func.count()).select_from(GitSource)) == 0
def _seed_git_row(branch: str | None, exist: bool = True) -> None:
"""The git row in the suite's OWN DB session (the task's seam):
delete every row, then — when *exist* — insert the one
``kind='git'`` row whose URL basename matches the ``skills``
fixture source (public URL, no credential; *branch* pinned or
NULL). The resolver reads the table LIVE — the row never
touches the importer."""
with SessionLocal() as db:
db.execute(delete(GitSource))
if exist:
db.add(GitSource(url=ORIGIN_URL, kind="git", branch=branch))
db.commit()
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
"""Per-test query_log isolation (the KB itself is module-scoped
— the scripted turns never change it, so the documents persist
across the tests of this module)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE query_log"))
db.commit()
yield
with SessionLocal() as db:
db.execute(text("TRUNCATE query_log"))
db.commit()
# --------------------------------------------------------------------------
# Page helpers (the test_read_line_ranges / test_document_viewer
# house patterns)
# --------------------------------------------------------------------------
#: Captures the raw SSE ``data:`` payloads of the /api/chat stream
#: (a response clone read in the background) — wire-level
#: assertions for the ``tool`` / ``done`` frames, independent of
#: the UI rendering.
SSE_HOOK = """
() => {
if (window.__sseInstalled) return;
window.__sseInstalled = true;
window.__sseFrames = [];
const origFetch = window.fetch;
window.fetch = async function (...args) {
const res = await origFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : args[0].url;
if (url.includes('/api/chat')) {
res.clone().text().then((bodyText) => {
for (const block of bodyText.split('\\n\\n')) {
const line = block.trim();
if (line.startsWith('data: ')) {
window.__sseFrames.push(line.slice(6));
}
}
});
}
} catch (e) { /* non-clonable responses: ignored */ }
return res;
};
}
"""
def _install_page_hooks(page: Page) -> None:
page.evaluate(SSE_HOOK)
def _frames(page: Page, deadline_s: float = 120.0) -> list[dict]:
"""The SSE frames captured since the last submit (``_submit``
clears the buffer), once the hook's background read settles."""
deadline = time.monotonic() + deadline_s
while True:
raw = page.evaluate("() => window.__sseFrames || []")
parsed = [json.loads(line) for line in raw if line]
if any(f.get("type") == "done" for f in parsed):
return parsed
if time.monotonic() > deadline:
raise AssertionError(
f"SSE hook captured no `done` frame (frames so far: "
f"{len(parsed)}) — hook install failed?"
)
time.sleep(0.05)
def _tool_frames(frames: list[dict]) -> list[dict]:
return [f for f in frames if f.get("type") == "tool"]
def _submit(page: Page, question: str) -> None:
page.evaluate("window.__sseFrames = []")
page.fill("#message-input", question)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _wait_settled(page: Page, timeout_ms: int = 120_000) -> None:
"""The turn is complete: answer text in the bubble, button
recovered (the phase-48 settle wait)."""
expect(page.locator(".msg.brain .bubble").last).not_to_have_text(
"", timeout=timeout_ms
)
expect(page.locator("#send-btn")).to_be_enabled(timeout=timeout_ms)
expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout_ms)
def _bubble_text(page: Page) -> str:
"""The turn's rendered answer (the brain bubble's text content
— the mock's scripted echo lands here: the house scripted-turn
lens on the read result's ``origin:``/``branch:`` lines)."""
return page.locator(".msg.brain .bubble").last.text_content() or ""
def _open_modal_for_chip(page: Page, chip_text: str) -> None:
"""Drive the story's modal surface: the turn's cited chip
(phase 119 A1 — done.sources is the READ docs, so the read
document is the turn's ONLY chip) opens the same-page document
modal (the ``test_document_viewer`` contract)."""
chip = page.locator(".msg.brain .source-chip", has_text=chip_text)
expect(chip).to_have_count(1, timeout=30_000)
chip.first.click()
expect(page.locator("#doc-modal")).not_to_have_attribute("hidden")
expect(page.locator("#doc-modal")).to_be_visible()
# --------------------------------------------------------------------------
# 1. The answer carries the repository link (the model-side
# contract, through the real read result's ``origin:`` line)
# --------------------------------------------------------------------------
def test_answer_carries_origin_link(
page: Page, app_url: str, seeded_kb: None, db_ready: None
) -> None:
_seed_git_row(branch=None)
page.set_default_timeout(60_000)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, Q_SKILLS)
_wait_settled(page)
frames = _frames(page)
# The scripted read on the wire — the turn's only tool call.
assert _tool_frames(frames) == [
{"type": "tool", "name": "read", "argument": SKILLS_SP}
], _tool_frames(frames)
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is False, done
# The read document is the turn's cited source (the chip the
# modal tests click — phase 119 A1).
assert any(
s["source"] == SOURCE and s["path"] == SKILLS_REL for s in done["sources"]
), done["sources"]
# The read result's surface (the mock's verbatim echo in the
# answer — the house scripted-turn lens): the sanitized
# ``origin:`` line the SERVER put in the result the model saw
# (the task-01 resolver's output — the git row's URL,
# credential-free).
text = _bubble_text(page)
assert f"origin: {ORIGIN_URL}" in text, text
# The done answer carries the repository URL (the scripted
# echo — the model-side contract, deterministic): the mock
# parsed the URL from the origin line — it cannot appear in
# the answer unless the server sent it.
assert ANSWER_SENTENCE in text, text
# --------------------------------------------------------------------------
# 2. The modal's meta row shows the "Original" external anchor
# --------------------------------------------------------------------------
def test_modal_shows_original_anchor(
page: Page, app_url: str, seeded_kb: None, db_ready: None
) -> None:
_seed_git_row(branch=None)
page.set_default_timeout(60_000)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, Q_SKILLS)
_wait_settled(page)
_open_modal_for_chip(page, SKILLS_REL)
expect(page.locator("#doc-modal-title")).to_have_text(
"Phased execution for pi.dev"
)
# The meta row's "Original" anchor — the task-03 contract
# (ONE shared renderDocument site: the modal AND
# /document.html show it).
anchor = page.locator("#doc-modal-meta .doc-origin")
expect(anchor).to_have_count(1)
expect(anchor).to_have_attribute("href", ORIGIN_URL)
expect(anchor).to_have_attribute("target", "_blank")
expect(anchor).to_have_attribute("rel", "noopener")
# The copyable-link contract: the full URL VERBATIM as the
# visible text — NO branch in the text (branch=None), and the
# title is the bare URL (the pathCode precision idiom, no
# branch suffix).
expect(anchor).to_have_text(ORIGIN_URL)
expect(anchor).to_have_attribute("title", ORIGIN_URL)
# --------------------------------------------------------------------------
# 3. The pinned branch rides the read result and the anchor's title
# --------------------------------------------------------------------------
def test_pinned_branch_in_title(
page: Page, app_url: str, seeded_kb: None, db_ready: None
) -> None:
_seed_git_row(branch=PINNED_BRANCH)
page.set_default_timeout(60_000)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, Q_SKILLS)
_wait_settled(page)
# The read result's surface (the mock's verbatim echo): the
# origin line AND the pinned branch line (the task-01 second
# line, only when the row pins a branch).
text = _bubble_text(page)
assert f"origin: {ORIGIN_URL}" in text, text
assert f"branch: {PINNED_BRANCH}" in text, text
# The done answer still carries the repository URL (tests
# 1–3's model-side contract — the branch never joins the
# URL).
assert ANSWER_SENTENCE in text, text
_open_modal_for_chip(page, SKILLS_REL)
anchor = page.locator("#doc-modal-meta .doc-origin")
expect(anchor).to_have_count(1)
expect(anchor).to_have_attribute("href", ORIGIN_URL)
# The text stays the bare URL (copyable — the branch never
# joins the anchor text)…
expect(anchor).to_have_text(ORIGIN_URL)
# …the branch rides the title (the task-03 label contract).
expect(anchor).to_have_attribute(
"title", f"{ORIGIN_URL} — branch: {PINNED_BRANCH}"
)
# --------------------------------------------------------------------------
# 4. The local-source control is byte-identical (no ``origin:``
# line in the read result, no anchor in the meta row)
# --------------------------------------------------------------------------
def test_local_source_has_no_origin(
page: Page, app_url: str, seeded_kb: None, db_ready: None
) -> None:
_seed_git_row(branch=None, exist=False) # the pre-phase world: NO git rows at all
page.set_default_timeout(60_000)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, Q_CONTROL)
_wait_settled(page)
frames = _frames(page)
assert _tool_frames(frames) == [
{"type": "tool", "name": "read", "argument": CONTROL_SP}
], _tool_frames(frames)
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is False, done
# The byte-identity guard on the read result's surface (the
# mock's verbatim echo): the result carried NO origin line —
# the local source's result is byte-identical to pre-phase,
# so the mock's deterministic no-origin sentence answers.
text = _bubble_text(page)
assert ORIGIN_LINK_NO_ORIGIN in text, text
assert "origin:" not in text, text
assert "branch:" not in text, text
# …and the modal's meta row is the pre-phase badge row: NO
# .doc-origin anchor (the content payload's origin fields are
# absent on the wire — never null).
_open_modal_for_chip(page, CONTROL_REL)
expect(page.locator("#doc-modal-title")).to_have_text("Control notes")
expect(page.locator("#doc-modal-meta .doc-origin")).to_have_count(0)
# The pre-phase badges are untouched (the row still shows
# source · format · path · created · indexed · chunks).
expect(page.locator("#doc-modal-meta .doc-source-badge")).to_have_text(
CONTROL
)
expect(page.locator("#doc-modal-meta .doc-path")).to_have_text(
CONTROL_REL
)
expect(page.locator("#doc-modal-meta .doc-created")).not_to_be_empty()