Files
hyperframes/packages/aws-lambda/src/chromium.test.ts
T
James Russo c50f59a53b feat(lambda): add Lambda handler, ZIP bundling, and BeginFrame probe (#878)
* feat(lambda): add Lambda handler, ZIP bundling, and BeginFrame probe

Phase 6 of the distributed rendering plan: AWS Lambda turnkey adoption
(see DISTRIBUTED-RENDERING-PLAN.md §11 Phase 6 + §15).

This PR adds the new packages/aws-lambda/ workspace package that wraps
the OSS plan/renderChunk/assemble primitives in an AWS Lambda handler,
plus a build pipeline that bundles the handler + Chromium runtime +
ffmpeg into a deployable ZIP.

Architecture: ZIP deploy (not Docker image), Chrome via @sparticuz/chromium
with chrome-headless-shell fallback, dispatch on event.Action ∈ {plan,
renderChunk, assemble}.

The load-bearing concern — does @sparticuz/chromium's chrome-headless-shell
build honour CDP HeadlessExperimental.beginFrame? — is pinned by the new
scripts/probe-beginframe.ts regression guard. Probe boots the runtime
inside public.ecr.aws/lambda/nodejs:22, navigates to a static page, and
asserts beginFrame returns a PNG buffer. Verified locally + inside the
Docker container; both pass with hasDamage=true.

Sizes (sparticuz source): unzipped 157 MiB, zipped 99 MiB. Well under
the 240 MiB / 150 MiB in-house gates and the Lambda 250 MiB hard ceiling.

This is part of a stack of 8 PRs (3 in Phase 6a, 5 in Phase 6b); this is
PR 6.1.

* fix(lambda): address PR 878 review feedback

- Verify event.PlanHash against the untarred plan.json at the handler
  boundary before invoking the producer primitive. Throws typed
  PLAN_HASH_MISMATCH on divergence so Step Functions routes it as
  non-retryable; previously the field was schema bloat the handler
  ignored, leaving enforcement entirely inside the producer.
- Standardize on MiB throughout build-zip.ts, verify-zip-size.ts, and
  the README. Lambda's hard ceiling is 250 MiB (AWS docs label "250 MB"
  but use binary mebibytes); previously mixed units made the 248 MiB
  budget look like a ~5 MB margin instead of the 2 MiB it actually is.
- stageChromeHeadlessShell now picks Chrome versions via numeric semver
  comparison instead of lexicographic sort+reverse — the latter would
  silently pick "99.x" over "131.x" once Chrome cached three-digit
  majors that aren't width-aligned.
- Drop _setSparticuzChromiumForTests from the public index barrel.
  Test-only DI seam imported directly from ./chromium.js in tests.
- Replace require("node:fs") inside walkSize() with the top-level fs
  imports — file is ESM and the same module is already imported.

* docs(lambda): drop internal plan-doc refs from package README

* ci(windows): fix bun filter UNION bug excluding producer from Windows tests

`bun run --filter "!a" --filter "!b" test` composes as a UNION (any
package matching either negation runs), not an intersection. Effect:
@hyperframes/producer was still being tested on Windows even though
it's explicitly excluded — its regression harness (Docker + LFS golden
mp4 baselines) is Linux-only and was driving the 32min timeout.

Enumerate the packages we DO want to test instead.
2026-05-16 18:08:47 -04:00

100 lines
3.8 KiB
TypeScript

/**
* Unit tests for the Chrome runtime resolver.
*
* The actual @sparticuz/chromium probe lives in
* `scripts/probe-beginframe.ts` (run in a Lambda-like Docker container).
* These tests pin the env-var → source-selection logic so a misconfigured
* deploy fails loudly rather than silently picking the wrong binary.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
_setSparticuzChromiumForTests,
resolveChromeArgs,
resolveChromeExecutablePath,
resolveChromeSource,
} from "./chromium.js";
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
savedEnv.HYPERFRAMES_LAMBDA_CHROME_SOURCE = process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE;
savedEnv.HYPERFRAMES_LAMBDA_CHROME_PATH = process.env.HYPERFRAMES_LAMBDA_CHROME_PATH;
});
afterEach(() => {
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = savedEnv.HYPERFRAMES_LAMBDA_CHROME_SOURCE;
process.env.HYPERFRAMES_LAMBDA_CHROME_PATH = savedEnv.HYPERFRAMES_LAMBDA_CHROME_PATH;
_setSparticuzChromiumForTests(null);
});
describe("resolveChromeSource", () => {
it("defaults to sparticuz when no env var is set", () => {
delete process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE;
expect(resolveChromeSource()).toBe("sparticuz");
});
it("returns chrome-headless-shell when env var requests it", () => {
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "chrome-headless-shell";
expect(resolveChromeSource()).toBe("chrome-headless-shell");
});
it("accepts the short alias 'shell'", () => {
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "shell";
expect(resolveChromeSource()).toBe("chrome-headless-shell");
});
it("is case insensitive", () => {
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "Chrome-Headless-Shell";
expect(resolveChromeSource()).toBe("chrome-headless-shell");
});
it("falls back to sparticuz on an unknown value", () => {
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "wat";
expect(resolveChromeSource()).toBe("sparticuz");
});
});
describe("resolveChromeExecutablePath", () => {
it("returns the path from a stubbed sparticuz module", async () => {
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "sparticuz";
_setSparticuzChromiumForTests({
args: ["--fake-arg"],
executablePath: async () => "/tmp/sparticuz-chromium",
});
expect(await resolveChromeExecutablePath()).toBe("/tmp/sparticuz-chromium");
expect(await resolveChromeArgs()).toEqual(["--fake-arg"]);
});
it("reads chrome-headless-shell path from HYPERFRAMES_LAMBDA_CHROME_PATH", async () => {
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "chrome-headless-shell";
const dir = mkdtempSync(join(tmpdir(), "hf-chrome-test-"));
const binPath = join(dir, "chrome-headless-shell");
writeFileSync(binPath, "fake binary contents");
try {
process.env.HYPERFRAMES_LAMBDA_CHROME_PATH = binPath;
expect(await resolveChromeExecutablePath()).toBe(binPath);
expect(await resolveChromeArgs()).toEqual([]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("throws if chrome-headless-shell path is missing", async () => {
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "chrome-headless-shell";
delete process.env.HYPERFRAMES_LAMBDA_CHROME_PATH;
await expect(resolveChromeExecutablePath()).rejects.toThrow(
/HYPERFRAMES_LAMBDA_CHROME_PATH to be set/,
);
});
it("throws if chrome-headless-shell path doesn't exist on disk", async () => {
process.env.HYPERFRAMES_LAMBDA_CHROME_SOURCE = "chrome-headless-shell";
process.env.HYPERFRAMES_LAMBDA_CHROME_PATH = "/nonexistent/path/chrome-headless-shell";
await expect(resolveChromeExecutablePath()).rejects.toThrow(/does not exist/);
});
});