All gates re-verified against a stable tree. Report: **Verified (all 6 tasks in `complete/`; this was the final pass)** - `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`. - **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness. - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned). - **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code. - **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria. - **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
390 lines
18 KiB
Python
390 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)"
|
|
)
|
|
# Phase 191 (task 03): the name/label/tags row's two tags ("named" and
|
|
# one tag chip) joined this SAME rule — the reuse that keeps the token
|
|
# input from shipping its own tag CSS.
|
|
tag = css.find(
|
|
".git-source-hidden-count,\n.git-source-branch-count,\n"
|
|
".source-label-state,\n.source-label-tag {"
|
|
)
|
|
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"
|