All green. Final report:
**Phase 129 final verification pass — 100% green** (previous work intact; this pass re-verified everything and fixed two pre-existing README inconsistencies)
- Verified on-spec: `LLMClient.embed` per-batch `on_progress` hook; importer `on_chunk_progress` plumbing (text/image/PDF branches; summary embed excluded); `SyncStatus.current_chunk/chunks_total` reset+clear contract; UI `.sync-label-chunk` pinned part with untruncated `title`/`#sync-result`
- `uv run pytest --cov=app -q`: exit 0, ~3041 passed, 0 failed, `app/` coverage **99%** (>90% gate)
- `uv run pytest tests/e2e/test_sync_chunk_progress.py -v --no-cov` (isolated, DB up): **2 passed** (endpoint chunk climb with true `chunk_document` total + UI `· chunk n/m` part)
- `uv run ruff check .`: all checks passed · `uv run pyright`: 0 errors, 0 warnings
- Completion criteria: all 5 ✅ (incl. no behavior change in completed phases — full suite green, CLI/chat no-op paths byte-identical)
- Docs: README.md fixed 2 pre-existing inconsistencies (upload section claimed "unpacks + scans immediately" — phase 90 made it unpack+register only, scan deferred to Sync; troubleshooting claimed "120s client timeout" — actual guard is the 300 s pre-token `TURN_TIMEOUT_MS`). Phase-129 label behavior already documented. AGENTS.md re-verified consistent — no changes
- Removed stray untracked `NOTES.md` (8-byte artifact); no live-infra changes; no commits — all changes left in working tree for the harness
- Next pending phase: `130_chat_answer_clusters`
849 lines
39 KiB
Python
849 lines
39 KiB
Python
"""Phase 128 task 03 E2E (Playwright, mock-only): the RUNNING sync label
|
||
is fully visible at a phone viewport — the file path ellipsizes to fit
|
||
and the ``(n/m)`` count span sits in its own non-shrinking part that is
|
||
NEVER clipped — in BOTH long spans (import + summaries), while desktop
|
||
keeps the pre-phase strings and the ``min(16rem, 40vw)`` cap.
|
||
|
||
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 splits
|
||
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.
|
||
|
||
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 rendered ``#sync-label``'s bounding box is
|
||
INSIDE the viewport (left ≥ 0, right ≤ 390 — the bug's direct
|
||
negation), and the ``.sync-label-count`` part is VISIBLE (non-null
|
||
box) and NOT clipped — its right edge sits at or inside the label's
|
||
right edge and the button's right edge, with text matching
|
||
``\\(\\d+/\\d+\\)`` once a file is in flight; ``.sync-label-main``
|
||
carries ``Syncing… <file>`` — the FULL source/relative/path
|
||
(visually ellipsized, but the DOM text and the button ``title``
|
||
are untruncated, A4). Phase 129: the label's THIRD pinned part —
|
||
``.sync-label-chunk``, "· chunk n/m" (the in-flight file's
|
||
embed-batch position, batch granularity) — renders while a file's
|
||
embed batch is in flight (``chunks_total > 0``) and rides the
|
||
untruncated ``title`` too; the snapshot accepts it (optional —
|
||
the pre-first-batch frame has no chunk part yet; the phase-129
|
||
dedicated suite pins it strictly).
|
||
2. ``test_mobile_label_shows_summaries_count`` — same mobile
|
||
viewport; through to the summaries phase: ``.sync-label-main``
|
||
matches ``Summarizing folders…`` (the folder part optional — the
|
||
phase's first poll may precede the first hook), the
|
||
``.sync-label-count`` part matches ``\\(\\d+/3\\)`` and stays
|
||
visible (same clip pins as test 1); on settle the button leaves
|
||
the running label (the phase-32 terminal ``Synced HH:MM``) and the
|
||
count span is hidden + empty.
|
||
3. ``test_desktop_label_unchanged`` — viewport 1280×800; the same
|
||
sync: during import the full rendered label text (main + count +
|
||
optional phase-129 chunk concatenation) matches ``Syncing… <full
|
||
source/relative/path> (n/m)`` (the pre-phase byte-identical
|
||
string, the chunk part appended while a batch is in flight) and
|
||
the label's width respects the ``min(16rem, 40vw)`` cap (bounding
|
||
box width ≤ 256 px + slack — the shrinkable main part absorbs
|
||
the extra pinned part); the ``title`` carries 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.
|
||
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) the title carries
|
||
#: while a file's embed batch is in flight.
|
||
IMPORT_FULL_RE = re.compile(rf"^Syncing… {FILE_RE} \([1-4]/4\)(?: · chunk \d+/\d+)?$")
|
||
|
||
#: The summaries label's PARTS (phase 98 task 02, unit-pinned): main
|
||
#: "Summarizing folders… [source/folder]" (the folder part omitted
|
||
#: while ``current_summary`` is null) + count "(n/3)".
|
||
SUMMARY_MAIN_RE = re.compile(r"^Summarizing folders…(?: \S+)?$")
|
||
SUMMARY_COUNT_RE = re.compile(r"^\(\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).
|
||
MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
|
||
DESKTOP_VIEWPORT: ViewportSize = {"width": 1280, "height": 800}
|
||
|
||
#: The desktop cap: ``min(16rem, 40vw)`` = 256 px at the 16 px :root
|
||
#: (16rem) and the 1280 px viewport (40vw = 512 px) — the bounding
|
||
#: box width may not exceed the cap (a small slack covers float +
|
||
#: border rounding).
|
||
DESKTOP_CAP_PX = 256
|
||
CAP_SLACK_PX = 8
|
||
|
||
#: 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).
|
||
#
|
||
# Each returns null while the awaited label state is absent, and an
|
||
# ATOMIC geometry snapshot once it is present: the part texts, the
|
||
# button title, and the getBoundingClientRect boxes of the label, the
|
||
# count part, and the button — read in the SAME in-browser evaluation
|
||
# that verified the label state, so the numbers can never straddle a
|
||
# 2 s poll tick (the parts + the title are written together in one
|
||
# JS task by setSyncLabel).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
#: Test 1/3 — the in-flight IMPORT label: main carries "Syncing…
|
||
#: <full source/relative/path>" (the untruncated A4 text — the visual
|
||
#: ellipsis never touches textContent), the count part is VISIBLE
|
||
#: (not ``hidden``) with the "(n/4)" position, and the button title
|
||
#: carries the full untruncated label (main + " " + count, A4). Phase
|
||
#: 129: the THIRD pinned part — .sync-label-chunk ("· chunk n/m", the
|
||
#: in-flight file's embed-batch position) — rides the title too while
|
||
#: a file's embed batch is in flight (chunks_total > 0): the title
|
||
#: check accepts main + " " + count (+ " " + chunk when the part is
|
||
#: rendered) — optional, since the pre-first-batch frame has no chunk
|
||
#: part (the phase-129 dedicated suite pins the part strictly).
|
||
_JS_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),
|
||
};
|
||
}
|
||
"""
|
||
|
||
#: Test 2 — the SUMMARIES label: main carries "Summarizing folders…
|
||
#: [source/folder]" (the folder part optional — the phase's first
|
||
#: poll may precede the first hook), the count part is visible with
|
||
#: the "(n/3)" position, and the title carries the full untruncated
|
||
#: label (A4).
|
||
_JS_SUMMARY_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 = /^Summarizing folders…(?: \\S+)?$/;
|
||
const reCount = /^\\(\\d+\\/3\\)$/;
|
||
if (!reMain.test(main.textContent) || !reCount.test(count.textContent)) return null;
|
||
const title = btn.getAttribute('title');
|
||
if (title !== main.textContent + ' ' + count.textContent) 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,
|
||
title,
|
||
labelRect: rect(label),
|
||
countRect: rect(count),
|
||
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). 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; }",
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 label's MAIN part alone (the count part is hidden — its text
|
||
# is empty, so the parts' concatenation is exactly "Syncing…").
|
||
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-label")).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("")
|
||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||
|
||
|
||
def _assert_count_not_clipped(snap: dict[str, Any], viewport_w: int) -> None:
|
||
"""The phase 128 geometry contract for a rendered running label at
|
||
the given viewport: the label box is INSIDE the viewport (the bug's
|
||
direct negation), the count part is VISIBLE (a non-degenerate box),
|
||
and the count is NOT clipped — its right edge sits at or inside
|
||
the label's right edge (the label's own clip boundary) and the
|
||
button's right edge (the pill). The 1 px slack covers float
|
||
rounding only — the layout nests the boxes exactly."""
|
||
label, count, btn = snap["labelRect"], snap["countRect"], snap["btnRect"]
|
||
assert label["left"] >= 0, f"label runs off the left edge: {label}"
|
||
assert label["right"] <= viewport_w, (
|
||
f"label runs off the right edge of the {viewport_w}px viewport: {label}"
|
||
)
|
||
assert count["width"] > 0 and count["height"] > 0, (
|
||
f"the count part has no visible box: {count}"
|
||
)
|
||
assert count["left"] >= label["left"] - 1, (
|
||
f"the count part sits outside the label: {label} {count}"
|
||
)
|
||
assert count["right"] <= label["right"] + 1, (
|
||
f"the count part is clipped by the label (its box runs past the "
|
||
f"label's clip edge): label={label} count={count}"
|
||
)
|
||
assert count["right"] <= btn["right"] + 1, (
|
||
f"the count part runs off the pill: btn={btn} count={count}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 rendered ``#sync-label``
|
||
is an atomic snapshot of: the label box INSIDE the viewport (left ≥
|
||
0, right ≤ 390 — the bug's direct negation), the ``.sync-label-count``
|
||
part VISIBLE with its "(n/4)" position and its right edge at or
|
||
inside the label's and the button's right edges (NEVER clipped —
|
||
the pre-phase geometry fails exactly these pins, test 4
|
||
reproduces it), and the ``.sync-label-main`` part carrying
|
||
``Syncing… <full source/relative/path>`` — the file part may be
|
||
VISUALLY ellipsized, but the DOM text is untruncated and the button
|
||
``title`` carries the FULL label (main + " " + count, A4). The run
|
||
then settles (the phase-32 terminal shape)."""
|
||
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 label; 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_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||
).json_value()
|
||
|
||
# The parts (the phase-64 byte-identical strings, unit-pinned):
|
||
# main = "Syncing… <full source/relative/path>" (untruncated — the
|
||
# visual ellipsis never touches textContent), count = "(n/4)".
|
||
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"]
|
||
assert IMPORT_COUNT_RE.match(snap["countText"]), snap["countText"]
|
||
# A4: the button title carries the UNTRUNCATED label — the full
|
||
# path, not the ellipsized visual tail (the in-browser check
|
||
# already required title == main + " " + count; pin it here too).
|
||
# Phase 129: the pinned chunk part ("· chunk n/m") rides the title
|
||
# while a file's embed batch is in flight (optional — the
|
||
# pre-first-batch frame has none; the phase-129 dedicated suite
|
||
# pins it strictly).
|
||
full = snap["mainText"] + " " + snap["countText"]
|
||
if snap["chunkText"]:
|
||
full += " " + snap["chunkText"]
|
||
assert snap["title"] == full, snap
|
||
# The geometry contract (the fix): label inside the viewport, the
|
||
# count visible and never clipped.
|
||
_assert_count_not_clipped(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
|
||
``.sync-label-main`` as ``Summarizing folders… [source/folder]``
|
||
(the folder part optional — the phase's first poll may precede the
|
||
first hook) with the VISIBLE ``.sync-label-count`` part matching
|
||
``(n/3)`` (the 3 candidate folders) — never clipped (the same pins
|
||
as test 1) — and the title carrying the full untruncated label
|
||
(A4). On settle the button leaves the running label (the phase-32
|
||
terminal ``Synced HH:MM``) and the count span is hidden + empty."""
|
||
page.set_viewport_size(MOBILE_VIEWPORT)
|
||
_start_sync(page, app_url, synclabel_dir)
|
||
|
||
snap = page.wait_for_function(
|
||
_JS_SUMMARY_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||
).json_value()
|
||
|
||
assert SUMMARY_MAIN_RE.match(snap["mainText"]), snap["mainText"]
|
||
assert SUMMARY_COUNT_RE.match(snap["countText"]), snap["countText"]
|
||
assert snap["title"] == snap["mainText"] + " " + snap["countText"], snap
|
||
_assert_count_not_clipped(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:
|
||
during import the FULL rendered label text — the main + count
|
||
parts' concatenation (main + " " + count, the CSS gap standing in
|
||
for the old single span's space) — matches ``Syncing… <full
|
||
source/relative/path> (n/m)``: the pre-phase byte-identical string
|
||
(the parts rework changed the output SHAPE, not the text). The
|
||
label's width respects the ``min(16rem, 40vw)`` cap — 256 px at
|
||
this viewport (16 px :root) — with the count part pinned at the
|
||
right edge and the main part ellipsized (the same cap/ellipsis the
|
||
pre-phase label had), 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_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||
).json_value()
|
||
|
||
# The pre-phase byte-identical FULL string (main + " " + count +
|
||
# the phase-129 chunk part while a batch is in flight):
|
||
# "Syncing… <full source/relative/path> (n/m)" (+ " · chunk n/m").
|
||
full = snap["mainText"] + " " + snap["countText"]
|
||
if snap["chunkText"]:
|
||
full += " " + snap["chunkText"]
|
||
assert IMPORT_FULL_RE.match(full), full
|
||
assert snap["title"] == full, snap
|
||
# The count part is visible and pinned inside the label (no
|
||
# clipping on desktop either — the cap ellipsizes the MAIN part
|
||
# only).
|
||
count, label = snap["countRect"], snap["labelRect"]
|
||
assert count["width"] > 0 and count["height"] > 0, count
|
||
assert count["right"] <= label["right"] + 1, f"label={label} count={count}"
|
||
# The cap: min(16rem, 40vw) = 256 px (16 px :root, 1280 px
|
||
# viewport) — the bounding box width may not exceed it (+ slack).
|
||
assert label["width"] <= DESKTOP_CAP_PX + CAP_SLACK_PX, (
|
||
f"the label box exceeds the min(16rem, 40vw) cap: {label}"
|
||
)
|
||
|
||
_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 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_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)
|