Files
brain-of-reese/tests/unit/test_frontend_sync_upload.py
T
ducoterra 1139f007cf
Build and Push Containers / build-and-push-app (push) Successful in 2m32s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 128_sync_label_mobile
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`.
2026-09-27 23:58:57 -04:00

1008 lines
50 KiB
Python

"""Unit: the RAG-page sync button's phase-64 (task 04) live-file contract.
The browser behavior is E2E-covered (tests/e2e/test_sync_upload_progress.py,
task 06); here we pin the source-level wiring in sources.js, styles.css,
and the shell's RAG view markup (index.html — sources.html / git-
sources.html folded in, phase 76 task 02) — the fmtSyncLabel contract
(both kinds, file
present/absent, counts only when total > 0), enterSyncRunningState
writing the full untruncated path to the button title + #sync-result,
the two-job tick decision tree (sync running > upload running > sync
success > sync failed > upload success > upload failed > idle; the A3
settle never renders upload counts into #sync-result), and the
load-time re-attach of an in-flight upload run (phase 90: unpack +
register only — the bare "Importing…" label, no file) — so a silent
regression is caught without a browser.
Phase 64 task 05 adds the Sources-page (git-sources.js) upload
contract: the "Successfully uploaded — <file>" toast on the 202
(page-local, phase-55 pattern, success-only), the "Processing…"
label driven by the 2 s GET /api/git-sources/upload/status poll, the
409 re-attach (no error banner), the poll's terminal decision tree
(success → result line + announce + reload, NO second toast; failed
→ sanitized error banner + reload; idle → defensive restore), the
finally's never-restore-while-polling guard (PLAN §7.4), and the boot
re-attach branches.
Phase 90 re-points the upload contract to UNPACK + REGISTER ONLY:
the processing state is the BARE "Processing…" for the whole
background run (no current_file, no "(n/m)" counts, no title — the
scan's progress moved to the RAG page's Sync button), the button
reads exactly "Upload" (the phase-64 scan-suffixed label is gone), the
settled result line is
"Uploaded <name> — press Sync sources to import it." (fmtUploadResult
off the status's {"message": "uploaded"} detail; the nameless variant
after a reload / on the 409 re-attach), and the success announce
names the next step.
Phase 98 (task 02) pins the SYNC job's phase-aware label (D2):
fmtSyncPhaseLabel — "overview" → exactly "Writing KB overview…",
"summaries" → "Summarizing folders… <current_summary> (n/m)" (the
folder part omitted while current_summary is null → the bare
"Summarizing folders… (n/m)"), anything else → the byte-identical
phase-64 fmtSyncLabel("sync", …) fall-through — plus the status-object
threading (the tick's branch 1 + the initSyncButton running re-attach
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
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
SOURCES_JS = FRONTEND / "assets" / "sources.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
# Phase 76 (task 02): both HTML pages are folded into the ONE-document
# shell — the pinned comments now live in the RAG / Sources view
# sections of index.html.
SHELL_HTML = FRONTEND / "index.html"
GIT_SOURCES_JS = FRONTEND / "assets" / "git-sources.js"
def _js() -> str:
return SOURCES_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _html() -> str:
return SHELL_HTML.read_text(encoding="utf-8")
def _gjs() -> str:
return GIT_SOURCES_JS.read_text(encoding="utf-8")
def _ghtml() -> str:
return SHELL_HTML.read_text(encoding="utf-8")
def _gfn(js: str, name: str) -> str:
"""The source of the first `function <name>` in git-sources.js
(brace balanced — since phase 76 task 02 the functions live inside
mount(root), so the closing brace is indented, not line-leading).
"""
fn = js.find(f"function {name}")
assert fn != -1, f"{name} must be defined in git-sources.js"
open_idx = js.find("{", fn)
depth = 0
for i in range(open_idx, len(js)):
c = js[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return js[fn : i + 1]
raise AssertionError(f"unbalanced braces in {name}")
def _utick(js: str) -> str:
"""The upload poll tick inside startUploadPolling — from `const
tick = async () => {` to the next function (initUploadStatus), so
the whole decision tree is in the slice."""
fn = js.find("function startUploadPolling")
assert fn != -1, "startUploadPolling must be defined in git-sources.js"
tick = js.find("const tick = async () => {", fn)
assert tick != -1, "the tick must live inside startUploadPolling"
end = js.find("async function initUploadStatus", tick)
assert end != -1, "initUploadStatus must follow startUploadPolling"
return js[tick:end]
def _usubmit(js: str) -> str:
"""The upload form's submit handler — from the addEventListener to
the next top-level function (focusNewRow), so every branch (202 /
409 / other non-2xx / network / finally) is in the slice."""
start = js.find('uploadFormEl.addEventListener("submit"')
assert start != -1, "the upload form must wire a submit handler"
end = js.find("function focusNewRow", start)
assert end != -1, "focusNewRow must follow the upload handler"
return js[start:end]
def _fn(js: str, name: str) -> str:
"""The source of the first `function <name>` in js (brace balanced —
since phase 76 task 02 the functions live inside mount(root), so
the closing brace is indented, not line-leading; the house pin
pattern from tests/unit/test_sync_button.py)."""
fn = js.find(f"function {name}")
assert fn != -1, f"{name} must be defined in sources.js"
open_idx = js.find("{", fn)
depth = 0
for i in range(open_idx, len(js)):
c = js[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return js[fn : i + 1]
raise AssertionError(f"unbalanced braces in {name}")
def _tick(js: str) -> str:
"""The poll tick inside startSyncPolling — from `const tick = async
() => {` to the next function (startSync), so the whole two-job
decision tree is in the slice."""
fn = js.find("function startSyncPolling")
assert fn != -1, "startSyncPolling must be defined in sources.js"
tick = js.find("const tick = async () => {", fn)
assert tick != -1, "the tick must live inside startSyncPolling"
end = js.find("async function startSync", tick)
assert end != -1, "startSync must follow startSyncPolling"
return js[tick:end]
# ---------- fmtSyncLabel: the live-file label PARTS contract ----------
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
assert '"Importing"' in body and '"Syncing…"' in body
# the upload prefix must come from the kind check (ternary, in order)
i_kind = body.find('kind === "upload"')
i_importing = body.find('"Importing"')
i_syncing = body.find('"Syncing…"')
assert -1 < i_kind < i_importing < i_syncing
# the MAIN part: the file appended only when present
assert "currentFile ?" in body
assert "`${prefix} ${currentFile}`" in body
# 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})`" 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
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"
# ---------- 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; 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.
i_ov = body.find('status.phase === "overview"')
i_sum = body.find('status.phase === "summaries"')
assert -1 < i_ov < i_sum
ov = body[i_ov:i_sum]
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"
)
# "summaries": the exact template — optional folder, counts always.
i_fall = body.find("return fmtSyncLabel")
assert i_sum < i_fall, "the summaries branch precedes the fall-through"
sum_ = body[i_sum:i_fall]
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 (
"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 (
'return fmtSyncLabel("sync", status.current_file, '
"status.files_done, status.files_total);"
) in body[i_fall:]
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 parts, while the upload job (no status — its
endpoint has no phase) and the 202/409 click keep the bare
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)"
in body
)
assert "syncBtn.disabled = true" in body
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 parts builder; the
# fall-through (upload / the click, no status) is the byte-
# 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"
# 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) ----------
def test_tick_fetches_both_jobs_with_403_and_blip_rules() -> None:
"""Each tick fetches BOTH status endpoints (the sync and the
background upload run — phase 90: unpack + register, no scan).
The 403 backstop (button hidden) applies
to the SYNC fetch only — a 403 on the upload fetch is simply "no
upload" (never a hide); a network blip on either fetch retries next
tick (the tick reschedules, it never dies on a failed fetch)."""
tick = _tick(_js())
assert 'fetch("/api/sync/status")' in tick
assert 'fetch("/api/git-sources/upload/status")' in tick
assert "r.status === 403" in tick, "the sync fetch keeps the whoami backstop"
assert "ur.status === 403" not in tick, "upload 403 = no upload (A3)"
assert "ur.ok" in tick, "the upload fetch is read only when ok"
assert tick.count("catch {") >= 2, "both fetches are blip-tolerant"
assert tick.count("setTimeout(tick, SYNC_POLL_MS)") >= 3, (
"the blip and both running branches all reschedule"
)
# the sync-status blip must not settle the button: it reschedules.
i_nostatus = tick.find("if (!syncStatus)")
assert i_nostatus != -1
branch = tick[i_nostatus : i_nostatus + 200]
assert "setTimeout(tick, SYNC_POLL_MS)" in branch
assert "applySyncIdle" not in branch, "a blip is not an idle"
def test_tick_decision_tree_order_and_branches() -> None:
"""The phase-64 decision tree, in order (A3/A4): 1. sync running →
2. upload running → 3. sync success → 4. sync failed → 5. upload
success → 6. upload failed → 7. both idle. The running branches
enter the running state with THEIR job's kind + live file/counts;
the sync terminals are the unchanged phase-32 appliers; the upload
terminals settle "Sync sources" + clear #sync-result + hide a stale
error + emit a synthetic idle frame — never the upload's status
object, never fmtSyncResult (no upload counts in #sync-result, A3);
only the upload SUCCESS refreshes the catalog (the KB changed); the
upload failure raises no error surface on this page (the banner is
the Sources page's)."""
tick = _tick(_js())
b_sync_run = tick.find('syncStatus.state === "running"')
b_up_run = tick.find('uploadStatus && uploadStatus.state === "running"')
b_sync_ok = tick.find('syncStatus.state === "success"')
b_sync_fail = tick.find('syncStatus.state === "failed"')
b_up_ok = tick.find('uploadStatus && uploadStatus.state === "success"')
b_up_fail = tick.find('uploadStatus && uploadStatus.state === "failed"')
b_idle = tick.find("applySyncIdle(syncStatus)")
assert (
-1 < b_sync_run < b_up_run < b_sync_ok < b_sync_fail < b_up_ok < b_up_fail < b_idle
), "the tree must fire in the documented order"
# 1. sync running: the sync-kind live label, reschedule.
branch1 = tick[b_sync_run:b_up_run]
assert (
'"sync", syncStatus.current_file, syncStatus.files_done, syncStatus.files_total'
in branch1
)
assert "setTimeout(tick, SYNC_POLL_MS)" in branch1
# 2. upload running: the upload-kind live label (A3), reschedule.
branch2 = tick[b_up_run:b_sync_ok]
assert (
'"upload", uploadStatus.current_file, uploadStatus.files_done, uploadStatus.files_total'
in branch2
)
assert "setTimeout(tick, SYNC_POLL_MS)" in branch2
# 3 + 4. the sync terminals are the unchanged phase-32 appliers.
assert "applySyncSuccess(syncStatus)" in tick[b_sync_ok:b_sync_fail]
assert "applySyncFailure(syncStatus)" in tick[b_sync_fail:b_up_ok]
# 5. upload success: settle + clear + hide stale error + emit idle
# + the catalog refresh (the new documents must appear).
up_ok = tick[b_up_ok:b_up_fail]
for line in (
'settleSyncButton("Sync sources")',
'syncResult.textContent = ""',
"hideSyncError()",
'emitSyncStatus({ state: "idle" })',
"loadTree()", # phase 97: the catalog load is the tree fetch
):
assert line in up_ok, f"the upload-success settle must carry {line!r}"
assert "fmtSyncResult" not in up_ok, "no upload counts in #sync-result (A3)"
assert "applySyncSuccess" not in up_ok, "the sync applier is never an upload branch"
# 6. upload failed: settle only — no catalog refresh (the KB did
# not change) and no error surface on this page (A3).
up_fail = tick[b_up_fail:b_idle]
for line in (
'settleSyncButton("Sync sources")',
'syncResult.textContent = ""',
"hideSyncError()",
'emitSyncStatus({ state: "idle" })',
):
assert line in up_fail, f"the upload-failed settle must carry {line!r}"
assert "loadTree()" not in up_fail, "no KB change on a failed upload"
assert "showSyncError" not in up_fail, "no banner on this page (A3)"
assert "applySyncFailure" not in up_fail and "showSyncModal" not in up_fail
# 7. both idle: the unchanged idle settle.
assert "stopSyncPolling()" in tick[b_idle - 60 : b_idle + 40]
# ---------- the click branch + the load-time re-attach ----------
def test_click_branch_enters_sync_running_without_a_file() -> None:
"""The 202/409 branch of startSync enters the running state with
the sync kind and no file yet (the run is just starting — model
check / clone-pull: bare "Syncing…", A4); the emitSyncStatus({
state: "running" }) dedup via lastSyncState stays, and the poll
starts."""
js = _js()
idx = js.find("r.status === 202 || r.status === 409")
assert idx != -1
branch = js[idx : idx + 500]
assert "enterSyncRunningState(\"sync\", null, 0, 0)" in branch
assert 'emitSyncStatus({ state: "running" })' in branch
assert "lastSyncState !== \"running\"" in branch, "the dedup stays"
assert "startSyncPolling()" in branch
def test_reattach_adopts_a_running_upload_only() -> None:
"""initSyncButton: the sync terminals render the last result; the
RUNNING sync re-enters with the live file AND the whole status
(phase 98 task 02: the label is phase-aware — a mid-summaries
reload re-enters with the summaries label). With the sync IDLE it
fetches the upload status: a RUNNING upload run re-attaches
(running state, upload kind — phase 90: no live file, the label
stays bare "Importing…" — the synthetic running frame, the poll
starts); a terminal upload is a no-op — the fall-through is the
plain idle settle (the boot-time loadTree() already shows the
current catalog)."""
body = _fn(_js(), "initSyncButton")
assert "await fetchIsAdmin()" in body, "admin-only (no extra fetch)"
assert 'fetch("/api/git-sources/upload/status")' in body
# the sync running re-attach now carries the live file too
assert '"sync", status.current_file, status.files_done, status.files_total' in body
# the ONLY upload branch is the running one (A3)
assert "upload && upload.state === \"running\"" in body
assert 'upload.state === "success"' not in body, "a terminal upload never re-attaches"
assert 'upload.state === "failed"' not in body, "a terminal upload never re-attaches"
i_check = body.find("upload && upload.state === \"running\"")
i_fall = body.find("applySyncIdle(status)", i_check)
assert -1 < i_check < i_fall
branch = body[i_check:i_fall]
assert '"upload", upload.current_file, upload.files_done, upload.files_total' in branch
assert 'emitSyncStatus({ state: "running" })' in branch
assert "startSyncPolling()" in branch
# the idle settle is the fall-through (the last statement — the
# brace-balanced body ends with the closing brace)
assert body.rstrip().removesuffix("}").rstrip().endswith("applySyncIdle(status);")
def test_tick_sync_running_passes_the_status_for_the_phase_label() -> None:
"""Branch 1 of the two-job tick (phase 98 task 02): the sync
running state passes the WHOLE syncStatus object as the fifth
argument — the phase-aware label (overview / summaries) reads its
phase fields. The upload running branch stays on the BARE four-arg
label (its status has no phase — D2): no fifth argument, no status
object anywhere in the branch."""
tick = _tick(_js())
b_sync_run = tick.find('syncStatus.state === "running"')
b_up_run = tick.find('uploadStatus && uploadStatus.state === "running"')
b_sync_ok = tick.find('syncStatus.state === "success"')
assert -1 < b_sync_run < b_up_run < b_sync_ok
branch1 = tick[b_sync_run:b_up_run]
i_call = branch1.find("enterSyncRunningState(")
call = branch1[i_call:branch1.find(");", i_call)]
assert re.search(
r'"sync",\s*syncStatus\.current_file,\s*syncStatus\.files_done,\s*'
r"syncStatus\.files_total,\s*syncStatus\s*$",
call,
), "branch 1 passes the whole status (fifth arg) for the phase-aware label"
branch2 = tick[b_up_run:b_sync_ok]
i_call2 = branch2.find("enterSyncRunningState(")
call2 = branch2[i_call2:branch2.find(");", i_call2)]
assert re.fullmatch(
r'enterSyncRunningState\(\s*"upload",\s*uploadStatus\.current_file,\s*'
r"uploadStatus\.files_done,\s*uploadStatus\.files_total\s*$",
call2,
), "the upload job keeps the bare four-arg label — no status, no phase (D2)"
assert "phase" not in call2 and "summaries" not in call2
def test_reattach_sync_running_passes_the_status_for_the_phase_label() -> None:
"""initSyncButton (phase 98 task 02): the RUNNING sync re-attach
passes the WHOLE status object — a reload mid-summaries re-enters
the running state with the summaries label (the never-stale
contract); the RUNNING upload re-attach stays on the bare four-arg
label (its status has no phase — D2)."""
body = _fn(_js(), "initSyncButton")
i_sync = body.find('"sync", status.current_file')
assert i_sync != -1
call = body[i_sync:body.find(");", i_sync)]
assert re.search(
r'"sync",\s*status\.current_file,\s*status\.files_done,\s*'
r"status\.files_total,\s*status\s*$",
call,
), "the RUNNING sync re-attach passes the status object (fifth arg)"
i_up = body.find('"upload", upload.current_file')
assert i_up != -1
call2 = body[i_up:body.find(");", i_up)]
assert re.fullmatch(
r'"upload",\s*upload\.current_file,\s*upload\.files_done,\s*upload\.files_total\s*$',
call2,
), "the RUNNING upload re-attach keeps the bare four-arg label (D2)"
# ---------- the section header + the page comment ----------
def test_section_header_documents_the_two_job_contract() -> None:
"""The sync-button section marker comment documents the phase-64
contract: the live file label, the two-job decision tree (both
status endpoints), and the A3 settle behavior (catalog refresh; the
upload counts never render here)."""
js = _js()
marker = js.find("Sync sources button (Sources page only)")
assert marker != -1, "the sync section marker comment must stay"
header = js[marker : js.find("const syncBtn")]
assert "Phase 64 (task 04)" in header
assert "/api/git-sources/upload/status" in header, "the second job's endpoint"
assert "loadTree" in header, "the A3 catalog refresh (phase 97 rename)"
assert "A3" in header and "A4" in header
def test_sources_html_comment_documents_the_live_announcer() -> None:
"""The #sync-result comment in the shell's RAG view (formerly
sources.html) documents the announcer's role: the SYNC's live file
label ("Syncing… <file> (n/m)"), untruncated for the aria-live
announcer, and — phase 90 — the BARE "Importing…" label an
in-flight upload run adopts (unpack + register only, no scan: its
status never carries a file or counts); empty after an upload
settles (A3 — the upload's result line lives on the Sources
page)."""
html = _html()
idx = html.find('id="sync-result"')
assert idx != -1
comment = html[max(0, idx - 900):idx]
assert "Syncing… <file>" in comment, "the sync live-file label is documented"
assert '"Importing…"' in comment, "the (bare) upload label is documented"
assert "Phase 90" in comment, "the unpack-only rework is documented"
assert "A3" in comment, "the settle contract is documented"
# ---------- styles.css: the ellipsized live label ----------
def test_sync_label_css_ellipsis_truncation() -> None:
""".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-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",
):
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; flex: 1 1 auto; min-width: 0; }" in mbody, (
"the label fills the full-width pill; the main part ellipsizes against the pinned count"
)
# =====================================================================
# Phase 64 task 05 — the Sources-page upload (git-sources.js)
# =====================================================================
# ---------- the "Successfully uploaded" toast (A2) ----------
def test_upload_toast_single_node_status_and_auto_dismiss() -> None:
"""showUploadToast (A2 owner-locked): the phase-55 share-toast
pattern made page-local — a SINGLE node, lazy-created on the first
202 and reused (toasts never stack): a plain ``<div class="toast">``
appended to ``document.body``; ``role="status" aria-live="polite"``
(on THIS page the toast is the a11y announcer for the 202); the
text lands via ``textContent`` (XSS-safe — never innerHTML). A new
toast replaces a pending one: clear the prior dismiss timer, remove
the visible class, force a reflow (``offsetWidth`` — restarts the
CSS transition), re-add the class. Auto-dismiss: the 5000ms
(UPLOAD_TOAST_MS) timer is armed AFTER the visible class is added
and removes the class on fire. The existing .toast CSS is reused
as-is (no new styles)."""
js = _gjs()
body = _gfn(js, "showUploadToast")
assert "if (!uploadToastEl)" in body, "the node is created once, on first use"
assert 'document.createElement("div")' in body
assert 'uploadToastEl.className = "toast"' in body, "the phase-55 .toast CSS, as-is"
assert 'uploadToastEl.setAttribute("role", "status")' in body
assert 'uploadToastEl.setAttribute("aria-live", "polite")' in body
assert "document.body.appendChild(uploadToastEl)" in body
assert "uploadToastEl.textContent = message" in body
assert "innerHTML" not in body, "XSS contract: textContent only"
# Single instance: module-scope node + timer.
assert re.search(r"^let uploadToastEl = null", js, re.M), "the node is module scope"
assert re.search(r"^let uploadToastTimer = 0", js, re.M), "the timer is module scope"
assert "const UPLOAD_TOAST_MS = 5000" in js, "the ~5 s auto-dismiss (A2)"
# Re-trigger order: clear dismiss → remove class → force reflow →
# re-add the visible class.
clear_i = body.find("clearTimeout(uploadToastTimer)")
remove_i = body.find('uploadToastEl.classList.remove("is-visible")')
reflow_i = body.find("void uploadToastEl.offsetWidth")
add_i = body.find('uploadToastEl.classList.add("is-visible")')
assert -1 < clear_i < remove_i < reflow_i < add_i, (
"dismiss cleared → class removed → reflow forced → visible re-added"
)
# The auto-dismiss timer is armed AFTER the visible class is set.
timer_i = body.find("setTimeout")
assert -1 < add_i < timer_i and "UPLOAD_TOAST_MS" in body[timer_i:]
assert 'uploadToastEl.classList.remove("is-visible")' in body[timer_i:], (
"the pending dismiss removes the visible state"
)
def test_toast_fires_on_202_with_the_safe_name() -> None:
"""The 202 branch of the upload submit (A1/A2): the 202 body
(UploadAccepted) is parsed for the safe source name — a body parse
failure degrades to the picked file's name — and the toast fires
with `Successfully uploaded — <name>` BEFORE the scan finishes:
the file input clears, the processing state enters, and the poll
starts. The toast is the SINGLE success surface: exactly one call
site in the whole page (definition + one call — never a failure
branch, never the poll)."""
js = _gjs()
sub = _usubmit(js)
i202 = sub.find("r.status === 202")
i409 = sub.find("r.status === 409")
assert -1 < i202 < i409, "the 202 branch precedes the 409 re-attach"
branch = sub[i202:i409]
assert "let name = file.name;" in branch, "the degrade-to-picked-name fallback"
assert "await r.json()" in branch, "the UploadAccepted body is parsed"
assert "data.name" in branch, "the safe source name comes from the 202 body"
assert "lastUploadName = name" in branch, (
"the accepted 202's safe name is recorded for the result line (phase 90)"
)
assert "showUploadToast(`Successfully uploaded — ${name}`)" in branch
assert 'uploadFileInput.value = ""' in branch, "the file input clears at 202"
assert "enterUploadProcessingState()" in branch
assert "startUploadPolling()" in branch
# Success-only: exactly the definition + the single 202 call.
assert js.count("showUploadToast(") == 2, (
"the definition + exactly ONE call site (the 202 branch)"
)
# ---------- the processing state + the live label ----------
def test_processing_state_is_bare_for_the_whole_run() -> None:
"""Phase 90 (A2): the button's processing entry (202 / 409) is
disabled, BARE "Processing…", title cleared — and the tick's
RUNNING branch renders exactly that same bare label for the whole
background run: the unpack has no file-level progress, so the
phase-64 live-file interpolation is gone (no current_file, no
"(n/m)" counts, no file in the title), then it reschedules at the
2 s house cadence."""
js = _gjs()
state = _gfn(js, "enterUploadProcessingState")
assert "uploadBtn.disabled = true" in state
assert 'uploadBtn.textContent = "Processing…"' in state
assert 'uploadBtn.title = "";' in state, "the title stays clear (no live file)"
assert "const UPLOAD_POLL_MS = 2000" in js, "the SYNC_POLL_MS house value"
tick = _utick(js)
i_run = tick.find('status.state === "running"')
i_stop = tick.find("stopUploadPolling();")
assert -1 < i_run < i_stop, "the running branch precedes the terminal stop"
run = tick[i_run:i_stop]
assert 'uploadBtn.textContent = "Processing…"' in run, (
"the bare label — no file, no counts (phase 90, A2)"
)
assert 'uploadBtn.title = "";' in run, "the title stays clear"
assert "status.current_file" not in run, (
"no live file — the scan's progress moved to the sync"
)
assert "files_total" not in run and "files_done" not in run, "no (n/m) counts"
assert "uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS)" in run, "reschedule"
def test_fmt_upload_result_is_the_ready_for_sync_line() -> None:
"""Phase 90 (A2/A3): the result line reads the no-count
{"message": "uploaded"} detail and renders "Uploaded <name> —
press Sync sources to import it." (the name from the accepted
202); without a name (a reload re-render, the 409 re-attach) it
renders the nameless variant; the old sync-style count keys
(added / updated / unchanged / pruned) are no longer read at all.
The 202 branch records the safe name (lastUploadName); the poll's
settle and the boot re-attach both render the line from
(detail, lastUploadName); the button's idle label is "Upload"."""
js = _gjs()
body = _gfn(js, "fmtUploadResult")
assert "function fmtUploadResult(detail, name)" in body
assert 'detail.message === "uploaded"' in body
assert "Uploaded ${name} — press Sync sources to import it." in body
assert "Uploaded — press Sync sources to import it." in body, (
"the nameless variant (reload / 409 re-attach)"
)
for key in ("added", "updated", "unchanged", "pruned"):
assert key not in body, f"the sync-style count {key!r} is gone"
# The 202 branch records the safe name for the settled line.
sub = _usubmit(js)
i202 = sub.find("r.status === 202")
i409 = sub.find("r.status === 409")
assert "lastUploadName = name" in sub[i202:i409]
assert "let lastUploadName = null" in js, "page-local (null after a reload)"
# Both render sites pass (detail, lastUploadName).
assert "fmtUploadResult(detail, lastUploadName)" in _utick(js)
assert "fmtUploadResult(status.detail, lastUploadName)" in _gfn(js, "initUploadStatus")
# The button's idle label (static + the poll's restores).
restore = _gfn(js, "restoreUploadButton")
assert 'uploadBtn.textContent = "Upload"' in restore
# The phase-64 scan-suffixed label is gone (exactly "Upload") — the
# needles are split so this file carries no forbidden literal (the
# phase-90 rg criterion sweeps frontend/ app/ tests/ for it).
assert ("Upload " + "& scan") not in js and ("Upload " + "and scan") not in js
def test_start_upload_polling_double_start_guard() -> None:
"""startUploadPolling: single timer, one loop at a time — the
first statement bails when a poll is already active (the guard a
double 202/409 cannot bypass)."""
js = _gjs()
i = js.find("function startUploadPolling")
assert i != -1
head = js[i : i + 120]
assert "if (uploadPollTimer !== null) return;" in head
assert "let uploadPollTimer = null" in js, "null = no poll active"
# ---------- the 409 re-attach + the kept error branches ----------
def test_409_reattaches_without_an_error_banner() -> None:
"""409 (an upload is already in progress): NO error banner — the
phase-49 "server detail inline" branch does not apply to 409
anymore. It re-attaches to the in-flight run: the processing state
+ the poll (never stale). The OTHER non-2xx (422 format/name, 413
cap, 5xx) keep the phase-49 apiDetail banner + the kept file
selection; the network-failure fixed line stays."""
js = _gjs()
sub = _usubmit(js)
i409 = sub.find("r.status === 409")
assert i409 != -1
branch = sub[i409 : sub.find("// Other non-2xx", i409)]
assert "enterUploadProcessingState()" in branch
assert "startUploadPolling()" in branch
assert "uploadError" not in branch, "409 never raises the error banner"
assert "apiDetail" not in branch, "no server-detail branch for 409 anymore"
# The other non-2xx keeps the phase-49 convention (after the 409).
i_err = sub.find('await apiDetail(r, "Could not upload the archive — try again.")')
assert i_err > i409, "the other non-2xx branch follows the 409 re-attach (and was found)"
assert "uploadError.hidden = false" in sub[i_err:], "the banner shows for the other non-2xx"
assert "Could not upload the archive — is the app reachable?" in sub, "the network line stays"
# The no-file guard + the short transfer label stay.
assert "Choose an archive file to upload." in sub
assert 'uploadBtn.textContent = "Uploading…"' in sub
# ---------- the poll's terminal decision tree ----------
def test_upload_polling_decision_tree() -> None:
"""The upload poll tick (task 05): fetches
GET /api/git-sources/upload/status; a blip (non-ok / network /
unparseable) reschedules — the tick never dies on a failed fetch.
Then: running → live label + reschedule; ONE stop for the
terminals; success → the result line (fmtUploadResult — the
existing helper reads exactly these keys) + the announce + the row
reload + the cleared file input + the restored button — NO toast
(it already fired at the 202, A2); failed → the sanitized server
error banner (A2 failure UI) + the restored button + the row
reload (a post-swap failure keeps the row — the list state may
have changed; the selection is kept for a one-click re-upload);
idle → the defensive restore (a started run never returns to
idle)."""
tick = _utick(_gjs())
assert 'fetch("/api/git-sources/upload/status")' in tick
# The blip branch reschedules.
i_blip = tick.find("if (!status)")
assert i_blip != -1
assert "uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS)" in tick[i_blip:i_blip + 150]
# One stop, placed after the running branch and before the terminals.
assert tick.count("stopUploadPolling()") == 1
i_run = tick.find('status.state === "running"')
i_stop = tick.find("stopUploadPolling();")
i_ok = tick.find('status.state === "success"')
i_fail = tick.find('status.state === "failed"')
assert -1 < i_run < i_stop < i_ok < i_fail, "running < stop < success < failed"
# success: the ready-for-sync line (phase 90 A2/A3) + the
# next-step announce + reload, NO toast.
ok = tick[i_ok:i_fail]
for line in (
"fmtUploadResult(detail, lastUploadName)",
"uploadResult.hidden = false",
'announce("Archive uploaded — press Sync sources to import it.")',
'uploadFileInput.value = ""',
"restoreUploadButton()",
"loadSources()",
):
assert line in ok, f"the success settle must carry {line!r}"
assert "showUploadToast" not in tick, "no toast in the poll — it fired at the 202 (A2)"
# failed: the sanitized error banner + reload.
fail = tick[i_fail:]
assert "status.error" in fail
assert "uploadError.hidden = false" in fail
assert "restoreUploadButton()" in fail
assert "loadSources()" in fail, "the list state may have changed"
# idle: the defensive fall-through — a final restore, no more state
# checks after the failed branch.
i_idle = tick.rfind("restoreUploadButton()")
assert i_idle > i_fail
assert "state ===" not in tick[i_idle:], "the idle settle is the fall-through"
# ---------- the finally's never-restore-while-polling guard ----------
def test_finally_restores_only_when_no_poll_active() -> None:
"""The submit finally (PLAN §7.4): the button is restored ONLY
when no poll is active (``uploadPollTimer === null``) — while
startUploadPolling owns the button (the 202 / 409 paths) it stays
disabled / "Processing…", so an unconditional finally restore
would race the poll and leave a stale-looking idle button under a
running scan."""
sub = _usubmit(_gjs())
i = sub.find("} finally {")
assert i != -1
fin = sub[i:]
assert "if (uploadPollTimer === null)" in fin, "the guard: no poll → the button is ours"
assert "restoreUploadButton()" in fin
assert "uploadBtn.disabled = false" not in fin, "no unconditional restore in the finally"
# ---------- the boot re-attach ----------
def test_boot_reattach_branches() -> None:
"""initUploadStatus (the admin branch of the boot): the upload
status is fetched ONCE. running → the processing state + the poll
(a reload mid-scan re-attaches — no second upload, no error, no
toast); success → the last result line ONLY (no announce, no
toast — A2); failed → the error banner; idle → nothing (no
branch). The boot IIFE awaits it right after loadSources()."""
js = _gjs()
body = _gfn(js, "initUploadStatus")
assert body.count('fetch("/api/git-sources/upload/status")') == 1, "fetched ONCE at boot"
i_run = body.find('status.state === "running"')
i_ok = body.find('status.state === "success"')
i_fail = body.find('status.state === "failed"')
assert -1 < i_run < i_ok < i_fail
run = body[i_run:i_ok]
assert "enterUploadProcessingState()" in run
assert "startUploadPolling()" in run
assert "uploadError" not in run and "showUploadToast" not in run
ok = body[i_ok:i_fail]
# The last result line — the nameless variant after a reload
# (lastUploadName is null: the safe name was page-local, phase 90).
assert "fmtUploadResult(status.detail, lastUploadName)" in ok
assert "uploadResult.hidden = false" in ok
assert "announce(" not in ok, "no announce at boot (A2)"
assert "showUploadToast" not in ok, "no toast at boot (A2)"
fail = body[i_fail:]
assert "status.error" in fail
assert "uploadError.hidden = false" in fail
assert 'status.state === "idle"' not in body, "idle does nothing — no branch"
# The mount's tail (phase 76 task 02 — the boot IIFE is gone):
# after the list loads, the re-attach runs (admin branch only — the
# anonymous path returns before it), and it is the LAST statement
# of mount(root).
i_boot = js.rfind("await loadSources();")
tail = js[i_boot:i_boot + 400]
assert "await initUploadStatus();" in tail
assert "})();" not in js, "the top-level boot IIFE is gone (mount owns boot)"
assert tail.rstrip().removesuffix("}").rstrip().endswith("await initUploadStatus();")
# ---------- the page comment ----------
def test_git_sources_html_comment_documents_the_202_contract() -> None:
"""The #archive-upload-form comment in the shell's Sources view
(formerly git-sources.html) documents the phase-64 202 contract
(the phase-49 synchronous paragraph marked superseded): the 202 =
"safely on disk" + the JS-created toast (no markup), the bare
"Processing…" label via the status poll, the 409 re-attach without
an error banner — and phase 90's unpack-only rework (the run is
UNPACK + REGISTER ONLY and the success line points at the RAG
page's "Sync sources" button)."""
html = _ghtml()
idx = html.find('id="archive-upload-form"')
assert idx != -1
comment = html[max(0, idx - 1600):idx]
assert "Phase 64" in comment
assert "superseded" in comment, "the phase-49 synchronous paragraph is marked superseded"
assert "Successfully uploaded" in comment, "the toast is documented"
assert "Processing…" in comment, "the (now bare) label is documented"
assert "GET /api/git-sources/upload/status" in comment, "the polling endpoint"
assert "409 re-attaches" in comment, "the re-attach without an error banner"
assert "Phase 90" in comment, "the unpack-only rework is documented"
assert "UNPACK + REGISTER ONLY" in comment
assert "Sync sources" in comment, "the result line points at the RAG page's button"