Files
hyperframes/packages/aws-lambda/src/s3Transport.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

94 lines
3.1 KiB
TypeScript

/**
* Unit tests for the S3 URI parser + tar helpers. Real S3 network calls
* are covered by the dispatch tests in `handler.test.ts` via a fake
* S3Client; here we pin the lower-level helpers.
*/
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { formatS3Uri, parseS3Uri, tarDirectory, untarDirectory } from "./s3Transport.js";
let scratchRoot: string;
beforeAll(() => {
scratchRoot = mkdtempSync(join(tmpdir(), "hf-s3transport-test-"));
});
afterAll(() => {
rmSync(scratchRoot, { recursive: true, force: true });
});
describe("parseS3Uri", () => {
it("parses a simple bucket+key URI", () => {
expect(parseS3Uri("s3://my-bucket/path/to/object.zip")).toEqual({
bucket: "my-bucket",
key: "path/to/object.zip",
});
});
it("preserves nested keys", () => {
expect(parseS3Uri("s3://b/a/b/c/d.mp4").key).toBe("a/b/c/d.mp4");
});
it("throws on non-s3 schemes", () => {
expect(() => parseS3Uri("https://example.com/x")).toThrow(/expected s3:\/\//);
});
it("throws on missing key", () => {
expect(() => parseS3Uri("s3://bucket-only")).toThrow(/missing key/);
});
it("throws on empty bucket", () => {
expect(() => parseS3Uri("s3:///somekey")).toThrow(/empty bucket or key/);
});
});
describe("formatS3Uri", () => {
it("round-trips with parseS3Uri", () => {
const uri = "s3://my-bucket/path/to/object.zip";
expect(formatS3Uri(parseS3Uri(uri))).toBe(uri);
});
});
describe("tar round-trip", () => {
it("tars a directory and untars to identical contents", async () => {
const sourceDir = join(scratchRoot, "src");
const destDir = join(scratchRoot, "dest");
const tarPath = join(scratchRoot, "out.tar.gz");
const { mkdirSync } = await import("node:fs");
mkdirSync(join(sourceDir, "nested"), { recursive: true });
writeFileSync(join(sourceDir, "top.txt"), "hello-top");
writeFileSync(join(sourceDir, "nested", "inner.txt"), "hello-inner");
await tarDirectory(sourceDir, tarPath);
await untarDirectory(tarPath, destDir);
expect(readFileSync(join(destDir, "top.txt"), "utf-8")).toBe("hello-top");
expect(readFileSync(join(destDir, "nested", "inner.txt"), "utf-8")).toBe("hello-inner");
});
it("wipes the destination before extracting", async () => {
const sourceDir = join(scratchRoot, "src2");
const destDir = join(scratchRoot, "dest2");
const tarPath = join(scratchRoot, "out2.tar.gz");
const { mkdirSync } = await import("node:fs");
mkdirSync(sourceDir, { recursive: true });
writeFileSync(join(sourceDir, "fresh.txt"), "new");
mkdirSync(destDir, { recursive: true });
writeFileSync(join(destDir, "stale.txt"), "leftover");
await tarDirectory(sourceDir, tarPath);
await untarDirectory(tarPath, destDir);
// Stale file should be gone; fresh file should be present.
expect(readFileSync(join(destDir, "fresh.txt"), "utf-8")).toBe("new");
const { existsSync } = await import("node:fs");
expect(existsSync(join(destDir, "stale.txt"))).toBe(false);
});
});