phase: 128_sync_label_mobile
Build and Push Containers / build-and-push-app (push) Successful in 2m32s
Build and Push Containers / build-and-push-db (push) Successful in 12s

Everything verified and 100% green. The earlier rejection was a report-format issue, not a test failure — all work is in the working tree. Final report:

**Phase 128 final pass — verified (all 3 tasks complete; no code defects found)**
- Verified: `#sync-label` parts markup, `setSyncLabel` writer, part builders (A4 untruncated title/aria-live), flex-row CSS + mobile media query — all match design 1:1.
- `uv run pytest --cov=app --cov-report=term`: 3024 passed; TOTAL coverage **99%** (>90% gate).
- `uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov`: 4 passed (119s, isolated).
- `uv run ruff check .`: clean; `uv run pyright`: 0 errors.
- Criteria: (1) mobile 390×844 label in-viewport, count visible in import+summaries — ✅ E2E; (2) desktop strings/cap/title unchanged — ✅ E2E+unit; (3) E2E isolated green — ✅; (4) full suite green — ✅.
- Docs: README.md fixed 5 pre-existing gaps (PDF phase-126 section + `pdf` token, hidden-folders toggle ph.105, document dates ph.106, private git-source masked tokens ph.121, doc-draft Discard ph.115); AGENTS.md verified consistent — no changes.
- Notable: double-`-q` (addopts `-q` + CLI `-q`) suppresses pytest's summary line — standard `-qq` behavior, not a bug; exit codes confirm green.
- Next pending phase: `129_sync_chunk_progress`.
This commit is contained in:
2026-09-27 23:58:57 -04:00
parent 6e654df387
commit 1139f007cf
34 changed files with 2203 additions and 173 deletions
+816
View File
@@ -0,0 +1,816 @@
"""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).
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
concatenation) matches ``Syncing… <full source/relative/path>
(n/m)`` — the pre-phase byte-identical string — and the label's
width respects the ``min(16rem, 40vw)`` cap (bounding box width
≤ 256 px + slack); 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).
IMPORT_FULL_RE = re.compile(rf"^Syncing… {FILE_RE} \([1-4]/4\)$")
#: 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).
_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 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),
};
}
"""
#: 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).
assert snap["title"] == snap["mainText"] + " " + snap["countText"], 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):
# "Syncing… <full source/relative/path> (n/m)".
full = snap["mainText"] + " " + snap["countText"]
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)
+35 -24
View File
@@ -71,12 +71,13 @@ Test → observable mapping (Playwright Mapping Rule):
summaries_total == 3`` (the keep-final-counts convention).
2. ``test_sync_button_names_the_summary_phase`` (UI — the slow leg):
a fresh admin page on ``/sources.html`` clicks **Sync sources**;
the page's OWN 2 s poll renders ``#sync-label`` matching
``Summarizing folders… [folder] (n/3)`` (the asserted text is the
RENDERED DOM label, with the button ``title`` riding the same
untruncated value — A4); on settle the button reads the terminal
``Synced HH:MM`` (the phase-32 contract) and ``#sync-result``
carries the counts line.
the page's OWN 2 s poll renders ``#sync-label`` as the D2 PARTS
(phase 128: the main part ``Summarizing folders… [folder]`` + the
pinned count part ``(n/3)`` — the asserted text is the RENDERED
DOM parts, with the button ``title`` riding the full untruncated
string, main + " " + count — A4); on settle the button reads the
terminal ``Synced HH:MM`` (the phase-32 contract) and
``#sync-result`` carries the counts line.
3. ``test_missing_folder_summaries_read_as_pending_and_self_heal``
(tree — the fast mock leg): after a successful sync (every
candidate stores its canned line) NO marker is anywhere; DELETE
@@ -727,13 +728,15 @@ def test_sync_button_names_the_summary_phase(
) -> None:
"""A fresh admin page on ``/sources.html`` clicks **Sync sources**;
the page's OWN 2 s poll renders ``#sync-label`` as
``Summarizing folders… [source/folder] (n/3)`` (asserted on the
RENDERED text — the label is built by the page JS — with the
button ``title`` carrying the same untruncated value, A4); on
settle the button shows the terminal ``Synced HH:MM`` (the
phase-32 contract) and ``#sync-result`` carries the counts line.
The prelude/import labels stay the phase-64 shape (the endpoint
test pins those phases; the UI pins the new summaries label)."""
``Summarizing folders… [source/folder]`` + the pinned ``(n/3)``
count span (phase 128: the label is PARTS — the folder part is
optional — the phase's first poll may precede the first hook — and
the button ``title`` carries the full untruncated string, main +
" " + count, A4); on settle the button shows the terminal
``Synced HH:MM`` (the phase-32 contract) and ``#sync-result``
carries the counts line. The prelude/import labels stay the
phase-64 shape (the endpoint test pins those phases; the UI pins
the new summaries label)."""
page.set_default_timeout(30_000)
_register_source(app_url, str(syncsum_dir))
login(page, app_url) # lands on /sources.html (the button's home)
@@ -764,22 +767,30 @@ def test_sync_button_names_the_summary_phase(
expect(page.locator("#sync-error-banner")).to_be_hidden()
# UI layer (D2): the page's own 2 s poll names the summaries phase
# — the RENDERED label matches the D2 shape (the folder part is
# optional — the phase's first poll may precede the first hook)
# with the (n/3) counts, and the button ``title`` carries the same
# untruncated value (A4). ``wait_for_function`` checks BOTH in ONE
# in-browser evaluation: a poll tick updates label + title together
# in a single JS task, so the atomic check can never straddle one
# (the JS regex is the D2 shape, unit-pinned in
# tests/unit/test_frontend_sync_upload.py).
# — the RENDERED label matches the D2 PARTS shape (phase 128:
# the MAIN part carries "Summarizing folders… [folder]" — the
# folder part is optional, the phase's first poll may precede the
# first hook — and the pinned COUNT part carries the (n/3)
# counts), and the button ``title`` carries the full untruncated
# string (main + " " + count, A4). ``wait_for_function`` checks
# BOTH in ONE in-browser evaluation: a poll tick updates the parts
# + the title together in a single JS task, so the atomic check
# can never straddle one (the JS regexes are the D2 shapes,
# unit-pinned in tests/unit/test_frontend_sync_upload.py).
page.wait_for_function(
"""() => {
const label = document.querySelector('#sync-label');
const btn = document.querySelector('#sync-btn');
if (!label || !btn) return false;
const text = label.textContent;
const re = /^Summarizing folders…(?: \\S+)? \\(\\d+\\/3\\)$/;
return re.test(text) && btn.getAttribute('title') === text;
const main = label.querySelector('.sync-label-main');
const count = label.querySelector('.sync-label-count');
if (!main || !count || count.hidden) return false;
const reMain = /^Summarizing folders…(?: \\S+)?$/;
const reCount = /^\\(\\d+\\/3\\)$/;
return reMain.test(main.textContent)
&& reCount.test(count.textContent)
&& btn.getAttribute('title')
=== main.textContent + ' ' + count.textContent;
}""",
timeout=SETTLE_TIMEOUT_MS,
)
+32 -4
View File
@@ -604,9 +604,23 @@ def test_upload_toast_then_navigate_away(
expect(page.locator("#sync-error-banner")).to_be_hidden()
# UI layer: the label gains the live file at the page's 2 s poll
# tick ("Syncing… <source/relative/path> (n/m)").
# tick — the file rides the MAIN part, "(n/m)" the pinned COUNT
# span (phase 128: the space between them is the CSS gap, so the
# rendered concatenation carries none — the FULL string with the
# space rides the button title, A4).
expect(page.locator("#sync-label")).to_have_text(
re.compile(rf"Syncing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)"),
re.compile(rf"Syncing… {re.escape(UPLOAD_NAME)}/.+\.md\(\d+/{N_FILES}\)"),
timeout=SETTLE_TIMEOUT_MS,
)
# Phase 128: the "(n/m)" is NEVER clipped — it sits in its own
# visible count span, and the button title carries the full
# untruncated string (main + " " + count, A4).
expect(page.locator("#sync-label .sync-label-count")).to_be_visible(
timeout=SETTLE_TIMEOUT_MS
)
expect(btn).to_have_attribute(
"title",
re.compile(rf"^Syncing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)$"),
timeout=SETTLE_TIMEOUT_MS,
)
@@ -774,9 +788,23 @@ def test_sync_live_file_label(
expect(page.locator("#sync-error-banner")).to_be_hidden()
# UI layer: the label gains the live file at the page's 2 s poll
# tick ("Syncing… <source/relative/path> (n/m)").
# tick — the file rides the MAIN part, "(n/m)" the pinned COUNT
# span (phase 128: the space between them is the CSS gap, so the
# rendered concatenation carries none — the FULL string with the
# space rides the button title, A4).
expect(page.locator("#sync-label")).to_have_text(
re.compile(rf"Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md \(\d+/{N_FILES}\)"),
re.compile(rf"Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md\(\d+/{N_FILES}\)"),
timeout=SETTLE_TIMEOUT_MS,
)
# Phase 128: the "(n/m)" is NEVER clipped — it sits in its own
# visible count span, and the button title carries the full
# untruncated string (main + " " + count, A4).
expect(page.locator("#sync-label .sync-label-count")).to_be_visible(
timeout=SETTLE_TIMEOUT_MS
)
expect(btn).to_have_attribute(
"title",
re.compile(rf"^Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md \(\d+/{N_FILES}\)$"),
timeout=SETTLE_TIMEOUT_MS,
)
+175 -61
View File
@@ -46,6 +46,23 @@ pass the WHOLE status; the upload job and the 202/409 click stay on
the bare label — the upload status has no phase) and the A4 title-rule
adjustment (the full untruncated label rides the button title for
EVERY running label — the file-only rule is gone).
Phase 128 (task 01) re-points the label pins to the PARTS shape: the
label's string is split into a shrinks-to-fit MAIN part (prefix +
file/folder — the part CSS may ellipsize) and a pinned COUNT part
("(n/m)" — NEVER clipped, hidden whenever absent), so on a phone the
file path ellipsizes while the position stays on-screen. The builders
no longer return one string — they return the parts ({ main, count },
the same decision tree, the same byte-identical copy) — and ONE
writer, setSyncLabel(mainText, countText), lands them: mainText →
.sync-label-main's textContent, countText → .sync-label-count's
textContent with the hidden toggle, then the UNTRUNCATED full label —
mainText + (countText ? " " + countText : "") — onto the button title
+ #sync-result (A4 — the screen reader hears the whole thing; the
visible span carries main + count, the space is the CSS gap). Every
other label state (idle "Sync sources", settle "Synced HH:MM", the
202/409 click, the re-attach) goes through setSyncLabel(main) —
count hidden.
"""
from __future__ import annotations
@@ -159,18 +176,22 @@ def _tick(js: str) -> str:
return js[tick:end]
# ---------- fmtSyncLabel: the live-file label contract ----------
# ---------- fmtSyncLabel: the live-file label PARTS contract ----------
def test_fmt_sync_label_signature_and_prefixes() -> None:
"""fmtSyncLabel(kind, currentFile, done, total): `kind` picks the
prefix — "upload" → "Importing" (the background run's word, A3),
anything else → "Syncing…". The file is appended only when present
(sync: the bare prefix shows during clone/pull, before any file is
indexed — A4; phase 90: the upload run never carries a file or
counts, so its label is always the bare "Importing…"); the counts
are appended only when total > 0 (the import has started); file
before counts."""
def test_fmt_sync_label_returns_the_parts_contract() -> None:
"""fmtSyncLabel(kind, currentFile, done, total) — Phase 128
(task 01): the builder returns the PARTS, not one string. `kind`
picks the prefix — "upload" → "Importing" (the background run's
word, A3), anything else → "Syncing…". The MAIN part — the
prefix, the file appended only when present (sync: the bare
prefix shows during clone/pull, before any file is indexed — A4;
phase 90: the upload run never carries a file or counts, so its
label is always the bare "Importing"); the COUNT part — only when
total > 0 (the import has started), NO join space (the space is
the CSS gap between the parts — the full string's space is
re-joined by setSyncLabel for the title / announcer, A4). File
before counts in the full-string order."""
body = _fn(_js(), "fmtSyncLabel")
assert "function fmtSyncLabel(kind, currentFile, done, total)" in body
assert 'kind === "upload"' in body
@@ -180,31 +201,74 @@ def test_fmt_sync_label_signature_and_prefixes() -> None:
i_importing = body.find('"Importing"')
i_syncing = body.find('"Syncing…"')
assert -1 < i_kind < i_importing < i_syncing
# file appended only when present
# the MAIN part: the file appended only when present
assert "currentFile ?" in body
assert "`${prefix} ${currentFile}`" in body
# counts only when the import has started (total > 0)
# the COUNT part: only when the import has started (total > 0),
# no join space (the parts are joined by setSyncLabel, A4)
assert "total > 0" in body
assert "` (${done}/${total})`" in body
assert "`(${done}/${total})`" in body
assert "` (${done}/${total})`" not in body, ("no space in the count part")
i_file = body.find("currentFile ?")
i_counts = body.find("total > 0")
assert -1 < i_file < i_counts, "the file lands before the counts"
# the parts return (the byte-identical full strings hold when
# setSyncLabel joins them: "Syncing…", "Syncing… <file>",
# "Syncing… <file> (n/m)", "Importing")
assert "return { main, count }" in body
# ---------- enterSyncRunningState: full path to title + announcer ----------
def test_set_sync_label_is_the_single_parts_writer() -> None:
"""setSyncLabel(mainText, countText = "") — Phase 128 (task 01):
the SINGLE point that writes #sync-label. The MAIN part gets
mainText (textContent only — the house rule, no HTML strings);
the COUNT part gets countText when present (textContent +
hidden=false — the "(n/m)" position is visible, NEVER clipped) and
is emptied + hidden=true when absent (the count span is hidden
whenever there is no "(n/m)" to show). Then the UNTRUNCATED full
label — mainText + (countText ? " " + countText : "") — rides the
button title + #sync-result (the aria-live announcer) exactly as
before (A4 — the screen reader hears the whole thing)."""
body = _fn(_js(), "setSyncLabel")
assert 'function setSyncLabel(mainText, countText = "")' in body
# the parts: textContent only (the house rule), null-guarded refs
assert "syncLabelMain.textContent = mainText" in body
assert "syncLabelCount.textContent = countText" in body
# present count → visible; absent count → hidden, emptied
assert "syncLabelCount.hidden = false" in body
assert 'syncLabelCount.textContent = ""' in body
assert "syncLabelCount.hidden = true" in body
i_vis = body.find("syncLabelCount.hidden = false")
i_hid = body.find("syncLabelCount.hidden = true")
assert -1 < i_vis < i_hid, "the present-count branch precedes the absent one"
# A4: the untruncated full label rides the title + the announcer —
# main + " " + count (the join space), bare main when no count.
assert (
'const full = countText ? `${mainText} ${countText}` : mainText'
) in body, "the full label is main + ' ' + count (bare main when absent)"
i_full = body.find("const full")
i_title = body.find("syncBtn.title = full")
i_result = body.find("syncResult.textContent = full")
assert -1 < i_full < i_title < i_result, ("built once: full → title → announcer")
assert "innerHTML" not in body, "the label path is textContent only"
def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
# ---------- enterSyncRunningState: the parts + the A4 writer ----------
def test_fmt_sync_phase_label_is_the_exact_d2_parts_contract() -> None:
"""fmtSyncPhaseLabel(status) — the SYNC job's phase-aware label
(phase 98 task 02, D2): "overview" → exactly "Writing KB
overview…" (no folder, no counts); "summaries" →
"Summarizing folders… <current_summary> (n/m)" — the folder part
(one leading space + current_summary) is omitted while
current_summary is null (the phase's first poll → the bare
"Summarizing folders… (n/m)"); anything else (the null prelude,
"import") → the phase-64 fmtSyncLabel fall-through, byte-identical
(same builder, same fields, same order). The UPLOAD job never
enters here — its status has no phase (D2)."""
(phase 98 task 02, D2; phase 128 task 01: the PARTS). "overview"
→ main exactly "Writing KB overview…" (no folder, no count part);
"summaries" → main "Summarizing folders… <current_summary>" +
count "(n/m)" — the folder part (one leading space +
current_summary) is omitted while current_summary is null (the
phase's first poll → the bare "Summarizing folders…" main), and
the COUNT IS ALWAYS EMITTED in this phase (the hook always carries
a total); anything else (the null prelude, "import") → the
phase-64 fmtSyncLabel parts, byte-identical (same builder, same
fields, same order). The UPLOAD job never enters here — its status
has no phase (D2)."""
body = _fn(_js(), "fmtSyncPhaseLabel")
assert "function fmtSyncPhaseLabel(status)" in body
# "overview": the exact copy, no folder, no counts.
@@ -212,7 +276,8 @@ def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
i_sum = body.find('status.phase === "summaries"')
assert -1 < i_ov < i_sum
ov = body[i_ov:i_sum]
assert 'return "Writing KB overview…";' in ov
assert 'main: "Writing KB overview…"' in ov
assert 'count: ""' in ov, "the overview label carries no count part"
assert "current_summary" not in ov and "summaries_done" not in ov, (
"the overview label carries no folder and no counts"
)
@@ -223,10 +288,13 @@ def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
assert 'status.current_summary ? ` ${status.current_summary}` : ""' in sum_, (
"the folder part — one leading space + the folder, or nothing"
)
assert "main: `Summarizing folders…${folder}`" in sum_, (
"the exact D2 main copy (with and without the folder)"
)
assert (
"return `Summarizing folders…${folder} "
"(${status.summaries_done}/${status.summaries_total})`;"
) in sum_, "the exact D2 copy (with and without the folder)"
"count: `(${status.summaries_done}/${status.summaries_total})`"
) in sum_, "the count part is ALWAYS emitted in the summaries phase"
assert "total > 0" not in sum_, ("no conditional — the hook always carries a total")
# anything else (null prelude, "import"): byte-identical to today's
# sync label — same builder, same fields, same order.
assert (
@@ -235,18 +303,19 @@ def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
) in body[i_fall:]
def test_enter_running_state_is_phase_aware_and_announces_full_label() -> None:
def test_enter_running_state_is_phase_aware_and_writes_the_parts() -> None:
"""The running-state entry keeps the §7.4 never-stale mechanics
(disabled, aria-busy, spinning icon, the stale .is-error removed)
and — phase 98 (task 02, D2) — is phase-aware for the SYNC job:
a sync-kind call carrying the status object takes the
fmtSyncPhaseLabel form, while the upload job (no status — its
fmtSyncPhaseLabel parts, while the upload job (no status — its
endpoint has no phase) and the 202/409 click keep the bare
fmtSyncLabel label, byte-identical. The FULL untruncated label —
whatever its form — rides the button title (set for EVERY running
label: the phase-64 "title only when there is a current file"
rule is adjusted away) and #sync-result (the aria-live announcer);
CSS ellipsizes only the label span."""
fmtSyncLabel parts, byte-identical. Phase 128 (task 01): the entry
itself no longer writes the label — it hands the parts to the
SINGLE writer (setSyncLabel(main, count)), which lands the main
part, the pinned count part (hidden when absent), and the FULL
untruncated label on the button title + #sync-result (A4 — set
for EVERY running label; CSS ellipsizes the main part only)."""
body = _fn(_js(), "enterSyncRunningState")
assert (
"function enterSyncRunningState(kind, currentFile, done, total, status)"
@@ -256,25 +325,48 @@ def test_enter_running_state_is_phase_aware_and_announces_full_label() -> None:
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
assert "syncIcon.classList.add(\"is-spinning\")" in body
assert "syncBtn.classList.remove(\"is-error\")" in body
# the phase-aware split: sync + status → the D2 builder; the
# the phase-aware split: sync + status → the D2 parts builder; the
# fall-through (upload / the click, no status) is the byte-
# identical phase-64 builder.
# identical phase-64 parts builder.
i_phase = body.find("fmtSyncPhaseLabel(status)")
i_fall = body.find("fmtSyncLabel(kind, currentFile, done, total)")
assert 'kind === "sync" && status' in body
assert -1 < i_phase < i_fall, "the phase label precedes the fall-through"
# A4: the untruncated label rides the title for EVERY running label
# (the remove-when-no-file rule is gone) and #sync-result.
assert "syncBtn.title = label" in body, "the full label on hover, always"
assert 'syncBtn.removeAttribute("title")' not in body, (
"the title is set for every running label (D2: not just file labels)"
)
i_title = body.find("syncBtn.title = label")
i_span = body.find("syncLabel.textContent = label")
i_result = body.find("syncResult.textContent = label")
assert -1 < i_title < i_span < i_result, (
"one label: built once, written to title, span AND the announcer"
)
# the parts are handed to the single writer (A4 lives in it).
i_set = body.find("setSyncLabel(main, count)")
assert -1 < i_fall < i_set, "the parts are written after the split"
assert body.count("setSyncLabel(") == 1, "one writer call per entry"
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
assert "syncResult" not in body, ("the announcer write lives in setSyncLabel (A4)")
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
"""Every OTHER #sync-label writer — the idle "Sync sources", the
success settle "Synced HH:MM" (via settleSyncButton from
applySyncSuccess), the failure / upload-terminal "Sync sources"
settles — goes through setSyncLabel(main): the count part is
hidden (absent), and the label's full string (bare main) rides the
button title + #sync-result (A4, as with every label state). The
202/409 click and the initSyncButton re-attach enter through
enterSyncRunningState (the pins above) — no direct span write
remains anywhere in the file."""
js = _js()
settle = _fn(js, "settleSyncButton")
assert "function settleSyncButton(label)" in settle
assert "setSyncLabel(label)" in settle, "the settle label goes through the parts writer"
assert settle.count("setSyncLabel(") == 1
assert "syncLabel.textContent" not in settle, ("no direct span write — the parts")
# the success settle keeps the "Synced HH:MM" copy + the counts line
# AFTER the settle (the settle's announcer write is superseded by
# the counts — the phase-32 contract, unchanged).
success = _fn(js, "applySyncSuccess")
assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success
i_settle = success.find("settleSyncButton(")
i_counts = success.find("syncResult.textContent = fmtSyncResult(status.detail)")
assert -1 < i_settle < i_counts, "the counts line supersedes the settle label"
# NO direct #sync-label write remains anywhere in the module.
assert "syncLabel.textContent" not in js, ("the parts writer is the single entry point")
# ---------- the two-job tick (startSyncPolling) ----------
@@ -522,34 +614,56 @@ def test_sources_html_comment_documents_the_live_announcer() -> None:
def test_sync_label_css_ellipsis_truncation() -> None:
""".sync-label: the live-file label ellipsizes a long
source/relative/path inside the pill (A4) — inline-block with the
min(16rem, 40vw) cap, overflow hidden, text-overflow ellipsis, no
wrap, baseline-aligned; on mobile the pill goes full width with
the label visible (the cap lifts, and min-width: 0 engages the
ellipsis against the full width — the icon-only squeeze is
gone)."""
""".sync-label (phase 128 task 02): the live-file label is a FLEX
ROW of two parts inside the pill (A4) — the container is
inline-flex with the min(16rem, 40vw) cap + min-width: 0; the
.sync-label-main part is the shrinkable item (min-width: 0, overflow
hidden, text-overflow ellipsis, no wrap) whose long
source/relative/path ellipsizes; the .sync-label-count part is
flex 0 0 auto — NEVER clipped — in the .sync-result mono language
(a position reads as data). On mobile the pill goes full width and
the label FILLS it (the cap lifts, flex 1 1 auto) so the main part
ellipsizes against the width left by the pinned count (the
icon-only squeeze is gone)."""
css = _css()
block = re.search(r"\.sync-label\s*\{([^}]*)\}", css)
assert block, "styles.css must style .sync-label"
body = block.group(1)
for prop in (
"display: inline-block",
"display: inline-flex",
"align-items: baseline",
"gap: 0.35rem",
"min-width: 0",
"max-width: min(16rem, 40vw)",
):
assert prop in body, f".sync-label must carry {prop!r}"
assert "inline-block" not in body, "the old inline-block container is gone"
assert "vertical-align" not in body, "the flex row aligns the parts"
main_block = re.search(r"\.sync-label-main\s*\{([^}]*)\}", css)
assert main_block, "styles.css must style .sync-label-main"
for prop in (
"min-width: 0",
"overflow: hidden",
"text-overflow: ellipsis",
"white-space: nowrap",
"vertical-align: bottom",
):
assert prop in body, f".sync-label must carry {prop!r}"
assert prop in main_block.group(1), f".sync-label-main must carry {prop!r}"
count_block = re.search(r"\.sync-label-count\s*\{([^}]*)\}", css)
assert count_block, "styles.css must style .sync-label-count"
for prop in (
"flex: 0 0 auto",
"white-space: nowrap",
"font-family: var(--mono)",
):
assert prop in count_block.group(1), f".sync-label-count must carry {prop!r}"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "the ≤640px media query must stay"
mbody = mobile.group(1)
assert ".sync-btn { width: 100%; }" in mbody, (
"the mobile Sync pill is full width (the icon-only squeeze is gone)"
)
assert ".sync-label { max-width: none; min-width: 0; }" in mbody, (
"the label stays visible and ellipsizes against the full width"
assert ".sync-label { max-width: none; flex: 1 1 auto; min-width: 0; }" in mbody, (
"the label fills the full-width pill; the main part ellipsizes against the pinned count"
)
+50 -23
View File
@@ -34,6 +34,14 @@ FULL untruncated LABEL (the file label, "Writing KB overview…", or
"Summarizing folders… <folder> (n/m)") rides the button title for
EVERY running label; the phase-64 "title only when there is a current
file" (remove-when-null) rule is gone.
Phase 128 (task 01): the label becomes PARTS — #sync-label ships a
main span (the idle text pre-JS) + a hidden count span (the pinned
"(n/m)"), the builders return { main, count }, and the single writer
setSyncLabel(main, count) lands the parts + the untruncated full
label (main + " " + count) on the title + #sync-result; the
running-state / settle pins move onto that shape (their labels stay
byte-identical as full strings).
"""
from __future__ import annotations
@@ -115,18 +123,30 @@ def test_sync_button_ships_hidden_and_labeled() -> None:
assert 'aria-label="Sync sources"' in attrs
def test_sync_button_has_icon_and_label_span() -> None:
def test_sync_button_has_icon_and_label_parts() -> None:
"""The button body is a refresh-cycle svg (aria-hidden — decorative,
the spin is the visible running state) + the .sync-label span with
the idle text, so the label can be swapped by sources.js."""
the idle text, so the label can be swapped by sources.js.
Phase 128 (task 01): #sync-label is PARTS — the MAIN span
(.sync-label-main) carries the idle text (the pre-JS label renders
from the static markup; the part CSS may ellipsize it), and the
COUNT span (.sync-label-count — the pinned "(n/m)" position, never
clipped) ships hidden + empty (there is no count at idle). The
class/id stay (the module query + the E2E label assertions)."""
text = _text(SHELL_HTML)
btn = text[text.find('id="sync-btn"') : text.find("</button>", text.find('id="sync-btn"'))]
assert re.search(r'<svg[^>]*class="sync-icon"[^>]*aria-hidden="true"', btn)
# class for the module query + id for the E2E label assertions
# Phase 128 (task 01): the parts markup — the exact shape (the main
# span holds the idle text, the count span is hidden + empty).
label = re.search(
r'<span\b[^>]*id="sync-label"[^>]*>\s*Sync sources\s*</span>', btn
r'<span\b[^>]*id="sync-label"[^>]*>'
r'<span class="sync-label-main">Sync sources</span>'
r'<span class="sync-label-count" hidden></span>'
r"</span>",
btn,
)
assert label, "the #sync-label span carrying the idle text is missing"
assert label, ("the #sync-label parts markup (main + hidden count) is missing")
assert 'class="sync-label"' in label.group(0)
@@ -275,30 +295,32 @@ def test_sources_js_running_state_is_never_stale() -> None:
adjusted — the FULL untruncated LABEL (the file label, the overview
label, or the summaries label — whatever the running form is) rides
the button title for EVERY running label; the "title only when
there is a current file" (remove-when-null) rule is gone."""
there is a current file" (remove-when-null) rule is gone.
Phase 128 (task 01): the entry hands the label PARTS (the
phase-aware split — sync + status → fmtSyncPhaseLabel, the
fall-through → the byte-identical fmtSyncLabel) to the SINGLE
writer, setSyncLabel(main, count), which lands the main part, the
pinned count part (hidden when absent), and the FULL untruncated
label on the button title + #sync-result (A4) — the direct span /
title / announcer writes are GONE from the entry (they live in
setSyncLabel)."""
js = _text(SOURCES_JS)
body = _body(js, "enterSyncRunningState")
assert "syncBtn.disabled = true" in body
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
assert "syncBtn.title = label" in body, (
"the full untruncated label on hover, for every running label"
)
assert 'syncBtn.removeAttribute("title")' not in body, (
"the title is set for EVERY running label (phase 98: the file-only "
"rule is adjusted away)"
)
title_i = body.find("syncBtn.title = label")
span_i = body.find("syncLabel.textContent = label")
result_i = body.find("syncResult.textContent = label")
assert -1 < title_i < span_i < result_i, (
"one label: built once, written to title, span AND the announcer"
)
# the parts split (phase 98) is intact…
assert "fmtSyncPhaseLabel(status)" in body
assert "fmtSyncLabel(kind, currentFile, done, total)" in body
# …and the single writer lands the parts (A4 in it — the title is
# set for EVERY running label: the file-only rule is adjusted away).
assert "setSyncLabel(main, count)" in body
assert body.count("setSyncLabel(") == 1, "the entry writes ONLY through the parts writer"
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
assert "syncResult.textContent" not in body, ("the announcer write lives in setSyncLabel (A4)")
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
assert "syncBtn.classList.remove(\"is-error\")" in body
assert "syncIcon.classList.add(\"is-spinning\")" in body
assert "fmtSyncLabel(kind, currentFile, done, total)" in body
assert "syncLabel.textContent = label" in body
assert "syncResult.textContent = label" in body, ("the announcer reads the full live path")
def test_sources_js_terminal_states() -> None:
@@ -345,7 +367,10 @@ def test_sources_js_settles_the_button_on_terminal() -> None:
"""settleSyncButton re-enables the control, drops aria-busy, the
title, and the failed affordances, and un-spins the icon — the
button can never sit disabled after a run reaches a terminal state
(failed included: retry-ready)."""
(failed included: retry-ready). Phase 128 (task 01): the settle /
idle label ("Sync sources", "Synced HH:MM") goes through the parts
writer — setSyncLabel(label), count hidden (absent), the full
string riding the title + #sync-result (A4, every label state)."""
js = _text(SOURCES_JS)
body = _body(js, "settleSyncButton")
assert "syncBtn.disabled = false" in body
@@ -353,6 +378,8 @@ def test_sources_js_settles_the_button_on_terminal() -> None:
assert 'syncBtn.removeAttribute("title")' in body
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
assert "syncIcon.classList.remove(\"is-spinning\")" in body
assert "setSyncLabel(label)" in body, ("the settle label goes through the parts writer")
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
def test_sources_js_never_starts_a_second_poll_loop() -> None: