fix(build): Containerfile builds again — relative module imports, all four pages and shared assets in the image
This commit is contained in:
@@ -0,0 +1,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).
|
||||
Reference in New Issue
Block a user