fix(build): Containerfile builds again — relative module imports, all four pages and shared assets in the image
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
"""Integration: Containerfile stage-1 (frontend) asset coverage pin (phase 23).
|
||||
|
||||
HERMETIC — no podman, no network, no database: this suite parses the
|
||||
``Containerfile`` and ``frontend/`` as plain text and pins the image-build
|
||||
coverage that the TODO L6 bug ("Fix Containerfile build not working")
|
||||
demonstrated can silently rot in two independent ways:
|
||||
|
||||
* the stage-1 ``cp`` line only copies the pages that existed when it was
|
||||
written (``document.html`` / ``login.html`` + their scripts +
|
||||
``markdown.js`` were all missing from the image), and
|
||||
* absolute module imports (``import … from "/assets/header.js"``) break
|
||||
the esbuild bundle, so a "fixed" stage 1 can still ship a broken page.
|
||||
|
||||
The pins (each is one test, per the phase-23 task file):
|
||||
|
||||
1. every ``frontend/*.html`` page is copied into stage 1's ``/out``
|
||||
— exactly (a new page without a ``cp`` entry fails; a ``cp`` of a
|
||||
deleted page also fails);
|
||||
2. every local ``assets/`` / ``/assets/`` ``src=``/``href=`` reference in
|
||||
the pages is produced by a stage-1 line (``esbuild … --outfile`` or
|
||||
``cp``) — the missing-``markdown.js``-style gap cannot reappear;
|
||||
3. the set of ``type="module"`` page scripts the HTML references equals
|
||||
the set of inputs esbuild ``--bundle``s in stage 1;
|
||||
4. ``header.js`` is imported relatively by every page script and loaded
|
||||
by NO direct ``<script>`` tag (single-evaluation design pin,
|
||||
owner-confirmed 2026-08-24);
|
||||
5. ``markdown.js`` is a classic script: stage-1 minify line WITHOUT
|
||||
``--bundle``, and no top-level ``import``/``export`` in the source
|
||||
(the source-level assumption that makes that build line safe);
|
||||
6. the frontend stage pins a concrete ``esbuild@X.Y.Z`` (no floating
|
||||
version — the exact pinned 0.25.5 is what the diagnosis reproduced
|
||||
against).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = REPO_ROOT / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
CONTAINERFILE = REPO_ROOT / "Containerfile"
|
||||
|
||||
# A local asset reference: src="assets/…" or src/href="/assets/…"
|
||||
# (data: URIs, "#main" anchors, and same-origin page links never match).
|
||||
_ASSET_REF_RE = re.compile(r"""(?:src|href)\s*=\s*["']((?:/)?assets/[^"']+)["']""")
|
||||
_SCRIPT_TAG_RE = re.compile(r"<script\b[^>]*>", re.IGNORECASE)
|
||||
_MODULE_ATTR_RE = re.compile(r"""type\s*=\s*["']module["']""", re.IGNORECASE)
|
||||
_SRC_ATTR_RE = re.compile(r"""src\s*=\s*["']([^"']+)["']""")
|
||||
_TOP_LEVEL_MODULE_RE = re.compile(r"^\s*(?:import\b|export\b)", re.MULTILINE)
|
||||
|
||||
|
||||
def _stage1_lines() -> list[str]:
|
||||
"""The physical lines of the ``frontend`` build stage (FROM … up to,
|
||||
but not including, the next FROM)."""
|
||||
assert CONTAINERFILE.is_file(), f"missing Containerfile: {CONTAINERFILE}"
|
||||
lines = CONTAINERFILE.read_text(encoding="utf-8").splitlines()
|
||||
start = next(
|
||||
i
|
||||
for i, ln in enumerate(lines)
|
||||
if re.match(r"^FROM\s+\S+\s+AS\s+frontend\b", ln, re.IGNORECASE)
|
||||
)
|
||||
end = len(lines)
|
||||
for j in range(start + 1, len(lines)):
|
||||
if re.match(r"^FROM\s", lines[j], re.IGNORECASE):
|
||||
end = j
|
||||
break
|
||||
return lines[start:end]
|
||||
|
||||
|
||||
def _is_esbuild_invocation(ln: str) -> bool:
|
||||
"""True if the line actually RUNS esbuild (not just mentions it in a
|
||||
comment or in the npm install line)."""
|
||||
stripped = ln.lstrip()
|
||||
return not stripped.startswith("#") and bool(re.search(r"(?:^|&&)\s*esbuild\b", ln))
|
||||
|
||||
|
||||
def _esbuild_outputs(stage1: list[str]) -> dict[str, bool]:
|
||||
"""basename of every asset a stage-1 esbuild line writes → was it
|
||||
``--bundle``d? (Only lines with an ``--outfile`` count.)"""
|
||||
outputs: dict[str, bool] = {}
|
||||
for ln in stage1:
|
||||
if not _is_esbuild_invocation(ln):
|
||||
continue
|
||||
m = re.search(r"--outfile=(\S+)", ln)
|
||||
assert m, f"stage-1 esbuild line without --outfile: {ln.strip()}"
|
||||
assert m.group(1).startswith("/out/assets/"), (
|
||||
f"stage-1 asset must be written under /out/assets (the served "
|
||||
f"/assets/ URL space), got {m.group(1)}"
|
||||
)
|
||||
outputs[m.group(1).rsplit("/", 1)[-1]] = "--bundle" in ln
|
||||
return outputs
|
||||
|
||||
|
||||
def _esbuild_bundle_inputs(stage1: list[str]) -> set[str]:
|
||||
"""Basenames of the inputs to every stage-1 ``esbuild … --bundle``."""
|
||||
inputs: set[str] = set()
|
||||
for ln in stage1:
|
||||
if _is_esbuild_invocation(ln) and "--bundle" in ln:
|
||||
m = re.search(r"esbuild\s+(\S+)", ln)
|
||||
assert m, f"stage-1 esbuild --bundle line without an input: {ln.strip()}"
|
||||
inputs.add(m.group(1).rsplit("/", 1)[-1])
|
||||
return inputs
|
||||
|
||||
|
||||
def _cp_produced_basenames(stage1: list[str]) -> set[str]:
|
||||
"""Basenames written into the image by stage-1 ``cp`` lines (the last
|
||||
argument is the destination; a ``/``-suffixed one is a directory)."""
|
||||
produced: set[str] = set()
|
||||
for ln in stage1:
|
||||
m = re.search(r"\bcp\s+(.+?)(?:\s*\\)?\s*$", ln)
|
||||
if not m:
|
||||
continue
|
||||
args = m.group(1).split()
|
||||
assert len(args) >= 2, f"malformed cp line in stage 1: {ln.strip()}"
|
||||
dest = args[-1]
|
||||
if dest.endswith("/"):
|
||||
produced.update(a.rsplit("/", 1)[-1] for a in args[:-1])
|
||||
else: # single-file rename: the image sees the destination name
|
||||
produced.add(dest.rsplit("/", 1)[-1])
|
||||
return produced
|
||||
|
||||
|
||||
def _local_asset_basenames() -> set[str]:
|
||||
"""Every local assets/ reference (basenames) across all frontend pages."""
|
||||
refs: set[str] = set()
|
||||
for html in sorted(FRONTEND.glob("*.html")):
|
||||
text = html.read_text(encoding="utf-8")
|
||||
refs.update(m.group(1).rsplit("/", 1)[-1] for m in _ASSET_REF_RE.finditer(text))
|
||||
return refs
|
||||
|
||||
|
||||
def _module_script_basenames() -> set[str]:
|
||||
"""Basenames of the ``<script type="module" src=…>`` page scripts."""
|
||||
names: set[str] = set()
|
||||
for html in sorted(FRONTEND.glob("*.html")):
|
||||
for tag in _SCRIPT_TAG_RE.findall(html.read_text(encoding="utf-8")):
|
||||
if _MODULE_ATTR_RE.search(tag):
|
||||
m = _SRC_ATTR_RE.search(tag)
|
||||
assert m, f"{html.name}: module <script> without src: {tag}"
|
||||
names.add(m.group(1).rsplit("/", 1)[-1])
|
||||
return names
|
||||
|
||||
|
||||
# ---------- 1. page coverage ----------
|
||||
|
||||
|
||||
def test_every_html_page_is_copied_into_stage1() -> None:
|
||||
"""Every ``frontend/*.html`` page is copied into stage 1's ``/out`` —
|
||||
and the ``cp`` set is EXACTLY the pages on disk: a new page without a
|
||||
matching cp entry (image 404s) and a cp of a deleted page (stale build
|
||||
step) both fail here."""
|
||||
stage1 = _stage1_lines()
|
||||
on_disk = {p.name for p in FRONTEND.glob("*.html")}
|
||||
assert on_disk, "frontend/ contains no .html pages — test is blind"
|
||||
|
||||
copied: set[str] = set()
|
||||
for ln in stage1:
|
||||
if ".html" not in ln:
|
||||
continue
|
||||
m = re.search(r"\bcp\s+(.+?)(?:\s*\\)?\s*$", ln)
|
||||
if not m:
|
||||
continue
|
||||
args = m.group(1).split()
|
||||
dest = args[-1]
|
||||
assert dest.rstrip("/") == "/out", f"pages must be copied into /out, got {dest}"
|
||||
copied.update(a.rsplit("/", 1)[-1] for a in args[:-1] if a.endswith(".html"))
|
||||
|
||||
missing = on_disk - copied
|
||||
extra = copied - on_disk
|
||||
assert not missing, (
|
||||
f"pages missing from stage 1's cp line (404 in the image): {sorted(missing)}"
|
||||
)
|
||||
assert not extra, (
|
||||
f"stage 1 copies pages that no longer exist in frontend/: {sorted(extra)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------- 2. asset-reference coverage ----------
|
||||
|
||||
|
||||
def test_every_local_asset_reference_is_produced() -> None:
|
||||
"""Every local assets/… src/href in the pages is produced by a stage-1
|
||||
line — esbuild ``--outfile=/out/assets/<name>`` or a ``cp`` of it.
|
||||
This is what catches a missing-markdown.js-style gap: a page that
|
||||
references an asset stage 1 never builds is a 404 in the image."""
|
||||
stage1 = _stage1_lines()
|
||||
produced = set(_esbuild_outputs(stage1)) | _cp_produced_basenames(stage1)
|
||||
refs = _local_asset_basenames()
|
||||
assert refs, "no local assets/ references found in the pages — test is blind"
|
||||
missing = refs - produced
|
||||
assert not missing, (
|
||||
f"local assets referenced by the pages but not produced by stage 1 "
|
||||
f"(404 in the image): {sorted(missing)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------- 3. page-module ⇄ bundle-input parity ----------
|
||||
|
||||
|
||||
def test_page_module_scripts_are_bundled() -> None:
|
||||
"""The set of ``type="module"`` page scripts the HTML references
|
||||
(basenames) EQUALS the set of inputs esbuild ``--bundle``s in stage 1
|
||||
(today: app.js, sources.js, document.js, login.js). A new page script
|
||||
without a bundle entry would 404 in the image; a bundle input whose
|
||||
page no longer references it is dead build weight."""
|
||||
stage1 = _stage1_lines()
|
||||
html_modules = _module_script_basenames()
|
||||
bundled = _esbuild_bundle_inputs(stage1)
|
||||
assert html_modules, "no module page scripts found in the pages — test is blind"
|
||||
assert html_modules == bundled, (
|
||||
f"page module scripts {sorted(html_modules)} != esbuild bundle inputs "
|
||||
f"{sorted(bundled)} — stage 1 must bundle exactly the pages' modules"
|
||||
)
|
||||
|
||||
|
||||
# ---------- 4. single-evaluation design pin ----------
|
||||
|
||||
|
||||
def test_header_module_is_imported_not_directly_loaded() -> None:
|
||||
"""Owner-confirmed design (2026-08-24, A4-2): NO page loads header.js
|
||||
with a direct ``<script>`` tag — in the image the bundled page script
|
||||
already contains the header code, so a raw header.js tag would evaluate
|
||||
the module TWICE (duplicate sign-out listener, double init). Every page
|
||||
script imports it relatively instead (``from "./header.js"``) — a
|
||||
hoisted import that guarantees evaluation order in dev AND in the
|
||||
bundle, and the only form esbuild can resolve."""
|
||||
htmls = sorted(FRONTEND.glob("*.html"))
|
||||
assert htmls, "no frontend pages — test is blind"
|
||||
for html in htmls:
|
||||
text = html.read_text(encoding="utf-8")
|
||||
for tag in _SCRIPT_TAG_RE.findall(text):
|
||||
m = _SRC_ATTR_RE.search(tag)
|
||||
assert m is None or m.group(1).rsplit("/", 1)[-1] != "header.js", (
|
||||
f"{html.name}: direct header.js <script> tag — double-evaluation "
|
||||
f"trap in the image: {tag}"
|
||||
)
|
||||
page_scripts = _module_script_basenames()
|
||||
assert page_scripts, "no module page scripts found — test is blind"
|
||||
for name in sorted(page_scripts):
|
||||
js_path = ASSETS / name
|
||||
assert js_path.is_file(), f"page script missing: {js_path}"
|
||||
body = js_path.read_text(encoding="utf-8")
|
||||
assert re.search(r"""from\s+["']\./header\.js["']""", body), (
|
||||
f"{name}: must import the shared header module relatively "
|
||||
f'("from \\"./header.js\\"")'
|
||||
)
|
||||
assert '"/assets/header.js"' not in body, (
|
||||
f"{name}: absolute header import breaks the esbuild stage-1 bundle"
|
||||
)
|
||||
|
||||
|
||||
# ---------- 5. markdown.js is a classic script ----------
|
||||
|
||||
|
||||
def test_markdown_js_is_a_produced_classic_script() -> None:
|
||||
"""markdown.js ships minified WITHOUT --bundle (it is a classic global
|
||||
script — window.markdownRender — loaded by index.html and
|
||||
document.html), and the source has no top-level import/export: that
|
||||
source-level fact is exactly what makes the no-bundle build line safe.
|
||||
Pinning both sides keeps the assumption honest."""
|
||||
stage1 = _stage1_lines()
|
||||
outputs = _esbuild_outputs(stage1)
|
||||
assert "markdown.js" in outputs, (
|
||||
"markdown.js has no stage-1 esbuild --outfile line — index.html and "
|
||||
"document.html would 404 the renderer in the image"
|
||||
)
|
||||
assert outputs["markdown.js"] is False, (
|
||||
"markdown.js must NOT be --bundled: it is a classic global script, and "
|
||||
"bundling it would strip the window-level globals the pages rely on"
|
||||
)
|
||||
md_path = ASSETS / "markdown.js"
|
||||
assert md_path.is_file(), f"missing {md_path}"
|
||||
hits = _TOP_LEVEL_MODULE_RE.findall(md_path.read_text(encoding="utf-8"))
|
||||
assert not hits, f"markdown.js has top-level module syntax: {hits!r}"
|
||||
|
||||
|
||||
# ---------- 6. pinned esbuild ----------
|
||||
|
||||
|
||||
def test_esbuild_stays_pinned() -> None:
|
||||
"""The frontend stage installs esbuild at a CONCRETE X.Y.Z. The phase-23
|
||||
diagnosis was reproduced against the exact pinned 0.25.5; a floating
|
||||
version (latest, ^, ~, or no spec) would let an upstream release change
|
||||
the bundle behavior with no diff to see."""
|
||||
stage1 = _stage1_lines()
|
||||
install_lines = [ln for ln in stage1 if re.search(r"\bnpm\s+(?:install|i|add)\b", ln)]
|
||||
assert install_lines, "no npm install line in the frontend stage"
|
||||
pinned = [
|
||||
v for ln in install_lines for v in re.findall(r"\besbuild@(\d+\.\d+\.\d+)\b", ln)
|
||||
]
|
||||
assert pinned, (
|
||||
"the frontend stage must pin esbuild@X.Y.Z — a floating esbuild is how "
|
||||
"the bundle behavior rots silently"
|
||||
)
|
||||
for ln in install_lines:
|
||||
for m in re.finditer(r"\besbuild(@\S*)?", ln):
|
||||
spec = m.group(1) or ""
|
||||
assert re.fullmatch(r"@\d+\.\d+\.\d+", spec), (
|
||||
f"esbuild must be installed with a concrete pin, got "
|
||||
f"{m.group(0)!r} in: {ln.strip()}"
|
||||
)
|
||||
Reference in New Issue
Block a user