fix(build): Containerfile builds again — relative module imports, all four pages and shared assets in the image

This commit is contained in:
2026-08-24 22:54:30 -04:00
parent 0adc9b5801
commit d7a4064616
16 changed files with 616 additions and 222 deletions
@@ -1,114 +0,0 @@
# Phase 22 — Animated Background: Make It Actually Animate
**Source:** `TODO.md` L5 — *"Fix background animation not working, just
blinking"*
**Story:** `.agent/user_stories/background-animation.md` (created by task 02)
**Context:** `frontend/assets/styles.css` — the phase-08 animated
background block (~lines 60–100): `body::before` (44px drifting grid,
1px lines at ~35% `--line` alpha, radial mask
`radial-gradient(120% 90% at 50% 0%, black 25%, transparent 78%)`,
`animation: bg-grid-drift 60s linear infinite`) and `body::after` (two
soft radial glows, `animation: bg-glow-breathe 14s ease-in-out infinite
alternate`, opacity 0.65↔1 + scale 1↔1.05). Both layers are
`position: fixed; inset: 0; z-index: -1; pointer-events: none`. `html`
owns the `var(--bg)` canvas and `body` is `background: transparent`
(~lines 42–54) — if any later rule occludes that, the layers vanish.
The **phase-08 design comments are the spec** for what "working" means.
## Objective
Owner report 2026-08-24: the background "just blinks" — i.e. the motion
the phase-08 design promised (a slow, seamless grid drift + a gentle
glow breathe) is not perceived; at most a flicker/blink is visible.
Diagnose which layer(s) actually fail in a real Chromium viewport, fix
the CSS, and leave a background that visibly and smoothly animates as
designed — no blink, no static frame, no jank.
## Owner-confirmed (2026-08-24, roadmap A3)
1. **Intended effect = the phase-08 design comments:** seamless 60s grid
drift (one cell per loop) + 14s ease glow breathing. The fix serves
that design, not a redesign.
2. **Pure CSS, zero JS** (phase-08 anchor) — no animation JS, no new
assets, no `filter: blur` (perf note in the block).
## Design / diagnostic plan
The fix is found, not guessed — work through this checklist in a real
Chromium window (dev server, full page, ~15s of observation):
1. **Per-layer visibility:** toggle each pseudo-element (DevTools
generated-content / a temp outline) and screenshot — is the grid
visible at all? Is only the glow (the "blink" the user perceives)
alive?
2. **Grid layer:** sample `background-position` on `body::before` at two
timestamps — is it actually moving? Is the radial mask fading the
visible region so small that the 44px/60s drift is imperceptible?
(If the drift is real but too faint: raise the grid line alpha and/or
the mask's visible radius — smallest change that reads as "smooth
drift".)
3. **Glow layer:** is the 14s breathe reading as a *blink*? (If the
opacity swing 0.65↔1 is perceived as pulsing: lengthen the period
and/or narrow the opacity delta so it reads as breathing.)
4. **Occlusion check:** confirm nothing later in `styles.css` (or in
`html`/`body` rules) paints an opaque background over the
`z-index: -1` layers — the phase-08 comment at ~line 42 is the
contract.
5. **Apply the fix in `styles.css`** — document the found root cause in
the phase report (screenshot before/after in
`.agent/screenshots/22_background_animation/`).
## Dependencies
- `08_story_dark_tech_theme` (complete) — owns the layers, the palette,
and the "pure CSS, zero JS" anchor this phase must respect.
- `07_story_responsive_polish` (complete) — no new overflow at 360px
(both layers are `fixed; inset: 0` — keep it that way).
## Tasks
1. `01_fix_background_animation.md` — diagnosis + the CSS fix +
source-level unit pins.
2. `02_e2e_story_suite_commit.md` — `tests/e2e/test_background_animation.py`
(the story gate, isolated), regression suites, story file, final
validation, the single atomic commit, phase move to `complete/`.
## Locked decisions
- **Phase-08 anchor honored** — pure CSS, zero JS, no `filter: blur`,
WCAG AA palette untouched (background layers carry no text).
**A11 untouched** — no CDN, no new assets. **A16 honored** — one new
story E2E suite + adapted regressions. No anchor changed.
## Testing & Quality
- **Unit (source-level, new `tests/unit/test_background_animation.py`,
repo source-pin pattern):** `styles.css` still defines
`@keyframes bg-grid-drift` and `@keyframes bg-glow-breathe`;
`body::before` animates `bg-grid-drift` with `linear infinite`;
`body::after` animates `bg-glow-breathe`; both layers remain
`position: fixed; z-index: -1; pointer-events: none`; `html` keeps
`background: var(--bg)` and `body` keeps `background: transparent`
(the no-occlusion contract). Pin the **final** values the fix lands
on (durations/opacities may move per the design plan).
- **Integration:** none (no `app/` changes).
- **Coverage:** frontend-only; the >90% `app/` gate is unaffected.
- **E2E:** `tests/e2e/test_background_animation.py` (task 02), green
**in isolation** (prereq `podman compose up -d db`).
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] In a real Chromium viewport, the background visibly and smoothly
animates (grid drift + glow breathe) — screenshot before/after in
the phase report; owner's "just blinking" perception gone.
- [ ] Root cause documented in `.agent/reports/22_background_animation/`.
- [ ] `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number.
- [ ] `uv run pytest tests/e2e/test_background_animation.py -v --no-cov`
green in isolation; regressions green in isolation:
`test_dark_tech_theme.py`, `test_responsive_polish.py`.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] UI Structure Check (AGENTS.md rule 5): layers stay behind content
(`z-index: -1`, `pointer-events: none`), no text/contrast impact,
no 360px overflow.
- [ ] `.agent/user_stories/background-animation.md` exists.
- [ ] One `--no-gpg-sign` commit (below);
`.agent/phases/todo/22_background_animation/` moved to
`.agent/phases/complete/`.
## Commit
```bash
git add -A .agent/ frontend/ tests/ && git commit --no-gpg-sign -m "fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design"
```
@@ -1,68 +0,0 @@
# Task 02 — E2E story suite, story file, validation, commit
**Phase:** `22_background_animation` · **Source:** `TODO.md` L5
## Objective
The story gate: `tests/e2e/test_background_animation.py` proves both
background layers are actually running animations (not just declared),
plus regressions, story file, final validation, and the single atomic
commit.
## Work
1. `tests/e2e/test_background_animation.py` (new). The layers are CSS
pseudo-elements, so assert via computed style + the Web Animations
API (Chromium reports pseudo-element CSS animations through
`element.getAnimations()`):
1. `test_grid_layer_animation_running` —
`getComputedStyle(document.body, "::before").animationName` is the
grid-drift keyframe (final name from task 01), timing function
`linear`, iteration count `infinite`; and a matching entry in
`document.body.getAnimations()` with `playState === "running"`.
2. `test_glow_layer_animation_running` — same for `"::after"` with
the glow-breathe keyframe; `playState === "running"`.
3. `test_animations_advance` — sample `animation.currentTime` (or
the `getAnimations()` entry's `currentTime`) for both layers,
wait ~500ms (`page.wait_for_timeout`), assert both advanced —
the animations are truly running, not paused.
4. `test_background_layers_contracts` — both pseudo-elements:
`position: fixed`, `z-index: -1`, `pointer-events: none`;
`document.documentElement` computed `background-color` is the
palette bg (the canvas stays on `html`); `document.body` computed
`background-color` is `rgba(0, 0, 0, 0)` (no occlusion).
5. `test_no_horizontal_overflow_with_layers` (regression, 360px) —
viewport 360px: `document.documentElement.scrollWidth <=
clientWidth` (the phase-07 pin, replicated locally).
2. `.agent/user_stories/background-animation.md` (new) — story file per
the repo format: goal, the bug report verbatim from `TODO.md` L5, the
owner-confirmed A3 decisions + the found root cause (from task 01's
report), E2E mapping table.
3. Run the suite **in isolation** (prereq `podman compose up -d db`):
`uv run pytest tests/e2e/test_background_animation.py -v --no-cov`.
4. Regressions, in isolation, one command each:
- `uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov`
- `uv run pytest tests/e2e/test_responsive_polish.py -v --no-cov`
5. Final validation: `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number (>90% gate);
`uv run ruff check . && uv run pyright` clean.
6. **UI Structure Check** (AGENTS.md rule 5): layers stay behind
content, no text/contrast impact, no overflow at 360px.
7. Finish the phase report (`.agent/reports/22_background_animation/` —
E2E results + the task-01 screenshots).
8. Commit (one atomic commit) and move the phase:
```bash
git add -A .agent/ frontend/ tests/
git commit --no-gpg-sign -m "fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design"
mv .agent/phases/todo/22_background_animation .agent/phases/complete/
```
## Testing & Quality
- Story suite green **in isolation**; both regression suites green in
isolation; full unit+integration suite green; `app/` coverage at or
above today's number (>90%); ruff + pyright clean.
## Completion Criteria
- [ ] `test_background_animation.py` 5/5 in isolation.
- [ ] Regressions (dark tech theme, responsive polish) green in
isolation.
- [ ] Story file + phase report (with screenshots) exist.
- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`.
@@ -0,0 +1,120 @@
# Phase 23 report — Containerfile: build the whole app image again
**Date:** 2026-08-24 · **Source:** `TODO.md` L6 ("Fix Containerfile build
not working") · **Story:** `.agent/user_stories/containerfile-build.md`
## Root cause (verified, not guessed — logs in this directory)
1. **Build failure:** phase 19's absolute `import … from
"/assets/header.js"` is resolved by esbuild as the *filesystem* path
`/assets/header.js` → `✘ [ERROR] Could not resolve "/assets/header.js"`,
reproduced with the exact pinned esbuild **0.25.5**
(`repro_esbuild_0.25.5.log`, `repro_podman_build_before_fix.log`).
2. **Under-shipped image:** stage 1 bundled only `app.js` +
`sources.js` and copied only `index.html` + `sources.html` —
`document.html` / `login.html` / `document.js` / `login.js` /
`markdown.js` were all missing from the image.
3. **Double-evaluation trap:** the four direct `<script
src="/assets/header.js">` tags would evaluate the header module
twice in the image (bundled copy + raw module) → duplicate sign-out
listener, double init.
4. **Second blocker (task 01):** a local `npm install` of esbuild left
the binary off the next `RUN`'s PATH (`esbuild: not found`) → the
stage installs it globally.
## Fix
| piece | after |
|---|---|
| page-script header import | `from "./header.js"` (relative — owner-confirmed over an esbuild alias) |
| direct header.js `<script>` tags | **removed from all four pages** (the hoisted import covers evaluation order) |
| esbuild install | `npm install --no-audit --no-fund -g esbuild@0.25.5` (pinned, on PATH) |
| stage-1 bundles | `app.js`, `sources.js`, `document.js`, `login.js` |
| stage-1 minifies | `styles.css` + `markdown.js` (no `--bundle` — classic script) |
| stage-1 copies | all four HTML pages into `/out` |
## Build gate — `podman build` (task 01, `podman_build_after_fix.log`)
```
[1/3] STEP 3/5: RUN npm install --no-audit --no-fund -g esbuild@0.25.5
[1/3] STEP 5/5: RUN mkdir -p /out/assets && esbuild ./assets/app.js --bundle … && … && cp ./index.html ./sources.html ./document.html ./login.html /out/
../out/assets/app.js 14.3kb ⚡ Done in 2ms
../out/assets/sources.js 2.3kb ⚡ Done in 2ms
../out/assets/document.js 2.4kb ⚡ Done in 2ms
../out/assets/login.js 1.5kb ⚡ Done in 2ms
[3/3] COMMIT brain-of-reese/app:phase23test
Successfully tagged localhost/brain-of-reese/app:phase23test
39f5563a1b101b274d81d86aa06f86a4d6d1bba453e8dba40d6f48f987537019
BUILD_EXIT=0
```
## Image smoke (task 01, `image_smoke.log`)
Throwaway Postgres 17 + pgvector (`bor-smoke-db`, isolated network),
production env, teardown done:
- app boots: migrations applied, uvicorn up, `/api/health` →
`{"status":"ok","db":"up",…}`
- pages: `GET /`, `/sources.html`, `/document.html`, `/login.html` → **200**
- assets: `app.js`, `sources.js`, `document.js`, `login.js`,
`markdown.js`, `styles.css` → **200**
- single-evaluation: raw `/assets/header.js` → **404** (expected — the
header code ships inside each page bundle); all four bundles are
minified and contain the header markers (`sign-out-btn`,
`/api/logout`)
- No CDN rule: `cdn_violations=0` across all four served pages
- `direct_header_script_tags=0`
- → **`SMOKE_PASS`**
## Task 02 — hermetic coverage pin + validation
`tests/integration/test_containerfile_assets.py` (6 tests, no podman /
no network / no DB — parses `Containerfile` + `frontend/` as text):
1. `test_every_html_page_is_copied_into_stage1` — cp set == pages on disk
2. `test_every_local_asset_reference_is_produced` — every `assets/`
src/href has a stage-1 producer
3. `test_page_module_scripts_are_bundled` — HTML module set == esbuild
`--bundle` input set
4. `test_header_module_is_imported_not_directly_loaded` — no direct
header.js tag anywhere; every page script imports `./header.js`
relatively
5. `test_markdown_js_is_a_produced_classic_script` — minified without
`--bundle`; no top-level import/export in the source
6. `test_esbuild_stays_pinned` — concrete `esbuild@X.Y.Z`, no floating
spec
Mutation-checked while authoring: removing `login.html` from the cp
line (→ test 1 fails), `esbuild@latest` (→ test 6 fails), deleting the
`markdown.js` line (→ tests 2+5 fail), deleting the `document.js`
bundle line (→ test 3 fails), re-adding a direct header.js tag (→
tests 3+4 fail). All pins fire.
### Test / lint / coverage results (exact commands)
| command | result |
|---|---|
| `uv run pytest tests/integration/test_containerfile_assets.py -v --no-cov` | 6 passed |
| `uv run pytest tests/e2e/test_smoke.py -v --no-cov` (isolated) | **3 passed** (`e2e_smoke.log`) |
| `uv run pytest tests/e2e/test_shared_header.py -v --no-cov` (isolated) | **6 passed** (`e2e_shared_header.log`) |
| `uv run pytest tests/e2e/test_chat_persistence.py -v --no-cov` (isolated) | **4 passed** (`e2e_chat_persistence.log`) |
| `uv run pytest` | **335 passed** (329 → 335: the six new pins) |
| `uv run pytest --cov=app --cov-report=term-missing` | **335 passed; TOTAL 1109 stmts, 14 missed, 99%** (≥ today's 99%; >90% gate) |
| `uv run ruff check . && uv run pyright` | **All checks passed! / 0 errors, 0 warnings** |
Dev-server behavior unchanged: the tag removal is covered by the three
isolated E2E regressions above (browser-level header init, sign-out
binding exactly once, chat persistence across navigation).
## Decisions
- Relative imports over an esbuild alias (owner-confirmed A4-1).
- Direct header.js tags removed over shipping a raw header.js
(owner-confirmed A4-2 — single module evaluation).
- No Playwright suite for this phase (build/infrastructure): the gate
is the hermetic integration pin + the real `podman build` + image
smoke recorded above + the dev-server E2E regressions (A16 note in
the phase plan).
- No LOCKED anchor changed (A11 No-CDN and A16 honored).
- Build-evidence image `localhost/brain-of-reese/app:phase23test` kept
locally.
+131
View File
@@ -0,0 +1,131 @@
# Story: Containerfile Builds the Whole App Again
**Phase:** `23_containerfile_build` · **Gate:** hermetic integration
`tests/integration/test_containerfile_assets.py` + real `podman build`
+ image smoke (no Playwright suite — build/infrastructure phase, per
the phase plan's A16 note)
## Narrative
As **the owner**, I reported (2026-08-24, TODO.md L6) that the
Containerfile build "is not working". I want
`podman build -f Containerfile .` to succeed again — and, more
importantly, for the resulting image to serve the **whole app**: all
four pages (chat, sources, document viewer, login) with their bundled,
minified, local-only assets (No CDN rule, A11), the shared header
module evaluated exactly once per page, and a build contract that
fails loudly when a future page/script/asset outgrows the Containerfile.
- **Given** the 3-stage `Containerfile` (esbuild frontend bundle → uv
python deps → slim runtime serving `/app/static`)
- **When** `podman build -f Containerfile .` runs
- **Then** the build is green and the image serves `/`,
`/sources.html`, `/document.html`, `/login.html` with 200 and every
referenced asset local and minified — with `header.js` inlined into
each page bundle exactly once.
## Bug report (verbatim, TODO.md L6)
> Fix Containerfile build not working
## Verified diagnosis (2026-08-24, phase 23 — not a guess)
1. **Root cause of the build failure:** phase 19 switched the page
scripts to `import … from "/assets/header.js"` (an absolute URL).
esbuild resolves that as the *filesystem* path `/assets/header.js`
and the stage-1 bundle dies:
`✘ [ERROR] Could not resolve "/assets/header.js"`
(reproduced with esbuild **0.25.5**, the exact pinned version, on a
copy of `frontend/` — `repro_esbuild_0.25.5.log`).
2. **Secondary gap (image would be broken even if it built):** stage 1
bundled only `app.js` + `sources.js` and copied only `index.html` +
`sources.html`. Missing from the image: `document.html` +
`login.html` (phases 10/16), `document.js` + `login.js`, and
`markdown.js` (classic script loaded by `index.html` +
`document.html`).
3. **Latent double-evaluation trap:** all four HTML pages also load
`<script type="module" src="/assets/header.js">` directly while the
page script imports it. In dev the browser dedupes (same module
URL) — but in the image the bundled page script already contains the
header code, so shipping a raw `header.js` too would evaluate the
module **twice** (duplicate sign-out listener, double init). The
direct tags are redundant: the page script's `import` is hoisted and
guarantees `header.js` evaluates before the page script's body calls
`initSharedHeader()`, in dev and in the bundle alike.
4. **Second build blocker (found in task 01):** a *local* `npm install`
of esbuild left the binary in `node_modules/.bin`, invisible to the
next `RUN` — `esbuild: not found`. The stage must install globally.
## Owner-confirmed (2026-08-24, roadmap A4)
1. **Relative imports** (`from "./header.js"`) over an esbuild alias —
simpler, verified working, dev-server behavior unchanged (files are
side-by-side).
2. **Remove the four redundant direct `header.js` script tags** (the
design above) rather than ship a raw `header.js` into the image —
single module evaluation, no duplicate listeners.
3. The image must cover **all four pages + all local assets** they
reference — the integration test (task 02) enforces this coverage so
the gap cannot silently reappear.
## Fix
| piece | before | after |
|---|---|---|
| page-script header import | `from "/assets/header.js"` (absolute — esbuild fatal) | **`from "./header.js"`** (relative, bundles cleanly) |
| direct header.js `<script>` tags | 4 (one per page — double-eval trap) | **0** (the hoisted import covers evaluation order) |
| esbuild install | local (binary off the next RUN's PATH) | **`npm install -g esbuild@0.25.5`** (pinned, on PATH) |
| stage-1 bundles | `app.js`, `sources.js` | **`app.js`, `sources.js`, `document.js`, `login.js`** |
| stage-1 minifies | `styles.css` | **`styles.css`**, **`markdown.js`** (no `--bundle` — classic script) |
| stage-1 copies | `index.html`, `sources.html` | **all four pages** into `/out` |
## Acceptance criteria
1. Local esbuild 0.25.5 bundles all four page scripts cleanly.
2. `podman build -f Containerfile .` green (log excerpt in
`.agent/reports/23_containerfile_build/`).
3. Image smoke: container runs (throwaway Postgres 17 + pgvector);
`GET /`, `/sources.html`, `/document.html`, `/login.html` → 200;
`/assets/app.js` minified and contains the header code;
`/assets/markdown.js` 200; no `http(s)://` asset reference in any
served page (No CDN rule).
4. Dev server unchanged in behavior: `test_smoke.py`,
`test_shared_header.py`, `test_chat_persistence.py` green in
isolation.
5. `test_containerfile_assets.py` 6/6 within the full suite;
`uv run pytest` green; `app/` coverage ≥ today's number (>90% gate);
ruff + pyright clean.
## Test mapping table
**Test Scenario → `tests/integration/test_containerfile_assets.py`**
(hermetic — parses `Containerfile` + `frontend/` as text; no podman,
no network, no DB):
1. `test_every_html_page_is_copied_into_stage1` — every
`frontend/*.html` is copied by a stage-1 `cp` into `/out`, and the
copied set is exactly the pages on disk (new page without cp entry
→ fail; cp of a deleted page → fail).
2. `test_every_local_asset_reference_is_produced` — every local
`assets/`/`/assets/` `src=`/`href=` in the pages is produced by a
stage-1 line (esbuild `--outfile=/out/assets/<name>` or a `cp`) —
the missing-`markdown.js`-style gap cannot reappear.
3. `test_page_module_scripts_are_bundled` — the `type="module"` page
scripts referenced by the HTML (basenames) equal the esbuild
`--bundle` inputs in stage 1 (today: `app.js`, `sources.js`,
`document.js`, `login.js`).
4. `test_header_module_is_imported_not_directly_loaded` — no HTML
carries a direct `<script … src="…/header.js">` tag (single-evaluation
design pin, owner-confirmed A4-2) and every page script imports it
relatively (`from "./header.js"` — no absolute form).
5. `test_markdown_js_is_a_produced_classic_script` — `markdown.js` has
a stage-1 minify line **without** `--bundle` and no top-level
`import`/`export` in the source (both sides of the classic-script
assumption pinned).
6. `test_esbuild_stays_pinned` — the frontend stage pins a concrete
`esbuild@X.Y.Z` (no `latest`/`^`/`~`/bare spec — the exact pinned
0.25.5 is what the diagnosis reproduced against).
Complementary source pins live in `tests/unit/test_shared_header.py::
test_header_module_loads_before_the_page_script` (unit) and the real
build + image smoke are recorded in
`.agent/reports/23_containerfile_build/` (task 01).
+10 -5
View File
@@ -9,13 +9,18 @@
# ---------- Stage 1: frontend ---------- # ---------- Stage 1: frontend ----------
FROM docker.io/node:22-alpine AS frontend FROM docker.io/node:22-alpine AS frontend
WORKDIR /build WORKDIR /build
RUN npm install --no-audit --no-fund esbuild@0.25.5 # Global install: puts the pinned esbuild binary on the PATH for the build step below
# (a local `npm install` leaves it in node_modules/.bin, invisible to RUN).
RUN npm install --no-audit --no-fund -g esbuild@0.25.5
COPY frontend ./ COPY frontend ./
RUN mkdir -p /out/assets \ RUN mkdir -p /out/assets \
&& esbuild ./assets/app.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/app.js \ && esbuild ./assets/app.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/app.js \
&& esbuild ./assets/sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/sources.js \ && esbuild ./assets/sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/sources.js \
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \ && esbuild ./assets/document.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/document.js \
&& cp ./index.html ./sources.html /out/ && esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
&& cp ./index.html ./sources.html ./document.html ./login.html /out/
# ---------- Stage 2: python dependencies ---------- # ---------- Stage 2: python dependencies ----------
FROM docker.io/python:3.12-slim AS python FROM docker.io/python:3.12-slim AS python
+1 -1
View File
@@ -63,7 +63,7 @@
* All DOM ids match frontend/index.html. * All DOM ids match frontend/index.html.
*/ */
import { fetchIsAdmin, initSharedHeader } from "/assets/header.js"; import { fetchIsAdmin, initSharedHeader } from "./header.js";
const messagesEl = document.querySelector("#messages"); const messagesEl = document.querySelector("#messages");
const emptyState = document.querySelector("#empty-state"); const emptyState = document.querySelector("#empty-state");
+1 -1
View File
@@ -23,7 +23,7 @@
* fresh (clear the phase-14 conversation key, then navigate to "/"). * fresh (clear the phase-14 conversation key, then navigate to "/").
*/ */
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "/assets/header.js"; import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const source = params.get("source") || ""; const source = params.get("source") || "";
+1 -1
View File
@@ -17,7 +17,7 @@
* All DOM ids match frontend/login.html. * All DOM ids match frontend/login.html.
*/ */
import { fetchIsAdmin, initSharedHeader } from "/assets/header.js"; import { fetchIsAdmin, initSharedHeader } from "./header.js";
const form = document.querySelector("#login-form"); const form = document.querySelector("#login-form");
const passwordInput = document.querySelector("#login-password"); const passwordInput = document.querySelector("#login-password");
+1 -1
View File
@@ -12,7 +12,7 @@
* fresh (clear the phase-14 conversation key, then navigate to "/"). * fresh (clear the phase-14 conversation key, then navigate to "/").
*/ */
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "/assets/header.js"; import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
const tbody = document.querySelector("#docs-tbody"); const tbody = document.querySelector("#docs-tbody");
const emptyEl = document.querySelector("#sources-empty"); const emptyEl = document.querySelector("#sources-empty");
+3 -4
View File
@@ -74,10 +74,9 @@
</footer> </footer>
<script src="assets/markdown.js"></script> <script src="assets/markdown.js"></script>
<!-- Phase 19: shared header module (whoami caching, Sign in/out, <!-- Phase 19: the shared header module loads through the page script's
sign-out binding) loads before the page script, which calls own `import "./header.js"` — a hoisted import that is evaluated
initSharedHeader() at boot. --> before the page script body calls initSharedHeader() at boot. -->
<script type="module" src="/assets/header.js"></script>
<script type="module" src="assets/document.js"></script> <script type="module" src="assets/document.js"></script>
</body> </body>
</html> </html>
+3 -4
View File
@@ -113,10 +113,9 @@
</footer> </footer>
<script src="assets/markdown.js"></script> <script src="assets/markdown.js"></script>
<!-- Phase 19: shared header module (whoami caching, Sign in/out, <!-- Phase 19: the shared header module loads through the page script's
Sources-link toggle, sign-out binding) loads before the page own `import "./header.js"` — a hoisted import that is evaluated
script, which calls initSharedHeader() at boot. --> before the page script body calls initSharedHeader() at boot. -->
<script type="module" src="/assets/header.js"></script>
<script type="module" src="/assets/app.js"></script> <script type="module" src="/assets/app.js"></script>
</body> </body>
</html> </html>
+3 -2
View File
@@ -63,8 +63,9 @@
<!-- Phase 19: shared header module — the login page reuses it for the <!-- Phase 19: shared header module — the login page reuses it for the
Sources-link toggle only (no chat controls in this markup, so Sources-link toggle only (no chat controls in this markup, so
none appear). --> none appear). It loads through the page script's own
<script type="module" src="/assets/header.js"></script> `import "./header.js"` (hoisted, evaluated before the page script
body calls initSharedHeader() at boot). -->
<script type="module" src="/assets/login.js"></script> <script type="module" src="/assets/login.js"></script>
</body> </body>
</html> </html>
+3 -4
View File
@@ -119,10 +119,9 @@
</div> </div>
</footer> </footer>
<!-- Phase 19: shared header module (whoami caching, Sign in/out, <!-- Phase 19: the shared header module loads through the page script's
Sources-link toggle, sign-out binding) loads before the page own `import "./header.js"` — a hoisted import that is evaluated
script, which calls initSharedHeader() at boot. --> before the page script body calls initSharedHeader() at boot. -->
<script type="module" src="/assets/header.js"></script>
<script type="module" src="/assets/sources.js"></script> <script type="module" src="/assets/sources.js"></script>
</body> </body>
</html> </html>
@@ -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()}"
)
+36 -17
View File
@@ -127,22 +127,32 @@ def test_nav_sources_is_absent_from_the_viewer() -> None:
def test_sources_and_viewer_carry_the_shared_controls() -> None: def test_sources_and_viewer_carry_the_shared_controls() -> None:
"""Sources AND the document viewer gain the New Chat button + the """Sources AND the document viewer gain the New Chat button + the
Sign in / Sign out pair (both starting hidden — initSharedHeader Sign in / Sign out pair (both starting hidden — initSharedHeader
reveals exactly one after whoami), and they load header.js.""" reveals exactly one after whoami), and their page scripts load
header.js (phase 23: via the page script's relative import, not a
direct script tag)."""
for html in (SOURCES_HTML, DOCUMENT_HTML): for html in (SOURCES_HTML, DOCUMENT_HTML):
text = _text(html) text = _text(html)
assert 'id="new-chat-btn"' in text assert 'id="new-chat-btn"' in text
assert re.search(r'id="sign-in-link"[^>]*\bhidden\b', text) assert re.search(r'id="sign-in-link"[^>]*\bhidden\b', text)
assert re.search(r'id="sign-out-btn"[^>]*\bhidden\b', text) assert re.search(r'id="sign-out-btn"[^>]*\bhidden\b', text)
assert "header.js" in text, "page must load the shared header module" for js_file in (SOURCES_JS, DOCUMENT_JS):
assert 'from "./header.js"' in _text(js_file), (
f"{js_file.name}: page script must load the shared header module"
)
# Each page's Sign in link returns to ITS OWN page after login. # Each page's Sign in link returns to ITS OWN page after login.
assert 'href="/login.html?next=/sources.html"' in _text(SOURCES_HTML) assert 'href="/login.html?next=/sources.html"' in _text(SOURCES_HTML)
assert 'href="/login.html?next=/document.html"' in _text(DOCUMENT_HTML) assert 'href="/login.html?next=/document.html"' in _text(DOCUMENT_HTML)
def test_header_module_loads_before_the_page_script() -> None: def test_header_module_loads_before_the_page_script() -> None:
"""Every page loads header.js (type=module) BEFORE its page script, """Phase 23 (owner-confirmed single-evaluation design): NO page loads
so the sign-out binding and the whoami cache exist when the page header.js with a direct <script> tag anymore. Each page script
script boots.""" imports it relatively (`from "./header.js"`) — a hoisted import that
the browser evaluates BEFORE the page script body runs, and that the
image bundler inlines into the page bundle. The sign-out binding and
the whoami cache therefore exist when the page script boots, and
header.js can never be evaluated twice on a page (a tag + import pair
would double-bind the sign-out listener)."""
cases = [ cases = [
(INDEX_HTML, "app.js"), (INDEX_HTML, "app.js"),
(SOURCES_HTML, "sources.js"), (SOURCES_HTML, "sources.js"),
@@ -151,12 +161,18 @@ def test_header_module_loads_before_the_page_script() -> None:
] ]
for html, page_script in cases: for html, page_script in cases:
srcs = _script_srcs(html) srcs = _script_srcs(html)
header_idx = [i for i, s in enumerate(srcs) if "header.js" in s] assert [s for s in srcs if "header.js" in s] == [], (
page_idx = [i for i, s in enumerate(srcs) if page_script in s] f"{html.name}: no direct header.js <script> tag (single-evaluation design)"
assert header_idx, f"{html.name}: must load header.js" )
assert page_idx, f"{html.name}: must load {page_script}" assert [s for s in srcs if page_script in s], (
assert header_idx[0] < page_idx[0], ( f"{html.name}: must load {page_script}"
f"{html.name}: header.js must load before {page_script}" )
js = _text(ASSETS / page_script)
assert 'from "./header.js"' in js, (
f"{page_script}: must import the shared header module relatively"
)
assert 'from "/assets/header.js"' not in js, (
f"{page_script}: absolute header import would break the esbuild bundle"
) )
@@ -177,9 +193,10 @@ def test_app_js_delegates_the_shared_controls_to_header_module() -> None:
"""app.js imports the shared module, runs initSharedHeader() at boot """app.js imports the shared module, runs initSharedHeader() at boot
(BEFORE the phase-14 restore), takes its isAdmin from the cached (BEFORE the phase-14 restore), takes its isAdmin from the cached
fetchIsAdmin(), and owns NO whoami fetch and NO sign-out binding fetchIsAdmin(), and owns NO whoami fetch and NO sign-out binding
anymore (both moved to header.js).""" anymore (both moved to header.js). Phase 23: the import is relative
(`./header.js`) so esbuild can bundle it into the image."""
js = _text(APP_JS) js = _text(APP_JS)
assert 'from "/assets/header.js"' in js assert 'from "./header.js"' in js
assert "fetchIsAdmin" in js and "initSharedHeader" in js assert "fetchIsAdmin" in js and "initSharedHeader" in js
assert "signOutBtn.addEventListener" not in js, ( assert "signOutBtn.addEventListener" not in js, (
"the sign-out binding moved to header.js" "the sign-out binding moved to header.js"
@@ -200,9 +217,10 @@ def test_app_js_delegates_the_shared_controls_to_header_module() -> None:
def test_login_js_uses_the_shared_fetch_is_admin() -> None: def test_login_js_uses_the_shared_fetch_is_admin() -> None:
"""login.js switches its whoami check to the shared cached promise """login.js switches its whoami check to the shared cached promise
(one request per page) and calls initSharedHeader for the Sources (one request per page) and calls initSharedHeader for the Sources
link; its already-admin → redirect behavior is unchanged.""" link; its already-admin → redirect behavior is unchanged. Phase 23:
the import is relative (`./header.js`)."""
js = _text(LOGIN_JS) js = _text(LOGIN_JS)
assert 'from "/assets/header.js"' in js assert 'from "./header.js"' in js
assert "fetchIsAdmin" in js assert "fetchIsAdmin" in js
assert "fetchIsAdmin()" in js assert "fetchIsAdmin()" in js
assert "initSharedHeader()" in js assert "initSharedHeader()" in js
@@ -214,10 +232,11 @@ def test_non_chat_pages_bind_new_chat_to_the_chat_page() -> None:
"""On sources and the viewer, New Chat means "go to the chat, """On sources and the viewer, New Chat means "go to the chat,
fresh": the binding clears the phase-14 key (clearChatStorage) and fresh": the binding clears the phase-14 key (clearChatStorage) and
navigates to "/" — and both pages run initSharedHeader() at boot navigates to "/" — and both pages run initSharedHeader() at boot
on the shared cached whoami.""" on the shared cached whoami. Phase 23: the import is relative
(`./header.js`)."""
for js_file in (SOURCES_JS, DOCUMENT_JS): for js_file in (SOURCES_JS, DOCUMENT_JS):
js = _text(js_file) js = _text(js_file)
assert 'from "/assets/header.js"' in js assert 'from "./header.js"' in js
assert "initSharedHeader()" in js assert "initSharedHeader()" in js
btn_idx = js.find("new-chat-btn") btn_idx = js.find("new-chat-btn")
clear_idx = js.find("clearChatStorage();") clear_idx = js.find("clearChatStorage();")