mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
Add client-side validation for the new config.variables field (introduced in PR 9.1) and a 256 KiB cap on the full Step Functions Standard execution input. Both checks throw a typed InvalidConfigError BEFORE the SDK calls StartExecution — catching the obvious mistakes locally instead of as a States.DataLimitExceeded 50 ms into the execution. validateVariablesPayload walks the variables tree and rejects: - functions, Symbols, BigInts, non-finite numbers - undefined leaves (silently dropped by JSON.stringify — would surprise the caller when their value doesn't show up in the render) - non-plain objects (Date, Map, class instances) — Date's toJSON does round-trip as a string, but the composition gets a string, not a Date, so explicit reject is clearer validateStepFunctionsInputSize measures the actual UTF-8 byte length of JSON.stringify(input) against the 256 KiB cap. We use Standard workflows (per the plan §6.2 / §15.2) for execution-history visibility, so the cap is 256 KiB (Express would be 32 KiB). The error message names the actual byte count, the cap, and points at the templates-on-lambda#working-with-large-variables section so users know to URL-reference media assets instead of inlining them. Both helpers are exported from @hyperframes/aws-lambda/sdk so adapters that build custom Step Functions inputs (batch verbs, future Temporal ports) can reuse the same gates. Phase 9 PR 9.2 of the distributed rendering plan.
330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
||
import type { SerializableDistributedRenderConfig } from "../events.js";
|
||
import {
|
||
InvalidConfigError,
|
||
MAX_STEP_FUNCTIONS_INPUT_BYTES,
|
||
validateDistributedRenderConfig,
|
||
validateStepFunctionsInputSize,
|
||
validateVariablesPayload,
|
||
} from "./validateConfig.js";
|
||
|
||
const VALID: SerializableDistributedRenderConfig = {
|
||
fps: 30,
|
||
width: 1920,
|
||
height: 1080,
|
||
format: "mp4",
|
||
};
|
||
|
||
describe("validateDistributedRenderConfig", () => {
|
||
it("returns the same reference on the happy path", () => {
|
||
expect(validateDistributedRenderConfig(VALID)).toBe(VALID);
|
||
});
|
||
|
||
it("accepts optional fields when valid", () => {
|
||
const cfg: SerializableDistributedRenderConfig = {
|
||
...VALID,
|
||
codec: "h265",
|
||
quality: "high",
|
||
crf: 18,
|
||
chunkSize: 240,
|
||
maxParallelChunks: 16,
|
||
runtimeCap: "lambda",
|
||
hdrMode: "force-sdr",
|
||
};
|
||
expect(validateDistributedRenderConfig(cfg)).toBe(cfg);
|
||
});
|
||
|
||
it.each([
|
||
["null config", null as unknown as SerializableDistributedRenderConfig, "config"],
|
||
[
|
||
"wrong fps",
|
||
{ ...VALID, fps: 25 as 24 | 30 | 60 } satisfies SerializableDistributedRenderConfig,
|
||
"config.fps",
|
||
],
|
||
[
|
||
"non-integer width",
|
||
{ ...VALID, width: 1280.5 } satisfies SerializableDistributedRenderConfig,
|
||
"config.width",
|
||
],
|
||
[
|
||
"odd width (yuv420p parity)",
|
||
{ ...VALID, width: 1281 } satisfies SerializableDistributedRenderConfig,
|
||
"config.width",
|
||
],
|
||
[
|
||
"out-of-range height",
|
||
{ ...VALID, height: 8000 } satisfies SerializableDistributedRenderConfig,
|
||
"config.height",
|
||
],
|
||
[
|
||
"unsupported format",
|
||
{
|
||
...VALID,
|
||
format: "gif",
|
||
} as unknown as SerializableDistributedRenderConfig,
|
||
"config.format",
|
||
],
|
||
[
|
||
"codec with non-mp4 format",
|
||
{ ...VALID, format: "mov", codec: "h264" } satisfies SerializableDistributedRenderConfig,
|
||
"config.codec",
|
||
],
|
||
[
|
||
"unknown codec",
|
||
{
|
||
...VALID,
|
||
codec: "av1",
|
||
} as unknown as SerializableDistributedRenderConfig,
|
||
"config.codec",
|
||
],
|
||
[
|
||
"crf + bitrate together",
|
||
{ ...VALID, crf: 18, bitrate: "10M" } satisfies SerializableDistributedRenderConfig,
|
||
"config.crf",
|
||
],
|
||
[
|
||
"crf out of range",
|
||
{ ...VALID, crf: 60 } satisfies SerializableDistributedRenderConfig,
|
||
"config.crf",
|
||
],
|
||
[
|
||
"malformed bitrate",
|
||
{ ...VALID, bitrate: "fast" } satisfies SerializableDistributedRenderConfig,
|
||
"config.bitrate",
|
||
],
|
||
[
|
||
"non-positive chunkSize",
|
||
{ ...VALID, chunkSize: 0 } satisfies SerializableDistributedRenderConfig,
|
||
"config.chunkSize",
|
||
],
|
||
[
|
||
"chunkSize over Lambda ceiling",
|
||
{ ...VALID, chunkSize: 9999 } satisfies SerializableDistributedRenderConfig,
|
||
"config.chunkSize",
|
||
],
|
||
[
|
||
"maxParallelChunks 0",
|
||
{ ...VALID, maxParallelChunks: 0 } satisfies SerializableDistributedRenderConfig,
|
||
"config.maxParallelChunks",
|
||
],
|
||
[
|
||
"unknown runtimeCap",
|
||
{
|
||
...VALID,
|
||
runtimeCap: "azure",
|
||
} as unknown as SerializableDistributedRenderConfig,
|
||
"config.runtimeCap",
|
||
],
|
||
[
|
||
"force-hdr rejected",
|
||
{
|
||
...VALID,
|
||
hdrMode: "force-hdr",
|
||
} as unknown as SerializableDistributedRenderConfig,
|
||
"config.hdrMode",
|
||
],
|
||
])("rejects %s with field=%s", (_label, input, expectedField) => {
|
||
try {
|
||
validateDistributedRenderConfig(input);
|
||
throw new Error("expected validateDistributedRenderConfig to throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
expect((err as InvalidConfigError).field).toBe(expectedField);
|
||
expect((err as InvalidConfigError).name).toBe("InvalidConfigError");
|
||
}
|
||
});
|
||
|
||
describe("variables", () => {
|
||
it("accepts a plain JSON object", () => {
|
||
const cfg: SerializableDistributedRenderConfig = {
|
||
...VALID,
|
||
variables: {
|
||
title: "Hello",
|
||
accent: "#ff0000",
|
||
nested: { items: [1, 2, 3], visible: true, note: null },
|
||
},
|
||
};
|
||
expect(validateDistributedRenderConfig(cfg)).toBe(cfg);
|
||
});
|
||
|
||
it("rejects variables that's an array, not a plain object", () => {
|
||
try {
|
||
validateDistributedRenderConfig({
|
||
...VALID,
|
||
variables: [1, 2, 3] as unknown as Record<string, unknown>,
|
||
});
|
||
throw new Error("expected throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
expect((err as InvalidConfigError).field).toBe("config.variables");
|
||
}
|
||
});
|
||
|
||
it("rejects functions inside variables", () => {
|
||
try {
|
||
validateVariablesPayload({ greet: () => "hi" });
|
||
throw new Error("expected throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
expect((err as InvalidConfigError).field).toBe("config.variables.greet");
|
||
expect((err as Error).message).toMatch(/function/i);
|
||
}
|
||
});
|
||
|
||
it("rejects undefined leaves (silently dropped by JSON.stringify)", () => {
|
||
try {
|
||
validateVariablesPayload({ title: "x", maybe: undefined });
|
||
throw new Error("expected throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
expect((err as InvalidConfigError).field).toBe("config.variables.maybe");
|
||
}
|
||
});
|
||
|
||
it("rejects BigInt values", () => {
|
||
try {
|
||
validateVariablesPayload({ count: 9_007_199_254_740_993n });
|
||
throw new Error("expected throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
expect((err as InvalidConfigError).field).toBe("config.variables.count");
|
||
}
|
||
});
|
||
|
||
it("rejects NaN / Infinity numbers", () => {
|
||
try {
|
||
validateVariablesPayload({ ratio: Number.NaN });
|
||
throw new Error("expected throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
expect((err as InvalidConfigError).field).toBe("config.variables.ratio");
|
||
}
|
||
try {
|
||
validateVariablesPayload({ ratio: Number.POSITIVE_INFINITY });
|
||
throw new Error("expected throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
expect((err as InvalidConfigError).field).toBe("config.variables.ratio");
|
||
}
|
||
});
|
||
|
||
it("rejects Symbols", () => {
|
||
try {
|
||
validateVariablesPayload({ id: Symbol("hi") });
|
||
throw new Error("expected throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
expect((err as InvalidConfigError).field).toBe("config.variables.id");
|
||
}
|
||
});
|
||
|
||
it("rejects Date instances (non-plain objects)", () => {
|
||
try {
|
||
validateVariablesPayload({ when: new Date("2026-01-01") });
|
||
throw new Error("expected throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
expect((err as InvalidConfigError).field).toBe("config.variables.when");
|
||
expect((err as Error).message).toMatch(/Date|non-plain/);
|
||
}
|
||
});
|
||
|
||
it("walks into arrays and reports nested paths", () => {
|
||
try {
|
||
validateVariablesPayload({ items: ["a", { broken: () => 1 }] });
|
||
throw new Error("expected throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
expect((err as InvalidConfigError).field).toBe("config.variables.items[1].broken");
|
||
}
|
||
});
|
||
|
||
it("rejects circular references with a typed error instead of stack-overflowing", () => {
|
||
const cyclic: Record<string, unknown> = { title: "x" };
|
||
cyclic.self = cyclic;
|
||
try {
|
||
validateVariablesPayload(cyclic);
|
||
throw new Error("expected throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
expect((err as Error).message).toMatch(/circular/i);
|
||
}
|
||
});
|
||
|
||
it("rejects cycles via arrays too", () => {
|
||
const arr: unknown[] = ["a"];
|
||
arr.push(arr);
|
||
try {
|
||
validateVariablesPayload({ items: arr });
|
||
throw new Error("expected throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
expect((err as Error).message).toMatch(/circular/i);
|
||
}
|
||
});
|
||
|
||
it("round-trips through JSON for the validated set", () => {
|
||
const variables = {
|
||
title: "Personalised render",
|
||
scene: { intro: { lines: ["one", "two"], delay: 0.5 } },
|
||
tags: ["alpha", "beta"],
|
||
active: true,
|
||
nothing: null,
|
||
};
|
||
validateVariablesPayload(variables);
|
||
const round = JSON.parse(JSON.stringify(variables));
|
||
expect(round).toEqual(variables);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("validateStepFunctionsInputSize", () => {
|
||
it("accepts inputs under the 256 KiB cap", () => {
|
||
const input = {
|
||
ProjectS3Uri: "s3://bucket/sites/abc/project.tar.gz",
|
||
Config: { fps: 30, width: 1280, height: 720, format: "mp4" },
|
||
};
|
||
expect(() => validateStepFunctionsInputSize(input)).not.toThrow();
|
||
});
|
||
|
||
it("rejects inputs over the 256 KiB cap with a message that names the byte count", () => {
|
||
// Build a variables blob that pushes the serialised input over the cap.
|
||
// 256 KiB ÷ 2 bytes per char × 1 char per byte for ASCII; pad to 260 KiB
|
||
// worth of payload so the serialiser overhead is dwarfed.
|
||
const huge = "x".repeat(260 * 1024);
|
||
const input = {
|
||
ProjectS3Uri: "s3://bucket/sites/abc/project.tar.gz",
|
||
Config: {
|
||
fps: 30,
|
||
width: 1280,
|
||
height: 720,
|
||
format: "mp4",
|
||
variables: { blob: huge },
|
||
},
|
||
};
|
||
try {
|
||
validateStepFunctionsInputSize(input);
|
||
throw new Error("expected throw");
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(InvalidConfigError);
|
||
const msg = (err as Error).message;
|
||
expect(msg).toMatch(/256/);
|
||
// Names the actual byte count so users see how far over the cap they are.
|
||
const serialized = JSON.stringify(input);
|
||
const expectedBytes = Buffer.byteLength(serialized, "utf8");
|
||
expect(msg).toContain(String(expectedBytes));
|
||
// Pointer to the docs section on URL'ing assets.
|
||
expect(msg).toMatch(/templates-on-lambda/);
|
||
}
|
||
});
|
||
|
||
it("MAX_STEP_FUNCTIONS_INPUT_BYTES is 256 KiB", () => {
|
||
expect(MAX_STEP_FUNCTIONS_INPUT_BYTES).toBe(256 * 1024);
|
||
});
|
||
|
||
it("rejects non-JSON-serializable roots with a clear error", () => {
|
||
// A top-level function reference makes JSON.stringify return undefined.
|
||
expect(() => validateStepFunctionsInputSize(() => "boom")).toThrow(/not JSON-serializable/);
|
||
});
|
||
});
|