Files
brain-of-reese/tests/unit/test_git_source_branch_ui.py
T
ducoterra d3f8c7f2c8
Build and Push Containers / build-and-push-app (push) Successful in 2m34s
Build and Push Containers / build-and-push-db (push) Successful in 14s
phase: 135_git_source_branch
All verification complete. No edits were needed by this pass — the phase work and docs were already consistent and green.

**Phase 135 final verification pass — report**

- Verified all 5 tasks' work present & correct: migration 0024 (reversible), `GitSource.branch` + 4 schema shapes, `validate_branch_name` (full check order per design), `clone_or_pull(…, branch)` (HEAD guard first, `--branch` clone, fetch+ff merge), both call sites pass `row.branch`, UI (add-form field, editor field, row tag), `file://` URL transport.
- Completion criteria: (1) branch-mirrored through Sync + CLI — **pass** (E2E + integration); (2) `branch=None` byte-identical — **pass** (`test_branch_none_argv_is_byte_identical_pre_phase_sequence` green); (3) fixed 422s / local-row 422 / tri-state — **pass**; (4) mismatch fails loudly with remove-and-re-add — **pass** (E2E); (5) coverage 99% >90%, E2E isolated green — **pass**; (6) no regressions — **pass**.
- Tests: `uv run pytest --cov=app --cov-report=term-missing` → 3223 passed, app/ 99%; `uv run pytest tests/e2e/test_git_source_branch.py -v --no-cov` → 4 passed (16.4 s); `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings.
- Documentation: README.md reviewed in full — phase-135 branch section accurate; the phase's README diff also correctly fixed a pre-existing stale line (shallow `--depth 1` clone vs full-history, phase 107). Routes, env defaults, feature lists verified against code. AGENTS.md — all 10 rules + quick-reference verified (referenced tests, PLAN.md, phases dirs, commands all accurate). **Verified consistent — no changes needed.**
- No live-infrastructure changes (dev DB already running and healthy; no live steps in this phase).
- Next pending phase: `.agents/phases/todo/136_force_push_reset`.
2026-09-28 21:15:03 -04:00

384 lines
18 KiB
Python

"""Unit: the branch-pin UI on the Sources page (phase 135, task 04).
``TODO.md`` L4: "Need to be able to specify the branch for a git
source." The Sources manager lets the owner see, set, change, and
clear a source's branch (ASSUMPTION B6):
* the ADD form gains the optional ``#git-source-branch`` (maxlength
200, autocomplete=off, the visible "optional — default: the repo's
default branch" hint) BETWEEN the URL and Token fields; the submit
body is ``(url, token, branch) => ({ url, ...(branch ? { branch } :
{}), ...(token ? { token } : {}) })`` — a BLANK branch OMITS the key
(NULL = the repo's default branch — the pre-phase-135 clone/pull
behavior), and 201 clears all three credential-free inputs;
* the per-row EDITOR dialog gains ``#ignore-editor-branch`` (after the
source line, before the ignore textarea, with the "Leave empty for
the repo's default branch." hint line) — VISIBLE FOR GIT ROWS ONLY
(local rows hide label + input + hint and the save body OMITS the
key — local rows are walked, not cloned; the API's 422 is the
backstop), opens PREFILLED from the stored value (``s.branch ?? ""``
— unlike the token, the API reports it, so there IS a field to read
it from);
* THE TRI-STATE ASYMMETRY: the git row's PATCH body ALWAYS includes
``branch`` (the trimmed value — an EMPTY save is the deliberate
CLEAR leg: NULL = the repo's default branch), unlike the token's
blank = keep (the token stays the conditional key — untouched);
* every STORED git row with a branch shows a ``branch: <name>`` text
tag in the source cell (the "hidden on" tag idiom — text +
background, never color alone: the text IS the signal);
env-fallback rows (no ``s.id``) and NULL-branch rows show no tag;
the source cell's title names the branch (hover precision).
The browser behavior itself is E2E-gated by the phase-135 story suite
(``tests/e2e/test_git_source_branch.py``, task 05); like the phase-121
``test_git_source_token.py`` house pattern, this module pins the
source-level contract a silent regression would break.
"""
from __future__ import annotations
import re
from pathlib import Path
from app.schemas import GitSourceIn, GitSourceOut, GitSourcePatchIn, GitSourceRow
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
SHELL_HTML = FRONTEND / "index.html"
GIT_SOURCES_JS = FRONTEND / "assets" / "git-sources.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
ADD_BRANCH_PLACEHOLDER = "main, release/1.2, …"
EDIT_BRANCH_PLACEHOLDER = "(default branch)"
ADD_HINT_TEXT = "optional — default: the repo's default branch"
EDITOR_HINT_TEXT = "Leave empty for the repo's default branch."
ADD_BODY_EXPR = (
"(url, token, branch) => ({ url, ...(branch ? { branch } : {}), "
"...(token ? { token } : {}) })"
)
BRANCH_TAG_CLASS = "git-source-branch-count"
BRANCH_TAG_TEXT_EXPR = "`branch: ${s.branch}`"
TITLE_EXPR = 's.id && s.branch ? `${value} — branch: ${s.branch}` : value'
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _js_text() -> str:
return _read(GIT_SOURCES_JS)
def _fn(js: str, name: str) -> str:
"""The source of a (possibly async) top-level function via
balanced-brace counting (the test_git_source_token.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 _input_tag(html: str, id_attr: str) -> str:
m = re.search(rf"<input[^>]*id=\"{id_attr}\"[^>]*>", html, re.S)
assert m, f"missing <input id={id_attr}> in the shell"
return m.group(0)
# ---------------------------------------------------------------------------
# The add form — #git-source-branch (between URL and Token)
# ---------------------------------------------------------------------------
def test_add_form_branch_field_is_optional_text() -> None:
"""``#git-source-branch`` (task 04 step 1): ``type="text"`` (a
branch is a name, not a credential — no masking), the 200-char cap
mirrored (the schema's max_length), ``autocomplete="off"``, the
placeholder, INSIDE ``#git-source-form`` BETWEEN the URL input and
the Token input (before the submit button), and a visible
``<label for=…>`` (WCAG — never aria-label-only) carrying the
"optional" hint span."""
html = _read(SHELL_HTML)
tag = _input_tag(html, "git-source-branch")
assert 'type="text"' in tag, "the branch is a plain name (no masking)"
assert 'maxlength="200"' in tag, "the schema cap, mirrored in the form"
assert 'autocomplete="off"' in tag, "no browser save offer"
assert ADD_BRANCH_PLACEHOLDER in tag
form_i = html.find('id="git-source-form"')
url_i = html.find('id="git-source-url"')
br_i = html.find('id="git-source-branch"')
tok_i = html.find('id="git-source-token"')
btn_i = html.find('id="git-source-add"')
assert -1 < form_i < url_i < br_i < tok_i < btn_i, (
"the field sits in the add form, between URL and Token"
)
label = re.search(r"<label[^>]*for=\"git-source-branch\"[^>]*>(.*?)</label>", html, re.S)
assert label, "a visible <label for=…> (never aria-label-only)"
assert "Branch" in label.group(1)
hint = re.search(r"<span[^>]*class=\"field-hint\"[^>]*>(.*?)</span>", label.group(1))
assert hint and hint.group(1) == ADD_HINT_TEXT, "the visible 'optional' hint span"
def test_add_form_submit_omits_a_blank_branch() -> None:
"""The submit body is ``(url, token, branch) => ({ url, ...(branch
? { branch } : {}), ...(token ? { token } : {}) })`` — a BLANK
branch omits the key (NULL = the repo's default branch);
wireAddForm reads the field (trimmed) and passes it to the body
callback AFTER the token, and 201 clears all three inputs
(the credential-free inputs clear together)."""
js = _js_text()
assert f"body: {ADD_BODY_EXPR}" in js, "the submit body omits the key when blank"
wire = _fn(js, "wireAddForm")
read_i = wire.find('const branch = branchInput ? branchInput.value.trim() : "";')
tok_i = wire.find('const token = tokenInput ? tokenInput.value.trim() : "";')
call_i = wire.find("opts.body(value, token, branch)", read_i)
assert -1 < tok_i < read_i < call_i, (
"the branch is read (trimmed) after the token and passed to the body"
)
clear_url_i = wire.find('input.value = ""')
clear_tok_i = wire.find('tokenInput.value = ""', clear_url_i)
clear_br_i = wire.find('branchInput.value = ""', clear_tok_i)
assert -1 < clear_url_i < clear_tok_i < clear_br_i, "201: all three inputs clear"
def test_add_form_branch_opt_in_the_wire_opts() -> None:
"""The wireAddForm call site threads ``branchInput`` into the opts
(next to the token lookup — the add form's single wiring point)."""
js = _js_text()
lookup_i = js.find('root.querySelector("#git-source-branch")')
assert lookup_i != -1, "the element lookup next to the token lookups"
opts = js[js.find("wireAddForm({") :]
opts = opts[: opts.find("});")]
assert "branchInput," in opts, "the opts object gains branchInput"
# ---------------------------------------------------------------------------
# The per-row editor — #ignore-editor-branch (git rows only, prefilled,
# empty-save = clear)
# ---------------------------------------------------------------------------
def test_editor_branch_field_sits_after_source_before_textarea() -> None:
"""``#ignore-editor-branch`` (task 04 step 1): ``type="text"`` +
the 200-char cap + ``autocomplete="off"`` + the "(default branch)"
placeholder, INSIDE ``#ignore-editor-dialog`` AFTER the source line
and BEFORE the ignore textarea (before the token field + error
line), with a visible label and the muted hint line."""
html = _read(SHELL_HTML)
tag = _input_tag(html, "ignore-editor-branch")
assert 'type="text"' in tag
assert 'maxlength="200"' in tag
assert 'autocomplete="off"' in tag
assert f'placeholder="{EDIT_BRANCH_PLACEHOLDER}"' in tag
dialog_i = html.find('id="ignore-editor-dialog"')
src_i = html.find('id="ignore-editor-source"')
br_i = html.find('id="ignore-editor-branch"')
copy_i = html.find('id="ignore-editor-copy"')
ta_i = html.find('id="ignore-editor-textarea"')
tok_i = html.find('id="ignore-editor-token"')
err_i = html.find('id="ignore-editor-error"')
assert -1 < dialog_i < src_i < br_i < copy_i < ta_i < tok_i < err_i, (
"the field sits after the source line, before the ignore textarea"
)
label = re.search(r"<label[^>]*for=\"ignore-editor-branch\"[^>]*>(.*?)</label>", html, re.S)
assert label and "Branch" in label.group(1), "a visible label (WCAG)"
assert 'class="ignore-editor-label"' in (label.group(0) if label else ""), (
"the house dialog-label styling"
)
hint = re.search(
r"<p class=\"ignore-editor-branch-hint\">(.*?)</p>", html, re.S
)
assert hint and hint.group(1).strip() == EDITOR_HINT_TEXT, "the muted hint line"
def test_editor_opens_prefilled_for_git_hidden_for_local() -> None:
"""openIgnoreEditor: git rows open the branch field prefilled from
the stored value (``s.branch ?? ""`` — there IS a field to read it
from, unlike the token) and VISIBLE; local rows HIDE the whole
field group (label + input + hint — local rows are walked, not
cloned) and clear the value."""
js = _js_text()
open_body = _fn(js, "openIgnoreEditor")
assert "const branchIsGit = s.kind !== \"local\";" in open_body, (
"the git-row gate"
)
prefill_i = open_body.find('ignoreBranchInput.value = branchIsGit ? (s.branch ?? "") : "";')
assert prefill_i != -1, "prefilled from the stored value (s.branch ?? \"\")"
for frag in (
"ignoreBranchLabel.hidden = !branchIsGit",
"ignoreBranchHint.hidden = !branchIsGit",
"ignoreBranchInput.hidden = !branchIsGit",
):
assert frag in open_body, f"the field group hides for local rows: {frag!r}"
label_i = open_body.find("ignoreBranchLabel.hidden = !branchIsGit")
hint_i = open_body.find("ignoreBranchHint.hidden = !branchIsGit")
assert -1 < label_i < hint_i < prefill_i, "the group hides before the prefill"
def test_editor_save_always_sends_branch_for_git_rows() -> None:
"""saveIgnorePaths: the branch is read (trimmed) before the PATCH;
the git row's body ALWAYS includes ``branch`` —
``...(isGitRow ? { branch } : {})`` — an EMPTY value is the
deliberate CLEAR leg (the tri-state asymmetry, documented at the
read site), while a LOCAL row's body OMITS the key; the token
stays the conditional key (blank = keep — untouched) and lands
AFTER the branch key."""
js = _js_text()
save = _fn(js, "saveIgnorePaths")
read_i = save.find('const branch = ignoreBranchInput ? ignoreBranchInput.value.trim() : "";')
assert read_i != -1, "the branch is read (trimmed) before the PATCH"
assert "const isGitRow = t.kind !== \"local\";" in save, "the git-row gate"
# The asymmetry is documented at the read site (the docstring
# comment above the read names the clear leg).
comment_i = save.find("the tri-state ASYMMETRY", 0, read_i)
assert -1 < comment_i < read_i, "the tri-state asymmetry is documented at the read"
body_i = save.find("...(isGitRow ? { branch } : {})")
tok_i = save.find("...(token ? { token } : {})", body_i)
assert -1 < read_i < body_i < tok_i, (
"git rows always send branch (\"\" = clear); the token stays conditional"
)
assert "ignore_paths: lines" in save[body_i - 60 : body_i + 60], (
"the ignore_paths replace is untouched"
)
def test_editor_close_resets_the_branch_field() -> None:
"""closeIgnoreEditor: the branch input resets to "" (it re-opens
prefilled from the server row — the phase-121 token reset
precedent, with a prefilled twist)."""
js = _js_text()
close_body = _fn(js, "closeIgnoreEditor")
assert 'ignoreBranchInput.value = ""' in close_body, "resets on close"
# ---------------------------------------------------------------------------
# makeRow — the "branch: <name>" tag + the hover title
# ---------------------------------------------------------------------------
def test_make_row_branch_tag_is_stored_git_rows_only() -> None:
"""makeRow: the ``branch: <name>`` text tag is appended to the
source cell IFF ``s.id && s.branch`` (env-fallback rows carry no
id, NULL-branch rows no truthy branch — no tag either way); the
tag uses the "hidden on" idiom (text + background — the
.git-source-branch-count class, grouped with
.git-source-hidden-count in styles.css) and the TEXT is the signal
(``branch: <name>`` — never color alone)."""
js = _js_text()
make = _fn(js, "makeRow")
guard_i = make.find("if (s.id && s.branch) {")
assert guard_i != -1, "stored git rows with a branch only"
block = make[guard_i :]
block = block[: block.find("\n }")]
assert f"branchTag.className = \"{BRANCH_TAG_CLASS}\";" in block
assert f"branchTag.textContent = {BRANCH_TAG_TEXT_EXPR};" in block, (
"the text IS the signal (branch: <name>)"
)
assert "urlTd.append(branchTag)" in block, "the tag lands in the source cell"
# The tag comes after the hidden tag (the source-cell tag order:
# ignored → hidden → branch) and before the cell is appended.
hidden_i = make.find('hiddenTag.className = "git-source-hidden-count"')
append_i = make.find("tr.appendChild(urlTd)")
assert -1 < hidden_i < guard_i < append_i
def test_make_row_title_names_the_branch() -> None:
"""The source cell's title (the hover-precision idiom) includes the
pinned branch for stored git rows with one; every other row keeps
the bare value (the pre-phase-135 title — the token E2E's
to_have_attribute("title", URL) pins stay green)."""
js = _js_text()
make = _fn(js, "makeRow")
assert f"urlTd.title = {TITLE_EXPR};" in make, (
"the title names the branch (stored git rows only)"
)
# ---------------------------------------------------------------------------
# Cross-file wire contract — the JS key names match the API shapes
# ---------------------------------------------------------------------------
def test_js_branch_keys_match_the_api_shapes() -> None:
"""The wire contract: the POST body key and the PATCH body key are
both ``branch`` (task 04 step 3 — the branch rides the existing
payloads), and every API shape carries the field (task 01/02):
``GitSourceIn`` (create), ``GitSourcePatchIn`` (tri-state),
``GitSourceOut`` + ``GitSourceRow`` (the row the UI renders —
``s.branch`` is read off it). A rename on either side breaks."""
assert "branch" in GitSourceIn.model_fields
assert "branch" in GitSourcePatchIn.model_fields
assert "branch" in GitSourceOut.model_fields
assert "branch" in GitSourceRow.model_fields
js = _js_text()
assert "...(branch ? { branch } : {})" in js, "the POST body key (blank-omits)"
assert "...(isGitRow ? { branch } : {})" in js, "the PATCH body key (always for git)"
assert "(s.branch ?? \"\")" in js, "the prefill reads s.branch off the row"
# ---------------------------------------------------------------------------
# The module docstring — the phase-135 paragraph (the house pattern)
# ---------------------------------------------------------------------------
def test_module_docstring_carries_the_phase_135_contract() -> None:
"""The git-sources.js module docstring gained the phase-135
paragraph (the field, the tri-state asymmetry, the tag rule)."""
doc = _js_text().split("*/", 2)[0] # the module docstring (first block)
for frag in (
"Phase 135 (task 04)",
"#git-source-branch",
"#ignore-editor-branch",
"s.branch ?? \"\"",
"THE TRI-STATE ASYMMETRY",
"branch: <name>",
"the tag appears/disappears",
):
assert frag in doc, f"the module docstring lost: {frag!r}"
# ---------------------------------------------------------------------------
# styles.css — house-styled rules (no second input language)
# ---------------------------------------------------------------------------
def test_branch_field_css_rules_are_house_styled() -> None:
"""styles.css: the add form's branch input joins the URL/token box
group (mono, >=44px floor, placeholder + focus rules); the editor's
branch field is full panel width at the 44px floor (the token
field's box language); the hint line carries the .field-hint values
(ink-soft, 0.8rem); the row tag joins the .git-source-hidden-count
rule (text + a distinct background, never color alone); the mobile
squeeze groups the add input with URL/token."""
css = _read(STYLES_CSS)
grouped = css.find("#git-source-url,\n#git-source-branch,\n#git-source-token {")
assert grouped != -1, "#git-source-branch shares the URL/token box"
rule = css[grouped : css.find("}", grouped)]
assert "min-height: 44px" in rule and "var(--mono)" in rule
assert "#git-source-branch::placeholder" in css, "the muted placeholder"
assert "#git-source-branch:focus-visible" in css, "the brand focus border"
br = css.find("#ignore-editor-branch {")
assert br != -1, "the editor's branch field rule"
br_rule = css[br : css.find("}", br)]
assert "width: 100%" in br_rule and "min-height: 44px" in br_rule
hint = css.find(".ignore-editor-branch-hint {")
assert hint != -1, "the muted hint line rule"
hint_rule = css[hint : css.find("}", hint)]
assert "var(--ink-soft)" in hint_rule and "font-size: 0.8rem" in hint_rule, (
"the .field-hint values (the token field's hint styling)"
)
tag = css.find(".git-source-hidden-count,\n.git-source-branch-count {")
assert tag != -1, "the row tag joins the 'hidden on' tag rule"
tag_rule = css[tag : css.find("}", tag)]
assert "background: var(--bg)" in tag_rule and "border" in tag_rule, (
"text + background (never color alone — WCAG 1.4.1)"
)
mobile = css.find("#git-source-url,\n #git-source-branch,\n #git-source-token,")
assert mobile != -1, "the mobile squeeze groups the branch input"