mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
146 lines
6.1 KiB
TypeScript
146 lines
6.1 KiB
TypeScript
// @vitest-environment node
|
||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { __testing } from "./videoFrameInjector.js";
|
||
import { DEFAULT_CONFIG } from "../config.js";
|
||
|
||
const { createFrameSourceCache } = __testing;
|
||
|
||
const SHARED_STATS = { evictions: 0, oversizedRejections: 0 };
|
||
|
||
describe("frame source cache eviction", () => {
|
||
let dir: string;
|
||
|
||
beforeEach(() => {
|
||
dir = mkdtempSync(join(tmpdir(), "hf-frame-cache-test-"));
|
||
});
|
||
|
||
afterEach(() => {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
// Each PNG is base64-encoded into the data URI, so the cached string is
|
||
// ~4/3 the file size plus a small `data:image/png;base64,` prefix. Build
|
||
// distinct files so eviction has predictable victims.
|
||
function writeFrame(name: string, sizeBytes: number): string {
|
||
const filePath = join(dir, name);
|
||
writeFileSync(filePath, Buffer.alloc(sizeBytes, 0));
|
||
return filePath;
|
||
}
|
||
|
||
it("evicts oldest entry when entry count exceeds limit", async () => {
|
||
const cache = createFrameSourceCache(2, Number.MAX_SAFE_INTEGER);
|
||
const a = writeFrame("a.png", 16);
|
||
const b = writeFrame("b.png", 16);
|
||
const c = writeFrame("c.png", 16);
|
||
|
||
await cache.get(a);
|
||
await cache.get(b);
|
||
expect(cache.stats().entries).toBe(2);
|
||
|
||
await cache.get(c);
|
||
expect(cache.stats().entries).toBe(2);
|
||
expect(cache.stats().evictions).toBe(1);
|
||
|
||
// Verify the *oldest* entry (a) was the victim — the LRU contract.
|
||
// A later get(a) is a miss-then-insert, which would also evict whichever
|
||
// entry is now oldest. We instrument the eviction counter to detect it.
|
||
const evictionsBefore = cache.stats().evictions;
|
||
await cache.get(a);
|
||
expect(cache.stats().evictions).toBe(evictionsBefore + 1);
|
||
// After re-inserting `a`, `b` is the next oldest. `c` is now newest.
|
||
// Touch `b` (move-to-front) → next eviction would be `c`, not `b`.
|
||
});
|
||
|
||
it("evicts oldest entry when byte budget is exceeded", async () => {
|
||
// 1 KB raw frame → ~1.4 KB base64 + ~22-byte data URI prefix. Pick a
|
||
// budget that comfortably fits two URIs but not three, so the third
|
||
// get() forces eviction even though the entry-count cap (100) is far
|
||
// from the limit.
|
||
const cache = createFrameSourceCache(100, 4 * 1024);
|
||
const a = writeFrame("a.png", 1024);
|
||
const b = writeFrame("b.png", 1024);
|
||
const c = writeFrame("c.png", 1024);
|
||
|
||
await cache.get(a);
|
||
await cache.get(b);
|
||
expect(cache.stats().entries).toBe(2);
|
||
|
||
await cache.get(c);
|
||
const afterC = cache.stats();
|
||
// The byte budget is the contract — the cache MUST stay under it after
|
||
// an insert that would otherwise overflow. Entry count is incidental.
|
||
expect(afterC.bytes).toBeLessThanOrEqual(4 * 1024);
|
||
expect(afterC.entries).toBeLessThan(3);
|
||
});
|
||
|
||
it("returns the served URL untouched when frameSrcResolver yields one", async () => {
|
||
let served: string | null = "/served/frame.png";
|
||
const cache = createFrameSourceCache(4, 64 * 1024, () => served);
|
||
const file = writeFrame("a.png", 256);
|
||
|
||
expect(await cache.get(file)).toBe("/served/frame.png");
|
||
// Cache stays empty because the resolver short-circuits the read.
|
||
expect(cache.stats()).toMatchObject({ entries: 0, bytes: 0 });
|
||
|
||
served = null;
|
||
const dataUri = await cache.get(file);
|
||
expect(dataUri.startsWith("data:image/png;base64,")).toBe(true);
|
||
expect(cache.stats().entries).toBe(1);
|
||
});
|
||
|
||
it("treats a re-read as a cache hit (no second file read)", async () => {
|
||
const cache = createFrameSourceCache(2, Number.MAX_SAFE_INTEGER);
|
||
const a = writeFrame("a.png", 64);
|
||
|
||
const first = await cache.get(a);
|
||
const second = await cache.get(a);
|
||
expect(second).toBe(first);
|
||
expect(cache.stats().entries).toBe(1);
|
||
});
|
||
|
||
it("skips caching an entry that alone exceeds the byte budget (no self-eviction)", async () => {
|
||
// 64 KB raw → ~88 KB base64 + prefix. Budget of 32 KB rejects this entry.
|
||
// The contract: caller still gets the data URI; cache stays empty so
|
||
// future inserts aren't blocked by the rejected entry's bookkeeping.
|
||
const cache = createFrameSourceCache(100, 32 * 1024);
|
||
const big = writeFrame("big.png", 64 * 1024);
|
||
|
||
const dataUri = await cache.get(big);
|
||
expect(dataUri.startsWith("data:image/png;base64,")).toBe(true);
|
||
expect(cache.stats().entries).toBe(0);
|
||
expect(cache.stats().bytes).toBe(0);
|
||
expect(cache.stats().oversizedRejections).toBe(1);
|
||
expect(cache.stats().evictions).toBe(0);
|
||
|
||
// A subsequent normal-sized entry must cache cleanly — the rejection
|
||
// path didn't pollute internal state.
|
||
const small = writeFrame("small.png", 1024);
|
||
await cache.get(small);
|
||
expect(cache.stats().entries).toBe(1);
|
||
});
|
||
|
||
it("at the production default (1500 MB), 1080p frames stay cached", async () => {
|
||
// Regression for the post-PR-#662 default: previously the cache held up
|
||
// to 256 entries × ~8 MB ≈ 2 GB at 1080p. The new byte-budget default of
|
||
// 1500 MB caps it tighter (~187 entries at 1080p ≈ 6s @ 30fps). This
|
||
// test pins the math so a future tweak to the default is visible.
|
||
const oneEightyP_jpegSize = 8 * 1024 * 1024; // ~8 MB JPEG (data URI)
|
||
const defaultBytesLimit = DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb * 1024 * 1024;
|
||
const expectedMaxEntries = Math.floor(defaultBytesLimit / oneEightyP_jpegSize);
|
||
expect(expectedMaxEntries).toBeGreaterThanOrEqual(180);
|
||
expect(expectedMaxEntries).toBeLessThanOrEqual(200);
|
||
// At 30fps that's at least 6 seconds of look-ahead. Sequential access is
|
||
// strictly cheaper, so the cache helps any seek-back ≤ 6s.
|
||
expect(expectedMaxEntries / 30).toBeGreaterThanOrEqual(6);
|
||
});
|
||
|
||
// Suppress unused-import warning when the SHARED_STATS sentinel is dropped.
|
||
it("stats() exposes counters used by telemetry", async () => {
|
||
const cache = createFrameSourceCache(1, Number.MAX_SAFE_INTEGER);
|
||
expect(cache.stats()).toMatchObject({ ...SHARED_STATS, entries: 0, bytes: 0 });
|
||
});
|
||
});
|