"""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 — " 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 — 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… (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). """ 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 ` 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 ` 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_fmt_sync_phase_label_is_the_exact_d2_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… (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).""" 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 'return "Writing KB overview…";' in ov 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 ( "return `Summarizing folders…${folder} " "(${status.summaries_done}/${status.summaries_total})`;" ) in sum_, "the exact D2 copy (with and without the folder)" # 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_announces_full_label() -> 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 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.""" 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 builder; the # fall-through (upload / the click, no status) is the byte- # identical phase-64 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 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… (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… " 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 ``
`` 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 — ` 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 — 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"