Adds the render-and-export half of the slice: a Playwright-based render
worker that takes resolved BannerSpecs, builds self-contained HTML5 ad
units (absolute-positioned DOM, GSAP timeline, IAB clickTag, baked
typography + fonts), captures a backup PNG, and packages each into a zip
on disk. A new POST /api/export route drives it from /review via an
"Export ZIPs (N)" button.
What ships
- packages/render-worker (new package, promoted from the apps/ placeholder)
- build-runtime-html: pure function producing the self-contained
index.html. Inlines @font-face for Inter Regular/Bold, the resolved
spec as JSON, GSAP's minified bundle, and a runtime IIFE that mirrors
build-timeline.ts semantics (fade_in / fade_out / hold + padding tween).
Layers with a fade_in event ship with opacity:0 inline to avoid a
first-frame flash.
- render-to-zip: stages temp dir, opens Playwright file://, awaits
document.fonts.ready, pauses window.__tl and seeks to t=1.0s,
screenshots backup.png, assembles JSZip on disk under
exports/<safe(campaign_id)>/<safe(version_id)>.zip.
- render-many: single Chromium, min(N,3) concurrent contexts, fonts
+ GSAP read once. Returns { exported, errors } so a single bad spec
doesn't kill the batch.
- safe-slug: sanitises path segments to [A-Za-z0-9_-], 'unknown' fallback.
- 14 tests (8 build-runtime-html + 6 safe-slug). All green.
- apps/web/app/api/export/route.ts: validates each incoming spec with
BannerSpecSchema (failures land in the errors list, not a 400), dynamic-
imports the render-worker, calls renderMany, returns paths + byte sizes.
- apps/web/app/review/ReviewClient.tsx: export state machine (idle |
exporting | done | error), "Export ZIPs (N)" button beside Play-All,
success summary listing zip paths.
- apps/render-worker/ deleted; superseded by the package shape.
dropflow wasm wiring (Day-3 bug that surfaced on Day 4)
dropflow 0.5.1's wasm.js does `await environment.wasmLocator()` at module
top level. In Next.js 14.2.x, webpack's experiments.topLevelAwait flag is
ignored (regression vs 14.1.x), so any locator install that runs after
the dropflow chunk loads is too late — wasm.js's default locator throws
"Wasm location not configured" at module-init time.
Fix is a small pnpm patch on dropflow/dist/src/environment-browser.js,
which is imported by api.js BEFORE wasm.js via the `#register-default-
environment` browser-condition subpath. The patch installs a wasmLocator
that fetches /dropflow.wasm at exactly the right point in dropflow's
own initialization. patches/dropflow@0.5.1.patch is committed and wired
through pnpm.patchedDependencies.
Supporting changes in the layout-engine to keep the static module graph
dropflow-free where possible:
- browser/wasm-locator.ts: dynamic-imports dropflow/environment.js.
- browser/dropflow-wrapper.ts: dynamic-imports dropflow itself; uses an
inline structural type instead of `import type * as DropflowNs`.
- core/measure.ts: removed eager dropflow import; receives the namespace
via setDropflow() from whichever wrapper inits first.
- apps/web/lib/engine.ts: dynamic-imports the layout-engine browser
barrel so dropflow doesn't enter /review's synchronous graph.
- BannerCanvas.tsx + ParityClient.tsx: pull DEMO_TEMPLATE_300x250 from
the dropflow-free @banner-studio/layout-engine/templates subpath
rather than /browser. ParityClient also dynamic-imports resolveLayout.
Other plumbing
- next.config.mjs: server externals for playwright / playwright-core /
chromium-bidi / jszip / @banner-studio/render-worker so webpack doesn't
try to bundle dynamic CJS requires it can't trace.
- SLICE_DEVIATIONS.md: added §6 (render-worker as a package not an app),
§7 (hero image remote-referenced, not bundled), §8 (CM360 click-macro
deferred — IAB clickTag only).
Acceptance gate (Day 4)
- pnpm -r test: 40 tests across 4 packages green.
- pnpm -r build: clean, /api/export present in the Next route table.
- Manual smoke: /review → Export ZIPs → real .zip files in
exports/demo-campaign/. Unzipped index.html opens in Chrome, animates,
and the clickTag opens example.com/<row>.url in a new tab.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
101 lines
4.6 KiB
TypeScript
101 lines
4.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import baseline from '../../../apps/web/lib/parity/baseline.json' assert { type: 'json' };
|
|
import type { BannerSpec, ArtboardSpec } from '@banner-studio/types';
|
|
import { buildRuntimeHtml } from '../src/build-runtime-html.js';
|
|
|
|
// Wrap the parity baseline into the full BannerSpec envelope. The baseline
|
|
// JSON is intentionally partial (no campaign_id/version_id/etc.) — those
|
|
// envelope fields are slice-only and not relevant to layout parity. The
|
|
// build-runtime-html test owns the responsibility of providing them.
|
|
function makeSpec(): BannerSpec {
|
|
// Deep-clone via JSON so per-test mutations (e.g. content edits) don't
|
|
// bleed into other tests through the shared imported JSON module.
|
|
const artboard = JSON.parse(
|
|
JSON.stringify(baseline.artboards[0])
|
|
) as ArtboardSpec;
|
|
return {
|
|
template_id: 'demo-300x250',
|
|
template_version: 1,
|
|
campaign_id: 'demo-campaign',
|
|
version_id: 'v1',
|
|
copy_variant: 'A',
|
|
generated_at: '2026-05-15T00:00:00.000Z',
|
|
ai_reasoning: {
|
|
asset_selection: 'hero crop chosen for visual punch',
|
|
copy_rationale: 'concise headline',
|
|
variant_selection: 'A',
|
|
animation_rationale: 'staggered fade-in keeps focus'
|
|
},
|
|
artboards: [artboard],
|
|
ad_server_profile: 'iab_standard',
|
|
click_destinations: [{ id: 'cta', url: 'https://example.com/buy' }]
|
|
};
|
|
}
|
|
|
|
const FAKE_GSAP = '/* fake gsap.min.js */ var gsap = { timeline: function(){ return { to: function(){return this;} }; } };';
|
|
|
|
describe('buildRuntimeHtml', () => {
|
|
it('inlines the spec JSON in a typed script tag', () => {
|
|
const html = buildRuntimeHtml({ spec: makeSpec(), gsapSource: FAKE_GSAP });
|
|
expect(html).toContain('<script id="banner-spec" type="application/json">');
|
|
// Spec JSON contains campaign_id from the envelope.
|
|
expect(html).toContain('"campaign_id":"demo-campaign"');
|
|
});
|
|
|
|
it('exposes the click URL from click_destinations[0]', () => {
|
|
const html = buildRuntimeHtml({ spec: makeSpec(), gsapSource: FAKE_GSAP });
|
|
expect(html).toContain('var clickTag = "https://example.com/buy"');
|
|
expect(html).toContain('href="https://example.com/buy"');
|
|
});
|
|
|
|
it('emits a div for each non-group resolved layer', () => {
|
|
const html = buildRuntimeHtml({ spec: makeSpec(), gsapSource: FAKE_GSAP });
|
|
expect(html).toContain('id="layer-hero"');
|
|
expect(html).toContain('id="layer-headline"');
|
|
expect(html).toContain('id="layer-subheadline"');
|
|
expect(html).toContain('id="layer-cta"');
|
|
// The group wrapper should NOT be in the DOM — children are flat in the
|
|
// resolved spec, the group only ever existed in the template.
|
|
expect(html).not.toContain('id="layer-main-group"');
|
|
});
|
|
|
|
it('references the locally hosted Inter fonts', () => {
|
|
const html = buildRuntimeHtml({ spec: makeSpec(), gsapSource: FAKE_GSAP });
|
|
expect(html).toContain("url('./fonts/Inter-Regular.ttf')");
|
|
expect(html).toContain("url('./fonts/Inter-Bold.ttf')");
|
|
});
|
|
|
|
it('forces opacity:0 on layers with a fade_in event', () => {
|
|
const html = buildRuntimeHtml({ spec: makeSpec(), gsapSource: FAKE_GSAP });
|
|
// All four layers in the baseline have fade_in events.
|
|
const heroMatch = html.match(/id="layer-hero"[^>]*style="([^"]+)"/);
|
|
expect(heroMatch?.[1]).toContain('opacity:0');
|
|
const headlineMatch = html.match(/id="layer-headline"[^>]*style="([^"]+)"/);
|
|
expect(headlineMatch?.[1]).toContain('opacity:0');
|
|
});
|
|
|
|
it('inlines the GSAP source and calls gsap.timeline()', () => {
|
|
const html = buildRuntimeHtml({ spec: makeSpec(), gsapSource: FAKE_GSAP });
|
|
expect(html).toContain('fake gsap.min.js');
|
|
expect(html).toContain('gsap.timeline(');
|
|
});
|
|
|
|
it('escapes potentially unsafe content', () => {
|
|
const spec = makeSpec();
|
|
spec.artboards[0]!.layers.find((l) => l.layer_id === 'headline')!.content =
|
|
'<script>alert(1)</script>Hello & "world"';
|
|
const html = buildRuntimeHtml({ spec, gsapSource: FAKE_GSAP });
|
|
// The visible headline must be rendered as escaped text, not as a real script.
|
|
expect(html).toContain('<script>alert(1)</script>Hello &');
|
|
// It must NOT contain a literal <script>alert(1)</script> outside the inline
|
|
// JSON (the JSON also escapes <\/script>).
|
|
const stripped = html.replace(/<script id="banner-spec"[\s\S]*?<\/script>/g, '');
|
|
expect(stripped).not.toMatch(/<script>alert\(1\)<\/script>/);
|
|
});
|
|
|
|
it('includes the headline content text', () => {
|
|
const html = buildRuntimeHtml({ spec: makeSpec(), gsapSource: FAKE_GSAP });
|
|
expect(html).toContain('Hello world');
|
|
expect(html).toContain('Shop now');
|
|
});
|
|
});
|