"""Unit: the remove confirmation modal on /git-sources.html (phase 69, task 02). Removal is a TOTAL removal (owner request 2026-09-02): the row, the source's indexed documents (chunks + embeddings), and — for git clones and uploaded archives — the files on the server's disk, all immediately (task 01's ``DELETE /api/git-sources/{id}`` rewire). The page must confirm that through a real in-app ``role="alertdialog"`` modal — ``window.confirm`` is retired — that names the source and states the policy BEFORE the request goes out. The browser behavior itself is E2E-gated by the phase-69 story suite (``tests/e2e/test_source_removal_cleanup.py`` — task 03: focus, Escape, ≥44px targets, the a11y interaction layer); like the other frontend-adjacent unit files (``test_stale_ui_copy.py`` / ``test_summary_edit_ui.py`` house pattern), this module pins the source-level contract a silent regression would break: * ``window.confirm`` is GONE from the whole frontend (the only native confirm the app ever shipped); * the static ``#remove-confirm-dialog`` markup — ``role="alertdialog"``, ``aria-modal``, ``aria-labelledby``/``aria-describedby``, hidden by default, the six child ids, the ``role="alert"`` error line, real ``type="button"`` buttons, the locked modal copy (verbatim); * the JS lifecycle — ``openRemoveConfirm`` (textContent-only source population with makeRow's ``value`` expression, error cleared, focus on Cancel, the trigger recorded), cancel = Escape / Cancel button / backdrop (no request; focus returns to the trigger; a no-op while a DELETE is in flight), ``confirmRemove`` (§7.4 in-flight state: both buttons disable + "Removing…", success → close/reload/announce — the removal confirmation is the LAST announcement, non-2xx → the in-modal alert line + dialog stays open, network → the fixed reachable? line, re-enable in the finally); * the stale "prunes on the next sync" removal copy is GONE from ``git-sources.js`` + the shell (phase 76 task 02 folded git- sources.html into index.html's Sources view); the new hint copy is PRESENT (the README pins are task 03's); * styles.css — the modal classes on the house dark-tech palette (phase-08 tokens only, no CDN, no blur), ≥44px buttons, the ``[hidden]`` override, the err-token destructive pair. """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" # Phase 76 (task 02): git-sources.html is folded into the ONE-document # shell — the dialog markup now lives in the Sources view section of # index.html (moved verbatim, ids unchanged). SHELL_HTML = FRONTEND / "index.html" JS = FRONTEND / "assets" / "git-sources.js" CSS = FRONTEND / "assets" / "styles.css" #: The locked modal copy (phase 69, 00_phase.md) — one fixed paragraph #: for both kinds (the UI cannot tell an upload from the owner's own #: directory; naming the source above it makes the target #: unambiguous). Pinned verbatim against the normalized text. MODAL_COPY = ( "This permanently removes the source entry, all of its indexed " "documents from the knowledge base, and — for git clones and " "uploaded archives — the files on the server's disk. Files in " "your own local directories are never touched. This cannot be undone." ) #: The new hint-box contract (phase 69): removal is immediate and #: total; the modal spells it out; the Sync button mirrors the #: remaining sources (upstream churn is still pruned on that run). HINT_TOTAL_REMOVAL = "Removing a source is a total removal, done immediately" HINT_MODAL_SPELLS_OUT = ( "the confirmation modal spells out exactly what will be deleted" ) HINT_FOREVER_SAFE = "files in your own local directories are never touched" #: The success announce (the existing #git-sources-announcer live #: region). ANNOUNCE_OK = "Source removed — its files and index entries were cleaned up." #: The retired confirm copy (task 02 stale-copy pins). OLD_STAY_INDEXED = "stays indexed until the next sync" # "prunes/pruned … on the next sync" in any shape (the old hint + # confirm message). OLD_NEXT_SYNC = re.compile(r"prun\w+[^.]{0,120}?next sync") def _text(path: Path) -> str: return path.read_text(encoding="utf-8") def _norm(text: str) -> str: """Collapse whitespace runs — the markup wraps long lines, so the locked copy is pinned against the normalized text.""" return re.sub(r"\s+", " ", text).strip() def _js() -> str: return _text(JS) def _css() -> str: return _text(CSS) def _fn(js: str, name: str) -> str: """The source of a (possibly async) top-level function via balanced-brace counting (the test_summary_edit_ui.py helper).""" for prefix in ("async function ", "function "): start = js.find(f"{prefix}{name}(") if start != -1: depth = 0 for i in range(js.find("{", start), len(js)): if js[i] == "{": depth += 1 elif js[i] == "}": depth -= 1 if depth == 0: return js[start : i + 1] raise AssertionError(f"unbalanced braces in {name}()") raise AssertionError(f"{name}() must exist in git-sources.js") def _element_block(html: str, id_attr: str, tag: str = "div") -> str: """The element's full markup (a balanced-tag walk — the dialog nests the backdrop / panel / actions divs; the hint is a

).""" marker = f'id="{id_attr}"' i = html.find(marker) assert i != -1, f"missing id={id_attr} in the shell's Sources view" opens = [m.start() for m in re.finditer(rf"<{tag}\b", html[:i])] assert opens, f"no <{tag}> owns id={id_attr}" open_i = opens[-1] depth = 0 for m in re.finditer(rf"<{tag}\b[^>]*>|", html[open_i :]): if m.group(0).startswith(f""): depth -= 1 else: depth += 1 if depth == 0: return html[open_i : open_i + m.end()] raise AssertionError(f"unbalanced <{tag}> for id={id_attr}") # ---------- window.confirm is gone (the whole frontend) ---------- def test_window_confirm_is_gone_from_the_whole_frontend() -> None: """``window.confirm`` — the only native confirm the app ever shipped — is absent from EVERY frontend file (markup, scripts, styles — comments included: the retirement note must not keep the literal).""" for path in sorted(FRONTEND.rglob("*")): if path.is_file(): assert "window.confirm" not in path.read_text(encoding="utf-8"), ( f"window.confirm survived in {path.relative_to(FRONTEND)}" ) # ---------- the static dialog markup ---------- def test_dialog_markup_is_the_locked_alertdialog() -> None: """#remove-confirm-dialog: role="alertdialog" + aria-modal + the labelled/describedby pair, hidden by default, inside the manager (#git-sources-content — the static-markup convention, stable E2E selectors); all six child ids present; the error line is role="alert"; both buttons are real type="button"; the title is the locked h2; the modal copy is the locked paragraph verbatim.""" html = _text(SHELL_HTML) frag = _element_block(html, "remove-confirm-dialog") open_tag = frag[: frag.find(">") + 1] assert 'role="alertdialog"' in open_tag assert 'aria-modal="true"' in open_tag assert 'aria-labelledby="remove-confirm-title"' in open_tag assert 'aria-describedby="remove-confirm-copy"' in open_tag assert "hidden" in open_tag, "the dialog ships hidden" # Static markup INSIDE the manager (the #git-sources-hint / gate # convention) — E2E can wait for the content to be revealed. assert html.find('id="git-sources-content"') < html.find("remove-confirm-dialog") for child in ( "remove-confirm-title", "remove-confirm-source", "remove-confirm-copy", "remove-confirm-error", "remove-confirm-cancel", "remove-confirm-remove", ): assert f'id="{child}"' in frag, f"missing #{child} in the dialog" title = re.search(r"]*id=\"remove-confirm-title\"[^>]*>(.*?)", frag, re.S) assert title and _norm(title.group(1)) == "Remove this source?" err = re.search(r"]*id=\"remove-confirm-error\"[^>]*>", frag) assert err and 'role="alert"' in err.group(0), "the in-modal error is role=alert" for btn in ("remove-confirm-cancel", "remove-confirm-remove"): m = re.search(rf"]*id=\"{btn}\"[^>]*>", frag) assert m and 'type="button"' in m.group(0), f"#{btn} is a real type=button" cancel = re.search(r']*id="remove-confirm-cancel"[^>]*>(.*?)', frag, re.S) remove = re.search(r']*id="remove-confirm-remove"[^>]*>(.*?)', frag, re.S) assert cancel and _norm(cancel.group(1)) == "Cancel" assert remove and _norm(remove.group(1)) == "Remove source" assert _norm(MODAL_COPY) in _norm(frag), "the locked modal copy, verbatim" # The source value is a (mono) — textContent-only in JS. src = re.search(r"]*id=\"remove-confirm-source\"[^>]*>", frag) assert src, "#remove-confirm-source is a " # ---------- the JS lifecycle ---------- def test_open_populates_source_via_text_content_and_focuses_cancel() -> None: """openRemoveConfirm(s, triggerBtn): the source value is textContent ONLY (never innerHTML — the credential-masking discipline, phase 32) with makeRow's exact ``value`` expression (``s.path ?? s.url`` for local rows, ``s.url`` for git); the error line clears; the dialog unhides; the trigger is recorded; the keydown handler attaches; and focus lands on Cancel — the safe default for a destructive action (AFTER the unhide).""" body = _fn(_js(), "openRemoveConfirm") assert "s.kind === \"local\"" in body, "the kind-typed value branch" assert ( "removeSourceEl.textContent = isLocal ? s.path ?? s.url : s.url" in body ), "the same `value` expression makeRow uses, via textContent" assert "removeSourceEl.innerHTML" not in _js(), "XSS contract: textContent only" assert "removeError.hidden = true" in body, "a new attempt starts clean" assert "removeTriggerBtn = triggerBtn" in body, "the trigger is recorded" trigger_i = body.find("removeTriggerBtn = triggerBtn") unhide_i = body.find("removeDialog.hidden = false") attach_i = body.find('document.addEventListener("keydown", onRemoveDialogKeydown)') focus_i = body.find("removeCancelBtn.focus()") assert -1 < trigger_i < unhide_i < attach_i < focus_i, ( "record the trigger → unhide → attach keydown → focus Cancel" ) def test_row_button_opens_the_modal_and_the_row_error_span_is_retired() -> None: """makeRow's per-row Remove button opens the modal (openRemoveConfirm(s, btn)) — no confirm call, no per-row error span (the modal carries the in-flight error line); the retired .git-source-row-error class is gone from the JS AND the CSS.""" js = _js() make = _fn(js, "makeRow") assert "openRemoveConfirm(s, btn)" in make, "the row's Remove opens the modal" assert "removeSource(" not in js, "the window.confirm-era flow is gone" assert "git-source-row-error" not in js assert "git-source-row-error" not in _css(), "the dead per-row error CSS is retired" def test_cancel_paths_close_without_a_request() -> None: """Cancel (Cancel button / Escape / backdrop) closes as cancel: the dialog hides, the error line clears, the buttons reset ("Remove source"), the keydown handler detaches, and focus RETURNS to the recorded trigger — and the cancel path never fetches. A cancel while a DELETE is in flight is a no-op (no half-cancel of an in-progress server-side removal).""" js = _js() cancel = _fn(js, "cancelRemoveConfirm") guard_i = cancel.find("if (removeInFlight) return") close_i = cancel.find("closeRemoveConfirm()") assert -1 < guard_i < close_i, "the in-flight guard precedes the close" assert "fetch" not in cancel, "cancel never sends a request" close = _fn(js, "closeRemoveConfirm") hide_i = close.find("removeDialog.hidden = true") clear_i = close.find("removeError.hidden = true") reset_i = close.find('removeRemoveBtn.textContent = "Remove source"') detach_i = close.find('document.removeEventListener("keydown", onRemoveDialogKeydown)') save_i = close.find("const trigger = removeTriggerBtn") null_i = close.find("removeTriggerBtn = null") focus_i = close.find("trigger.focus()") assert -1 < hide_i < clear_i < reset_i < detach_i < save_i < null_i < focus_i, ( "hide → clear → reset → detach → save trigger → focus return" ) assert "removeCancelBtn.disabled = false" in close assert "removeRemoveBtn.disabled = false" in close def test_escape_and_backdrop_and_cancel_button_all_cancel() -> None: """While open (handler attached on document in openRemoveConfirm, detached in closeRemoveConfirm): Escape → preventDefault + cancelRemoveConfirm; the dim backdrop AND the Cancel button wire to cancelRemoveConfirm (only "Remove source" wires to confirmRemove). Tab/Shift+Tab stay inside the two-button modal (aria-modal honored for keyboard users).""" js = _js() keydown = _fn(js, "onRemoveDialogKeydown") esc_i = keydown.find('e.key === "Escape"') prevent_i = keydown.find("e.preventDefault()", esc_i) cancel_i = keydown.find("cancelRemoveConfirm()", esc_i) assert -1 < esc_i < prevent_i < cancel_i, "Escape: prevent + cancel" assert 'e.key === "Tab"' in keydown, "the two-button focus cycle" assert 'removeCancelBtn.addEventListener("click", cancelRemoveConfirm)' in js assert 'removeBackdrop.addEventListener("click", cancelRemoveConfirm)' in js assert 'removeRemoveBtn.addEventListener("click", confirmRemove)' in js def test_confirm_runs_the_inflight_never_stale_lifecycle() -> None: """confirmRemove: the §7.4 in-flight state BEFORE the fetch — both buttons disable + the confirm relabels "Removing…"; one DELETE /api/git-sources/{id}. Success (204): close (focus return) → loadSources → announce (the removal confirmation is the LAST announcement — the reload's "N sources listed." must not overwrite it). Non-2xx: the in-modal role=alert line (apiDetail, 422 shape-aware) and the dialog STAYS open (no closeRemoveConfirm in the failure slice). Network failure: the fixed reachable? line. The finally re-enables BOTH buttons + relabels "Remove source" — never stale on any outcome.""" body = _fn(_js(), "confirmRemove") guard_i = body.find("if (!removingId || removeInFlight) return") inflight_i = body.find("removeInFlight = true") clear_i = body.find("removeError.hidden = true") dis_c = body.find("removeCancelBtn.disabled = true") dis_r = body.find("removeRemoveBtn.disabled = true") label_i = body.find('"Removing…"') fetch_i = body.find("fetch(`/api/git-sources/${encodeURIComponent(removingId)}`") method_i = body.find('method: "DELETE"', fetch_i) assert -1 < guard_i < inflight_i < clear_i < dis_c < dis_r < label_i < fetch_i < method_i, ( "guard → in-flight → clear error → disable both + label → DELETE" ) # Success: close → reload → announce (the exact order, the exact # announce string) — the removal confirmation is the LAST # announcement: the reload's "N sources listed." must not # overwrite it (phase 69 task 03's E2E pins the success line on # the announcer after a real removal). ok_i = body.find("if (r.ok)") close_i = body.find("closeRemoveConfirm()", ok_i) reload_i = body.find("await loadSources()", close_i) announce_i = body.find(f'announce("{ANNOUNCE_OK}")', reload_i) assert -1 < ok_i < close_i < reload_i < announce_i assert body.count("closeRemoveConfirm()") == 1, ( "only the success path closes — failures stay open for one retry" ) # Non-2xx: the in-modal alert line, dialog stays open. nonok_i = body.find("Could not remove the source — try again.") catch_i = body.find("} catch {") assert -1 < nonok_i < catch_i assert "await apiDetail(r," in body[body.find("if (r.ok)"):catch_i], ( "the server detail is apiDetail-extracted (422 shape-aware)" ) failure_slice = body[body.rfind("// non-2xx", 0, catch_i):catch_i] assert "closeRemoveConfirm" not in failure_slice, "failure keeps the dialog open" # Network: the fixed reachable? line. net_i = body.find("Could not remove the source — is the app reachable?", catch_i) assert -1 < net_i < body.find("finally"), "the network copy lands in the catch" # Never stale: the finally re-enables BOTH buttons + relabels. fin_i = body.find("finally") fin = body[fin_i:] assert "removeInFlight = false" in fin assert "removeCancelBtn.disabled = false" in fin assert "removeRemoveBtn.disabled = false" in fin assert 'removeRemoveBtn.textContent = "Remove source"' in fin # ---------- the stale copy is gone; the new hint is present ---------- def test_stale_next_sync_removal_copy_is_gone() -> None: """The phase-35 "prunes on the next sync" removal contract is superseded: the retired confirm copy AND any 'prune(s/d) … next sync' shape are absent from git-sources.js + the shell (code AND comments — the docstring copy moved with the flow). The README pins are task 03's.""" for path in (JS, SHELL_HTML): raw = _text(path) norm = _norm(raw) assert OLD_STAY_INDEXED not in raw, ( f"retired confirm copy still in {path.name}" ) m = OLD_NEXT_SYNC.search(norm) assert m is None, f"'next sync' removal copy survived in {path.name}: {m.group(0)!r}" def test_new_hint_copy_is_present_in_the_html() -> None: """The #git-sources-hint carries the new contract: removal is a total removal, done immediately (the modal spells it out; foreign local directories are never touched), and the Sync button still mirrors the remaining sources (upstream churn is pruned on that run — not 'on the next sync').""" hint = _norm(_element_block(_text(SHELL_HTML), "git-sources-hint", tag="p")) for frag in (HINT_TOTAL_REMOVAL, HINT_MODAL_SPELLS_OUT, HINT_FOREVER_SAFE): assert frag in hint, f"the new hint copy is missing: {frag!r}" assert "pruned on that run" in hint, "the Sync-mirror clause (upstream churn)" assert "next sync" not in hint, "no 'next sync' removal claim in the hint" def test_module_docstring_carries_the_new_contract() -> None: """The git-sources.js module docstring's remove bullet + scope boundary moved with the flow: the modal names the source + states the policy, and removal performs the FULL cleanup server-side (row + index + app-managed files) — with the navigating-away note (the row + index commit first; an interrupted file step leaves an inert orphan dir).""" doc = _js().split("*/", 2)[0] # the module docstring (first block) for frag in ( 'role="alertdialog"', "textContent ONLY", 'relabels "Removing…"', "not recommended", "inert orphan", "FULL cleanup server-side", "foreign local directories are never touched", ): assert frag in doc, f"the module docstring lost: {frag!r}" # ---------- styles.css ---------- def test_modal_css_classes_present_and_house_tokens_only() -> None: """styles.css carries the modal class family on the house dark-tech palette (phase-08 tokens): the overlay + backdrop + panel, the title / source / copy / error / actions / two-button chrome; the [hidden] override (the documented, testable contract); the global 3px :focus-visible outline is NOT suppressed; no CDN; no blur (the phase-08 no-blur perf anchor).""" css = _css() for cls in ( ".remove-confirm", ".remove-confirm-backdrop", ".remove-confirm-panel", ".remove-confirm-title", ".remove-confirm-source", ".remove-confirm-copy", ".remove-confirm-error", ".remove-confirm-actions", ".remove-confirm-btn", ".remove-confirm-cancel", ".remove-confirm-remove", ): assert f"{cls} " in css or f"{cls}." in css or f"{cls}[" in css, ( f"styles.css must style {cls}" ) hidden = css.find(".remove-confirm[hidden]") assert hidden != -1 and "display: none" in css[hidden : hidden + 60], ( "the hidden attr must beat the display rule" ) assert ":focus-visible {" in css and "outline: 3px solid var(--brand)" in css # No blur (the phase-08 no-blur perf anchor) — the backdrop rule # itself must not carry backdrop-filter. backdrop = css[css.find(".remove-confirm-backdrop {") :] backdrop = backdrop[: backdrop.find("\n}")] # Comments stripped — the note "no backdrop-filter (no-blur)" must # not trip the pin; only a real declaration may. backdrop = re.sub(r"/\*.*?\*/", "", backdrop, flags=re.S) assert "backdrop-filter" not in backdrop, "no blur (phase-08 perf anchor)" assert "url(http" not in css and "@import url(" not in css, ( "no CDN (AGENTS.md rule 6)" ) def test_modal_css_targets_and_contrast_pairs() -> None: """The WCAG 2.1 AA basics in CSS: both buttons >=44px; the destructive button rides the err token family (err-ink on err-bg 9.3:1, the err-line border — the .tuning-delete / .steering-delete convention; the hover inverts to --bg on --err-line, 5.2:1); Cancel is the ghost ink-soft family (5.1:1 on --surface); the error line is the err pair; the panel caps at the 46rem chat-column width or the viewport.""" css = _css() btn = css[css.find(".remove-confirm-btn {"):] btn = btn[: btn.find("\n}")] assert "min-height: 44px" in btn and "min-width: 44px" in btn remove = css[css.find(".remove-confirm-remove {"):] remove = remove[: remove.find("\n}")] for prop in ("var(--err-bg)", "var(--err-ink)", "var(--err-line)"): assert prop in remove, f"the destructive pair must keep {prop}" hover = css[css.find(".remove-confirm-remove:hover:not(:disabled) {"):] hover = hover[: hover.find("\n}")] assert "var(--err-line)" in hover and "var(--bg)" in hover, ( "the hover inversion: dark --bg on --err-line (5.2:1)" ) cancel = css[css.find(".remove-confirm-cancel {"):] cancel = cancel[: cancel.find("\n}")] assert "var(--ink-soft)" in cancel and "transparent" in cancel err = css[css.find(".remove-confirm-error {"):] err = err[: err.find("\n}")] assert "var(--err-ink)" in err and "var(--err-bg)" in err panel = css[css.find(".remove-confirm-panel {"):] panel = panel[: panel.find("\n}")] assert "min(46rem" in panel, "the 46rem chat-column cap (or the viewport)"