**Phase 150 final verification pass — complete, 100% green.** All 6 task files were already in `complete/`, so this was verification + doc pass. - Verified: `#sync-progress` is the sole sync text surface + sole `aria-live` region (`role="status"`, polite, no `aria-hidden`); `span#sync-result` gone from HTML/JS/CSS/tests (incl. runtime-built `RETIRED_ID` absence pins in the unit suites); `setSyncProgress` single writer with text-driven visibility; running-only box write in `setSyncLabel`; all 9 settle/cancel sites routed; `requestCancel` pre-fetch clear is a NO-OP. - Tests: `uv run pytest` → **3463 passed**; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run ruff check .` + `uv run pyright` → clean (0 errors); `tests/e2e/test_sync_progress_box.py --no-cov` → **8 passed in isolation**; all 18 migrated sync/image-PDF E2E files green in isolation (77 tests). - Completion criteria: 1) grep clean ✅ 2) sole live region, attrs correct ✅ 3) in-flight/settle/cancel/idle box states proven by E2E + unit pins ✅ 4) icon-only running button + restore ✅ 5) full gates green ✅ 6) no completed-phase behavior change (18 family suites + full suite green) ✅ 7) commit — left to harness per pass instructions (working tree untouched, no `git add`/`commit`). - Documentation: README.md fixed 2 pre-existing inconsistencies (stale `~/Homelab`+`~/Deployments` source model in the intro — retired in phase 61 — now describes the git/local/archive Sources model; "Requires AIPI_KEY" in retrieval-quality now states `BOR_LLM_API_KEY` in `.env` or `$AIPI_KEY`, matching the scripts' real convention). Everything else (URL table, env defaults vs `app/config.py`, sync/phase-150 copy, prod claims) verified accurate. AGENTS.md: verified consistent — no changes needed. - Notable: no code changes were required — the phase implementation was correct as left; no live infrastructure touched. - Next pending phase: `151_pdf_image_extraction` in `.agents/phases/todo/`.
917 lines
43 KiB
Python
917 lines
43 KiB
Python
"""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.
|
||
Phase 150 folded the announcer into the box: it is now the SOLE
|
||
sync text surface AND the sole aria-live region (the retired
|
||
announcer span is gone). 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) are UNCHANGED;
|
||
phase 150 re-pointed the box pin — the box STAYS visible at settle
|
||
to carry the counts line (the fmtSyncResult string the retired
|
||
announcer spoke, byte-identical). 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 150: the box is NOT run-scoped — it STAYS visible at
|
||
# settle to carry the counts line (the fmtSyncResult shape the
|
||
# retired announcer spoke, byte-identical: the fresh 4-file
|
||
# fixture's "4 added"), hiding only when its text is empty.
|
||
expect(page.locator("#sync-progress")).to_be_visible()
|
||
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
|
||
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)
|