Files
brain-of-reese/tests/e2e/test_sync_label_mobile.py
T
ducoterra dd524246ec
Build and Push Containers / build-and-push-app (push) Successful in 3m35s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 149_sync_progress_box
All verification complete. The `ink-soft`/`--surface` pair the box uses is already pinned AA by the existing theme E2E suites (8.2:1 dark, ~6.9:1 light). Everything is green — no fixes were needed.

**Phase 149 final verification report**

- Verified (no re-work needed): `#sync-progress` box wiring (single `setSyncLabel` writer with `running` flag; `is-running` class travels with the spin; aria-label owned by the writer), the panel/icon-only-button CSS, and the 5-test dedicated E2E; all 6 pre-existing sync E2Es' running pins migrated to the box, settled pins untouched.
- Dedicated E2E: `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → **5 passed in 198.6 s** (isolation, slow-proxy mock; under the 600 s budget).
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **3463 passed, app/ coverage 99%** (>90% gate).
- Migrated sync E2Es, each in isolation: `test_sync_button` 3 passed, `test_sync_cancel` 3, `test_sync_chunk_progress` 2, `test_sync_label_mobile` 4, `test_sync_summary_visibility` 3, `test_sync_upload_progress` 4 — all green.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Criteria: in-flight spinner-only button + full untruncated wrapped box (desktop & mobile 390×844, `scrollWidth ≤ clientWidth`) ✓; settle byte-identical ("Synced HH:MM" + `3 added`, box hidden, aria restored) ✓; `#sync-result` sole live region (box `aria-hidden`), mid-run reload re-attaches ✓; WCAG (AA contrast both themes via the pinned ink-soft/surface pair, focus-visible, reduced-motion kill intact at styles.css L2201) ✓; cancel/409/error/upload/status API unchanged (green suites above) ✓.
- Documentation: README.md reviewed in full (sync-states paragraph now matches the shipped UI; env-var table matches `app/config.py` and covers all `.env.example` vars; pages/commands verified) and AGENTS.md reviewed in full — **verified consistent, no changes needed**.
- Notable: no defects found in this or prior work; no live infrastructure touched.
- Next pending phase: none — `todo/` will be empty once the harness moves 149 (only 149 was pending).
2026-10-01 21:14:48 -04:00

910 lines
42 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 128 task 03 E2E (Playwright, mock-only): the RUNNING sync
detail is fully visible at a phone viewport — in BOTH long spans
(import + summaries) — while desktop keeps the pre-phase full
strings.
The dedicated story suite for ``128_sync_label_mobile`` (A16 — one
Playwright file per phase, run in isolation): the owner's report was
that ``Syncing… name/of/file/here (2/3)`` "doesn't show up on mobile" —
pre-phase, ``#sync-label`` was ONE inline-block span (whole label
string, one ellipsis) whose mobile media query LIFTED the
``min(16rem, 40vw)`` cap (``max-width: none``), so the ellipsized tail
— where ``(n/m)`` lives — was clipped away on a phone. Phase 128 split
the label into two parts (``.sync-label-main`, the shrinkable
prefix + file/folder, + ``.sync-label-count`, the pinned position) so
the count is never the ellipsis's sacrifice.
Phase 149 (task 01) RE-POINTED the running-state pins: the owner's
follow-up was that the full live label (a long
``source/relative/path`` + the counts + the chunk part) is clipped
in the pill even on desktop, and the fix moves the running detail
out of the pill entirely — while a run is in flight the button is
the spinner (the ``is-running`` class; task 02's CSS hides the label
parts), and a detail box under the button row (``#sync-progress``)
carries the SAME full untruncated label — wrapping, never clipped,
``#sync-result`` staying the sole aria-live announcer. Tests 1–3
therefore pin the BOX: visible while the run is in flight, its text
the pre-phase byte-identical full string ("Syncing… <full
source/relative/path> (n/4)" / "Summarizing folders… <folder>
(n/3)" + the phase-129 "· chunk n/m" while a batch is in flight),
inside the viewport at 390 px and 1280 px, the button in its
running state, and the ``title`` carrying the same full string
(A4 — one source, two surfaces). The SETTLED pins (the phase-32
"Synced HH:MM" label, the count span hidden + empty, the box hidden)
are UNCHANGED. Test 4 (the pre-phase-128 rules' clip reproduction)
pins the old markup/CSS counterfactually — the label parts still
receive their text under any CSS (the writer is unchanged), so it
holds on the DOM text + injected rules regardless of the phase-149
box. The injected set ALSO neutralizes the phase-149 hide rule
(``.sync-btn.is-running .sync-label { display: none }`` — task 02's
specificity (0,2,0) would beat the injected (0,1,0) ``.sync-label``
rule and leave the parts display:none, making the geometry vacuous);
pre-phase, that rule simply did not exist, so the faithful
pre-phase counterfactual restores the parts' inline-block display
at matching specificity, last in the cascade.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the
deterministic mock plus the phase-64 ``tests/e2e/slow_llm.py`` delay
proxy that stretches the run past the UI's 2 s status poll, so the
rendered-label assertions are deterministic, not races.
Timing fixture (the phase-98 sizing rationale, re-sized for this
suite): ``SLOW_DELAY_S = 1.5`` — the import span (4 file embeds
≈ 6 s) and the summaries span (3 folder summaries ≈ 4.5 s) each
outlive the UI's 2 s poll by ≥ 2×, so the page's OWN 2 s poll renders
each running label and holds it in the DOM for at least one full poll
period (any window ≥ the period catches it). The whole changed sync is
≈ 10 LLM calls (2 model probes + 4 file embeds + 1 overview +
3 folder summaries) ≈ 15–18 s — well inside the generous settle
budgets.
The suite runs ONE module app (the phase-79 full-inventory pattern —
it binds its own port) over the shared E2E Postgres: the SLOW app
(``BOR_LLM_BASE_URL`` = the slow proxy in front of the mock).
KB fixture — a host temp dir (``tmp_path_factory``; the app runs on
the same host) registered as a ``kind=local`` source (the
``test_local_directory_sources.py`` / phase-94 API-registration
pattern; no git anywhere): ``synclabel/`` with TWO files in ``alpha/``
and TWO in ``bravo/`` — the summaries phase then has 3 candidate
folders (the source root + the two folders — the recursive-subtree
≥ 1 rule), a non-trivial ``(n/m)``.
Autouse cleanup (the phase-96 pattern): before each test wait for no
running sync on the app, then truncate the shared registry + KB
tables (documents/chunks/query_log/steering_notes/kb_overview/
git_sources/folder_summaries) — each test seeds its own source and
runs its own sync; after each test the same truncate.
Test → observable mapping (Playwright Mapping Rule):
1. ``test_mobile_label_fits_viewport_with_count_in_import`` — admin
page on the sources view at viewport **390×844** (mobile); click
**Sync sources**; while the import phase is running (the page's
own 2 s poll): the detail box (``#sync-progress``) is VISIBLE and
an atomic snapshot of: its text the FULL untruncated label
``Syncing… <full source/relative/path> (n/4)`` (+ the phase-129
"· chunk n/m" part while a batch is in flight — optional, the
pre-first-batch frame has none) with the ``(n/4)`` count IN the
text (never clipped — nothing is ellipsized away), the box's
bounding box INSIDE the viewport (left ≥ 0, right ≤ 390 — the
bug's direct negation), the button in its running state
(``is-running`` — the spinner), and the button ``title``
carrying the SAME full string (A4 — one source, two surfaces).
The run then settles (the phase-32 terminal shape; the box hides).
2. ``test_mobile_label_shows_summaries_count`` — same mobile
viewport; through to the summaries phase: the box text matches
``Summarizing folders… [source/folder] (n/3)`` (the folder part
optional — the phase's first poll may precede the first hook — the
``(n/3)`` count always present, IN the text) with the same
box-geometry pins as test 1 (inside the viewport), the ``title``
carrying the same full string (A4); on settle the button leaves
the running label (the phase-32 terminal ``Synced HH:MM``), the
count span is hidden + empty, and the box hides.
3. ``test_desktop_label_unchanged`` — viewport 1280×800; the same
sync: during import the box text matches ``Syncing… <full
source/relative/path> (n/m)`` (+ the phase-129 chunk part while a
batch is in flight) — the pre-phase byte-identical FULL string
(phase 149 moved it from the pill's parts to the box — the text
is unchanged), the box inside the viewport, the button in its
running state (``is-running``), and the ``title`` carrying the
same untruncated string (A4).
4. ``test_pre_phase_css_reproduces_the_clip`` — the pin check: the
SAME mobile run with the pre-phase-128 ``.sync-label`` rules
re-injected (CSSOM ``insertRule`` into the origin's own linked
stylesheet — the A20 CSP ``default-src 'self'`` blocks inline
``<style>`` injection, while script-driven rule insertion into an
allowed stylesheet is CSP-clean; inline-block, the cap lifted, one
ellipsis over the whole label, the count riding the ellipsized
tail) reproduces the reported bug geometrically — the count part's
box extends past the label's clip edge (and the pill's right
edge), i.e. the count is painted away. Tests 1–2's "count not
clipped" pins fail against exactly this geometry, which is what
makes them the regression guard (completion criterion: "the
mobile tests fail against the pre-phase markup/CSS").
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from playwright.sync_api import Page, ViewportSize, expect
from sqlalchemy import text
from app.config import Settings as _Settings
from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
MOCK_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module's app binds its own port
# instead (a same-port second uvicorn dies on bind and would drive the
# wrong server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCLABEL", "8164"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The slow-LLM proxy's port (the conftest's mock LLM stays on
#: MOCK_PORT — the proxy fronts it).
SLOW_PORT = int(os.environ.get("E2E_SLOW_LLM_PORT_SYNCLABEL", "8905"))
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
#: Per-LLM-request delay on the proxy — sized so the import span (4
#: file embeds ≈ 6 s) and the summaries span (3 folder summaries
#: ≈ 4.5 s) each outlive the UI's 2 s poll period by ≥ 2× (the
#: phase-98 rationale): the page's own 2 s poll renders each running
#: label and holds it in the DOM for at least one full poll period —
#: the rendered-label assertions are deterministic, not races. A
#: changed sync is ≈ 10 calls ≈ 15–18 s (see the module docstring).
SLOW_DELAY_S = "1.5"
# --------------------------------------------------------------------------
# Fixture constants (deterministic, token-controlled)
# --------------------------------------------------------------------------
#: The local source — the temp directory's basename (``kind=local`` →
#: the directory's basename is the source name, phase 38).
SOURCE = "synclabel"
#: The two ≥ 2-doc folders (the summaries candidate set = root + these).
FOLDER_A = "alpha"
FOLDER_B = "bravo"
FOLDERS = (FOLDER_A, FOLDER_B)
N_FILES = 4 # two folders × 2 docs
N_CANDIDATES = 3 # the source root + the two folders (the ≥ 1-doc rule)
#: The four fixture files' full source/relative paths — the A4
#: untruncated label's file part (the status endpoint's
#: ``current_file`` rides it verbatim).
FILE_PATHS = tuple(
f"{SOURCE}/{folder}/{folder}-{letter}.md"
for folder in FOLDERS
for letter in ("a", "b")
)
FILE_RE = r"synclabel/(?:alpha|bravo)/(?:alpha|bravo)-[ab]\.md"
#: The in-flight import label's PARTS (the phase-64 byte-identical
#: strings, unit-pinned in tests/unit/test_frontend_sync_upload.py):
#: main "Syncing… <full source/relative/path>" + count "(n/4)" —
#: ``done`` starts at 1 (the importer counts the file as current
#: BEFORE indexing it), so ``n`` ∈ 1..4. (Test 4 — the pre-phase
#: rules' clip reproduction — pins the PARTS' DOM text directly.)
IMPORT_MAIN_RE = re.compile(rf"^Syncing… {FILE_RE}$")
IMPORT_COUNT_RE = re.compile(r"^\([1-4]/4\)$")
#: The pre-phase byte-identical FULL string (main + " " + count — the
#: single span's text before phase 128; the A4 title keeps it), plus
#: the OPTIONAL phase-129 chunk part (" · chunk n/m" — the in-flight
#: file's embed-batch position, batch granularity). Phase 149 (task
#: 01): the box (#sync-progress) carries this EXACT string while the
#: import is in flight (tests 1 + 3) — the title carries it too
#: (A4 — one source, two surfaces).
IMPORT_FULL_RE = re.compile(rf"^Syncing… {FILE_RE} \([1-4]/4\)(?: · chunk \d+/\d+)?$")
#: The summaries label's FULL string (phase 98 task 02, unit-pinned):
#: "Summarizing folders… [source/folder] (n/3)" (the folder part
#: omitted while ``current_summary`` is null — the phase's first poll
#: may precede the first hook — the (n/3) count always present). Phase
#: 149 (task 01): the box carries this EXACT string while the
#: summaries phase is in flight (test 2) — the title carries it too.
SUMMARY_FULL_RE = re.compile(r"^Summarizing folders…(?: \S+)? \(\d+/3\)$")
#: "Synced HH:MM" — the local-time last-result label (sources.js's
#: fmtSyncTime), any hour/minute (the test_sync_button.py pattern).
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}$")
#: The settled boot label — idle ("Sync sources") or the RE-ATTACHED
#: terminal from a previous test's sync (the module app is shared, its
#: in-memory sync state survives the test boundary, and the phase-32
#: boot re-attach renders the last result).
IDLE_OR_SETTLED = re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$")
#: The viewports (the task's mapping): the phone (the reported bug)
#: and the desktop (the unchanged-contract leg). Phase 149 (task 01):
#: the geometry pins now apply to the DETAIL BOX (a block in the
#: 72rem container — it can only shrink toward the container's
#: content width, never overflow the viewport); the old pill-label
#: cap (min(16rem, 40vw)) applied to the ellipsized pill label, which
#: is no longer the running surface (the button is the spinner).
MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
DESKTOP_VIEWPORT: ViewportSize = {"width": 1280, "height": 800}
#: Generous settle budgets: a changed sync against the slowed LLM is
#: ≈ 15–18 s; the UI's 2 s poll settles at most one tick after the
#: terminal state lands.
SETTLE_TIMEOUT_MS = 90_000
def _md(title: str, body: str) -> str:
return f"# {title}\n\n{body}\n"
# ---------------------------------------------------------------------------
# The in-browser snapshot functions (Playwright Mapping Rule — the
# rendered DOM is the observable).
#
# Phase 149 (task 01): the RUNNING detail rides the detail box —
# #sync-progress (visible IFF a run is in flight) with the FULL
# untruncated label in its .sync-progress-text span. Each snapshot
# returns null while the awaited box state is absent, and an ATOMIC
# snapshot once it is present: the box's text + the button's title +
# is-running flag + the getBoundingClientRect boxes of the box and
# the button — read in the SAME in-browser evaluation that verified
# the box state, so the numbers can never straddle a 2 s poll tick
# (the box text + the title are written together in one JS task by
# the single setSyncLabel writer — A4: one source, two surfaces).
# ---------------------------------------------------------------------------
#: Test 1/3 — the in-flight IMPORT detail: the box is VISIBLE and its
#: text is the FULL untruncated label — "Syncing… <full
#: source/relative/path> (n/4)" (+ the optional phase-129 " · chunk
#: n/m" part while a file's embed batch is in flight — the
#: pre-first-batch frame has none; the phase-129 dedicated suite pins
#: it strictly) — the count is IN the text (never clipped — nothing
#: is ellipsized away), the button is in its running state
#: (is-running — the spinner), and the title carries the SAME full
#: string (A4).
_JS_IMPORT_BOX_SNAPSHOT = """
() => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const btn = document.querySelector('#sync-btn');
if (!box || !textEl || !btn || box.hidden) return null;
const t = textEl.textContent;
const reFull = new RegExp(
"^Syncing… synclabel/(?:alpha|bravo)/(?:alpha|bravo)-[ab]\\\\.md " +
"\\\\([1-4]/4\\\\)(?: · chunk \\\\d+/\\\\d+)?$"
);
if (!reFull.test(t)) return null;
const title = btn.getAttribute('title');
if (title !== t) return null;
const rect = (el) => {
const b = el.getBoundingClientRect();
return { left: b.left, right: b.right, width: b.width, height: b.height };
};
return {
boxText: t,
title,
isRunning: btn.classList.contains('is-running'),
boxRect: rect(box),
btnRect: rect(btn),
};
}
"""
#: Test 2 — the SUMMARIES detail: the box is VISIBLE and its text is
#: the full "Summarizing folders… [source/folder] (n/3)" (the folder
#: part optional — the phase's first poll may precede the first hook —
#: the (n/3) count always present, IN the text), the button is in its
#: running state, and the title carries the SAME full string (A4).
_JS_SUMMARY_BOX_SNAPSHOT = """
() => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const btn = document.querySelector('#sync-btn');
if (!box || !textEl || !btn || box.hidden) return null;
const t = textEl.textContent;
const reFull = /^Summarizing folders…(?: \\S+)? \\(\\d+\\/3\\)$/;
if (!reFull.test(t)) return null;
const title = btn.getAttribute('title');
if (title !== t) return null;
const rect = (el) => {
const b = el.getBoundingClientRect();
return { left: b.left, right: b.right, width: b.width, height: b.height };
};
return {
boxText: t,
title,
isRunning: btn.classList.contains('is-running'),
boxRect: rect(box),
btnRect: rect(btn),
};
}
"""
#: The pre-phase-128 ``.sync-label`` geometry, re-injected for test 4
#: (the bug reproduction): the base rule's inline-block + single
#: ellipsis with the mobile media query's lifted cap (``max-width:
#: none``) — the label sizes to its FULL content and its ONE ellipsis
#: owns the whole tail, so the count (now riding a child span inside
#: that ellipsized box) is clipped away with it. The parts' own rules
#: are neutralized (no flex row, no per-part ellipsis, the pre-phase
#: non-mono count font). Phase 149 (task 02): the set ALSO
#: neutralizes the phase-149 hide rule — ``.sync-btn.is-running
#: .sync-label { display: none }`` (0,2,0) would beat the injected
#: (0,1,0) ``.sync-label`` rule and leave the parts display:none, so
#: the counterfactual restores the parts' pre-phase inline-block
#: display at matching specificity (last in the cascade it wins);
#: pre-phase, the hide rule simply did not exist. The rules are
#: complete rule strings — they go in via CSSOM ``insertRule``
#: (appended to the origin's own linked stylesheet, so the cascade
#: position is "last wins" like an injected ``<style>`` tag — which
#: the A20 CSP would block). The probe measured this override
#: reproduces the owner's report at 390 px with the repo's fonts (the
#: count's box fully past the label's clip edge and past the pill's
#: right edge).
_PRE_PHASE_SYNC_RULES = (
".sync-label { display: inline-block; align-items: initial; gap: 0; "
"max-width: none; min-width: 0; overflow: hidden; "
"text-overflow: ellipsis; white-space: nowrap; }",
".sync-label-main { min-width: 0; overflow: visible; text-overflow: clip; }",
".sync-label-count { flex: 0 1 auto; font-family: inherit; }",
".sync-btn.is-running .sync-label { display: inline-block; }",
)
#: Test 4 ONLY — the PARTS-level in-flight IMPORT snapshot (the
#: counterfactual clip reproduction): the writer still lands the label
#: PARTS (main + count + optional chunk) in the DOM for every running
#: label (phase 149's box reads the same writer output), so under the
#: re-injected pre-phase-128 CSS the parts' texts + geometry are the
#: observable — the count part's box runs past the label's clip edge
#: (painted away) + off the pill's right edge (the bug). Reads the
#: parts' textContent + hidden flags, the button title (A4), and the
#: getBoundingClientRect boxes of the label, the count part, and the
#: button — atomically (one in-browser evaluation, so the numbers
#: never straddle a 2 s poll tick).
_JS_PARTS_IMPORT_SNAPSHOT = """
() => {
const label = document.querySelector('#sync-label');
const btn = document.querySelector('#sync-btn');
if (!label || !btn) return null;
const main = label.querySelector('.sync-label-main');
const count = label.querySelector('.sync-label-count');
if (!main || !count || count.hidden) return null;
const reMain = /^Syncing… synclabel\\/(?:alpha|bravo)\\/(?:alpha|bravo)-[ab]\\.md$/;
const reCount = /^\\([1-4]\\/4\\)$/;
if (!reMain.test(main.textContent) || !reCount.test(count.textContent)) return null;
const chunk = label.querySelector('.sync-label-chunk');
const chunkText = (chunk && !chunk.hidden) ? chunk.textContent : '';
const title = btn.getAttribute('title');
const full = main.textContent + ' ' + count.textContent
+ (chunkText ? ' ' + chunkText : '');
if (title !== full) return null;
const rect = (el) => {
const b = el.getBoundingClientRect();
return { left: b.left, right: b.right, width: b.width, height: b.height };
};
return {
mainText: main.textContent,
countText: count.textContent,
chunkText,
title,
labelRect: rect(label),
countRect: rect(count),
btnRect: rect(btn),
};
}
"""
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def synclabel_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The one-source temp tree (see the module docstring): the app
server runs on the same host, so the paths are visible to it.
The directory NAME is the source name (``kind=local``, phase
38)."""
root = tmp_path_factory.mktemp("bor_synclabel")
src = root / SOURCE
for folder in FOLDERS:
(src / folder).mkdir(parents=True)
for letter in ("a", "b"):
(src / folder / f"{folder}-{letter}.md").write_text(
_md(
f"Synclabel {folder.title()} {letter.upper()}",
f"Synclabel {folder} fixture note {letter.upper()}: "
f"covers topic {letter.upper()} of the {SOURCE} source tree.",
),
encoding="utf-8",
)
assert len(list(src.rglob("*.md"))) == N_FILES
return src
def _app_env(base_url: str, tmp_path_factory: pytest.TempPathFactory) -> dict[str, str]:
"""The per-module app env (the conftest pattern, cf.
``test_oneshot_llm_retry.py`` / ``test_kb_tree.py``): the
``base_url`` picks the LLM (the slow proxy in front of the mock),
``BOR_GIT_SOURCES`` forced empty (the sync source is this suite's
own DB-registered local row), and the leak-guarded code defaults."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1" if USE_REAL_LLM else base_url
)
# Mock-calibrated threshold (conftest pattern): this suite never
# asks the chat model anything — the gate is never on a path.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
# Phase 67: instant retry waits + the code-default budget (the
# conftest leak-guard pattern).
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# The repo's .env file carries the owner's BOR_GIT_SOURCES (the app
# reads it from cwd) — override it with an EMPTY value (the env var
# beats the .env file): the registry must hold EXACTLY the local
# directory this suite registers.
env["BOR_GIT_SOURCES"] = ""
# Leak guards (conftest pattern): an operator's local (gitignored)
# .env cannot leak corpus-specific settings into the app under test.
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
_Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts"))
return env
@pytest.fixture(scope="module")
def slow_llm(mock_llm: int) -> Iterator[int]:
"""The delay-injecting reverse proxy in front of the mock LLM
(``tests/e2e/slow_llm.py``) — the timing fixture for the whole
suite: the rendered running labels need the import/summaries spans
to outlive the UI's 2 s poll (see ``SLOW_DELAY_S``)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["SLOW_LLM_DELAY_S"] = SLOW_DELAY_S
env["E2E_MOCK_PORT"] = str(MOCK_PORT)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "tests.e2e.slow_llm:app",
"--host", "127.0.0.1", "--port", str(SLOW_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{SLOW_URL}/v1/models")
yield SLOW_PORT
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_server(
mock_llm: int,
slow_llm: int,
synclabel_dir: Path,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[str]:
"""The SLOW app under test (the whole suite): ``BOR_LLM_BASE_URL``
is the slow proxy in front of the mock (the timing fixture — see
the module docstring). ``synclabel_dir`` is a dependency only for
the fixture ordering (the temp tree exists before the first test).
"""
env = _app_env(f"{SLOW_URL}/v1", tmp_path_factory)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
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 candidate count and the counts the syncs and the label
assertions pin exactly."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources, folder_summaries"
)
)
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
def _wait_sync_settled(base_url: str, timeout_s: float = 180.0) -> None:
"""No background sync may leak across tests (the run state lives in
the app's memory, and a still-running run would keep importing into
the NEXT test's truncated KB): wait for the app's sync status to
be non-running BEFORE the truncate. Own admin session (the
endpoint is admin-only) — usually a no-op: the tests settle only
after their run's terminal state."""
with httpx.Client(base_url=base_url, timeout=5.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
if r.json()["state"] != "running":
return
time.sleep(0.2)
raise AssertionError(f"a sync was still running at a test boundary: {base_url}")
@pytest.fixture(autouse=True)
def _clean(app_url: str, db_ready: None) -> Iterator[None]:
"""Per-test KB isolation (the phase-96 cleanup pattern): the
shared E2E Postgres is truncated before AND after every test —
each test seeds its own source and runs its own sync, so the KB
rows + folder_summaries rows of the temp source are exactly this
test's own. The pre-wait keeps a leaked run from the previous test
(the module app is shared) from racing the truncate."""
_wait_sync_settled(app_url)
_truncate_all()
yield
_truncate_all()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _register_source(base_url: str, path: str) -> None:
"""Register the temp directory through the authenticated API (the
``test_local_directory_sources.py`` / phase-94 pattern — a plain
directory is an API/DB-only operation)."""
with httpx.Client(base_url=base_url, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
r = client.post(
"/api/git-sources", json={"kind": "local", "path": path}
)
assert r.status_code == 201, r.text
def _start_sync(page: Page, app_url: str, sync_dir: Path) -> None:
"""Register the source, land the admin on the sources view, wait
for the settled boot label (the phase-64 pattern — also proves the
phase-32 boot re-attach completed, so no later re-render can
clobber the running state), then click **Sync sources** and pin
the click's immediate 202 state (the bare "Syncing…" — the count
part hidden, the phase-64 contract)."""
_register_source(app_url, str(sync_dir))
login(page, app_url) # lands on /sources.html (the button's home)
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
expect(page.locator("#sync-label")).to_have_text(IDLE_OR_SETTLED)
expect(btn).to_be_enabled()
btn.click()
# The click's immediate state (the 202 moment — the bare prefix,
# the phase-64 contract): disabled, aria-busy, spinning icon, and
# the detail box revealed with the bare label (phase 149 — the
# running detail rides the box; the button is the spinner).
expect(btn).to_be_disabled()
expect(btn).to_have_attribute("aria-busy", "true")
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(page.locator("#sync-error-banner")).to_be_hidden()
def _wait_terminal(app_url: str, timeout_s: float = 120.0) -> dict[str, Any]:
"""Poll the status endpoint (own admin session) until the UI-
triggered run reaches a terminal state — the source of truth for
the settle (the UI's 2 s poll renders it at most one tick later).
A FAILED terminal fails the test with the terminal body (the
``detail`` / error text) — never an opaque label timeout."""
with httpx.Client(base_url=app_url, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
body = r.json()
if body["state"] in ("success", "failed"):
assert body["state"] == "success", body
return body
time.sleep(0.2)
raise AssertionError(f"the run never reached a terminal state: {body}")
def _settle(page: Page, app_url: str) -> None:
"""The phase-32 success settle: the run reaches a SUCCESS terminal
at the endpoint, then the button shows "Synced HH:MM" (the count
part hidden + empty — the settle goes through the parts writer),
the button re-enabled, the running state fully cleared, no error."""
_wait_terminal(app_url)
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
btn = page.locator("#sync-btn")
expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy")
count = page.locator(".sync-label-count")
expect(count).to_be_hidden()
expect(count).to_have_text("")
# Phase 149 (task 01): the detail box is run-scoped — the settle
# (the writer's default running = false) hides it again.
expect(page.locator("#sync-progress")).to_be_hidden()
expect(page.locator("#sync-error-banner")).to_be_hidden()
def _assert_box_fits_viewport(snap: dict[str, Any], viewport_w: int) -> None:
"""The phase 149 geometry contract for the rendered running detail
at the given viewport: the detail box is VISIBLE (a non-degenerate
box) and INSIDE the viewport (left ≥ 0, right ≤ the viewport
width — the owner's clipped-detail report, directly negated). The
box is a block in the 72rem centered container, so it can only
shrink toward the container's content width — it never overflows
the viewport — and its text is the FULL untruncated label (the
"(n/m)" count is IN the text; the wrap that keeps long paths
on-screen is task 02's overflow-wrap: anywhere, pinned by the
phase-149 dedicated suite). The 1 px slack covers float rounding
only."""
box = snap["boxRect"]
assert box["width"] > 0 and box["height"] > 0, (
f"the detail box has no visible box: {box}"
)
assert box["left"] >= 0, f"the detail box runs off the left edge: {box}"
assert box["right"] <= viewport_w + 1, (
f"the detail box runs off the right edge of the {viewport_w}px "
f"viewport: {box}"
)
# ---------------------------------------------------------------------------
# 1. Mobile (390×844) — import: the label fits the viewport and the
# (n/m) count is visible, never clipped
# ---------------------------------------------------------------------------
def test_mobile_label_fits_viewport_with_count_in_import(
page: Page, app_url: str, synclabel_dir: Path, db_ready: None
) -> None:
"""A fresh admin page on the sources view at a PHONE viewport
(390×844) clicks **Sync sources**; while the import phase is
running (the page's own 2 s poll renders it — the span outlives
the poll by ≥ 2× under the slow proxy), the DETAIL BOX
(``#sync-progress`` — phase 149: the running detail left the
pill; the button is the spinner) is an atomic snapshot of:
VISIBLE with the FULL untruncated label ``Syncing… <full
source/relative/path> (n/4)`` (+ the phase-129 "· chunk n/m" part
while a batch is in flight — optional) with the ``(n/4)`` count IN
the text (NEVER clipped — nothing is ellipsized away), the box's
bounding box INSIDE the viewport (left ≥ 0, right ≤ 390 — the
bug's direct negation), the button in its running state
(``is-running``), and the button ``title`` carrying the SAME full
string (A4 — one source, two surfaces). The run then settles (the
phase-32 terminal shape; the box hides)."""
page.set_viewport_size(MOBILE_VIEWPORT)
_start_sync(page, app_url, synclabel_dir)
# The page's own 2 s poll renders the in-flight import detail in
# the box; the atomic in-browser snapshot pins its text + geometry
# together. (The Playwright sync API wraps a non-primitive result
# in a JSHandle — .json_value() unwraps the snapshot dict.)
snap = page.wait_for_function(
_JS_IMPORT_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value()
# The FULL untruncated label — the complete
# source/relative/path, the (n/4) count IN the text (never
# clipped), the optional phase-129 chunk part.
assert IMPORT_FULL_RE.match(snap["boxText"]), snap["boxText"]
# A4: the button title carries the SAME untruncated label (the
# in-browser check already required title == box text; pin it here
# too — one source, two surfaces).
assert snap["title"] == snap["boxText"], snap
# Phase 149: the running button is the spinner (is-running rides
# the spin — the detail is the box's, not the pill's).
assert snap["isRunning"] is True, snap
# The geometry contract (the fix): the box is visible and inside
# the viewport.
_assert_box_fits_viewport(snap, MOBILE_VIEWPORT["width"])
_settle(page, app_url)
# ---------------------------------------------------------------------------
# 2. Mobile (390×844) — summaries: the folder span's (n/3) count stays
# visible through the long span; the settle hides the count part
# ---------------------------------------------------------------------------
def test_mobile_label_shows_summaries_count(
page: Page, app_url: str, synclabel_dir: Path, db_ready: None
) -> None:
"""Same phone viewport; the sync runs through to the SUMMARIES
phase (the post-import span where the file count sits still — the
owner's "the number pauses" leg): the page's own 2 s poll renders
the DETAIL BOX (``#sync-progress`` — phase 149) with
``Summarizing folders… [source/folder] (n/3)`` (the folder part
optional — the phase's first poll may precede the first hook — the
``(n/3)`` count of the 3 candidate folders always present, IN the
text — never clipped) with the same box-geometry pins as test 1
(inside the viewport), the button in its running state, and the
title carrying the SAME full string (A4). On settle the button
leaves the running label (the phase-32 terminal ``Synced HH:MM``),
the count span is hidden + empty, and the box hides."""
page.set_viewport_size(MOBILE_VIEWPORT)
_start_sync(page, app_url, synclabel_dir)
snap = page.wait_for_function(
_JS_SUMMARY_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value()
assert SUMMARY_FULL_RE.match(snap["boxText"]), snap["boxText"]
assert snap["title"] == snap["boxText"], snap
assert snap["isRunning"] is True, snap
_assert_box_fits_viewport(snap, MOBILE_VIEWPORT["width"])
_settle(page, app_url)
# ---------------------------------------------------------------------------
# 3. Desktop (1280×800) — unchanged: the pre-phase byte-identical
# string, the min(16rem, 40vw) cap, the title's untruncated label
# ---------------------------------------------------------------------------
def test_desktop_label_unchanged(
page: Page, app_url: str, synclabel_dir: Path, db_ready: None
) -> None:
"""A DESKTOP viewport (1280×800) keeps the pre-phase contract —
the TEXT: during import the detail box (``#sync-progress`` —
phase 149 moved the running detail out of the pill to the box;
the owner's report was that the full label was clipped in the
pill even on desktop) carries ``Syncing… <full
source/relative/path> (n/m)`` (+ the phase-129 chunk part while a
batch is in flight): the pre-phase byte-identical FULL string
(phase 149 changed the SURFACE, not the text). The box is inside
the viewport, the button is in its running state (``is-running``
— the spinner), and the button ``title`` carries the same
untruncated string (A4)."""
page.set_viewport_size(DESKTOP_VIEWPORT)
_start_sync(page, app_url, synclabel_dir)
snap = page.wait_for_function(
_JS_IMPORT_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value()
# The pre-phase byte-identical FULL string ("Syncing… <full
# source/relative/path> (n/m)" + " · chunk n/m" while a batch is
# in flight) — now in the box, untruncated and wrapping.
assert IMPORT_FULL_RE.match(snap["boxText"]), snap["boxText"]
assert snap["title"] == snap["boxText"], snap
# Phase 149: the running button is the spinner (is-running);
# the count is IN the box's full text (the never-clipped pin —
# nothing is ellipsized away on desktop either).
assert snap["isRunning"] is True, snap
_assert_box_fits_viewport(snap, DESKTOP_VIEWPORT["width"])
_settle(page, app_url)
# ---------------------------------------------------------------------------
# 4. The pin check — the pre-phase CSS reproduces the clip (the count
# rides the ellipsized tail and is painted away on a phone)
# ---------------------------------------------------------------------------
def test_pre_phase_css_reproduces_the_clip(
page: Page, app_url: str, synclabel_dir: Path, db_ready: None
) -> None:
"""The same mobile run with the PRE-PHASE-128 ``.sync-label`` rules
re-injected (CSSOM ``insertRule`` into the origin's own linked
stylesheet — inline-block, the cap lifted, one ellipsis over the
whole label, the count riding the ellipsized tail; the injected
set also neutralizes the phase-149 ``is-running`` hide rule, which
pre-phase did not exist): the reported bug comes back geometrically —
the count part's box extends past the label's clip edge (its right
edge outside the label's right
edge, so the label's ``overflow: hidden`` paints it away) and past
the pill's right edge (it runs off the button). This is the
geometry tests 1–2's "count not clipped" pins reject — the suite's
regression sensitivity, made permanent (the completion criterion:
the mobile tests fail against the pre-phase markup/CSS)."""
page.set_viewport_size(MOBILE_VIEWPORT)
_start_sync(page, app_url, synclabel_dir)
# Re-inject the pre-phase geometry BEFORE the running label
# renders (the boot settled under the real CSS — the override only
# concerns the in-flight label the 2 s poll is about to write).
# CSSOM rule insertion into the allowed origin stylesheet is the
# CSP-clean path (the A20 ``default-src 'self'`` blocks inline
# ``<style>`` injection — add_style_tag would be refused).
page.evaluate(
"""(rules) => {
for (const sheet of document.styleSheets) {
if (!sheet.href || !sheet.href.includes('styles.css')) continue;
// Explicit END index: this Chromium build's
// index-less insertRule PREPENDS (the CSSOM spec says
// append) — at the front the override would lose every
// same-specificity conflict to the base rules.
for (const rule of rules) sheet.insertRule(rule, sheet.cssRules.length);
}
}""",
list(_PRE_PHASE_SYNC_RULES),
)
snap = page.wait_for_function(
_JS_PARTS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value()
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"]
assert IMPORT_COUNT_RE.match(snap["countText"]), snap["countText"]
label, count, btn = snap["labelRect"], snap["countRect"], snap["btnRect"]
# The clip: the count's box runs past the label's clip edge — the
# label's overflow: hidden paints it away (the probe measured the
# count FULLY past the edge at 390 px with the repo's fonts — the
# "(n/m) doesn't show up on mobile" report).
assert count["right"] > label["right"] + 1, (
f"pre-phase CSS did not clip the count — the pin would be vacuous: "
f"label={label} count={count}"
)
# …and it runs off the pill's right edge (toward the viewport's
# edge — the "off-screen" half of the report).
assert count["right"] > btn["right"] + 1, (
f"pre-phase CSS kept the count inside the pill: btn={btn} count={count}"
)
# Let the run finish (the override only shaped the rendered
# label — the status endpoint + the settle contract are
# CSS-agnostic); the next test truncates + re-seeds.
_settle(page, app_url)