forked from heygen-com/hyperframes
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhandler.test.ts
More file actions
507 lines (467 loc) · 18.3 KB
/
handler.test.ts
File metadata and controls
507 lines (467 loc) · 18.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
/**
* Handler dispatch unit tests.
*
* Asserts that:
* - The handler routes Action="plan" / "renderChunk" / "assemble" to the
* matching OSS primitive.
* - It unwraps Step Functions `{ Payload }` and `{ Input }` envelopes.
* - It rejects unknown actions with a clear message.
* - It plumbs S3 download/upload calls in the correct order.
*
* The real OSS primitives are NOT exercised here — they live in
* `@hyperframes/producer/distributed` and have their own coverage in
* `packages/producer`. The Lambda handler is thin glue; this file pins
* the glue's contract.
*/
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AssembleResult, ChunkResult, PlanResult } from "@hyperframes/producer/distributed";
import type { AssembleEvent, LambdaEvent, PlanEvent, RenderChunkEvent } from "./events.js";
import { handler, unwrapEvent } from "./handler.js";
interface FakeS3Op {
kind: "download" | "upload";
uri: string;
bytes?: number;
}
/**
* In-memory S3 stand-in. Records every operation so test assertions can
* pin the exact sequence of downloads and uploads, plus fakes the GetObject
* stream so {@link downloadS3ObjectToFile} writes the expected bytes.
*/
class FakeS3Client {
ops: FakeS3Op[] = [];
// Map S3 URIs → byte buffers the fake serves.
objects = new Map<string, Buffer>();
// Methods called by the real S3 transport — minimal surface so the
// handler's call sites don't need rewriting under test.
async send(command: unknown): Promise<unknown> {
const op = command as { input: { Bucket: string; Key: string } } & {
constructor: { name: string };
};
const cmdName = op.constructor?.name ?? "";
const uri = `s3://${op.input.Bucket}/${op.input.Key}`;
if (cmdName === "GetObjectCommand") {
const bytes = this.objects.get(uri) ?? Buffer.alloc(0);
this.ops.push({ kind: "download", uri, bytes: bytes.length });
// Mock the AWS SDK stream contract just enough for pipeline() to
// pump bytes into a write stream.
const { Readable } = await import("node:stream");
return { Body: Readable.from([bytes]) };
}
if (cmdName === "PutObjectCommand") {
// Buffer the body so we can record how many bytes were uploaded; the
// handler's hot path streams from disk, but tests pin the count.
const body = (command as { input: { Body: NodeJS.ReadableStream | Buffer } }).input.Body;
let bytes = 0;
if (Buffer.isBuffer(body)) {
bytes = body.length;
} else if (body && typeof (body as NodeJS.ReadableStream).pipe === "function") {
for await (const chunk of body as NodeJS.ReadableStream) {
bytes += (chunk as Buffer).length;
}
}
this.ops.push({ kind: "upload", uri, bytes });
this.objects.set(uri, Buffer.alloc(bytes));
return {};
}
return {};
}
}
const tmpDirs: string[] = [];
beforeEach(() => {
// Each test gets its own tmp root so concurrent test runs don't share state.
});
afterEach(() => {
for (const dir of tmpDirs) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// Best-effort cleanup.
}
}
tmpDirs.length = 0;
});
function makeTmpRoot(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-lambda-test-"));
tmpDirs.push(dir);
return dir;
}
describe("unwrapEvent", () => {
it("returns a bare event unchanged", () => {
const event: PlanEvent = {
Action: "plan",
ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/abc/",
Config: { fps: 30, width: 1920, height: 1080, format: "mp4" },
};
expect(unwrapEvent(event).Action).toBe("plan");
});
it("unwraps a Step Functions { Payload } envelope", () => {
const inner: RenderChunkEvent = {
Action: "renderChunk",
PlanS3Uri: "s3://bucket/plan.tar.gz",
PlanHash: "deadbeef",
ChunkIndex: 3,
ChunkOutputS3Prefix: "s3://bucket/renders/abc/",
Format: "mp4",
};
const wrapped: LambdaEvent = { Payload: inner };
expect(unwrapEvent(wrapped).Action).toBe("renderChunk");
});
it("unwraps multiple levels of envelopes", () => {
const inner: AssembleEvent = {
Action: "assemble",
PlanS3Uri: "s3://bucket/plan.tar.gz",
ChunkS3Uris: ["s3://bucket/chunks/0001.mp4"],
AudioS3Uri: null,
OutputS3Uri: "s3://bucket/output.mp4",
Format: "mp4",
};
const doubly: LambdaEvent = { Payload: { Input: inner } };
expect(unwrapEvent(doubly).Action).toBe("assemble");
});
it("throws on unknown action", () => {
expect(() => unwrapEvent({ Action: "doSomething" } as unknown as LambdaEvent)).toThrow(
/no recognised Action/,
);
});
});
describe("handler dispatch", () => {
it("routes Action='plan' to the plan primitive", async () => {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
// Seed a fake project tarball so the untar step has something to chew on.
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
const planMock = mock(
async (_projectDir: string, _config: unknown, planDir: string): Promise<PlanResult> => {
// Simulate plan() writing a minimal planDir.
mkdirSync(planDir, { recursive: true });
writeFileSync(join(planDir, "plan.json"), JSON.stringify({ planHash: "fakehash" }));
mkdirSync(join(planDir, "meta"), { recursive: true });
writeFileSync(join(planDir, "meta", "chunks.json"), "[]");
return {
planDir,
planHash: "fakehash",
chunkCount: 4,
totalFrames: 720,
fps: 30 as const,
width: 1920,
height: 1080,
format: "mp4" as const,
ffmpegVersion: "6.0",
producerVersion: "0.0.0-test",
};
},
);
const renderChunkMock = mock(async () => {
throw new Error("should not be called");
});
const assembleMock = mock(async () => {
throw new Error("should not be called");
});
const event: PlanEvent = {
Action: "plan",
ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/abc/",
Config: { fps: 30, width: 1920, height: 1080, format: "mp4" },
};
const result = await handler(event, {
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
primitives: {
plan: planMock as unknown as typeof import("@hyperframes/producer/distributed").plan,
renderChunk:
renderChunkMock as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
assemble:
assembleMock as unknown as typeof import("@hyperframes/producer/distributed").assemble,
},
tmpRoot,
skipChromeResolution: true,
});
expect(result.Action).toBe("plan");
if (result.Action !== "plan") throw new Error("unreachable");
expect(result.PlanHash).toBe("fakehash");
expect(result.ChunkCount).toBe(4);
expect(planMock).toHaveBeenCalledTimes(1);
expect(renderChunkMock).not.toHaveBeenCalled();
expect(assembleMock).not.toHaveBeenCalled();
// Plan should have downloaded the project zip and uploaded the plan tar.
expect(
s3.ops.some((o) => o.kind === "download" && o.uri === "s3://bucket/project.tar.gz"),
).toBe(true);
});
it("plan honors a pre-set PRODUCER_HEADLESS_SHELL_PATH instead of re-resolving Chrome", async () => {
// Mirrors the renderChunk env-var guard — when a caller (e.g. SAM-local
// RIE smoke) seeds the path, handlePlan must not overwrite it.
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
const planMock = mock(
async (_projectDir: string, _config: unknown, planDir: string): Promise<PlanResult> => {
mkdirSync(planDir, { recursive: true });
writeFileSync(join(planDir, "plan.json"), JSON.stringify({ planHash: "fakehash" }));
mkdirSync(join(planDir, "meta"), { recursive: true });
writeFileSync(join(planDir, "meta", "chunks.json"), "[]");
return {
planDir,
planHash: "fakehash",
chunkCount: 1,
totalFrames: 30,
fps: 30 as const,
width: 1920,
height: 1080,
format: "mp4" as const,
ffmpegVersion: "6.0",
producerVersion: "0.0.0-test",
};
},
);
const renderChunkMock = mock(async () => {
throw new Error("should not be called");
});
const assembleMock = mock(async () => {
throw new Error("should not be called");
});
const event: PlanEvent = {
Action: "plan",
ProjectS3Uri: "s3://bucket/project.tar.gz",
PlanOutputS3Prefix: "s3://bucket/renders/abc/",
Config: { fps: 30, width: 1920, height: 1080, format: "mp4" },
};
const sentinel = "/tmp/test-chrome-sentinel";
const prev = process.env.PRODUCER_HEADLESS_SHELL_PATH;
process.env.PRODUCER_HEADLESS_SHELL_PATH = sentinel;
try {
// Note: no skipChromeResolution flag — the guard must short-circuit
// because PRODUCER_HEADLESS_SHELL_PATH is already set.
await handler(event, {
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
primitives: {
plan: planMock as unknown as typeof import("@hyperframes/producer/distributed").plan,
renderChunk:
renderChunkMock as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
assemble:
assembleMock as unknown as typeof import("@hyperframes/producer/distributed").assemble,
},
tmpRoot,
});
expect(process.env.PRODUCER_HEADLESS_SHELL_PATH).toBe(sentinel);
expect(planMock).toHaveBeenCalledTimes(1);
} finally {
if (prev === undefined) {
delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
} else {
process.env.PRODUCER_HEADLESS_SHELL_PATH = prev;
}
}
});
it("routes Action='renderChunk' to the renderChunk primitive", async () => {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
// Seed a planDir tarball with a minimal structure renderChunk would
// observe; the test mock doesn't read it, but the handler untar step does.
s3.objects.set("s3://bucket/plan.tar.gz", await makeMinimalPlanTar());
const renderChunkMock = mock(
async (
_planDir: string,
_chunkIndex: number,
outputChunkPath: string,
): Promise<ChunkResult> => {
// Write a fake chunk file so the upload step has bytes to send.
writeFileSync(outputChunkPath, Buffer.from("FAKE-MP4-CHUNK"));
return {
outputPath: outputChunkPath,
outputKind: "file",
framesEncoded: 240,
sha256: "0".repeat(64),
durationMs: 12345,
perfPath: outputChunkPath + ".perf.json",
};
},
);
const planMock = mock(async () => {
throw new Error("should not be called");
});
const assembleMock = mock(async () => {
throw new Error("should not be called");
});
const event: RenderChunkEvent = {
Action: "renderChunk",
PlanS3Uri: "s3://bucket/plan.tar.gz",
PlanHash: "fakehash",
ChunkIndex: 2,
ChunkOutputS3Prefix: "s3://bucket/renders/abc/",
Format: "mp4",
};
const result = await handler(event, {
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
primitives: {
plan: planMock as unknown as typeof import("@hyperframes/producer/distributed").plan,
renderChunk:
renderChunkMock as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
assemble:
assembleMock as unknown as typeof import("@hyperframes/producer/distributed").assemble,
},
tmpRoot,
skipChromeResolution: true,
});
expect(result.Action).toBe("renderChunk");
if (result.Action !== "renderChunk") throw new Error("unreachable");
expect(result.ChunkIndex).toBe(2);
expect(result.Sha256).toBe("0".repeat(64));
expect(result.FramesEncoded).toBe(240);
expect(renderChunkMock).toHaveBeenCalledTimes(1);
});
it("rejects renderChunk when event.PlanHash diverges from plan.json", async () => {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
// The fixture's plan.json has planHash="fakehash"; the event below
// claims something else, so the handler must throw PLAN_HASH_MISMATCH
// before invoking the primitive.
s3.objects.set("s3://bucket/plan.tar.gz", await makeMinimalPlanTar());
const renderChunkMock = mock(async () => {
throw new Error("primitive should not be called on a hash mismatch");
});
const planMock = mock(async () => {
throw new Error("should not be called");
});
const assembleMock = mock(async () => {
throw new Error("should not be called");
});
const event: RenderChunkEvent = {
Action: "renderChunk",
PlanS3Uri: "s3://bucket/plan.tar.gz",
PlanHash: "not-the-real-hash",
ChunkIndex: 0,
ChunkOutputS3Prefix: "s3://bucket/renders/abc/",
Format: "mp4",
};
let caught: unknown;
try {
await handler(event, {
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
primitives: {
plan: planMock as unknown as typeof import("@hyperframes/producer/distributed").plan,
renderChunk:
renderChunkMock as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
assemble:
assembleMock as unknown as typeof import("@hyperframes/producer/distributed").assemble,
},
tmpRoot,
skipChromeResolution: true,
});
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).name).toBe("PLAN_HASH_MISMATCH");
expect((caught as Error).message).toMatch(/not-the-real-hash/);
expect(renderChunkMock).not.toHaveBeenCalled();
});
it("routes Action='assemble' to the assemble primitive", async () => {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
s3.objects.set("s3://bucket/plan.tar.gz", await makeMinimalPlanTar());
s3.objects.set("s3://bucket/chunks/0001.mp4", Buffer.from("CHUNK-1"));
s3.objects.set("s3://bucket/chunks/0002.mp4", Buffer.from("CHUNK-2"));
const assembleMock = mock(
async (
_planDir: string,
_chunkPaths: readonly string[],
_audioPath: string | null,
outputPath: string,
): Promise<AssembleResult> => {
writeFileSync(outputPath, Buffer.from("FAKE-FINAL-MP4"));
return {
outputPath,
durationMs: 7777,
framesEncoded: 480,
fileSize: 14,
};
},
);
const event: AssembleEvent = {
Action: "assemble",
PlanS3Uri: "s3://bucket/plan.tar.gz",
ChunkS3Uris: ["s3://bucket/chunks/0001.mp4", "s3://bucket/chunks/0002.mp4"],
AudioS3Uri: null,
OutputS3Uri: "s3://bucket/renders/abc/output.mp4",
Format: "mp4",
};
const result = await handler(event, {
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
primitives: {
plan: mock(async () => {
throw new Error("should not be called");
}) as unknown as typeof import("@hyperframes/producer/distributed").plan,
renderChunk: mock(async () => {
throw new Error("should not be called");
}) as unknown as typeof import("@hyperframes/producer/distributed").renderChunk,
assemble:
assembleMock as unknown as typeof import("@hyperframes/producer/distributed").assemble,
},
tmpRoot,
skipChromeResolution: true,
});
expect(result.Action).toBe("assemble");
if (result.Action !== "assemble") throw new Error("unreachable");
expect(result.OutputS3Uri).toBe("s3://bucket/renders/abc/output.mp4");
expect(result.FramesEncoded).toBe(480);
expect(assembleMock).toHaveBeenCalledTimes(1);
});
it("rejects unknown actions", async () => {
const tmpRoot = makeTmpRoot();
await expect(
handler({ Action: "doSomething" } as unknown as LambdaEvent, {
s3: new FakeS3Client() as unknown as import("@aws-sdk/client-s3").S3Client,
tmpRoot,
skipChromeResolution: true,
}),
).rejects.toThrow(/no recognised Action/);
});
});
// ── helpers ─────────────────────────────────────────────────────────────────
/**
* Build the smallest valid `.tar.gz` the handler's untar step accepts: a
* single file inside an archive. Uses the npm `tar` package (same as
* `s3Transport.ts`) so the fixture builder runs cross-platform — Windows
* doesn't ship GNU tar in `/usr/bin/tar`, and bare Alpine containers
* don't ship `tar` at all. Keeps the test runnable everywhere the rest
* of the suite runs.
*/
async function makeMinimalProjectTar(): Promise<Buffer> {
const tar = await import("tar");
const { mkdtempSync: mk, readFileSync, rmSync: rm, writeFileSync: wf } = await import("node:fs");
const dir = mk(join(tmpdir(), "hf-lambda-mktar-"));
try {
wf(join(dir, "index.html"), "<!doctype html><title>test</title>");
const tarPath = join(dir, "out.tar.gz");
await tar.create({ gzip: true, file: tarPath, cwd: dir }, ["index.html"]);
return readFileSync(tarPath);
} finally {
rm(dir, { recursive: true, force: true });
}
}
/**
* Build a minimal `.tar.gz` for a tiny planDir containing `plan.json` +
* `meta/chunks.json`. Used by renderChunk/assemble tests where the handler
* untars but the mock primitive doesn't inspect contents.
*/
async function makeMinimalPlanTar(): Promise<Buffer> {
const tar = await import("tar");
const {
mkdtempSync: mk,
mkdirSync: md,
readFileSync: rf,
writeFileSync: wf,
} = await import("node:fs");
const dir = mk(join(tmpdir(), "hf-lambda-test-plan-"));
tmpDirs.push(dir);
md(join(dir, "meta"), { recursive: true });
wf(join(dir, "plan.json"), JSON.stringify({ planHash: "fakehash" }));
wf(join(dir, "meta", "chunks.json"), "[]");
const tarPath = join(dir, "out.tar.gz");
await tar.create({ gzip: true, file: tarPath, cwd: dir }, ["plan.json", "meta"]);
return rf(tarPath);
}