Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,47 @@ const makeTestPreviewWebContents = (
} as unknown as TestPreviewWebContents;
};

const makeStillImage = (pixels = "fresh-png") => ({
toPNG: () => Buffer.from(pixels),
toJPEG: () => Buffer.from(pixels),
getSize: () => ({ width: 100, height: 80 }),
});

const makeTestStillImageWebContents = (...args: Parameters<typeof makeTestPreviewWebContents>) => {
const wc = makeTestPreviewWebContents(...args);
Object.assign(wc, { isDevToolsOpened: () => false });
holdStillImagePaint(wc, Promise.resolve());
return wc;
};

const holdStillImagePaint = (
wc: TestPreviewWebContents,
paint: Promise<void> | Array<Promise<void>>,
) => {
vi.mocked(wc.debugger.sendCommand).mockImplementation(async (method) => {
if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } };
if (method === "Page.createIsolatedWorld") return { executionContextId: 7 };
if (method === "Runtime.evaluate") {
await (Array.isArray(paint) ? paint.shift() : paint);
return { result: { value: true } };
}
return undefined;
});
};

const setupStill = (manager: PreviewManager.PreviewManager["Service"], paint = Promise.resolve()) =>
Effect.gen(function* () {
const image = makeStillImage();
const capturePage = vi.fn<() => Promise<typeof image>>().mockResolvedValue(image);
const wc = makeTestStillImageWebContents(capturePage);
holdStillImagePaint(wc, paint);
fromId.mockReturnValue(wc);
yield* manager.createTab("tab_still");
yield* manager.registerWebview("tab_still", wc.id);
yield* manager.setColorScheme("tab_still", "system");
return { image, capturePage, wc, capture: manager.captureScreenshot("tab_still") };
});

const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service"]) =>
Effect.gen(function* () {
const capturePage = vi.fn(async () => ({
Expand Down Expand Up @@ -1824,6 +1865,30 @@ describe("PreviewManager", () => {
),
);

effectIt.effect("captures final pixels after warmup fails and paint readiness settles", () =>
withManager((manager) =>
Effect.gen(function* () {
const paint = Promise.withResolvers<void>();
const { capturePage, wc, capture } = yield* setupStill(manager, paint.promise);
capturePage
.mockRejectedValueOnce(new Error("UnknownVizError"))
.mockRejectedValueOnce(new Error("UnknownVizError"))
.mockRejectedValueOnce(new Error("UnknownVizError"))
.mockResolvedValue(makeStillImage("final-png"));
const fiber = yield* capture.pipe(Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust(300);
expect(capturePage).toHaveBeenCalledTimes(3);
expect(writeFile).not.toHaveBeenCalled();
expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]);
paint.resolve();
const artifact = yield* Fiber.join(fiber);
expect(capturePage).toHaveBeenCalledTimes(4);
expect(writeFile.mock.calls).toEqual([[artifact.path, Buffer.from("final-png")]]);
expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]);
}),
),
);

effectIt.effect("preserves both screenshots of the same site in the same millisecond", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand All @@ -1837,6 +1902,8 @@ describe("PreviewManager", () => {
const capturePage = vi
.fn<() => Promise<ReturnType<typeof pngImage>>>()
.mockResolvedValueOnce(pngImage(firstPng))
.mockResolvedValueOnce(pngImage(firstPng))
.mockResolvedValueOnce(pngImage(secondPng))
.mockResolvedValueOnce(pngImage(secondPng));
fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
yield* manager.createTab("tab_1");
Expand All @@ -1857,6 +1924,115 @@ describe("PreviewManager", () => {
),
);

effectIt.effect(
"waits for paint before capturing and restores rendering after overlapping requests",
() =>
withManager((manager) =>
Effect.gen(function* () {
const paints = [Promise.withResolvers<void>(), Promise.withResolvers<void>()];
const pendingPaints = paints.map((paint) => paint.promise);
const { image, capturePage, wc, capture } = yield* setupStill(manager);
holdStillImagePaint(wc, pendingPaints);
const first = Promise.withResolvers<typeof image>();
const second = Promise.withResolvers<typeof image>();
capturePage
.mockResolvedValueOnce(makeStillImage("discarded-warmup"))
.mockResolvedValueOnce(makeStillImage("discarded-warmup"))
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const hostThrottling = vi.fn();
yield* manager.setMainWindow({
isDestroyed: () => false,
once: vi.fn(),
webContents: { setBackgroundThrottling: hostThrottling },
} as never);
const one = yield* capture.pipe(Effect.forkChild({ startImmediately: true }));
const two = yield* capture.pipe(Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust(0);
expect(capturePage).toHaveBeenCalledTimes(2);
expect(pendingPaints).toHaveLength(0);
expect(writeFile).not.toHaveBeenCalled();
expect(hostThrottling.mock.calls).toEqual([[false]]);
expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]);
paints.forEach((paint) => paint.resolve());
yield* TestClock.adjust(0);
expect(capturePage).toHaveBeenCalledTimes(4);
first.resolve(image);
yield* Fiber.join(one);
expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]);
second.resolve(image);
yield* Fiber.join(two);
const saved = writeFile.mock.calls.map(([, bytes]) => bytes);
expect(saved).toEqual([image.toPNG(), image.toPNG()]);
expect(hostThrottling.mock.calls).toEqual([[false], [true]]);
expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]);
}),
),
);

effectIt.effect("keeps picture-in-picture rendering after a still image completes", () =>
withManager((manager) =>
Effect.gen(function* () {
const { image, capturePage, wc, capture } = yield* setupStill(manager);
const pending = Promise.withResolvers<typeof image>();
capturePage
.mockResolvedValueOnce(image)
.mockResolvedValueOnce(makeStillImage("discarded-warmup"))
.mockReturnValueOnce(pending.promise);
const { pictureInPictureWindow } = makeTestPictureInPictureWindow();
browserWindowConstructor.mockImplementation(function () {
return pictureInPictureWindow;
});
yield* manager.openPictureInPicture("tab_still");
const still = yield* capture.pipe(Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust(0);
expect(capturePage).toHaveBeenCalledTimes(3);
expect(writeFile).not.toHaveBeenCalled();
pending.resolve(image);
yield* Fiber.join(still);
expect(writeFile.mock.calls.map((call) => call[1])).toEqual([Buffer.from("fresh-png")]);
expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]);
yield* manager.closePictureInPicture("tab_still");
expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]);
}),
),
);

effectIt.effect("keeps a pending still image rendering when recording ends", () =>
withManager((manager) =>
Effect.gen(function* () {
const { image, capturePage, wc, capture } = yield* setupStill(manager);
const pending = Promise.withResolvers<typeof image>();
capturePage.mockResolvedValueOnce(image).mockReturnValue(pending.promise);
yield* manager.startRecording("tab_still");
const fiber = yield* capture.pipe(Effect.forkChild({ startImmediately: true }));
yield* TestClock.adjust(0);
yield* manager.stopRecording("tab_still");
expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]);
pending.resolve(image);
yield* Fiber.join(fiber);
expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]);
}),
),
);

effectIt.effect("saves available pixels when paint readiness never settles", () =>
withManager((manager) =>
Effect.gen(function* () {
const paint = Promise.withResolvers<void>();
const { capturePage, wc, capture } = yield* setupStill(manager, paint.promise);
const first = yield* Effect.exit(capture).pipe(
Effect.forkChild({ startImmediately: true }),
);
yield* TestClock.adjust(1_001);
expect(Exit.isSuccess(yield* Fiber.join(first))).toBe(true);
expect(capturePage).toHaveBeenCalledTimes(2);
expect(writeFile.mock.calls.map((call) => call[1])).toEqual([Buffer.from("fresh-png")]);
expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]);
}),
),
);

effectIt.effect("keeps every recorded guest unthrottled until its frame capture stops", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down
99 changes: 97 additions & 2 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,8 @@ interface ManagedListeners {
readonly webContents: Electron.WebContents;
}

type FrameCaptureConsumer = "picture-in-picture" | "recording";
// Each still-image request owns a separate, short-lived rendering lease.
type FrameCaptureConsumer = "picture-in-picture" | "recording" | symbol;

interface FrameCaptureSession {
readonly recordingInputOptions?: RecordingInputOptions;
Expand Down Expand Up @@ -2651,7 +2652,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const [createdAt, millis, image] = yield* Effect.all([
currentIso,
currentMillis,
capturePageWithRetry(
captureStillImage(
{
operation: "captureScreenshot.capturePage",
tabId,
Expand Down Expand Up @@ -2699,6 +2700,100 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
};
});

const captureStillImage = Effect.fn("PreviewManager.captureStillImage")(function* (
errorContext: PreviewOperationContext,
tabId: string,
wc: Electron.WebContents,
) {
const consumer = Symbol("still-image");
return yield* Effect.acquireUseRelease(
startFrameCapture(tabId, consumer),
() =>
Effect.gen(function* () {
// A DOM mutation can precede its compositor frame even in a shown
// window. Keep the original guest rendering through the next paint;
// a hidden guest otherwise returns old pixels or never completes.
const probe = Effect.fn("PreviewManager.probeStillImagePaint")(function* (
send: (
method: string,
params?: Record<string, unknown>,
) => Effect.Effect<unknown, PreviewOperationError>,
) {
// CDP executes against the current document without Electron's
// did-stop-loading gate. Isolate scheduling globals from page code.
const tree = (yield* send("Page.getFrameTree")) as
| { frameTree?: { frame?: { id?: string } } }
| undefined;
const frameId = tree?.frameTree?.frame?.id;
if (typeof frameId !== "string")
return yield* new PreviewOperationError({
...errorContext,
cause: new Error("The preview paint frame is unavailable."),
});
const world = (yield* send("Page.createIsolatedWorld", {
frameId,
worldName: "t3-preview-still-image",
})) as { executionContextId?: number } | undefined;
if (typeof world?.executionContextId !== "number")
return yield* new PreviewOperationError({
...errorContext,
cause: new Error("The preview paint context is unavailable."),
});
const response = (yield* send("Runtime.evaluate", {
// Settle and release scheduled work when only timers keep running.
// The outer deadline still bounds a frozen renderer.
expression: `new Promise(resolve => { let frame; const timer = setTimeout(() => { cancelAnimationFrame(frame); resolve(false); }, ${CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS}); frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => { clearTimeout(timer); resolve(true); }); }); })`,
contextId: world.executionContextId,
awaitPromise: true,
returnByValue: true,
})) as { result?: { value?: unknown }; exceptionDetails?: unknown } | undefined;
if (response?.exceptionDetails || response?.result?.value !== true)
return yield* new PreviewOperationError({
...errorContext,
cause: new Error("The preview paint boundary was not confirmed."),
});
});
const paintReady = Effect.gen(function* () {
// Only use an existing owned session; never attach or displace
// another debugger to obtain a screenshot.
const control = (yield* SynchronizedRef.get(controlSessionsRef)).get(wc.id);
if (!control)
return yield* new PreviewOperationError({
...errorContext,
cause: new Error("The preview paint session is unavailable."),
});
return yield* probe((method, params) =>
attemptPromise(errorContext, () => control.debugger.sendCommand(method, params)),
);
}).pipe(
Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS),
Effect.catch((error) =>
Effect.logWarning(
"Preview paint readiness was not confirmed; capturing available pixels with mutation freshness unconfirmed.",
{ tabId, webContentsId: wc.id, error },
),
),
);
// Minimized guests can suspend frames even with throttling disabled.
// A native capture can resume them; discard its pixels and read again
// after the bounded paint probe.
const warmup = capturePageWithRetry(errorContext, tabId, wc).pipe(
Effect.catchTags({
PreviewOperationError: (error) =>
Effect.logWarning("Preview warmup capture failed; continuing with final capture.", {
tabId,
webContentsId: wc.id,
error,
}),
}),
);
yield* Effect.all([paintReady, warmup], { concurrency: 2, discard: true });
return yield* capturePageWithRetry(errorContext, tabId, wc);
}),
() => stopFrameCapture(tabId, consumer),
);
});

const capturePreviewFrame = Effect.fn("PreviewManager.capturePreviewFrame")(function* (
tabId: string,
) {
Expand Down
Loading