**Phase 121 final verification pass — all green** (all 4 tasks already in `complete/`; verified, no defects found, no changes needed) - Verified implementation vs phase design: migration `0021` (reversible, round-tripped via `alembic downgrade base` + `upgrade head` → head `0021`), `GitSource.token` column, `normalize_credential`/`clone_url_for`/`sanitize_url`, clone callers switched (`sync.py`, `import_docs.py`), masked token fields in add form + editor, `extra="forbid"` output shapes - Tests: `uv run pytest` → 2662 passed, 0 failed (exit 0); `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (≥90% gate) - Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings - E2E in isolation: `uv run pytest tests/e2e/test_git_source_tokens.py -v --no-cov` → **4 passed** Completion criteria: 1. Private repo (UI add or pasted embedded-token URL) clones with injected token; token absent from every API response, page text, title attr, and full HTML — **PASS** (integration raw-JSON assertions + E2E `_assert_token_nowhere`) 2. Legacy embedded-token rows still clone from stored URL; output sanitized — **PASS** (`test_sync_legacy_row_clones_with_original_stored_url`, `test_get_masks_legacy_embedded_token_row`, env-fallback masking) 3. Public/local sources byte-identical — **PASS** (verbatim-URL + no-userinfo-unchanged tests) 4. pytest / coverage / ruff / pyright — **PASS** (see above) 5. Commit + phase move — harness responsibility; task files already in `complete/`, changes left in working tree (no commit made, per protocol) Notable: no deviations; DB left at head, functional. Next pending phase: **122_image_documents** (then 123_chat_image_questions).
559 lines
26 KiB
Python
559 lines
26 KiB
Python
"""Unit: the per-source ignore-paths editor on the Sources page
|
|
(phase 89, task 05).
|
|
|
|
``TODO.md`` L3: "They should be able to type these files and folders
|
|
into a box on the sources page." Phase 89 gives every STORED source
|
|
row (git or local) a one-path-per-line ignore box on
|
|
``/git-sources.html``: a page-local alertdialog (the EXACT
|
|
``#remove-confirm-dialog`` pattern, phase 69) with a visible label, a
|
|
mono textarea prefilled from the row, the §7.4 never-stale save
|
|
lifecycle ("Saving…" while the ``PATCH /api/git-sources/{id}`` is
|
|
out — the A5 replace round-trips through ``GET``), and full a11y
|
|
(focus on Cancel, Escape / backdrop close as CANCEL, focus return,
|
|
``role="alert"`` error line that keeps the textarea content on 422).
|
|
Rows with a list show the ``N ignored`` count tag; env-fallback rows
|
|
(``id`` null) get NO box (A3 — nothing is stored to edit).
|
|
|
|
The browser behavior itself is E2E-gated by the phase-89 story suite
|
|
(``tests/e2e/test_source_ignore_paths.py``, task 06); like the other
|
|
frontend-adjacent unit files (the
|
|
``test_remove_confirm_modal.py`` house pattern), this module pins the
|
|
source-level contract a silent regression would break:
|
|
|
|
* the static ``#ignore-editor-dialog`` markup — ``role=
|
|
"alertdialog"`` + ``aria-modal`` + ``aria-labelledby``, hidden by
|
|
default, inside the manager, the visible ``<label for=…>`` (never
|
|
aria-label-only), the ``role="alert"`` error line, real
|
|
``type="button"`` buttons, the mono textarea (``rows=6`` /
|
|
``spellcheck="false"``);
|
|
* the JS lifecycle — ``openIgnoreEditor`` (textContent-only source
|
|
population with makeRow's ``value`` expression, the textarea
|
|
prefilled from ``(s.ignore_paths || [])``, error cleared, focus on
|
|
Cancel), cancel = Escape / Cancel button / backdrop (no request;
|
|
focus returns to the trigger; a no-op while a PATCH is in flight),
|
|
``saveIgnorePaths`` (§7.4 in-flight state: both buttons disable +
|
|
"Saving…", blank lines dropped client-side, success → close /
|
|
reload / announce — the update confirmation LAST, non-2xx → the
|
|
in-dialog alert line with the textarea content KEPT, network →
|
|
the fixed reachable? line, re-enable in the finally);
|
|
* the row wiring — the "Ignore paths" button exists ONLY in the
|
|
``s.id`` branch of ``makeRow`` (left of Remove; the aria-label is
|
|
the only place the value appears — never innerHTML), the ``N
|
|
ignored`` count tag on rows with a list (text, never color alone);
|
|
* styles.css — the dialog / button / count-tag rules on the house
|
|
dark-tech palette (phase-08 tokens only, no CDN, no blur),
|
|
≥44px targets, the ``[hidden]`` override, the mono full-width
|
|
textarea;
|
|
* the no-collision guard — every ``id="ignore-editor-…"`` appears
|
|
EXACTLY ONCE in the shell (the phase-46/76 contract).
|
|
"""
|
|
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 lives in the Sources view section of
|
|
# index.html (next to #remove-confirm-dialog).
|
|
SHELL_HTML = FRONTEND / "index.html"
|
|
JS = FRONTEND / "assets" / "git-sources.js"
|
|
CSS = FRONTEND / "assets" / "styles.css"
|
|
|
|
#: The dialog's static ids (the no-collision guard + the markup pins
|
|
#: key on exactly these).
|
|
DIALOG_IDS = (
|
|
"ignore-editor-dialog",
|
|
"ignore-editor-title",
|
|
"ignore-editor-source",
|
|
"ignore-editor-copy",
|
|
"ignore-editor-textarea",
|
|
"ignore-editor-token", # phase 121 (task 03): the masked token field
|
|
"ignore-editor-error",
|
|
"ignore-editor-cancel",
|
|
"ignore-editor-save",
|
|
)
|
|
|
|
#: The §7.4 in-flight label + the idle label (pinned verbatim).
|
|
SAVING_LABEL = "Saving…"
|
|
IDLE_LABEL = "Save"
|
|
COUNT_CLASS = "git-source-ignore-count"
|
|
ROW_BTN_CLASS = "git-source-ignore"
|
|
ARIA_LABEL_TEMPLATE = "`Edit ignored paths for ${kindLabel} source: ${value}`"
|
|
|
|
#: The success announce — the update confirmation is the LAST
|
|
#: announcement (the reload's "N sources listed." must not overwrite
|
|
#: it, the same order as the remove flow).
|
|
ANNOUNCE_OK = "Ignored paths updated for ${value}"
|
|
|
|
|
|
def _text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
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_remove_confirm_modal.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 <tag … id=…> element's full markup (a balanced-tag walk —
|
|
the dialog nests the backdrop / panel / actions divs)."""
|
|
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[^>]*>|</{tag}>", html[open_i :]):
|
|
if m.group(0).startswith(f"</{tag}>"):
|
|
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}")
|
|
|
|
|
|
def _css_rule(css: str, selector: str) -> str:
|
|
"""The declarations of a simple one-line-opening rule (comments
|
|
stripped first — a house comment may legally carry braces)."""
|
|
clean = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
|
start = clean.find(f"{selector} {{")
|
|
assert start != -1, f"missing rule {selector} in styles.css"
|
|
depth = 0
|
|
for i in range(clean.find("{", start), len(clean)):
|
|
if clean[i] == "{":
|
|
depth += 1
|
|
elif clean[i] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return clean[start : i + 1]
|
|
raise AssertionError(f"unbalanced braces in {selector}")
|
|
|
|
|
|
# ---------- the static dialog markup ----------
|
|
|
|
|
|
def test_dialog_markup_is_the_locked_alertdialog() -> None:
|
|
"""#ignore-editor-dialog: role="alertdialog" + aria-modal +
|
|
aria-labelledby, hidden by default, INSIDE the manager
|
|
(#git-sources-content, directly after #remove-confirm-dialog —
|
|
the static-markup convention, stable E2E selectors); every
|
|
static id present; the error line is role="alert" + hidden;
|
|
both buttons are real type="button"; the textarea is mono-
|
|
capable (rows=6, spellcheck=false, the A1 prefix placeholder)."""
|
|
html = _text(SHELL_HTML)
|
|
frag = _element_block(html, "ignore-editor-dialog")
|
|
open_tag = frag[: frag.find(">") + 1]
|
|
assert 'role="alertdialog"' in open_tag
|
|
assert 'aria-modal="true"' in open_tag
|
|
assert 'aria-labelledby="ignore-editor-title"' in open_tag
|
|
assert 'aria-describedby="ignore-editor-copy"' in open_tag
|
|
assert "hidden" in open_tag, "the dialog ships hidden"
|
|
# Placed in the manager, right after the remove-confirm dialog
|
|
# (the same placement logic).
|
|
assert html.find('id="git-sources-content"') < html.find('id="ignore-editor-dialog"')
|
|
assert html.find('id="remove-confirm-dialog"') < html.find('id="ignore-editor-dialog"')
|
|
for child in DIALOG_IDS:
|
|
assert f'id="{child}"' in frag, f"missing #{child} in the dialog"
|
|
# The visible label (WCAG — never aria-label-only): a real
|
|
# <label for="ignore-editor-textarea"> with the house label class.
|
|
label = re.search(
|
|
r"<label[^>]*class=\"ignore-editor-label\"[^>]*for=\"ignore-editor-textarea\"[^>]*>(.*?)</label>",
|
|
frag,
|
|
re.S,
|
|
)
|
|
assert label and label.group(1).strip(), "a visible block label for the textarea"
|
|
# The error line: role="alert", hidden by default.
|
|
err = re.search(r"<p[^>]*id=\"ignore-editor-error\"[^>]*>", frag)
|
|
assert err and 'role="alert"' in err.group(0) and "hidden" in err.group(0)
|
|
# The mono box: rows + spellcheck off + the A1 placeholder.
|
|
ta = re.search(r"<textarea[^>]*id=\"ignore-editor-textarea\"[^>]*>", frag, re.S)
|
|
assert ta, "#ignore-editor-textarea is a real <textarea>"
|
|
for attr in ('rows="6"', 'spellcheck="false"', 'placeholder="my/files/"'):
|
|
assert attr in ta.group(0), f"the textarea carries {attr}"
|
|
for btn in ("ignore-editor-cancel", "ignore-editor-save"):
|
|
m = re.search(rf"<button[^>]*id=\"{btn}\"[^>]*>", frag)
|
|
assert m and 'type="button"' in m.group(0), f"#{btn} is a real type=button"
|
|
cancel = re.search(r'<button[^>]*id="ignore-editor-cancel"[^>]*>(.*?)</button>', frag, re.S)
|
|
save = re.search(r'<button[^>]*id="ignore-editor-save"[^>]*>(.*?)</button>', frag, re.S)
|
|
assert cancel and cancel.group(1).strip() == "Cancel"
|
|
assert save and save.group(1).strip() == IDLE_LABEL
|
|
# The helper copy states the A1 prefix rule in plain words.
|
|
copy = re.search(r"<p[^>]*id=\"ignore-editor-copy\"[^>]*>(.*?)</p>", frag, re.S)
|
|
assert copy, "the helper copy paragraph"
|
|
copy_norm = re.sub(r"\s+", " ", copy.group(1))
|
|
assert "No wildcards" in copy_norm and "middle of a path" in copy_norm
|
|
|
|
|
|
def test_dialog_ids_appear_exactly_once() -> None:
|
|
"""The no-collision guard (the phase-46/76 contract): every
|
|
``id="ignore-editor-…"`` in the NEW markup appears EXACTLY ONCE
|
|
in the whole shell — no duplicated id (the dialog is the only
|
|
owner of each id)."""
|
|
html = _text(SHELL_HTML)
|
|
for id_ in DIALOG_IDS:
|
|
n = html.count(f'id="{id_}"')
|
|
assert n == 1, f'id={id_} appears {n} times in index.html (must be exactly 1)'
|
|
|
|
|
|
# ---------- the row wiring in makeRow ----------
|
|
|
|
|
|
def test_row_button_exists_only_in_the_stored_row_branch() -> None:
|
|
"""makeRow: the "Ignore paths" button is created ONLY in the
|
|
``s.id`` branch (A3 — env-fallback rows get the "from .env" tag,
|
|
no button) and sits BEFORE the Remove button (left of Remove).
|
|
The button is class git-source-ignore, type=button, textContent
|
|
label (never innerHTML — the value appears ONLY in the aria-
|
|
label, built via setAttribute); its click opens the editor."""
|
|
make = _fn(_js(), "makeRow")
|
|
branch_i = make.find("if (s.id) {")
|
|
else_i = make.find("} else {", branch_i)
|
|
bind_i = make.find("openIgnoreEditor(s, ignoreBtn)")
|
|
assert -1 < branch_i < bind_i < else_i, (
|
|
"the ignore button is created in the s.id branch (the phase-88 pin idiom)"
|
|
)
|
|
remove_i = make.find('btn.className = "git-source-remove"', bind_i)
|
|
assert remove_i > bind_i, "the ignore button is LEFT of Remove"
|
|
assert f'ignoreBtn.className = "{ROW_BTN_CLASS}"' in make
|
|
assert "ignoreBtn.type = \"button\"" in make
|
|
assert ARIA_LABEL_TEMPLATE in make, "the aria-label template (the only value site)"
|
|
assert "ignoreBtn.textContent = \"Ignore paths\"" in make, (
|
|
"a static text label — never icon-only"
|
|
)
|
|
assert "ignoreBtn.innerHTML" not in _js(), "XSS contract: no innerHTML on the button"
|
|
# The env-fallback branch (else) has no ignore button.
|
|
else_slice = make[else_i : make.find("tr.appendChild(actTd)", else_i)]
|
|
assert ROW_BTN_CLASS not in else_slice and "openIgnoreEditor" not in else_slice, (
|
|
"env-fallback rows get no ignore button (A3)"
|
|
)
|
|
|
|
|
|
def test_count_tag_shows_n_ignored_text_next_to_the_location() -> None:
|
|
"""makeRow: a stored row with a non-empty list gets the
|
|
``N ignored`` count tag in the LOCATION cell (after the
|
|
<code>) — text via textContent (never color alone, WCAG 1.4.1);
|
|
the list reads ``(s.ignore_paths || [])`` — the same expression
|
|
openIgnoreEditor prefills from (the GET round-trip marker)."""
|
|
make = _fn(_js(), "makeRow")
|
|
assert "s.id && (s.ignore_paths || []).length > 0" in make, (
|
|
"the count tag only for stored rows with a list"
|
|
)
|
|
assert f'count.className = "{COUNT_CLASS}"' in make
|
|
assert "count.textContent = `${s.ignore_paths.length} ignored`" in make, (
|
|
"N ignored — text, never color alone"
|
|
)
|
|
# It lands in the location cell (urlTd), not the actions cell.
|
|
append_i = make.find("urlTd.append(count)")
|
|
code_i = make.find("urlTd.append(badge, code)")
|
|
assert -1 < code_i < append_i, "the tag is appended to the location cell"
|
|
|
|
|
|
# ---------- the JS lifecycle ----------
|
|
|
|
|
|
def test_open_prefills_and_focuses_cancel() -> None:
|
|
"""openIgnoreEditor(s, triggerBtn): the source value is
|
|
textContent ONLY (never innerHTML — the credential-masking
|
|
discipline, phase 32) with makeRow's exact ``value`` expression;
|
|
the textarea prefills ``(s.ignore_paths || []).join("\\n")``
|
|
(one path per line — the round-trip marker); the error line
|
|
clears; the dialog unhides; the trigger is recorded; the keydown
|
|
handler attaches; and focus lands on Cancel — the safe default
|
|
(AFTER the unhide)."""
|
|
body = _fn(_js(), "openIgnoreEditor")
|
|
assert "s.kind === \"local\"" in body, "the kind-typed value branch"
|
|
assert (
|
|
"ignoreSourceEl.textContent = isLocal ? s.path ?? s.url : s.url" in body
|
|
), "the same `value` expression makeRow uses, via textContent"
|
|
assert "ignoreSourceEl.innerHTML" not in _js(), "XSS contract: textContent only"
|
|
assert 'ignoreTextarea.value = (s.ignore_paths || []).join("\\n")' in body, (
|
|
"the textarea prefills the stored list, one path per line"
|
|
)
|
|
assert "ignoreErrorEl.hidden = true" in body, "a new attempt starts clean"
|
|
assert "ignoreTriggerBtn = triggerBtn" in body, "the trigger is recorded"
|
|
trigger_i = body.find("ignoreTriggerBtn = triggerBtn")
|
|
unhide_i = body.find("ignoreDialog.hidden = false")
|
|
attach_i = body.find('document.addEventListener("keydown", onIgnoreDialogKeydown)')
|
|
focus_i = body.find("ignoreCancelBtn.focus()")
|
|
assert -1 < trigger_i < unhide_i < attach_i < focus_i, (
|
|
"record the trigger → unhide → attach keydown → focus Cancel"
|
|
)
|
|
|
|
|
|
def test_cancel_paths_close_without_a_request() -> None:
|
|
"""Cancel (Cancel button / Escape / backdrop) closes as cancel:
|
|
the dialog hides, the textarea + error line reset, the buttons
|
|
reset ("Save"), the keydown handler detaches, and focus RETURNS
|
|
to the recorded trigger — and the cancel path never fetches. A
|
|
cancel while a PATCH is in flight is a no-op (no half-cancel of
|
|
an in-progress save)."""
|
|
js = _js()
|
|
cancel = _fn(js, "cancelIgnoreEditor")
|
|
guard_i = cancel.find("if (ignoreInFlight) return")
|
|
close_i = cancel.find("closeIgnoreEditor()")
|
|
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, "closeIgnoreEditor")
|
|
hide_i = close.find("ignoreDialog.hidden = true")
|
|
reset_ta_i = close.find('ignoreTextarea.value = ""')
|
|
clear_i = close.find("ignoreErrorEl.hidden = true")
|
|
reset_btn_i = close.find('"Save"')
|
|
detach_i = close.find('document.removeEventListener("keydown", onIgnoreDialogKeydown)')
|
|
save_i = close.find("const trigger = ignoreTriggerBtn")
|
|
null_i = close.find("ignoreTriggerBtn = null")
|
|
focus_i = close.find("trigger.focus()")
|
|
assert -1 < hide_i < reset_ta_i < clear_i < reset_btn_i, (
|
|
"hide → reset textarea → clear error → reset buttons"
|
|
)
|
|
assert reset_btn_i < detach_i < save_i < null_i < focus_i, (
|
|
"→ detach → save trigger → focus return"
|
|
)
|
|
assert "ignoreCancelBtn.disabled = false" in close
|
|
assert "ignoreSaveBtn.disabled = false" in close
|
|
|
|
|
|
def test_escape_and_backdrop_and_cancel_button_all_cancel() -> None:
|
|
"""While open (handler attached on document in openIgnoreEditor,
|
|
detached in closeIgnoreEditor): Escape → preventDefault +
|
|
cancelIgnoreEditor; the dim backdrop AND the Cancel button wire
|
|
to cancelIgnoreEditor (only Save wires to saveIgnorePaths)."""
|
|
js = _js()
|
|
keydown = _fn(js, "onIgnoreDialogKeydown")
|
|
esc_i = keydown.find('e.key === "Escape"')
|
|
prevent_i = keydown.find("e.preventDefault()", esc_i)
|
|
cancel_i = keydown.find("cancelIgnoreEditor()", esc_i)
|
|
assert -1 < esc_i < prevent_i < cancel_i, "Escape: prevent + cancel"
|
|
assert 'ignoreCancelBtn.addEventListener("click", cancelIgnoreEditor)' in js
|
|
assert 'ignoreBackdrop.addEventListener("click", cancelIgnoreEditor)' in js
|
|
assert 'ignoreSaveBtn.addEventListener("click", saveIgnorePaths)' in js
|
|
|
|
|
|
def test_save_runs_the_inflight_never_stale_lifecycle() -> None:
|
|
"""saveIgnorePaths: the box's lines are split on newlines,
|
|
trimmed, and empty lines DROPPED (a blank line is a separator,
|
|
not an entry — the server still rejects empties defensively,
|
|
A4). The §7.4 in-flight state precedes the PATCH: both buttons
|
|
disable + the save relabels "Saving…" — one
|
|
``PATCH /api/git-sources/{id}`` with the lines as the whole
|
|
body list (A5 replace). Phase 121 (task 03): the masked token is
|
|
read AFTER the line parse and included in the body ONLY when
|
|
non-blank (the tri-state: blank → key omitted → no change, the
|
|
row's stored credential is kept). 200 → close (focus return) →
|
|
loadSources (the count tag lands) → announce (the update
|
|
confirmation is the LAST announcement). Non-2xx: the in-dialog
|
|
role=alert line (apiDetail, 422 shape-aware), the dialog STAYS
|
|
open and the textarea content is KEPT. Network failure: the
|
|
fixed reachable? line. The finally re-enables BOTH buttons +
|
|
relabels "Save" — never stale on any outcome."""
|
|
body = _fn(_js(), "saveIgnorePaths")
|
|
guard_i = body.find("if (!ignoreTarget || ignoreInFlight) return")
|
|
parse_i = body.find('.split("\\n")')
|
|
trim_i = body.find(".map((l) => l.trim())", parse_i)
|
|
drop_i = body.find(".filter(Boolean)", trim_i)
|
|
# Phase 121 (task 03): the token is read between the line parse
|
|
# and the in-flight flag (blank = the key is omitted).
|
|
token_i = body.find('ignoreTokenInput ? ignoreTokenInput.value.trim() : ""', drop_i)
|
|
inflight_i = body.find("ignoreInFlight = true", token_i)
|
|
dis_c = body.find("ignoreCancelBtn.disabled = true", inflight_i)
|
|
dis_s = body.find("ignoreSaveBtn.disabled = true", inflight_i)
|
|
label_i = body.find(f'"{SAVING_LABEL}"', dis_s)
|
|
fetch_i = body.find("`/api/git-sources/${", label_i)
|
|
method_i = body.find('method: "PATCH"', fetch_i)
|
|
body_i = body.find(
|
|
"JSON.stringify({ ignore_paths: lines, ...(token ? { token } : {}) })", method_i
|
|
)
|
|
assert -1 < guard_i < parse_i < trim_i < drop_i < token_i < inflight_i, (
|
|
"guard → split + trim + drop blank lines → read the token → in-flight"
|
|
)
|
|
assert -1 < dis_c < dis_s < label_i < fetch_i < method_i < body_i, (
|
|
"disable both + 'Saving…' → the PATCH (lines + token only when non-blank)"
|
|
)
|
|
# Success: close → reload → announce (the exact order) — the
|
|
# update confirmation is the LAST announcement: the reload's
|
|
# "N sources listed." must not overwrite it.
|
|
ok_i = body.find("if (r.ok)")
|
|
close_i = body.find("closeIgnoreEditor()", 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, (
|
|
"200: close → loadSources → announce (LAST)"
|
|
)
|
|
assert body.count("closeIgnoreEditor()") == 1, (
|
|
"only the success path closes — failures stay open for one retry"
|
|
)
|
|
# Non-2xx: the in-dialog alert line (apiDetail, 422 shape-aware);
|
|
# the dialog stays open and the textarea content is KEPT.
|
|
catch_i = body.find("} catch {")
|
|
nonok_slice = body[ok_i:catch_i]
|
|
assert "await apiDetail(r," in nonok_slice, (
|
|
"the server detail is apiDetail-extracted (422 shape-aware)"
|
|
)
|
|
assert "ignoreErrorEl.hidden = false" in nonok_slice, "the error line shows"
|
|
assert "ignoreTextarea.value" not in nonok_slice.split("if (r.ok)")[1], (
|
|
"the textarea content is KEPT on failure (the instruction survives)"
|
|
)
|
|
# Network: the fixed reachable? line.
|
|
net_i = body.find("Could not save the ignored paths — 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 = body[body.find("finally"):]
|
|
assert "ignoreInFlight = false" in fin
|
|
assert "ignoreCancelBtn.disabled = false" in fin
|
|
assert "ignoreSaveBtn.disabled = false" in fin
|
|
assert f'ignoreSaveBtn.textContent = "{IDLE_LABEL}"' in fin
|
|
|
|
|
|
def test_module_docstring_carries_the_phase_89_contract() -> None:
|
|
"""The git-sources.js module docstring gained the phase-89
|
|
bullet: the editor is per-STORED-row (A3 env rows excluded),
|
|
one path per line (the A1 prefix rule), the §7.4 "Saving…"
|
|
lifecycle, and the A5 replace round-trip."""
|
|
doc = _js().split("*/", 2)[0] # the module docstring (first block)
|
|
for frag in (
|
|
"Phase 89 (task 05)",
|
|
"ignore-paths editor",
|
|
"#ignore-editor-dialog",
|
|
"one path per line",
|
|
f'"{SAVING_LABEL}"',
|
|
"LAST announcement",
|
|
"N ignored",
|
|
):
|
|
assert frag in doc, f"the module docstring lost: {frag!r}"
|
|
|
|
|
|
# ---------- styles.css ----------
|
|
|
|
|
|
def test_dialog_css_rules_present_and_house_tokens_only() -> None:
|
|
"""styles.css carries the ignore-editor class family on the house
|
|
dark-tech palette (phase-08 tokens): the overlay + backdrop +
|
|
panel (the EXACT .remove-confirm treatment), the title / source /
|
|
copy / visible label / mono textarea / error / actions / two-
|
|
button chrome; the [hidden] override; no blur (the phase-08
|
|
no-blur perf anchor); no CDN."""
|
|
css = _css()
|
|
for selector in (
|
|
".ignore-editor",
|
|
".ignore-editor-backdrop",
|
|
".ignore-editor-panel",
|
|
".ignore-editor-title",
|
|
".ignore-editor-source",
|
|
".ignore-editor-copy",
|
|
".ignore-editor-label",
|
|
".ignore-editor-textarea",
|
|
".ignore-editor-error",
|
|
".ignore-editor-actions",
|
|
".ignore-editor-btn",
|
|
".ignore-editor-cancel",
|
|
".ignore-editor-save",
|
|
):
|
|
assert f"{selector} " in css or f"{selector}." in css or f"{selector}[" in css, (
|
|
f"styles.css must style {selector}"
|
|
)
|
|
hidden = css.find(".ignore-editor[hidden]")
|
|
assert hidden != -1 and "display: none" in css[hidden : hidden + 60], (
|
|
"the hidden attr must beat the display rule"
|
|
)
|
|
# No blur (the phase-08 no-blur perf anchor) — the backdrop rule
|
|
# itself must not carry backdrop-filter (comments stripped).
|
|
backdrop = re.sub(r"/\*.*?\*/", "", _css_rule(css, ".ignore-editor-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_textarea_rule_is_mono_and_full_width() -> None:
|
|
"""The box itself: full panel width (width: 100%) + the mono
|
|
stack (var(--mono)) + a comfortable min-height — the house
|
|
comment cites phase 89 on the block."""
|
|
rule = _css_rule(_css(), ".ignore-editor-textarea")
|
|
assert "width: 100%" in rule, "full panel width"
|
|
assert "var(--mono)" in rule, "the mono stack"
|
|
assert "min-height" in rule, "a comfortable minimum height"
|
|
# The section comment cites phase 89 (house comment style).
|
|
css = _css()
|
|
header = css[css.rfind("/*", 0, css.find(".ignore-editor {")) : css.find(".ignore-editor {")]
|
|
assert "phase 89" in header, "the house comment cites phase 89"
|
|
|
|
|
|
def test_dialog_button_and_target_contrast_pairs() -> None:
|
|
"""The WCAG 2.1 AA basics in CSS: both dialog buttons >=44px;
|
|
Cancel is the ghost ink-soft family (5.1:1 on --surface) with the
|
|
brand-soft hover (12.4:1); Save is the solid brand family (--bg
|
|
text on --brand 5.2:1, the .new-chat-btn convention) with the
|
|
lightened hover; the error line is the err pair; the panel caps
|
|
at the chat-column width (the --chat-column token — 72rem, owner
|
|
instruction 2026-09-12) or the viewport; the visible
|
|
label is ink-soft (5.1:1) — never a label-less textarea."""
|
|
css = _css()
|
|
btn = _css_rule(css, ".ignore-editor-btn")
|
|
assert "min-height: 44px" in btn and "min-width: 44px" in btn
|
|
cancel = _css_rule(css, ".ignore-editor-cancel")
|
|
assert "var(--ink-soft)" in cancel and "transparent" in cancel
|
|
cancel_hover = _css_rule(css, ".ignore-editor-cancel:hover:not(:disabled)")
|
|
assert "var(--brand-soft)" in cancel_hover and "var(--brand-ink)" in cancel_hover
|
|
save = _css_rule(css, ".ignore-editor-save")
|
|
assert "var(--brand)" in save and "var(--bg)" in save, (
|
|
"Save: the solid brand family (--bg text on --brand, 5.2:1)"
|
|
)
|
|
save_hover = _css_rule(css, ".ignore-editor-save:hover:not(:disabled)")
|
|
assert "background" in save_hover, "the hover lightens the fill"
|
|
err = _css_rule(css, ".ignore-editor-error")
|
|
assert "var(--err-ink)" in err and "var(--err-bg)" in err
|
|
panel = _css_rule(css, ".ignore-editor-panel")
|
|
assert "min(var(--chat-column)" in panel, (
|
|
"the chat-column cap via the token (or the viewport)"
|
|
)
|
|
label = _css_rule(css, ".ignore-editor-label")
|
|
assert "display: block" in label and "var(--ink-soft)" in label, (
|
|
"a visible block label (WCAG — never aria-label-only)"
|
|
)
|
|
|
|
|
|
def test_row_button_and_count_tag_css_rules() -> None:
|
|
"""The row chrome: .git-source-ignore reuses the
|
|
.git-source-remove idiom (>=44px target, same size/spacing) in a
|
|
NEUTRAL secondary fill with the brand-soft hover (distinct from
|
|
the destructive Remove's err hover); .git-source-ignore-count is
|
|
a small inline tag — text + a distinct background (never color
|
|
alone), AA on both theme surfaces (--ink on --bg 16.7:1)."""
|
|
css = _css()
|
|
row = _css_rule(css, ".git-source-ignore")
|
|
assert "min-height: 44px" in row and "min-width: 44px" in row, "the >=44px rule"
|
|
assert "var(--ink-soft)" in row, "the neutral secondary resting fill"
|
|
row_hover = _css_rule(css, ".git-source-ignore:hover:not(:disabled)")
|
|
assert "var(--brand-soft)" in row_hover and "var(--brand-ink)" in row_hover, (
|
|
"the hover takes the brand pair (Remove hovers to the err pair)"
|
|
)
|
|
assert "var(--err-" not in row, "not the destructive err family"
|
|
count = _css_rule(css, ".git-source-ignore-count")
|
|
assert "var(--ink)" in count and "var(--bg)" in count, (
|
|
"text + a distinct background (never color alone)"
|
|
)
|