All completion criteria verified — everything is green, no defects found. Final report: ## Phase 97 final verification pass — ALL GREEN **Verified (no code changes needed):** - `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css` - `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate - Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved **Gates (exact commands):** - `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓) - `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors** - `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation - 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview` **Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run) **Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
769 lines
37 KiB
Python
769 lines
37 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.
|
|
"""
|
|
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 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."""
|
|
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
|
|
# file appended only when present
|
|
assert "currentFile ?" in body
|
|
assert "`${prefix} ${currentFile}`" in body
|
|
# counts only when the import has started (total > 0)
|
|
assert "total > 0" in body
|
|
assert "` (${done}/${total})`" in body
|
|
i_file = body.find("currentFile ?")
|
|
i_counts = body.find("total > 0")
|
|
assert -1 < i_file < i_counts, "the file lands before the counts"
|
|
|
|
|
|
# ---------- enterSyncRunningState: full path to title + announcer ----------
|
|
|
|
|
|
def test_enter_running_state_writes_full_path_to_title_and_announcer() -> None:
|
|
"""The running-state entry keeps the §7.4 never-stale mechanics
|
|
(disabled, aria-busy, spinning icon, the stale .is-error removed)
|
|
and — phase 64 — writes the FULL untruncated current file to the
|
|
button title (removed when null: no file yet) and the full
|
|
untruncated fmtSyncLabel text to BOTH the label span and
|
|
#sync-result (the aria-live announcer reads the full live path;
|
|
CSS only ellipsizes the button's span)."""
|
|
body = _fn(_js(), "enterSyncRunningState")
|
|
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
|
|
assert "syncBtn.title = currentFile" in body, "the full path on hover"
|
|
assert 'syncBtn.removeAttribute("title")' in body, "removed when no file yet"
|
|
i_set = body.find("if (currentFile) syncBtn.title = currentFile")
|
|
i_remove = body.find('syncBtn.removeAttribute("title")')
|
|
assert -1 < i_set < i_remove, "title is set (not removed) only when a file exists"
|
|
assert "fmtSyncLabel(kind, currentFile, done, total)" in body
|
|
i_label = body.find("fmtSyncLabel(kind, currentFile, done, total)")
|
|
i_span = body.find("syncLabel.textContent = label")
|
|
i_result = body.find("syncResult.textContent = label")
|
|
assert -1 < i_label < i_span < i_result, (
|
|
"one label: built once, written to the span AND the announcer"
|
|
)
|
|
|
|
|
|
# ---------- 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 branches are unchanged (running
|
|
re-enters with the live file; the terminals render the last
|
|
result). 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);")
|
|
|
|
|
|
# ---------- 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: 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)."""
|
|
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",
|
|
"max-width: min(16rem, 40vw)",
|
|
"overflow: hidden",
|
|
"text-overflow: ellipsis",
|
|
"white-space: nowrap",
|
|
"vertical-align: bottom",
|
|
):
|
|
assert prop in body, f".sync-label 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"
|
|
)
|
|
|
|
|
|
# =====================================================================
|
|
# 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"
|