Files
worldmonitor/tests/brief-carousel.test.mjs
Elie Habib 4853645d53 fix(brief): switch carousel to @vercel/og on edge runtime (#3210)
* fix(brief): switch carousel to @vercel/og on edge runtime

Every attempt to ship the Phase 8 Telegram carousel on Vercel's
Node serverless runtime has failed at cold start:

- PR #3174 direct satori + @resvg/resvg-wasm: Vercel edge bundler
  refused the `?url` asset import required by resvg-wasm.
- PR #3174 (fix) direct satori + @resvg/resvg-js native binding:
  Node runtime accepted it, but Vercel's nft tracer does not follow
  @resvg/resvg-js/js-binding.js's conditional
  `require('@resvg/resvg-js-<platform>-<arch>-<libc>')` pattern,
  so the linux-x64-gnu peer package was never bundled. Cold start
  threw MODULE_NOT_FOUND, isolate crashed,
  FUNCTION_INVOCATION_FAILED on every request including OPTIONS,
  and Telegram reported WEBPAGE_CURL_FAILED with no other signal.
- PR #3204 added `vercel.json` `functions.includeFiles` to force
  the binding in, but (a) the initial key was a literal path that
  Vercel micromatch read as a character class (PR #3206 fixed),
  (b) even with the corrected `api/brief/carousel/**` wildcard, the
  function still 500'd across the board. The `functions.includeFiles`
  path appears honored in the deployment manifest but not at runtime
  for this particular native-binding pattern.

Fix: swap the renderer to @vercel/og's ImageResponse, which is
Vercel's first-party wrapper around satori + resvg-wasm with
Vercel-native bundling. Runs on Edge runtime — matches every other
API route in the project. No native binding, no includeFiles, no
nft tracing surprises. Cold start ~300ms, warm ~30ms.

Changes:
- server/_shared/brief-carousel-render.ts: replace renderCarouselPng
  (Uint8Array) with renderCarouselImageResponse (ImageResponse).
  Drop ensureLibs + satori + @resvg/resvg-js dynamic-import dance.
  Keep layout builders (buildCover/buildThreads/buildStory) and
  font loading unchanged — the Satori object trees are
  wire-compatible with ImageResponse.
- api/brief/carousel/[userId]/[issueDate]/[page].ts: flip
  `runtime: 'nodejs'` -> `runtime: 'edge'`. Delegate rendering to
  the renderer's ImageResponse and return it directly; error path
  still 503 no-store so CDN + Telegram don't pin a bad render.
- vercel.json: drop the now-useless `functions.includeFiles` block.
- package.json: drop direct `@resvg/resvg-js` and `satori` deps
  (both now bundled inside @vercel/og).
- tests/deploy-config.test.mjs: replace the native-binding
  regression guards with an assertion that no `functions` block
  exists (with a comment pointing at the skill documenting the
  micromatch gotcha for future routes).
- tests/brief-carousel.test.mjs: updated comment references.

Verified:
- typecheck + typecheck:api clean
- test:data 5814/5814 pass
- node -e test: @vercel/og imports cleanly in Node (tests that
  reach through the renderer file no longer depend on native
  bindings)

Post-deploy validation:
  curl -I -H "User-Agent: TelegramBot (like TwitterBot)" \
    "https://www.worldmonitor.app/api/brief/carousel/<uid>/<slot>/0"
  # Expect: HTTP/2 403 (no token) or 200 (valid token)
  # NOT:    HTTP/2 500 FUNCTION_INVOCATION_FAILED

Then tail Railway digest logs on the next tick; the
`[digest] Telegram carousel 400 ... WEBPAGE_CURL_FAILED` line
should stop appearing, and the 3-image preview should actually land
on Telegram.

* Add renderer smoke test + fix Cache-Control duplication

Reviewer flagged residual risk: no dedicated carousel-route smoke
test for the @vercel/og path. Adds one, and catches a real bug in
the process.

Findings during test-writing:

1. @vercel/og's ImageResponse runs CLEANLY in Node via tsx — the
   comment in brief-carousel.test.mjs saying "we can't test the
   render in Node" was true for direct satori + @resvg/resvg-wasm
   but no longer holds after PR #3210. Pure Node render works
   end-to-end: satori tree-parse, jsdelivr font fetch, resvg-wasm
   init, PNG output. ~850ms first call, ~20ms warm.

2. ImageResponse sets its own default
   `Cache-Control: public, immutable, no-transform, max-age=31536000`.
   Passing Cache-Control via the constructor's headers option
   APPENDS rather than overrides, producing a duplicated
   comma-joined value like
   `public, immutable, no-transform, max-age=31536000, public, max-age=60`
   on the Response. The route handler was doing exactly this via
   extraHeaders. Fix: drop our Cache-Control override and rely on
   @vercel/og's 1-year immutable default — envelope is only
   immutable for its 7d Redis TTL so the effective ceiling is 7d
   anyway (after that the route 404s before render).

Changes:

- tests/brief-carousel.test.mjs: 6 new assertions under
  `renderCarouselImageResponse`:
    * renders cover / threads / story pages, each returning a
      valid PNG (magic bytes + size range)
    * rejects a structurally empty envelope
    * threads non-cache extraHeaders onto the Response
    * pins @vercel/og's Cache-Control default so it survives
      caller-supplied Cache-Control overrides (regression guard
      for the bug fixed in this commit)
- api/brief/carousel/[userId]/[issueDate]/[page].ts: remove the
  stacked Cache-Control; lean on @vercel/og default. Drop the now-
  unused `PAGE_CACHE_TTL` constant. Comment explains why.

Verified:
- test:data 5820/5820 pass (was 5814, +6 smoke)
- typecheck + typecheck:api clean
- Render smoke: cover 825ms / threads 23ms / story 16ms first run
  (wasm init dominates first render)
2026-04-19 15:18:12 +04:00

278 lines
12 KiB
JavaScript

// Phase 8 — carousel URL parsing + page index helpers + renderer smoke.
//
// After the @vercel/og refactor (PR #3210), the full render path
// actually runs cleanly in Node via tsx — ImageResponse wraps satori
// + resvg-wasm and both work in plain Node. So in addition to the
// pure plumbing tests (URL derivation + page index mapping) we now
// end-to-end each of the three layouts, asserting PNG magic bytes
// and a plausible byte range. This catches Satori tree-shape
// regressions, font-load breakage, and resvg-wasm init issues long
// before they'd surface in a Vercel deploy.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pageFromIndex, renderCarouselImageResponse } from '../server/_shared/brief-carousel-render.ts';
// Import the URL helper via dynamic eval of the private function.
// The digest cron is .mjs; we re-declare the same logic here to lock
// the behaviour. If the cron's copy drifts, this test stops guarding
// the contract and should be migrated to shared import.
//
// Kept in-sync via a grep assertion at the bottom of this file.
function carouselUrlsFrom(magazineUrl) {
try {
const u = new URL(magazineUrl);
const m = u.pathname.match(/^\/api\/brief\/([^/]+)\/(\d{4}-\d{2}-\d{2}-\d{4})\/?$/);
if (!m) return null;
const [, userId, issueSlot] = m;
const token = u.searchParams.get('t');
if (!token) return null;
return [0, 1, 2].map(
(p) => `${u.origin}/api/brief/carousel/${userId}/${issueSlot}/${p}?t=${token}`,
);
} catch {
return null;
}
}
describe('pageFromIndex', () => {
it('maps 0 → cover, 1 → threads, 2 → story', () => {
assert.equal(pageFromIndex(0), 'cover');
assert.equal(pageFromIndex(1), 'threads');
assert.equal(pageFromIndex(2), 'story');
});
it('returns null for out-of-range indices', () => {
assert.equal(pageFromIndex(-1), null);
assert.equal(pageFromIndex(3), null);
assert.equal(pageFromIndex(100), null);
assert.equal(pageFromIndex(Number.NaN), null);
});
});
describe('carouselUrlsFrom', () => {
const magazine = 'https://www.worldmonitor.app/api/brief/user_abc/2026-04-18-0800?t=XXX';
it('derives three signed carousel URLs from a valid magazine URL', () => {
const urls = carouselUrlsFrom(magazine);
assert.ok(urls);
assert.equal(urls.length, 3);
assert.equal(urls[0], 'https://www.worldmonitor.app/api/brief/carousel/user_abc/2026-04-18-0800/0?t=XXX');
assert.equal(urls[1], 'https://www.worldmonitor.app/api/brief/carousel/user_abc/2026-04-18-0800/1?t=XXX');
assert.equal(urls[2], 'https://www.worldmonitor.app/api/brief/carousel/user_abc/2026-04-18-0800/2?t=XXX');
});
it('preserves origin (localhost, preview deploys, etc.)', () => {
const urls = carouselUrlsFrom('http://localhost:3000/api/brief/user_a/2026-04-18-1300?t=T');
assert.equal(urls[0], 'http://localhost:3000/api/brief/carousel/user_a/2026-04-18-1300/0?t=T');
});
it('returns null for a URL without a token', () => {
assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/brief/user_a/2026-04-18-0800'), null);
});
it('returns null when the path is not the magazine route', () => {
assert.equal(carouselUrlsFrom('https://worldmonitor.app/dashboard?t=X'), null);
assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/other/path/2026-04-18-0800?t=X'), null);
});
it('returns null when the slot is date-only (no HHMM suffix)', () => {
assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/brief/user_a/2026-04-18?t=X'), null);
});
it('returns null when slot is not YYYY-MM-DD-HHMM', () => {
assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/brief/user_a/today?t=X'), null);
});
it('returns null on garbage input without throwing', () => {
assert.equal(carouselUrlsFrom('not a url'), null);
assert.equal(carouselUrlsFrom(''), null);
assert.equal(carouselUrlsFrom(null), null);
});
});
describe('carouselUrlsFrom — contract parity with seed-digest-notifications.mjs', () => {
it('the cron embeds the same function body (guards drift)', async () => {
const { readFileSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { dirname, resolve } = await import('node:path');
const __d = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(resolve(__d, '../scripts/seed-digest-notifications.mjs'), 'utf-8');
assert.match(src, /function carouselUrlsFrom\(magazineUrl\)/, 'cron must export carouselUrlsFrom');
assert.match(src, /\/api\/brief\/carousel\/\$\{userId\}\/\$\{issueSlot\}\/\$\{p\}\?t=\$\{token\}/, 'cron path template must match test fixture');
});
});
// REGRESSION: PR #3174 review P1. The edge route MUST NOT return
// a 200 placeholder PNG on render failure. A 1x1 blank cached 7d
// immutable by Telegram/CDN would lock in a broken preview for
// the life of the brief. Only 200s serve PNG bytes; every failure
// path is a non-2xx JSON with no-cache.
describe('carousel route — no placeholder PNG on failure', () => {
it('the route source never serves image/png on the render-failed path', async () => {
const { readFileSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { dirname, resolve } = await import('node:path');
const __d = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(
resolve(__d, '../api/brief/carousel/[userId]/[issueDate]/[page].ts'),
'utf-8',
);
// Old impl had errorPng() returning a 1x1 transparent PNG at 200 +
// 7d cache. If that pattern ever comes back, this test fails.
assert.doesNotMatch(src, /\berrorPng\b/, 'errorPng helper must not be reintroduced');
// Render-failed branch must return 503 with noStore.
assert.match(
src,
/render_failed.{0,200}503.{0,400}noStore:\s*true/s,
'render failure must 503 with no-store',
);
});
it('FONT_URL uses a Satori-parseable format (ttf / otf / woff — NOT woff2)', async () => {
// REGRESSION: an earlier head shipped a woff2 URL. Satori parses
// ttf / otf / woff only — a woff2 buffer throws on every render,
// the route returns 503, the carousel never delivers. Lock the
// format here so a future swap can't regress.
const { readFileSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { dirname, resolve } = await import('node:path');
const __d = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(
resolve(__d, '../server/_shared/brief-carousel-render.ts'),
'utf-8',
);
const fontUrlMatch = src.match(/const FONT_URL\s*=\s*['"]([^'"]+)['"]/);
assert.ok(fontUrlMatch, 'FONT_URL constant must exist');
const url = fontUrlMatch[1];
assert.doesNotMatch(url, /\.woff2($|\?|#)/i, 'woff2 is NOT supported by Satori — use ttf/otf/woff');
assert.match(url, /\.(ttf|otf|woff)($|\?|#)/i, 'FONT_URL must end in .ttf, .otf, or .woff');
});
it('the renderer honestly declares Google Fonts as a runtime dependency', async () => {
const { readFileSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { dirname, resolve } = await import('node:path');
const __d = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(
resolve(__d, '../server/_shared/brief-carousel-render.ts'),
'utf-8',
);
// Earlier comment lied about a "safe embedded/fallback path" that
// didn't exist. The corrected comment must either honestly declare
// the CDN dependency OR actually ship an embedded fallback font.
const hasHonestDependency =
/RUNTIME DEPENDENCY/i.test(src) || /hard runtime dependency/i.test(src);
const hasEmbeddedFallback = /const EMBEDDED_FONT_BASE64/.test(src);
assert.ok(
hasHonestDependency || hasEmbeddedFallback,
'font loading must EITHER declare the CDN dependency OR ship an embedded fallback',
);
});
});
// ── End-to-end renderer smoke ───────────────────────────────────────────
//
// Exercises @vercel/og's ImageResponse against each layout. Catches:
// - Satori tree-shape regressions (bad style/children keys throw)
// - Font fetch breakage (jsdelivr down, wrong format, etc.)
// - resvg-wasm init failure (rare but has happened)
// - PNG output corruption (wrong magic, zero bytes)
//
// Hits the real jsdelivr CDN for the Noto Serif TTF. Same network
// footprint as the rest of the data suite (which calls FRED, IMF,
// etc.). If that ever becomes a problem, swap loadFont() to an
// embedded base64 TTF per the comment in brief-carousel-render.ts.
const SAMPLE_ENVELOPE = {
version: 1,
issuedAt: Date.now(),
data: {
issue: '001',
dateLong: '19 April 2026',
user: { name: 'Test User' },
digest: {
greeting: 'Good morning',
lead: 'A sample lead line that gives the reader the day in one sentence.',
threads: [
{ tag: 'MIDDLE EAST', teaser: 'Iran re-closes the Strait of Hormuz' },
{ tag: 'UKRAINE', teaser: 'Kyiv authorities investigate terror attack' },
{ tag: 'LEBANON', teaser: 'French UNIFIL peacekeeper killed in attack' },
],
},
stories: [
{
category: 'Geopolitics',
country: 'IR',
threatLevel: 'HIGH',
headline: 'Iran closes Strait of Hormuz again, cites US blockade',
source: 'Reuters',
},
],
},
};
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
async function assertRendersPng(page) {
const res = await renderCarouselImageResponse(SAMPLE_ENVELOPE, page);
assert.equal(res.status, 200, `${page}: status should be 200`);
assert.equal(
res.headers.get('content-type'),
'image/png',
`${page}: content-type must be image/png`,
);
const buf = new Uint8Array(await res.arrayBuffer());
assert.ok(buf.byteLength > 5_000, `${page}: PNG body should be > 5KB, got ${buf.byteLength}`);
assert.ok(buf.byteLength < 500_000, `${page}: PNG body should be < 500KB, got ${buf.byteLength}`);
for (let i = 0; i < PNG_MAGIC.length; i++) {
assert.equal(buf[i], PNG_MAGIC[i], `${page}: byte ${i} should be PNG magic 0x${PNG_MAGIC[i].toString(16)}, got 0x${buf[i].toString(16)}`);
}
}
describe('renderCarouselImageResponse', () => {
it('renders the cover page to a valid PNG', async () => {
await assertRendersPng('cover');
});
it('renders the threads page to a valid PNG', async () => {
await assertRendersPng('threads');
});
it('renders the story page to a valid PNG', async () => {
await assertRendersPng('story');
});
it('rejects a structurally empty envelope', async () => {
await assert.rejects(
() => renderCarouselImageResponse({}, 'cover'),
/invalid envelope/,
);
});
it('threads the extraHeaders argument onto the Response', async () => {
const res = await renderCarouselImageResponse(SAMPLE_ENVELOPE, 'cover', {
'X-Test-Marker': 'carousel-smoke',
'Referrer-Policy': 'no-referrer',
});
assert.equal(res.headers.get('x-test-marker'), 'carousel-smoke');
assert.equal(res.headers.get('referrer-policy'), 'no-referrer');
});
it('keeps @vercel/og default Cache-Control (extraHeaders must NOT override it)', async () => {
// ImageResponse APPENDS rather than overrides Cache-Control when
// the caller passes one via headers. Guards the route handler
// choice to rely on @vercel/og's 1-year immutable default instead
// of stacking our own. If @vercel/og ever changes this semantics,
// this test fails and the route needs a review.
const res = await renderCarouselImageResponse(SAMPLE_ENVELOPE, 'cover', {
'Cache-Control': 'public, max-age=60',
});
const cc = res.headers.get('cache-control') ?? '';
assert.ok(
cc.includes('max-age=31536000'),
`expected @vercel/og's default 1-year cache to survive, got "${cc}"`,
);
});
});