|
| 1 | +# CDN end-to-end coverage — Implementation Plan |
| 2 | + |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | +
|
| 5 | +**Goal:** Close the test gap around absolute (CDN) `publicPath` and empty `manifestKeyPrefix`, reaching parity with Encore's `functional.js:256` and `config-generator.js:237`. Test-only; no production code changes. |
| 6 | + |
| 7 | +**Architecture:** Two additions — a new cross-bundler integration test that runs a real Vite build and a real Rsbuild build with a CDN `publicPath` and asserts CDN-prefixed URLs in `entrypoints.json`/`manifest.json`, plus one unit test asserting an explicit empty `manifestKeyPrefix` is preserved. Both characterize existing behaviour. |
| 8 | + |
| 9 | +**Tech Stack:** TypeScript (ESM, strict), vitest, `vite` + `@rsbuild/core` programmatic builds. |
| 10 | + |
| 11 | +## Global Constraints |
| 12 | + |
| 13 | +- ESM only, strict TypeScript, ES2017 target; `node:` prefix for Node builtins. |
| 14 | +- Tests live under `assets/test/`; run via `pnpm vitest run <file>` from the repo root; full suite via `pnpm test`. |
| 15 | +- Integration tests use `assets/test/fixtures/basic` (entries `app`, `admin`), a temp `outputPath` via `mkdtempSync`, and parse the emitted JSON — never the playground. |
| 16 | +- CDN config under test: `publicPath: 'https://cdn.example.com/assets/'` (trailing slash) + `manifestKeyPrefix: 'assets/'`. |
| 17 | +- Commit messages: Symfony style `[<Scope>] <Short description>`. Scopes: `[Tests]` for tests, `[Docs]` for AGENTS.md. |
| 18 | +- Spec: `docs/superpowers/specs/2026-07-10-cdn-manifestkeyprefix-guard-design.md`. |
| 19 | + |
| 20 | +**Note on TDD framing:** these are characterization tests for behaviour that already exists, so they are expected to PASS on first run. If a CDN integration test FAILS, that is a real bug — stop and report it before continuing. |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +### Task 1: Empty manifestKeyPrefix unit test |
| 25 | + |
| 26 | +**Files:** |
| 27 | +- Test: `assets/test/core/options.test.ts` (add one case to `describe('normalizeOptions')`) |
| 28 | + |
| 29 | +**Interfaces:** |
| 30 | +- Consumes: `normalizeOptions(options, cwd): ResolvedOptions` — unchanged. |
| 31 | +- Produces: nothing new. |
| 32 | + |
| 33 | +- [ ] **Step 1: Add the test** |
| 34 | + |
| 35 | +Insert after the "honors an explicit manifestKeyPrefix" test (`options.test.ts:31`): |
| 36 | + |
| 37 | +```ts |
| 38 | + it('honors an explicit empty manifestKeyPrefix', () => { |
| 39 | + const r = normalizeOptions({ publicPath: '/build/', manifestKeyPrefix: '' }, '/app'); |
| 40 | + expect(r.manifestKeyPrefix).toBe(''); |
| 41 | + }); |
| 42 | +``` |
| 43 | + |
| 44 | +- [ ] **Step 2: Run it** |
| 45 | + |
| 46 | +Run: `pnpm vitest run assets/test/core/options.test.ts` |
| 47 | +Expected: PASS (current code keeps `''` because `options?.manifestKeyPrefix ?? null` preserves the empty string and skips derivation). |
| 48 | + |
| 49 | +- [ ] **Step 3: Commit** |
| 50 | + |
| 51 | +```bash |
| 52 | +git add assets/test/core/options.test.ts |
| 53 | +git commit -m "[Tests] Cover explicit empty manifestKeyPrefix" |
| 54 | +``` |
| 55 | + |
| 56 | +--- |
| 57 | + |
| 58 | +### Task 2: CDN end-to-end integration test (Vite + Rsbuild) |
| 59 | + |
| 60 | +**Files:** |
| 61 | +- Create: `assets/test/integration/cdn.test.ts` |
| 62 | + |
| 63 | +**Interfaces:** |
| 64 | +- Consumes: default exports `../../src/vite` (Vite plugin) and `../../src/rsbuild` (Rsbuild plugin), both `(options?: Options) => plugin`; `Options` includes `outputPath`, `publicPath`, `manifestKeyPrefix`. |
| 65 | +- Produces: nothing new. |
| 66 | + |
| 67 | +- [ ] **Step 1: Write the test file** |
| 68 | + |
| 69 | +Create `assets/test/integration/cdn.test.ts`: |
| 70 | + |
| 71 | +```ts |
| 72 | +import { mkdtempSync, readFileSync } from 'node:fs'; |
| 73 | +import { tmpdir } from 'node:os'; |
| 74 | +import { join } from 'node:path'; |
| 75 | +import { createRsbuild } from '@rsbuild/core'; |
| 76 | +import { build } from 'vite'; |
| 77 | +import { describe, expect, it } from 'vitest'; |
| 78 | +import SymfonyRsbuild from '../../src/rsbuild'; |
| 79 | +import SymfonyVite from '../../src/vite'; |
| 80 | + |
| 81 | +const fixture = join(import.meta.dirname, '../fixtures/basic'); |
| 82 | +const CDN = 'https://cdn.example.com/assets/'; |
| 83 | +const CDN_URL_RE = /^https:\/\/cdn\.example\.com\/assets\//; |
| 84 | + |
| 85 | +describe('absolute (CDN) publicPath', () => { |
| 86 | + it('vite build emits CDN-prefixed URLs in entrypoints.json and manifest.json', async () => { |
| 87 | + const out = mkdtempSync(join(tmpdir(), 'ups-cdn-vite-')); |
| 88 | + await build({ |
| 89 | + root: fixture, |
| 90 | + logLevel: 'silent', |
| 91 | + build: { |
| 92 | + emptyOutDir: true, |
| 93 | + rollupOptions: { input: { app: join(fixture, 'app.js'), admin: join(fixture, 'admin.js') } }, |
| 94 | + }, |
| 95 | + plugins: [SymfonyVite({ outputPath: out, publicPath: CDN, manifestKeyPrefix: 'assets/' })], |
| 96 | + }); |
| 97 | + |
| 98 | + const entry = JSON.parse(readFileSync(join(out, 'entrypoints.json'), 'utf8')); |
| 99 | + expect(entry.publicPath).toBe(CDN); |
| 100 | + expect(entry.entryPoints.app.js[0]).toMatch(/^https:\/\/cdn\.example\.com\/assets\/app-.*\.js$/); |
| 101 | + |
| 102 | + const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8')); |
| 103 | + expect(manifest['assets/app.js']).toMatch(/^https:\/\/cdn\.example\.com\/assets\/app-.*\.js$/); |
| 104 | + for (const value of Object.values(manifest)) { |
| 105 | + expect(value).toMatch(CDN_URL_RE); |
| 106 | + } |
| 107 | + }, 30_000); |
| 108 | + |
| 109 | + it('rsbuild build emits CDN-prefixed URLs in entrypoints.json and manifest.json', async () => { |
| 110 | + const out = mkdtempSync(join(tmpdir(), 'ups-cdn-rsbuild-')); |
| 111 | + const rsbuild = await createRsbuild({ |
| 112 | + cwd: fixture, |
| 113 | + rsbuildConfig: { |
| 114 | + mode: 'production', |
| 115 | + source: { entry: { app: join(fixture, 'app.js'), admin: join(fixture, 'admin.js') } }, |
| 116 | + plugins: [SymfonyRsbuild({ outputPath: out, publicPath: CDN, manifestKeyPrefix: 'assets/' })], |
| 117 | + }, |
| 118 | + }); |
| 119 | + await rsbuild.build(); |
| 120 | + |
| 121 | + const entry = JSON.parse(readFileSync(join(out, 'entrypoints.json'), 'utf8')); |
| 122 | + expect(entry.publicPath).toBe(CDN); |
| 123 | + expect(entry.entryPoints.app.js.some((u: string) => CDN_URL_RE.test(u))).toBe(true); |
| 124 | + |
| 125 | + const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8')); |
| 126 | + expect(Object.keys(manifest).length).toBeGreaterThan(0); |
| 127 | + for (const value of Object.values(manifest)) { |
| 128 | + expect(value).toMatch(CDN_URL_RE); |
| 129 | + } |
| 130 | + }, 60_000); |
| 131 | +}); |
| 132 | +``` |
| 133 | + |
| 134 | +- [ ] **Step 2: Run the new file** |
| 135 | + |
| 136 | +Run: `pnpm vitest run assets/test/integration/cdn.test.ts` |
| 137 | +Expected: PASS (2 tests). Absolute `publicPath` already flows through `joinUrl` in `buildEntrypoints`/`buildManifest`, so both files carry CDN URLs. |
| 138 | +If it FAILS: a real CDN bug — stop, diagnose (systematic-debugging), fix the production code, then re-run. |
| 139 | + |
| 140 | +- [ ] **Step 3: Run the full suite** |
| 141 | + |
| 142 | +Run: `pnpm test` |
| 143 | +Expected: PASS (was 66; now 69 — +1 unit from Task 1, +2 integration here). |
| 144 | + |
| 145 | +- [ ] **Step 4: Commit** |
| 146 | + |
| 147 | +```bash |
| 148 | +git add assets/test/integration/cdn.test.ts |
| 149 | +git commit -m "[Tests] Add CDN publicPath end-to-end build coverage for Vite and Rsbuild" |
| 150 | +``` |
| 151 | + |
| 152 | +--- |
| 153 | + |
| 154 | +### Task 3: Fix the stale AGENTS.md paragraph |
| 155 | + |
| 156 | +**Files:** |
| 157 | +- Modify: `AGENTS.md` (the "The Symfony integration contract" section) |
| 158 | + |
| 159 | +**Interfaces:** none. |
| 160 | + |
| 161 | +- [ ] **Step 1: Replace the stale sentences** |
| 162 | + |
| 163 | +In `AGENTS.md`, read the "The Symfony integration contract" section and replace: |
| 164 | + |
| 165 | +> Encore enforces this by throwing (`../webpack-encore/lib/config/path-util.ts`, `validatePublicPathAndManifestKeyPrefix`); **porting that guard is still TODO** — the current factory does not throw and would use the absolute URL as the key prefix. The `publicPath === null` branch in `assets/src/index.ts` is likewise dead (`publicPath` always defaults to `build/`). |
| 166 | +
|
| 167 | +with: |
| 168 | + |
| 169 | +> Reprise ports the relevant half of Encore's `validatePublicPathAndManifestKeyPrefix` (`../webpack-encore/lib/config/path-util.js`) in `normalizeOptions`: an absolute `publicPath` (containing `://`) without an explicit `manifestKeyPrefix` throws. Encore's second branch — rejecting a `publicPath` not contained in `outputPath` — is intentionally not ported: Reprise's `outputPath` (a filesystem dir) and `publicPath` (a URL prefix) are decoupled, so that heuristic would reject valid configs. CDN URLs in `entrypoints.json`/`manifest.json` are covered end-to-end by `assets/test/integration/cdn.test.ts`. |
| 170 | +
|
| 171 | +(If the surrounding wording differs slightly, keep it and swap only these sentences.) |
| 172 | + |
| 173 | +- [ ] **Step 2: Commit** |
| 174 | + |
| 175 | +```bash |
| 176 | +git add AGENTS.md |
| 177 | +git commit -m "[Docs] Clarify the manifestKeyPrefix guard and CDN coverage" |
| 178 | +``` |
| 179 | + |
| 180 | +--- |
| 181 | + |
| 182 | +## Self-Review |
| 183 | + |
| 184 | +**Spec coverage:** |
| 185 | +- Empty manifestKeyPrefix parity → Task 1. ✓ |
| 186 | +- CDN e2e (Vite + Rsbuild) → Task 2. ✓ |
| 187 | +- Branch 2 rejection recorded, not implemented → no task (correct). ✓ |
| 188 | +- AGENTS.md correction → Task 3. ✓ |
| 189 | + |
| 190 | +**Placeholder scan:** No TBD/TODO; all test code shown in full. ✓ |
| 191 | + |
| 192 | +**Type consistency:** Plugins imported as default exports `SymfonyVite`/`SymfonyRsbuild`, both `(options?: Options) => plugin`; assertions read `entry.publicPath`, `entry.entryPoints.app.js`, `manifest[...]` — matching the shapes in `vite-build.test.ts`/`rsbuild-build.test.ts`. ✓ |
0 commit comments