**Phase 134 final verification pass — all green** (all 3 task files already in `complete/`; no implementation was missing) - Verified: 5-state `SyncStatus` + `cancelled` terminal (error None, final counts kept), `POST /api/sync/cancel` (202/409/403), cooperative `_check_cancelled` boundaries, shutdown `CancelledError` contract preserved, `#sync-cancel-btn` show/hide + neutral "Sync cancelled" settle, 8-branch poll tree, re-attach — code, unit, integration, and E2E layers all present - `uv run pytest --cov=app --cov-report=term-missing` → **3150 passed**, `app/` coverage **99%** (gate: >90%) - `uv run pytest tests/e2e/test_sync_cancel.py -v --no-cov` (isolated) → **3 passed** (47.6 s) - `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings **Completion criteria:** ① cancel endpoint 202/409/403 ✅ (unit + E2E) ② `cancelled` terminal reported by status, error null, counts kept ✅ (integration `test_cancel_mid_run_lands_in_cancelled_then_resyncs_clean`) ③ unrequested-cancel pin ✅ (`test_unrequested_cancel_is_never_swallowed_and_never_converts`) ④ UI lifecycle + clean re-sync (sentinel row, full 40-file import) ✅ (E2E) ⑤ suite/coverage/E2E gates ✅ ⑥ uncancelled runs byte-identical ✅ (all 3150 pre-existing tests green untouched) - Docs: README.md reviewed in full against code (pages, auth endpoints, every config default vs `app/config.py`, palette 9+8, fail-loud, cancel section) — consistent, no changes needed. AGENTS.md: fixed one pre-existing inaccuracy (rule 2 "move its file" → "move its directory" — phases are directories); everything else verified accurate - No live infrastructure touched; no deviations **Next pending phase:** `135_git_source_branch`
1354 lines
66 KiB
Python
1354 lines
66 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.
|
|
|
|
Phase 129 (task 04) adds the label's THIRD pinned part — the chunk
|
|
position: #sync-label ships a hidden .sync-label-chunk span after the
|
|
count span (the pinned "· chunk n/m" — the in-flight file's
|
|
embed-batch position, batch granularity: n the 1-based first chunk of
|
|
the in-flight batch, m the file's total content chunks). The ONLY
|
|
builder that reads the status's chunk fields is fmtSyncPhaseLabel's
|
|
import fall-through — "· chunk n/m" renders while chunks_total > 0
|
|
and hides in every other state (the prelude, the 202/409 click, the
|
|
upload run — its status has no chunk fields, overview, summaries —
|
|
its own (n/m) is its position, idle / settle). setSyncLabel(mainText,
|
|
countText, chunkText) lands the part under the same present/absent
|
|
contract as the count part, and the UNTRUNCATED full label — main +
|
|
" " + count + " " + chunk — rides the button title + #sync-result
|
|
(A4 — the in-flight import reads "Syncing… <file> (2/3) · chunk
|
|
14/38"). The part keeps the pinned mono CSS (flex 0 0 auto, nowrap,
|
|
--mono, the rule right after .sync-label-count). A node harness (the
|
|
test-document-viewer pattern) behaviorally pins the rendered parts +
|
|
the full title across every label state.
|
|
|
|
Phase 134 (task 02) extends the node harness to the Cancel control:
|
|
the extracted functions gain the REAL show/hide helpers + the REAL
|
|
settleSyncButton (step 8 now runs the settle itself — it goes through
|
|
the writer as before, plus it hides the Cancel control), and every
|
|
row snapshots the control's hidden/disabled pair — the behavioral
|
|
contract: shown + enabled in EVERY sync-running state (the entry
|
|
shows it for kind "sync"), hidden + disabled for the upload run
|
|
(the upload job is NOT cancellable in this phase) and after the
|
|
settle (settleSyncButton always hides it).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
#: The node harness needs node (the test-document-viewer pattern).
|
|
HAVE_NODE = shutil.which("node") is not None
|
|
|
|
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 = "", chunkText = "") — Phase
|
|
128 (task 01) + Phase 129 (task 04): 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 CHUNK part (phase 129) gets chunkText under the SAME contract
|
|
(present → the pinned "· chunk n/m" is visible, absent → emptied
|
|
+ hidden=true — the part renders only while a file's embed batch
|
|
is in flight). Then the UNTRUNCATED full label — mainText +
|
|
(countText ? " " + countText : "") + (chunkText ? " " + chunkText
|
|
: "") — rides the button title + #sync-result (the aria-live
|
|
announcer) (A4 — the screen reader hears the whole "Syncing…
|
|
<file> (2/3) · chunk 14/38")."""
|
|
body = _fn(_js(), "setSyncLabel")
|
|
assert 'function setSyncLabel(mainText, countText = "", chunkText = "")' in body
|
|
# the parts: textContent only (the house rule), null-guarded refs
|
|
assert "syncLabelMain.textContent = mainText" in body
|
|
assert "syncLabelCount.textContent = countText" in body
|
|
assert "syncLabelChunk.textContent = chunkText" 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"
|
|
# present chunk → visible; absent chunk → hidden, emptied (same contract)
|
|
assert "syncLabelChunk.hidden = false" in body
|
|
assert 'syncLabelChunk.textContent = ""' in body
|
|
assert "syncLabelChunk.hidden = true" in body
|
|
i_cvis = body.find("syncLabelChunk.hidden = false")
|
|
i_chid = body.find("syncLabelChunk.hidden = true")
|
|
assert -1 < i_cvis < i_chid, "the present-chunk branch precedes the absent one"
|
|
# A4: the untruncated full label rides the title + the announcer —
|
|
# main + " " + count + " " + chunk (the join spaces = the CSS
|
|
# gaps), bare main when both are absent.
|
|
assert (
|
|
"const full =\n"
|
|
" mainText +\n"
|
|
" (countText ? ` ${countText}` : \"\") +\n"
|
|
" (chunkText ? ` ${chunkText}` : \"\");"
|
|
) in body, ("the full label is main + ' ' + count + ' ' + chunk (spaces = the CSS gaps)")
|
|
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,
|
|
# NO chunk part (the summaries phase's own (n/m) is its position —
|
|
# the status's chunk fields are cleared at this transition, so the
|
|
# branch never reads them).
|
|
i_fall = body.find("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")
|
|
assert 'chunk: ""' in ov, "the overview label carries no chunk part"
|
|
assert 'chunk: ""' in sum_, (
|
|
"the summaries label carries no chunk part (its (n/m) is its position)"
|
|
)
|
|
assert "chunks_total" not in ov, "the overview branch never reads the chunk fields"
|
|
assert "chunks_total" not in sum_, "the summaries branch never reads the chunk fields"
|
|
# anything else (null prelude, "import"): byte-identical to today's
|
|
# sync label — same builder, same fields, same order — plus the
|
|
# phase-129 chunk part: "· chunk n/m" ONLY while chunks_total > 0
|
|
# (a file's embed batch is in flight — n the 1-based first chunk
|
|
# of the batch, m the file's total content chunks, batch
|
|
# granularity), "" otherwise (0/0 before the first batch — the
|
|
# part hides). The fall-through is the ONLY branch that reads the
|
|
# status's chunk fields.
|
|
i_destr = body.find("const { main, count } = fmtSyncLabel(")
|
|
assert -1 < i_destr < i_fall, "the fall-through destructures the phase-64 parts"
|
|
fall = body[i_destr:]
|
|
assert (
|
|
'const { main, count } = fmtSyncLabel(\n'
|
|
' "sync", status.current_file, status.files_done, status.files_total\n'
|
|
" );"
|
|
) in fall, "the phase-64 parts builder, unchanged"
|
|
assert "status.chunks_total > 0" in fall, (
|
|
"the chunk part renders only while a batch is in flight"
|
|
)
|
|
assert "`· chunk ${status.current_chunk}/${status.chunks_total}`" in fall
|
|
i_guard = fall.find("status.chunks_total > 0")
|
|
i_else = fall.find(": \"\";", i_guard)
|
|
assert -1 < i_guard < i_else, "0/0 (before the first batch) → the empty chunk part"
|
|
assert "return { main, count, chunk }" in fall
|
|
# the bare builder (the upload job + the 202/409 click) returns NO
|
|
# chunk part — those labels stay bare.
|
|
bare = _fn(_js(), "fmtSyncLabel")
|
|
assert "chunk" not in bare, "fmtSyncLabel stays chunk-free (upload + the click stay bare)"
|
|
|
|
|
|
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, chunk) — phase 129:
|
|
the chunk part destructures with a "" default), which lands the
|
|
main part, the pinned count part (hidden when absent), the pinned
|
|
chunk part (phase 129 — 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) —
|
|
# phase 129: the chunk part destructures with a "" default (the
|
|
# bare builders — the upload job, the 202/409 click — return no
|
|
# chunk part, so the part stays hidden there).
|
|
assert "const { main, count, chunk = \"\" } =" in body, (
|
|
"the chunk part destructures with the '' default (bare builders return none)"
|
|
)
|
|
i_set = body.find("setSyncLabel(main, count, chunk)")
|
|
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")
|
|
|
|
|
|
# =====================================================================
|
|
# Phase 129 (task 04) — the label's third part: the chunk position
|
|
# =====================================================================
|
|
|
|
|
|
def test_sync_label_markup_ships_the_hidden_chunk_part() -> None:
|
|
"""#sync-label (the shell's RAG view) ships the phase-129 THIRD
|
|
part after the count span — the pinned "· chunk n/m" span
|
|
(.sync-label-chunk), hidden + empty by default (the same ship
|
|
contract as the count span: no chunk position at idle — the part
|
|
renders only while a file's embed batch is in flight)."""
|
|
html = _html()
|
|
btn = html[html.find('id="sync-btn"') : html.find("</button>", html.find('id="sync-btn"'))]
|
|
label = re.search(
|
|
r'<span\b[^>]*id="sync-label"[^>]*>'
|
|
r'<span class="sync-label-main">Sync sources</span>'
|
|
r'<span class="sync-label-count" hidden></span>'
|
|
r'<span class="sync-label-chunk" hidden></span>'
|
|
r"</span>",
|
|
btn,
|
|
)
|
|
assert label, (
|
|
"the #sync-label parts markup (main + hidden count + hidden chunk) is missing"
|
|
)
|
|
|
|
|
|
def test_sync_label_chunk_css_is_the_pinned_part() -> None:
|
|
""".sync-label-chunk (phase 129 task 04): the same pinned-part
|
|
language as .sync-label-count — flex 0 0 auto (NEVER clipped),
|
|
white-space nowrap, the --mono font (a position reads as data) —
|
|
and it sits right AFTER the count rule (both mono parts read as
|
|
data)."""
|
|
css = _css()
|
|
count_block = re.search(r"\.sync-label-count\s*\{", css)
|
|
chunk_block = re.search(r"\.sync-label-chunk\s*\{([^}]*)\}", css)
|
|
assert chunk_block, "styles.css must style .sync-label-chunk"
|
|
for prop in ("flex: 0 0 auto", "white-space: nowrap", "font-family: var(--mono)"):
|
|
assert prop in chunk_block.group(1), f".sync-label-chunk must carry {prop!r}"
|
|
assert count_block and count_block.start() < chunk_block.start(), (
|
|
"the chunk rule sits right after the count rule"
|
|
)
|
|
|
|
|
|
#: The node harness script (the test-document-viewer pattern): the
|
|
#: REAL extracted label functions run against stub element refs, and
|
|
#: every label state is snapshotted as a JSON row —
|
|
#: [tag, title, announcer, main, count, countHidden, chunk, chunkHidden].
|
|
#: No JS template literals in the script body (plain strings only —
|
|
#: the script is assembled by concatenation, no placeholders).
|
|
_CHUNK_NODE_HEAD = """
|
|
const syncLabelMain = { textContent: "" };
|
|
const syncLabelCount = { textContent: "", hidden: true };
|
|
const syncLabelChunk = { textContent: "", hidden: true };
|
|
const syncResult = { textContent: "" };
|
|
const syncBtn = {
|
|
disabled: false,
|
|
title: "",
|
|
attrs: {},
|
|
setAttribute(k, v) { this.attrs[k] = v; },
|
|
removeAttribute(k) { delete this.attrs[k]; },
|
|
classList: { add() {}, remove() {} },
|
|
};
|
|
const syncIcon = { classList: { add() {}, remove() {} } };
|
|
// Phase 134 (task 02): the Cancel control — ships hidden + disabled
|
|
// (the markup's state); the extracted REAL show/hide helpers mutate
|
|
// it (show also sets the aria-label — the attrs pocket records it).
|
|
const syncCancelBtn = {
|
|
hidden: true,
|
|
disabled: true,
|
|
attrs: {},
|
|
setAttribute(k, v) { this.attrs[k] = v; },
|
|
removeAttribute(k) { delete this.attrs[k]; },
|
|
};
|
|
"""
|
|
|
|
_CHUNK_NODE_TAIL = """
|
|
const show = (tag) => {
|
|
console.log(JSON.stringify([
|
|
tag, syncBtn.title, syncResult.textContent,
|
|
syncLabelMain.textContent,
|
|
syncLabelCount.textContent, syncLabelCount.hidden,
|
|
syncLabelChunk.textContent, syncLabelChunk.hidden,
|
|
syncCancelBtn.hidden, syncCancelBtn.disabled,
|
|
]));
|
|
};
|
|
// 1. import, batch in flight (the task's fixture values, 14/38):
|
|
enterSyncRunningState("sync", "src/a/b.md", 2, 3, {
|
|
phase: "import", current_file: "src/a/b.md", files_done: 2, files_total: 3,
|
|
current_chunk: 14, chunks_total: 38,
|
|
});
|
|
show("import-inflight");
|
|
// 2. import before the first batch (0/0) — the chunk part hides:
|
|
enterSyncRunningState("sync", "src/a/b.md", 2, 3, {
|
|
phase: "import", current_file: "src/a/b.md", files_done: 2, files_total: 3,
|
|
current_chunk: 0, chunks_total: 0,
|
|
});
|
|
show("import-prebatch");
|
|
// 3. the prelude (null phase, no file yet):
|
|
enterSyncRunningState("sync", null, 0, 0, {
|
|
phase: null, current_file: null, files_done: 0, files_total: 0,
|
|
current_chunk: 0, chunks_total: 0,
|
|
});
|
|
show("prelude");
|
|
// 4. the 202/409 click (no status yet):
|
|
enterSyncRunningState("sync", null, 0, 0);
|
|
show("click");
|
|
// 5. the upload run (no status — its endpoint has no chunk fields, D2):
|
|
enterSyncRunningState("upload", null, 0, 0);
|
|
show("upload");
|
|
// 6. overview (the chunk fields are cleared at this transition):
|
|
enterSyncRunningState("sync", null, 4, 4, {
|
|
phase: "overview", current_file: null, files_done: 4, files_total: 4,
|
|
current_chunk: 0, chunks_total: 0,
|
|
});
|
|
show("overview");
|
|
// 7. summaries (the phase's own (n/m) is its position):
|
|
enterSyncRunningState("sync", null, 4, 4, {
|
|
phase: "summaries", current_summary: "src/alpha", files_done: 4, files_total: 4,
|
|
summaries_done: 2, summaries_total: 3, current_chunk: 0, chunks_total: 0,
|
|
});
|
|
show("summaries");
|
|
// 8. the idle settle goes through the REAL settle (phase 134 task
|
|
// 02: it hides the Cancel control too) — the settle itself goes
|
|
// through the writer with no chunk text:
|
|
settleSyncButton("Sync sources");
|
|
show("idle");
|
|
"""
|
|
|
|
|
|
def _run_node(script: str) -> str:
|
|
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
|
|
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
|
return proc.stdout
|
|
|
|
|
|
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
|
def test_chunk_part_renders_only_in_the_import_phase_under_node() -> None:
|
|
"""Behavioral check (node) of the REAL label path — the extracted
|
|
fmtSyncLabel / fmtSyncPhaseLabel / setSyncLabel /
|
|
enterSyncRunningState run against stub element refs (the
|
|
test-document-viewer pattern). The pinned "· chunk n/m" part (the
|
|
task's fixture values):
|
|
|
|
* import, batch in flight (chunks_total 38 > 0): the chunk part
|
|
shows "· chunk 14/38" and the UNTRUNCATED title + #sync-result
|
|
read exactly "Syncing… src/a/b.md (2/3) · chunk 14/38" (A4);
|
|
* import before the first batch (0/0): the chunk part hides and
|
|
the label stays "Syncing… src/a/b.md (2/3)";
|
|
* every OTHER state — prelude (null phase), the 202/409 click (no
|
|
status), the upload run (no status, D2), overview, summaries (its
|
|
own (n/m) is its position), the idle settle — leaves the chunk
|
|
part hidden + empty.
|
|
|
|
Phase 134 (task 02): every row also snapshots the Cancel
|
|
control's hidden/disabled pair — the behavioral contract:
|
|
shown + enabled in EVERY sync-running state, hidden + disabled
|
|
for the upload run (the upload job is NOT cancellable in this
|
|
phase) and after the (real) settle.
|
|
"""
|
|
js = _js()
|
|
fns = "\n".join(
|
|
_fn(js, name)
|
|
for name in (
|
|
"fmtSyncLabel",
|
|
"fmtSyncPhaseLabel",
|
|
"setSyncLabel",
|
|
# Phase 134 (task 02): the running entry's REAL cancel
|
|
# show/hide + the REAL settle (step 8) — extracted against
|
|
# the syncCancelBtn stub.
|
|
"showSyncCancelBtn",
|
|
"hideSyncCancelBtn",
|
|
"enterSyncRunningState",
|
|
"settleSyncButton",
|
|
)
|
|
)
|
|
script = _CHUNK_NODE_HEAD + fns + _CHUNK_NODE_TAIL
|
|
rows = [json.loads(line) for line in _run_node(script).splitlines() if line]
|
|
by_tag = {row[0]: row for row in rows}
|
|
# row = [tag, title, announcer, main, count, countHidden, chunk,
|
|
# chunkHidden, cancelHidden, cancelDisabled]
|
|
|
|
# 1. the in-flight import — the chunk part renders, the full
|
|
# untruncated label rides the title + the announcer (A4).
|
|
row = by_tag["import-inflight"]
|
|
assert row[3] == "Syncing… src/a/b.md", row
|
|
assert row[4] == "(2/3)" and row[5] is False, row
|
|
assert row[6] == "· chunk 14/38" and row[7] is False, (
|
|
"the chunk part shows the batch position (1-based first chunk / file total)"
|
|
)
|
|
assert row[1] == "Syncing… src/a/b.md (2/3) · chunk 14/38", row[1]
|
|
assert row[2] == row[1], "the announcer hears the whole thing (A4)"
|
|
|
|
# 2. the import before the first batch (0/0) — the part hides, the
|
|
# label is the byte-identical phase-64 string.
|
|
row = by_tag["import-prebatch"]
|
|
assert row[1] == "Syncing… src/a/b.md (2/3)", row[1]
|
|
assert row[2] == row[1]
|
|
assert row[6] == "" and row[7] is True, row
|
|
|
|
# 3. every other state — the chunk part is hidden + empty, and the
|
|
# title is the byte-identical pre-phase-129 label for that state.
|
|
for tag, expected_title in (
|
|
("prelude", "Syncing…"),
|
|
("click", "Syncing…"),
|
|
("upload", "Importing"),
|
|
("overview", "Writing KB overview…"),
|
|
("summaries", "Summarizing folders… src/alpha (2/3)"),
|
|
("idle", "Sync sources"),
|
|
):
|
|
row = by_tag[tag]
|
|
assert row[1] == expected_title, (tag, row)
|
|
assert row[2] == row[1], (tag, row)
|
|
assert row[6] == "" and row[7] is True, f"{tag} must hide the chunk part"
|
|
|
|
# Phase 134 (task 02): the Cancel control's behavioral contract —
|
|
# shown + enabled in EVERY sync-running state (the entry shows it
|
|
# for kind "sync"), hidden + disabled for the upload run (the
|
|
# upload job is NOT cancellable in this phase) and hidden +
|
|
# disabled after the real settle (settleSyncButton always hides
|
|
# it — every terminal + idle passes through it).
|
|
for tag in ("import-inflight", "import-prebatch", "prelude", "click", "overview", "summaries"):
|
|
row = by_tag[tag]
|
|
assert row[8] is False and row[9] is False, (
|
|
f"{tag}: the Cancel control must be shown + enabled while a sync run is in flight"
|
|
)
|
|
for tag in ("upload", "idle"):
|
|
row = by_tag[tag]
|
|
assert row[8] is True and row[9] is True, (
|
|
f"{tag}: the Cancel control must be hidden + disabled "
|
|
"(the upload job is not cancellable / the settle hides it)"
|
|
)
|
|
|
|
|
|
def test_every_other_state_passes_no_chunk_text() -> None:
|
|
"""Source-level pin of the render-only-while-chunks_total>0
|
|
contract: the import fall-through is the ONLY place in sources.js
|
|
that reads the status's chunk fields (the summaries/overview
|
|
branches and the bare builders never touch them), and every
|
|
NON-running label emission passes no chunk text (the part hides):
|
|
the settle/idle labels go through setSyncLabel(label) — the only
|
|
three-arg call site is the running entry."""
|
|
js = _js()
|
|
body = _fn(js, "fmtSyncPhaseLabel")
|
|
i_fall = body.find("fmtSyncLabel(")
|
|
pre = body[:i_fall]
|
|
assert "chunks_total" not in pre and "current_chunk" not in pre, (
|
|
"the overview/summaries branches never read the chunk fields"
|
|
)
|
|
fall = body[i_fall:]
|
|
assert "status.chunks_total" in fall and "status.current_chunk" in fall
|
|
assert js.count("setSyncLabel(main, count, chunk)") == 1
|
|
assert "setSyncLabel(label)" in js, "the settle/idle labels stay two-arg (no chunk)"
|
|
|
|
|
|
# ---------- 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"
|