|
1 | 1 | // @vitest-environment node |
2 | | -import { describe, it, expect, beforeEach, afterEach } from "vitest"; |
| 2 | +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; |
3 | 3 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
4 | 4 | import { tmpdir } from "node:os"; |
5 | 5 | import { join } from "node:path"; |
6 | | -import { __testing } from "./videoFrameInjector.js"; |
| 6 | +import { type Page } from "puppeteer-core"; |
| 7 | + |
| 8 | +// Hoist mocks before importing the module under test so the mock factory wins. |
| 9 | +// The cache-hygiene block exercises createVideoFrameInjector against stubbed |
| 10 | +// page-side primitives so we can assert on Node-side state (cache poisoning) |
| 11 | +// without standing up a real browser. |
| 12 | +const { injectVideoFramesBatchMock, syncVideoFrameVisibilityMock } = vi.hoisted(() => ({ |
| 13 | + injectVideoFramesBatchMock: vi.fn< |
| 14 | + (page: Page, updates: Array<{ videoId: string; dataUri: string }>) => Promise<string[]> |
| 15 | + >(async (_page, updates) => updates.map((u) => u.videoId)), |
| 16 | + syncVideoFrameVisibilityMock: vi.fn<(page: Page, ids: string[]) => Promise<void>>( |
| 17 | + async () => undefined, |
| 18 | + ), |
| 19 | +})); |
| 20 | + |
| 21 | +vi.mock("./screenshotService.js", () => ({ |
| 22 | + injectVideoFramesBatch: injectVideoFramesBatchMock, |
| 23 | + syncVideoFrameVisibility: syncVideoFrameVisibilityMock, |
| 24 | +})); |
| 25 | + |
| 26 | +import { __testing, createVideoFrameInjector } from "./videoFrameInjector.js"; |
| 27 | +import { type FrameLookupTable } from "./videoFrameExtractor.js"; |
7 | 28 | import { DEFAULT_CONFIG } from "../config.js"; |
8 | 29 |
|
9 | 30 | const { createFrameSourceCache } = __testing; |
@@ -143,3 +164,91 @@ describe("frame source cache eviction", () => { |
143 | 164 | expect(cache.stats()).toMatchObject({ ...SHARED_STATS, entries: 0, bytes: 0 }); |
144 | 165 | }); |
145 | 166 | }); |
| 167 | + |
| 168 | +describe("createVideoFrameInjector cache hygiene against page-side skips", () => { |
| 169 | + // Build a minimal FrameLookupTable stand-in that returns one fixed payload |
| 170 | + // for every time so we can drive the hook deterministically. The real |
| 171 | + // table is exercised exhaustively in videoFrameExtractor.test.ts. |
| 172 | + function fakeTable(payload: { videoId: string; framePath: string; frameIndex: number }) { |
| 173 | + return { |
| 174 | + getActiveFramePayloads: () => |
| 175 | + new Map([ |
| 176 | + [payload.videoId, { framePath: payload.framePath, frameIndex: payload.frameIndex }], |
| 177 | + ]), |
| 178 | + } as unknown as FrameLookupTable; |
| 179 | + } |
| 180 | + |
| 181 | + // Bypass the on-disk frame cache by handing back a synthetic data URI. |
| 182 | + function inlineResolver(framePath: string): string { |
| 183 | + return `data:image/png;base64,fake-${framePath}`; |
| 184 | + } |
| 185 | + |
| 186 | + beforeEach(() => { |
| 187 | + injectVideoFramesBatchMock.mockReset(); |
| 188 | + syncVideoFrameVisibilityMock.mockReset(); |
| 189 | + syncVideoFrameVisibilityMock.mockResolvedValue(undefined); |
| 190 | + }); |
| 191 | + |
| 192 | + it("does not poison the lastInjected cache when the page reports zero ids injected", async () => { |
| 193 | + // Regression for the agentic-finecut scenario after PR #1028's ancestor |
| 194 | + // skip: when injectVideoFramesBatch silently drops a video (its sub-comp |
| 195 | + // host is hidden), the caller used to record `lastInjectedFrame[v] = N` |
| 196 | + // anyway. On the next frame, if the source frameIndex is unchanged |
| 197 | + // (low-fps source, multiple output frames per source frame, or |
| 198 | + // non-frame-aligned host start), the cache short-circuits the second |
| 199 | + // call and the host's first visible frame paints blank because the |
| 200 | + // replacement <img> was never created. |
| 201 | + // |
| 202 | + // Pin the contract: when the page returns `[]` (no ids actually |
| 203 | + // injected), the cache must not record those frameIndexes, so a follow- |
| 204 | + // up call at the same frameIndex still issues an inject. |
| 205 | + const fakePage = {} as Page; |
| 206 | + const hook = createVideoFrameInjector( |
| 207 | + fakeTable({ videoId: "pip", framePath: "/p", frameIndex: 5 }), |
| 208 | + { |
| 209 | + frameSrcResolver: inlineResolver, |
| 210 | + }, |
| 211 | + ); |
| 212 | + expect(hook).not.toBeNull(); |
| 213 | + |
| 214 | + // First call: simulate the ancestor-hidden skip — page-side reports it |
| 215 | + // injected nothing. |
| 216 | + injectVideoFramesBatchMock.mockResolvedValueOnce([]); |
| 217 | + await hook!(fakePage, 0); |
| 218 | + expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(1); |
| 219 | + expect(injectVideoFramesBatchMock).toHaveBeenLastCalledWith(fakePage, [ |
| 220 | + { videoId: "pip", dataUri: "data:image/png;base64,fake-/p" }, |
| 221 | + ]); |
| 222 | + |
| 223 | + // Second call: same frameIndex, but the previous call did not really |
| 224 | + // paint. The cache must NOT short-circuit; the inject must run again. |
| 225 | + injectVideoFramesBatchMock.mockResolvedValueOnce(["pip"]); |
| 226 | + await hook!(fakePage, 0); |
| 227 | + expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(2); |
| 228 | + expect(injectVideoFramesBatchMock).toHaveBeenLastCalledWith(fakePage, [ |
| 229 | + { videoId: "pip", dataUri: "data:image/png;base64,fake-/p" }, |
| 230 | + ]); |
| 231 | + }); |
| 232 | + |
| 233 | + it("does cache normally when the page reports the id as injected", async () => { |
| 234 | + // Counter-test: when injection succeeds for a videoId, the cache must |
| 235 | + // record it and a second call at the same frameIndex must short-circuit. |
| 236 | + // This pins the happy path so a future refactor can't trade the skip |
| 237 | + // bug for a never-cache regression. |
| 238 | + const fakePage = {} as Page; |
| 239 | + const hook = createVideoFrameInjector( |
| 240 | + fakeTable({ videoId: "pip", framePath: "/p", frameIndex: 5 }), |
| 241 | + { |
| 242 | + frameSrcResolver: inlineResolver, |
| 243 | + }, |
| 244 | + ); |
| 245 | + |
| 246 | + injectVideoFramesBatchMock.mockResolvedValueOnce(["pip"]); |
| 247 | + await hook!(fakePage, 0); |
| 248 | + expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(1); |
| 249 | + |
| 250 | + await hook!(fakePage, 0); |
| 251 | + // Cache hit — no second inject for the same frameIndex. |
| 252 | + expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(1); |
| 253 | + }); |
| 254 | +}); |
0 commit comments