Files
brain-of-reese/tests/e2e/test_git_sources_card_rhythm.py
T
ducoterra 938d6cf09d
Build and Push Containers / build-and-push-app (push) Successful in 1m51s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 174_git_sources_card_rhythm
All checks pass. Both docs verified consistent. Final state — no live-infrastructure changes were needed (pure CSS fix; verification ran against the already-running local dev DB).

**Phase 174 final verification pass — report**

- **Verified (no new work needed; both tasks already complete in `complete/`):** the single `#git-sources-content { display:flex; flex-direction:column; gap:1.25rem }` rule in `frontend/assets/styles.css` (with the phase-174 root-cause comment), `.git-sources-shell` untouched, the 4-test unit pin module, and the 4-test Playwright suite.
- **Full suite:** `uv run pytest --cov=app --cov-report=term-missing` → **4116 passed, 99% `app/` coverage** (gate >90% ✓).
- **Lint/types:** `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings.
- **Dedicated E2E in isolation:** `uv run pytest tests/e2e/test_git_sources_card_rhythm.py -v --no-cov` → **4 passed** (1440px + 375px bounding-box rhythm pins 20±2px, hidden-children + revealed-joins-rhythm, anonymous gate, a11y/no-CDN).
- **Regression set, each in isolation:** `test_git_sources_admin.py` **6 passed**, `test_archive_upload_sources.py` **5 passed**, `test_git_source_dates.py` **4 passed**.
- **Completion criteria:** cards stack with uniform ≈20px gap at both viewports ✓; hidden children stay hidden / gate+content flip intact ✓; ≤640px forms column-stack + table scrolls inside its card (no page-level h-scroll) ✓; full suite + coverage + ruff + pyright ✓; dedicated + regression E2E suites ✓.
- **Documentation:** README.md reviewed in full (every `.env.example` var documented; all documented routes/pages/endpoints exist; every config default cross-checked against `app/config.py`; payload-limit table, ports 8123/8901, Containerfile/compose/entrypoint claims all match) — **verified consistent, no changes needed**. AGENTS.md verified (all referenced files/sections exist: PLAN §2/§7.1/§7.4/§9, `response_budget.py`, `debugging.py` conditional import, `.agents/` tracked + gitignore rules, `commit.gpgsign=false`, phase-pinned CSS classes) — **verified consistent, no changes needed**.
- **Notable:** commit + phase-dir move are handled by the harness per pipeline rules (no git history/index touched by this pass); no live-infrastructure changes were required.
- **Next pending phase:** `175_history_pagination` (in `todo/`).
2026-10-11 01:49:49 -04:00

564 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 174 E2E (Playwright): the Sources (git sources) page — the
card stack regains its 1.25rem rhythm.
Source: owner bug report 2026-10-21 (screenshot of
``/git-sources.html``): "the bubbles on the sources page are really
smooshed together" — the "Git sources" header card, the "Add a git
source" card, the "Upload a source archive" card, and the sources
table card stacked flush; only their 1px borders separated them.
Root cause: the ``.git-sources-shell`` gap (1.25rem) only separates
the sign-in gate from the single ``#git-sources-content`` wrapper;
every visible card is a child of THAT div, which was a plain block.
The fix (task 01) is the phase-171 ``#settings-content`` idiom
applied to the one remaining admin view whose cards live inside a
wrapper div: ``#git-sources-content`` itself becomes
``display: flex; flex-direction: column; gap: 1.25rem;`` — the
shell's OWN existing token (no new spacing value). This suite proves
the rhythm IN A BROWSER at 1440px and 375px, pins the
hidden-children behavior (D2) on both sides, keeps the anonymous
gate↔content flip (D3) intact, and runs the standard a11y / no-CDN
light pass (AGENTS.md rules 5/6).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_git_sources_card_rhythm.py -v --no-cov
This suite is UI-only: NO sync, NO git, NO network — the app boots
plain and the cards render regardless of row count (the one seeded
row per test below is the test-isolation reset that makes the
TABLE card render: git-sources.js hides ``#git-sources-table-wrap``
while the table has no rows and shows ``#git-sources-empty``
instead). The per-test ``git_sources`` TRUNCATE (the
``test_git_sources_admin.py`` pattern) makes each test's table state
its own.
Per-module app env (the conftest pattern, module-scoped — the
``test_git_sources_admin.py`` app shape): this suite's app boots
PLAIN but pins ``BOR_GIT_SOURCES=""`` (the phase-61 leak guard):
the operator's local (gitignored) ``.env`` is loaded by
pydantic-settings from ``cwd=REPO`` and may carry a real
``BOR_GIT_SOURCES`` value — with the per-test truncated (EMPTY)
``git_sources`` table, a leaked CSV would flip the page to the env
fallback (env rows + the visible ``#git-sources-env-note``) and
break the hidden-children pin and the four-card gap contract
below. The session app is never started in this isolated run, so
no port clash.
Test → contract mapping (one phase, one isolated file):
1. ``test_cards_stack_with_uniform_rhythm_desktop`` — the
"smooshed" pin at 1440×900 (admin): ``#git-sources-content``'s
computed display is ``flex`` (column, 20px gap — the 1.25rem
token at the 16px root) and the four visible cards —
``.page-head`` → ``#git-source-form`` → ``#archive-upload-form``
→ ``#git-sources-table-wrap`` — are pairwise disjoint with each
successive gap 20 ± 2px (pre-fix the gaps were ~0 — the 1px
borders).
2. ``test_cards_stack_with_uniform_rhythm_mobile`` — the pin at
375×720 (admin): the same four cards are pairwise disjoint with
≈20px gaps; the two form cards column-stack per the ≤640px block
(``#git-source-form`` / ``#archive-upload-form`` computed
``flex-direction: column``) and the table scrolls INSIDE its
card (the ``#git-sources-table-wrap`` right edge is left of the
viewport; no page-level horizontal scroll —
``document.scrollingElement.scrollWidth <= 375``).
3. ``test_hidden_children_stay_hidden_in_flex_wrapper`` — the D2
both-sides pin (admin): ``#git-sources-load-error`` and
``#git-sources-env-note`` (the phase docstring's
``#git-source-env-note`` names the element's CLASS) are not
visible (the ``[hidden]`` UA
``display:none`` survives the flex parent — the parent's
``display`` never overrides a child's own — ``offsetParent`` is
``None``); then the load-error's ``hidden`` is removed and the
element JOINS the rhythm (visible, ≥18px below the element above
it). The D3 regression (anonymous): ``#git-sources-gate``
visible, ``#git-sources-content`` hidden, NO
``/api/git-sources`` call (the ``test_git_sources_admin.py``
anonymous contract — the gate↔content flip is unchanged by the
fix).
4. ``test_sources_page_a11y_and_no_cdn_basics`` — the standard light
pass (AGENTS.md rules 5/6, the ``test_git_sources_admin.py``
a11y shape): landmarks on the view, the labeled form controls,
the 3px ``:focus-visible`` outline on a table-row control (a
real keyboard Tab walk), same-origin assets only.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from collections.abc import Iterator
from pathlib import Path
import pytest
from playwright.sync_api import FloatRect, Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.models import GitSource
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns
# its port in a combined run — this module app binds its own port
# instead (a same-port second uvicorn dies on bind and would drive
# the wrong server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_GITCARD", "8126"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
GIT_SOURCES_URL = "/git-sources.html"
#: The four visible cards of the stack, in DOM order inside
#: ``#git-sources-content`` (the hidden load-error / env-note
#: children sit between the page-head and the forms).
CARDS = (
"#git-sources-content .page-head",
"#git-source-form",
"#archive-upload-form",
"#git-sources-table-wrap",
)
#: The 1.25rem gap at the 16px root — the exact token (the pre-fix
#: gaps were ~0: the 1px borders).
GAP_PX = 20
GAP_TOL = 2
#: A revealed hidden child must sit at least this far below the
#: element above it (20px gap − the 2px tolerance — the D2
#: both-sides pin).
REVEALED_MIN_GAP = 18 # the task's floor (20px gap − 2px tolerance)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def app_server(mock_llm: int) -> Iterator[str]:
"""The real app under test — per-module env: BOOTS PLAIN (no
sync, no git, no network; the cards render regardless of row
count) with the phase-61 leak guard (``BOR_GIT_SOURCES=""`` —
see the module docstring)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated gate pins (conftest pattern — the phase-112
# lesson: the code-default floor violates the startup validator
# against the mock-calibrated threshold). No chat turn is ever
# sent in this suite, but the app boots with the same env shape.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to
# boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# Phase 61/62 leak guards: force the code defaults so an
# operator's local (gitignored) ``.env`` cannot leak corpus-
# specific strings into the app under test (derived from the
# class fields — never drifts from ``app/config.py``).
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
# The leak guard this suite exists for (module docstring): an
# EMPTY effective CSV — with the per-test truncated (empty)
# ``git_sources`` table, a leaked CSV would flip the page to the
# env fallback (env rows + the visible env note) and break the
# hidden-children pin and the four-card gap contract.
env["BOR_GIT_SOURCES"] = ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
def _truncate_git_sources() -> None:
with SessionLocal() as db:
db.execute(text("TRUNCATE git_sources"))
db.commit()
@pytest.fixture(autouse=True)
def _clean_git_sources(db_ready: None) -> Iterator[None]:
"""Fresh ``git_sources`` table per test (the
``test_git_sources_admin.py`` pattern) — each test's table state
is its own (this suite never syncs, so nothing re-populates it
mid-test). The table is emptied BOTH before and after every
test: suites run in isolation but share one Postgres, and a
leftover row would flip the next suite's app from the
``BOR_GIT_SOURCES`` env fallback to the DB list (the sync
story's ``effective_git_sources`` resolver reads the DB
first)."""
_truncate_git_sources()
yield
_truncate_git_sources()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _seed_row(url: str) -> None:
"""Store one row directly (the ``test_git_sources_admin.py``
``_seed_rows`` shape) — a stored row makes
``#git-sources-table-wrap`` render (git-sources.js:
``tableWrap.hidden = !hasRows`` — with no rows the empty
state replaces the table card) and renders the per-row action
controls the a11y walk targets (env-fallback rows render only
the "from .env" tag)."""
with SessionLocal() as db:
db.add(GitSource(url=url))
db.commit()
def _admin_git_sources_page(
page: Page, app_url: str, expect_sign_out: bool = True
) -> None:
"""Real form login landing on the git sources page (admin
settled: the manager revealed by the page module —
git-sources.js reveals it only once the cached whoami says
admin, so the reveal IS the settle gate; the shared header's
Sign out is visible on desktop, but at ≤640px the nav folds
into the hamburger, so the mobile test passes
``expect_sign_out=False``)."""
login(page, app_url, next=GIT_SOURCES_URL)
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
expect(page.locator("#git-sources-content")).to_be_visible(timeout=15_000)
expect(page.locator("#git-sources-gate")).to_be_hidden()
if expect_sign_out:
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
def _card_boxes(page: Page) -> list[FloatRect]:
boxes: list[FloatRect] = []
for sel in CARDS:
box = page.locator(sel).bounding_box()
assert box is not None, f"{sel} must render"
boxes.append(box)
return boxes
def _assert_rhythm(boxes: list[FloatRect], width: int) -> None:
"""The pre-fix regression pin: the four cards are pairwise
disjoint (no two card edges touch — pre-fix the gaps were ~0,
the 1px borders) and each successive gap is 20 ± 2px (the
1.25rem token)."""
for i in range(len(boxes)):
for j in range(i + 1, len(boxes)):
a, b = boxes[i], boxes[j]
disjoint = (
a["x"] + a["width"] <= b["x"] + 0.5
or b["x"] + b["width"] <= a["x"] + 0.5
or a["y"] + a["height"] <= b["y"] + 0.5
or b["y"] + b["height"] <= a["y"] + 0.5
)
assert disjoint, (
f"the {CARDS[i]} and {CARDS[j]} cards touch at {width}px "
f"(a is {a}, b is {b})"
)
for i in range(len(boxes) - 1):
gap = boxes[i + 1]["y"] - (boxes[i]["y"] + boxes[i]["height"])
assert GAP_PX - GAP_TOL <= gap <= GAP_PX + GAP_TOL, (
f"the gap between {CARDS[i]} and {CARDS[i + 1]} is {gap:.1f}px "
f"at {width}px (expected {GAP_PX} ± {GAP_TOL} — pre-fix the "
f"gaps were ~0: the 1px borders)"
)
# ---------------------------------------------------------------------------
# 1. The "smooshed" pin at 1440×900 (admin)
# ---------------------------------------------------------------------------
def test_cards_stack_with_uniform_rhythm_desktop(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
page.set_viewport_size({"width": 1440, "height": 900})
_seed_row("https://github.com/reese/card-rhythm.git")
_admin_git_sources_page(page, app_url)
# The wrapper IS the flex-column rhythm (task 01's rule,
# computed — the phase-171 idiom): display flex, column, and the
# EXACT 1.25rem token at the 16px root (20px — a regression to
# any other value fails).
style = page.evaluate(
"""() => {
const s = getComputedStyle(
document.getElementById("git-sources-content")
);
return {
display: s.display,
direction: s.flexDirection,
rowGap: s.rowGap,
columnGap: s.columnGap,
};
}"""
)
assert style["display"] == "flex", (
f"#git-sources-content must be display:flex (got {style['display']!r})"
)
assert style["direction"] == "column", (
f"#git-sources-content must be flex-direction:column "
f"(got {style['direction']!r})"
)
assert style["rowGap"] == "20px" and style["columnGap"] == "20px", (
f"#git-sources-content must keep the 1.25rem (20px) gap — the "
f"shell's own token (got {style['rowGap']!r}/"
f"{style['columnGap']!r})"
)
# The four always-visible cards (the seeded row keeps the table
# card rendered; the revealed load-error / env note join the
# same rhythm — pinned in test 3): pairwise disjoint, each
# successive gap 20 ± 2px.
_assert_rhythm(_card_boxes(page), 1440)
# ---------------------------------------------------------------------------
# 2. The pin at 375×720 (admin): the rhythm survives the mobile
# layout (the ≤640px block composes with the fix unchanged)
# ---------------------------------------------------------------------------
def test_cards_stack_with_uniform_rhythm_mobile(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
page.set_viewport_size({"width": 375, "height": 720})
_seed_row("https://github.com/reese/card-rhythm.git")
_admin_git_sources_page(page, app_url, expect_sign_out=False)
# The same four cards are pairwise disjoint with ≈20px gaps at
# 375px.
_assert_rhythm(_card_boxes(page), 375)
# The two form cards column-stack per the ≤640px block (the
# L6400–6403 media query — the fix composes with it unchanged).
for sel in ("#git-source-form", "#archive-upload-form"):
direction = page.evaluate(
f"() => getComputedStyle(document.querySelector('{sel}'))"
".flexDirection"
)
assert direction == "column", (
f"{sel} must column-stack at 375px (got {direction!r})"
)
# The table scrolls INSIDE its card: the card's right edge is
# left of the viewport…
card = page.locator("#git-sources-table-wrap").bounding_box()
assert card is not None, "the table card must render at 375px"
assert card["x"] + card["width"] <= 375 + 1, (
f"the table card's right edge ({card['x'] + card['width']:.1f}) "
f"reaches/past the 375px viewport — it must sit inside"
)
# …and the page itself never scrolls horizontally (the
# table-wrap's own overflow-x scroll is the mobile scroll).
scroll_width = page.evaluate(
"() => document.scrollingElement.scrollWidth"
)
assert scroll_width <= 375, (
f"page-level horizontal scroll at 375px "
f"(scrollWidth {scroll_width})"
)
# ---------------------------------------------------------------------------
# 3. The D2 both-sides pin (hidden children inside the flex wrapper)
# + the D3 anonymous gate↔content regression
# ---------------------------------------------------------------------------
def test_hidden_children_stay_hidden_in_flex_wrapper(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
page.set_viewport_size({"width": 1440, "height": 900})
_seed_row("https://github.com/reese/card-rhythm.git")
_admin_git_sources_page(page, app_url)
# --- the hidden side (D2): the [hidden] UA display:none
# survives the flex parent (the parent's display never overrides
# a child's own display) — both hidden children are absent from
# the layout, git-sources.js's reveal lifecycle untouched…
for sel in ("#git-sources-load-error", "#git-sources-env-note"):
el = page.locator(sel)
assert not el.is_visible(), f"{sel} must stay hidden in the flex wrapper"
offset = page.evaluate(f"() => document.querySelector('{sel}').offsetParent")
assert offset is None, (
f"{sel} must be display:none inside the flex wrapper "
f"(offsetParent {offset!r} — the [hidden] UA rule was "
f"overridden)"
)
# …and a revealed one JOINS the rhythm (the both-sides pin):
# un-hide the load-error and it stacks ≥18px below the element
# above it (the page-head — the env note is still hidden) with
# the same 1.25rem gap.
page.evaluate(
"() => document.getElementById('git-sources-load-error')"
".removeAttribute('hidden')"
)
err = page.locator("#git-sources-load-error")
assert err.is_visible(), "the un-hidden load error must become visible"
offset = page.evaluate(
"() => document.getElementById('git-sources-load-error').offsetParent"
)
assert offset is not None, "the un-hidden load error must be laid out"
err_box = err.bounding_box()
head_box = page.locator("#git-sources-content .page-head").bounding_box()
assert err_box is not None and head_box is not None
gap = err_box["y"] - (head_box["y"] + head_box["height"])
assert gap >= REVEALED_MIN_GAP, (
f"the revealed load error must join the rhythm: it sits "
f"{gap:.1f}px below the element above it (floor "
f"{REVEALED_MIN_GAP}px)"
)
# --- the D3 regression (anonymous): the gate↔content flip is
# unchanged by the fix — reset to anonymous (the session cookie
# cleared; the shared header's whoami is re-fetched on the
# reload — the module-level promise cache is per page load)…
page.context.clear_cookies()
api_calls: list[str] = []
page.on(
"request",
lambda r: api_calls.append(r.url) if "/api/git-sources" in r.url else None,
)
page.goto(app_url + GIT_SOURCES_URL)
# The header's whoami toggle has landed (one of Sign in / Sign
# out visible)…
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
# …the sign-in gate is what anonymous sees…
expect(page.locator("#git-sources-gate")).to_be_visible()
# …and the manager (the flex wrapper) stays hidden…
expect(page.locator("#git-sources-content")).to_be_hidden()
# …and the gate never called the admin API (the
# test_git_sources_admin.py anonymous contract; a short settle
# wait so the claim is made on a settled page).
page.wait_for_timeout(500)
assert api_calls == [], (
f"anonymous page called the git sources API: {api_calls}"
)
# ---------------------------------------------------------------------------
# 4. UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6)
# ---------------------------------------------------------------------------
def test_sources_page_a11y_and_no_cdn_basics(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_seed_row("https://github.com/reese/card-rhythm.git")
_admin_git_sources_page(page, app_url)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
# Standard app frame: landmarks + skip link (PLAN §7.2).
expect(page.locator("header.app-header")).to_have_count(1)
expect(page.locator('nav[aria-label="Primary"]')).to_have_count(1)
expect(page.locator("main#main")).to_have_count(1)
expect(page.locator("footer.app-footer")).to_have_count(1)
expect(page.locator(".skip-link")).to_have_count(1)
# The form controls are labeled (visible <label for=…>).
expect(page.get_by_label("Add a git source")).to_have_count(1)
expect(
page.get_by_label(
"Upload a source archive (.tar, .tar.gz, .tgz, .zip)"
)
).to_have_count(1)
# The table-row control reaches the 3px :focus-visible outline
# via a REAL keyboard Tab walk (the phase-171 walk shape: the
# skip link is the document's FIRST tabbable element — the
# script focus starts the cycle; every move after it is a real
# Tab keypress).
page.evaluate("() => document.querySelector('a.skip-link').focus()")
seen: list[str] = []
reached = False
for _ in range(60):
page.keyboard.press("Tab")
active = page.evaluate(
"""() => {
const el = document.activeElement;
if (!el) return { key: "", cls: "" };
return {
key: el.id || "",
cls: typeof el.className === "string" ? el.className : "",
};
}"""
)
seen.append(active["cls"] or active["key"] or "?")
if "git-source-remove" in active["cls"]:
reached = True
break
assert reached, (
f"the walk never reached the row's Remove control "
f"(seen={seen[:25]})"
)
outline = page.evaluate(
"() => getComputedStyle(document.activeElement).outlineWidth"
)
assert outline == "3px", (
f"focus-visible outline missing on the row control: {outline!r}"
)
# No CDN (rule 6): no https:// asset tags; every script/link
# ref is same-origin or a data: URI.
html = page.content()
assert 'src="https://' not in html and 'href="https://' not in html
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
)
assert refs, "expected local asset references"
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), (
f"non-local asset reference: {ref}"
)