fix(build): Containerfile builds again — relative module imports, all four pages and shared assets in the image
This commit is contained in:
@@ -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.
|
||||||
@@ -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).
|
||||||
+7
-2
@@ -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/document.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/document.js \
|
||||||
|
&& 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 \
|
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
||||||
&& cp ./index.html ./sources.html /out/
|
&& 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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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") || "";
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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()}"
|
||||||
|
)
|
||||||
@@ -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();")
|
||||||
|
|||||||
Reference in New Issue
Block a user