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
98 changes: 98 additions & 0 deletions apps/extension/src/long-screenshot/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { attachLongScreenshot } from "./background";
import { capturePage } from "./capture";
import { captureManual } from "./manual";
import { pruneScreenshots } from "./retention";
import { removeTiledScreenshot, TileWriter } from "./tiles";
import {
type CaptureReply,
Expand All @@ -16,6 +17,7 @@ vi.mock("./source", () => ({
openScreenshotSource: vi.fn(async () => ({ capture: vi.fn(), close: vi.fn() })),
}));
vi.mock("./manual", () => ({ captureManual: vi.fn() }));
vi.mock("./retention", () => ({ pruneScreenshots: vi.fn(async () => {}) }));
vi.mock("./tiles", () => ({
TileWriter: vi.fn(
class {
Expand Down Expand Up @@ -115,6 +117,7 @@ beforeEach(() => {
);
vi.mocked(TileWriter).mockClear();
vi.mocked(removeTiledScreenshot).mockClear();
vi.mocked(pruneScreenshots).mockReset().mockResolvedValue(undefined);
vi.mocked(captureManual)
.mockReset()
.mockImplementation(
Expand All @@ -140,6 +143,101 @@ describe("independent screenshot jobs", () => {
height: 2600,
};

it("prunes on startup while protecting the restored result and open previews", async () => {
const { api, call } = setup(completed);
const [tab] = await api.tabs.query();
const pendingPreview = {
...tab,
id: 9,
url: "about:blank",
pendingUrl: "chrome-extension://extension/long-screenshot.html?id=pending-preview",
};
api.tabs.query.mockResolvedValue([
tab,
{ ...tab, id: 8, url: "chrome-extension://extension/long-screenshot.html?id=open-preview" },
pendingPreview,
]);
await call("status");
const keep = vi.mocked(pruneScreenshots).mock.calls[0][0];
expect(await keep(completed.id)).toBe(true);
expect(await keep("open-preview")).toBe(true);
expect(await keep("pending-preview")).toBe(true);
expect(await keep("unused")).toBe(false);
});

it("prunes again when previews close, releasing their protection", async () => {
const { api, call } = setup();
const [tab] = await api.tabs.query();
api.tabs.query.mockResolvedValue([
tab,
{ ...tab, id: 8, url: "chrome-extension://extension/long-screenshot.html?id=preview" },
]);
await call("status");
expect(await vi.mocked(pruneScreenshots).mock.calls.at(-1)![0]("preview")).toBe(true);
api.tabs.query.mockResolvedValue([tab]);
api.tabs.onRemoved.emit(8);
await vi.waitFor(() => expect(pruneScreenshots).toHaveBeenCalledTimes(2));
expect(await vi.mocked(pruneScreenshots).mock.calls.at(-1)![0]("preview")).toBe(false);
});

it("protects previews opened during a sweep and their loaded document during pending navigation", async () => {
const { api, call } = setup();
await call("status");
const keep = vi.mocked(pruneScreenshots).mock.calls[0][0];
expect(await keep("late-preview")).toBe(false);
const [tab] = await api.tabs.query();
const preview = {
...tab,
id: 8,
url: "chrome-extension://extension/long-screenshot.html?id=late-preview",
pendingUrl: "https://example.com/next",
};
api.tabs.query.mockResolvedValue([tab, preview]);
expect(await keep("late-preview")).toBe(true);
api.tabs.query.mockResolvedValue([tab]);
api.tabs.onUpdated.emit(8, { url: "https://example.com/next" });
await vi.waitFor(() => expect(pruneScreenshots).toHaveBeenCalledTimes(2));
expect(await vi.mocked(pruneScreenshots).mock.calls.at(-1)![0]("late-preview")).toBe(false);
});

it("protects a preview still being created while a new capture replaces the result", async () => {
const { api, call } = setup(completed);
await call("status");
let created!: () => void;
api.tabs.create.mockImplementationOnce(
() =>
new Promise((resolve) => {
created = () => resolve({});
}),
);
const opening = call("preview", { id: completed.id });
await vi.waitFor(() => expect(api.tabs.create).toHaveBeenCalledOnce());
await call("start");
await vi.waitFor(() => expect(capturePage).toHaveBeenCalledOnce());
const keep = vi.mocked(pruneScreenshots).mock.calls.at(-1)![0];
expect(await keep(completed.id)).toBe(true);
created();
await opening;
expect(await keep(completed.id)).toBe(false);
resolveCapture({ width: 0, height: 0, blob: new Blob() });
await vi.waitFor(async () =>
expect(await call("status")).toMatchObject({ state: { phase: "error" } }),
);
});

it("keeps captures usable when retention storage temporarily fails", async () => {
vi.mocked(pruneScreenshots).mockRejectedValue(new Error("Storage busy"));
vi.mocked(capturePage).mockImplementationOnce(async (deps) => {
await deps.write({} as ImageBitmap, 800, 0, 0, 512);
return { width: 800, height: 512 };
});
const { api, call } = setup();
await call("start");
await vi.waitFor(() => expect(api.tabs.create).toHaveBeenCalledOnce());
expect(await call("status")).toMatchObject({ state: { phase: "complete" } });
expect(pruneScreenshots).toHaveBeenCalledTimes(3);
});

it("retains the result when the popup reopens on its source page or its own preview", async () => {
const { api, call } = setup(completed);
expect(await call("status")).toMatchObject({ state: completed });
Expand Down
53 changes: 46 additions & 7 deletions apps/extension/src/long-screenshot/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { i18n } from "@browser-skill/i18n";
import { capturePage } from "./capture";
import { captureManual } from "./manual";
import { createPageClient } from "./page-client";
import { pruneScreenshots } from "./retention";
import { openScreenshotSource } from "./source";
import { readTiledScreenshot, removeTiledScreenshot, TileWriter } from "./tiles";
import {
Expand All @@ -26,8 +27,9 @@ export function attachLongScreenshot(options: { isTabBusy(tabId: number): boolea
page?: (command: PageCommand) => Promise<unknown>;
};
let running: Job | null = null;
let openingPreviews = 0;
const openingPreviews = new Map<string, number>();
let writes = Promise.resolve();
let pruning = Promise.resolve();
const ready = chrome.storage.session
.get(LONG_SCREENSHOT_STATE)
.then(async (stored) => {
Expand All @@ -48,7 +50,34 @@ export function attachLongScreenshot(options: { isTabBusy(tabId: number): boolea
);
}
})
.catch(() => {});
.catch(() => {})
.then(() => prune());
function prune() {
pruning = pruning
.then(async () => {
const previewUrl = chrome.runtime.getURL("/long-screenshot.html");
const inUse = (id: string) =>
id === running?.id || id === state?.id || openingPreviews.has(id);
await pruneScreenshots(async (id) => {
if (inUse(id)) return true;
// Disk reads can overlap new previews. Revalidate immediately before
// deletion, including the document still visible during navigation.
const tabs = await chrome.tabs.query({});
return (
inUse(id) ||
tabs.some((tab) =>
[tab.url, tab.pendingUrl].some(
(url) =>
url?.startsWith(`${previewUrl}?`) && new URL(url).searchParams.get("id") === id,
),
)
);
});
})
// Retention is best effort; storage recovery retries on the next lifecycle event.
.catch(() => {});
return pruning;
}
function publish(next: CaptureState | null) {
state = next;
writes = writes
Expand All @@ -57,14 +86,17 @@ export function attachLongScreenshot(options: { isTabBusy(tabId: number): boolea
}
function clearResult() {
// Active jobs own their cancellation and partial-result lifecycle.
if (state && !running && !openingPreviews && !isCapturing(state)) publish(null);
if (state && !running && !openingPreviews.size && !isCapturing(state)) {
publish(null);
void prune();
}
}
async function clearResultOnPageChange() {
await ready;
const previous = state;
if (!previous || running || openingPreviews || isCapturing(previous)) return;
if (!previous || running || openingPreviews.size || isCapturing(previous)) return;
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
if (state !== previous || running || openingPreviews || !tab) return;
if (state !== previous || running || openingPreviews.size || !tab) return;
const pageUrl = tab.pendingUrl ?? tab.url;
if (!pageUrl) return;
if (tab.id === previous.tabId && (!previous.pageUrl || pageUrl === previous.pageUrl)) return;
Expand All @@ -89,6 +121,7 @@ export function attachLongScreenshot(options: { isTabBusy(tabId: number): boolea
void ready.then(() => {
if (state?.tabId === tabId && info.url !== state.pageUrl) clearResult();
else pageChanged();
void prune();
});
});
chrome.webNavigation.onCommitted.addListener((info) => {
Expand All @@ -101,6 +134,7 @@ export function attachLongScreenshot(options: { isTabBusy(tabId: number): boolea
chrome.tabs.onRemoved.addListener((tabId) => {
void ready.then(() => {
if (state?.tabId === tabId) clearResult();
void prune();
});
});
async function preview(id: string) {
Expand All @@ -109,13 +143,15 @@ export function attachLongScreenshot(options: { isTabBusy(tabId: number): boolea
? `&notice=${encodeURIComponent(state.notice)}`
: "";
// Chrome activates a newly created tab before its preview URL commits.
openingPreviews++;
openingPreviews.set(id, (openingPreviews.get(id) ?? 0) + 1);
try {
await chrome.tabs.create({
url: chrome.runtime.getURL(`/long-screenshot.html?id=${encodeURIComponent(id)}${notice}`),
});
} finally {
openingPreviews--;
const remaining = openingPreviews.get(id)! - 1;
if (remaining) openingPreviews.set(id, remaining);
else openingPreviews.delete(id);
}
}
async function start(requested: "auto" | "manual" | "visible" = "auto") {
Expand Down Expand Up @@ -194,6 +230,8 @@ export function attachLongScreenshot(options: { isTabBusy(tabId: number): boolea
void chrome.runtime.getPlatformInfo().catch(() => {});
}, 20_000);
try {
await prune();
controller.signal.throwIfAborted();
const mode = requested;
if (mode === "auto") await client.prepare();
await checkTab();
Expand Down Expand Up @@ -306,6 +344,7 @@ export function attachLongScreenshot(options: { isTabBusy(tabId: number): boolea
chrome.webNavigation.onBeforeNavigate.removeListener(navigated);
chrome.webNavigation.onCommitted.removeListener(navigated);
if (running?.id === id) running = null;
void prune();
}
if (complete) await preview(id).catch(() => {});
}
Expand Down
5 changes: 4 additions & 1 deletion apps/extension/src/long-screenshot/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SCREENSHOT_CHUNK_BYTES, ScreenshotExports } from "./exports";
import { removeTiledScreenshot } from "./tiles";

vi.mock("./tiles", () => ({ removeTiledScreenshot: vi.fn(async () => {}) }));
vi.mock("./tiles", async (importOriginal) => ({
...(await importOriginal<typeof import("./tiles")>()),
removeTiledScreenshot: vi.fn(async () => {}),
}));

let store: ScreenshotExports;
let live: Set<string>;
Expand Down
5 changes: 2 additions & 3 deletions apps/extension/src/long-screenshot/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
ScreenshotReleaseParams,
ScreenshotReleaseResult,
} from "@/transport/types";
import { removeTiledScreenshot } from "./tiles";
import { removeTiledScreenshot, screenshotRoot } from "./tiles";

export const SCREENSHOT_CHUNK_BYTES = 256 * 1024;
const EXPORT_IDLE_MS = 10 * 60_000;
Expand Down Expand Up @@ -38,10 +38,9 @@ export class ScreenshotExports {
}

private async removeOrphans() {
const root = await navigator.storage.getDirectory();
let directory: FileSystemDirectoryHandle;
try {
directory = await root.getDirectoryHandle("long-screenshots");
directory = await screenshotRoot();
} catch (error) {
if (error instanceof DOMException && error.name === "NotFoundError") return;
throw error;
Expand Down
58 changes: 56 additions & 2 deletions apps/extension/src/long-screenshot/long-screenshot.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const extensionPath = process.env.BSK_LONG_SCREENSHOT_EXTENSION ?? path.resolve(
async function withHarness(
run: (h: Awaited<ReturnType<typeof harness>>) => Promise<void>,
scale = 1,
prepareCapture = true,
) {
const server = createServer((req, res) => {
res.setHeader("Content-Type", "text/html");
Expand Down Expand Up @@ -51,7 +52,9 @@ async function withHarness(
await new Promise<void>((resolve) => server.close(() => resolve()));
}
async function runHarness(send: Send) {
await run(await harness(send, `http://127.0.0.1:${port}`));
await run(
await harness(send, `http://127.0.0.1:${port}`, undefined, undefined, prepareCapture),
);
}
}

Expand All @@ -60,6 +63,7 @@ async function harness(
baseUrl: string,
existingPage?: { targetId: string; sessionId: string },
knownOrigin?: string,
prepareCapture = true,
) {
const targets = () =>
send<{ targetInfos: { type: string; url: string; targetId: string }[] }>("Target.getTargets");
Expand Down Expand Up @@ -145,7 +149,8 @@ async function harness(
);
await new Promise((resolve) => setTimeout(resolve, 500));
await send("Page.enable", {}, page.sessionId);
await send("Page.captureScreenshot", { format: "png", fromSurface: true }, page.sessionId);
if (prepareCapture)
await send("Page.captureScreenshot", { format: "png", fromSurface: true }, page.sessionId);
const request = <T>(action: string, extra: object = {}) =>
evaluate<T>(
popup.sessionId,
Expand Down Expand Up @@ -223,6 +228,55 @@ async function harness(
describe.skipIf(!process.env.BSK_LONG_SCREENSHOT_CHROME)(
"full-page screenshot in a real extension",
() => {
it("prunes stored artifacts while retaining open previews and Agent exports", async () => {
await withHarness(
async (h) => {
const id = (index: number) =>
`00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
const preview = await h.create(`${h.origin}/long-screenshot.html?id=${id(7)}`, true);
await h.poll(() => h.evaluate(preview.sessionId, "document.readyState === 'complete'"));
await h.evaluate(
h.popup.sessionId,
`(async () => {
const root = await (await navigator.storage.getDirectory()).getDirectoryHandle('long-screenshots', {create: true});
for (let index = 1; index <= 8; index++) {
const id = '00000000-0000-4000-8000-' + String(index).padStart(12, '0');
const directory = await root.getDirectoryHandle(id, {create: true});
const manifest = await (await directory.getFileHandle('index.json', {create: true})).createWritable();
await manifest.write(JSON.stringify({id, createdAt: index >= 7 ? 1 : Date.now() - index * 1000}));
await manifest.close();
const image = await (await directory.getFileHandle('0.png', {create: true})).createWritable();
await image.write('retained artifact bytes');
await image.close();
}
await root.getDirectoryHandle('agent-retained', {create: true});
})()`,
);
const stored = () =>
h.evaluate<string[]>(
h.popup.sessionId,
`(async () => {
const root = await (await navigator.storage.getDirectory()).getDirectoryHandle('long-screenshots');
const ids = [];
for await (const id of root.keys()) ids.push(id);
return ids.sort();
})()`,
);
const trigger = await h.create("about:blank", true);
await h.send("Target.closeTarget", { targetId: trigger.targetId });
await expect
.poll(stored, { timeout: 10000 })
.toEqual([...[1, 2, 3, 4, 5, 7].map(id), "agent-retained"]);
await h.send("Target.closeTarget", { targetId: preview.targetId });
await expect
.poll(stored, { timeout: 10000 })
.toEqual([...[1, 2, 3, 4, 5].map(id), "agent-retained"]);
},
1,
false,
);
}, 30000);

it("clears the completed popup result on another tab while keeping its open preview usable", async () => {
await withHarness(async (h) => {
await h.startFromPopup();
Expand Down
Loading
Loading