Files
openclaw/extensions/diffs/src/browser.test.ts
Peter Steinberger 334c182c27 refactor(diffs): move ephemeral artifacts to SQLite (#109328)
* refactor(diffs): move ephemeral artifacts to SQLite

* fix(plugin-state): satisfy SDK validation gates

* test(diffs): satisfy lint contracts

* fix(plugin-state): count expired blobs toward quotas

* fix(plugin-state): harden blob storage boundaries

* fix(plugins): replace stale install provenance
2026-07-16 16:47:19 -07:00

869 lines
26 KiB
TypeScript

// Diffs tests cover browser plugin behavior.
import fs from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import type {
PluginBlobEntry,
PluginBlobEntryInfo,
PluginBlobStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { createMockServerResponse } from "openclaw/plugin-sdk/test-env";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../api.js";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
import { registerDiffsPlugin } from "./plugin.js";
import { createTempDiffRoot } from "./test-helpers.js";
const { launchMock } = vi.hoisted(() => ({
launchMock: vi.fn(),
}));
let PlaywrightDiffScreenshotter: typeof import("./browser.js").PlaywrightDiffScreenshotter;
vi.mock("playwright-core", () => ({
chromium: {
launch: launchMock,
},
}));
function firstMockCall(
mock: { mock: { calls: Array<readonly unknown[]> } },
label: string,
): readonly unknown[] {
const call = mock.mock.calls[0];
if (!call) {
throw new Error(`expected ${label} call`);
}
return call;
}
afterAll(() => {
vi.doUnmock("playwright-core");
vi.resetModules();
});
describe("PlaywrightDiffScreenshotter", () => {
let rootDir: string;
let outputPath: string;
let cleanupRootDir: () => Promise<void>;
beforeEach(async () => {
vi.useFakeTimers();
vi.resetModules();
({ PlaywrightDiffScreenshotter } = await import("./browser.js"));
({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-"));
outputPath = path.join(rootDir, "preview.png");
launchMock.mockReset();
});
afterEach(async () => {
await vi.runAllTimersAsync();
vi.useRealTimers();
await cleanupRootDir();
});
it("reuses the same browser across renders and closes it after the idle window", async () => {
const { pages, browser, screenshotter } = await createScreenshotterHarness();
await screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
await screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
expect(launchMock).toHaveBeenCalledTimes(1);
expect(browser.newPage).toHaveBeenCalledTimes(2);
const firstPageParams = (
browser.newPage.mock.calls as Array<[{ deviceScaleFactor?: number }?]>
)[0]?.[0];
expect(firstPageParams?.deviceScaleFactor).toBe(2);
expect(pages).toHaveLength(2);
expect(pages[0]?.close).toHaveBeenCalledTimes(1);
expect(pages[1]?.close).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000);
expect(browser.close).toHaveBeenCalledTimes(1);
await screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "light",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
expect(launchMock).toHaveBeenCalledTimes(2);
});
it("renders PDF output when format is pdf", async () => {
const { pages, screenshotter } = await createScreenshotterHarness();
const pdfPath = path.join(rootDir, "preview.pdf");
await screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath: pdfPath,
theme: "light",
image: {
format: "pdf",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
expect(launchMock).toHaveBeenCalledTimes(1);
expect(pages).toHaveLength(1);
const page = expectDefined(pages[0], "diffs browser page");
expect(page.pdf).toHaveBeenCalledTimes(1);
const pdfCall = firstMockCall(page.pdf, "PDF render")[0] as Record<string, unknown> | undefined;
if (!pdfCall) {
throw new Error("expected PDF render call");
}
expect(pdfCall).not.toHaveProperty("pageRanges");
expect(page.screenshot).toHaveBeenCalledTimes(0);
await expect(fs.readFile(pdfPath, "utf8")).resolves.toContain("%PDF-1.7");
});
it("fails fast when PDF render exceeds size limits", async () => {
const pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}> = [];
const browser = createMockBrowser(pages, {
boundingBox: { x: 40, y: 40, width: 960, height: 60_000 },
});
launchMock.mockResolvedValue(browser);
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
});
const pdfPath = path.join(rootDir, "oversized.pdf");
await expect(
screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath: pdfPath,
theme: "light",
image: {
format: "pdf",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
}),
).rejects.toThrow("Diff frame did not render within image size limits.");
expect(launchMock).toHaveBeenCalledTimes(1);
expect(pages).toHaveLength(1);
expect(pages[0]?.pdf).toHaveBeenCalledTimes(0);
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
});
it("fails fast when maxPixels is still exceeded at scale 1", async () => {
const { pages, screenshotter } = await createScreenshotterHarness();
await expect(
screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 1,
maxWidth: 960,
maxPixels: 10,
},
}),
).rejects.toThrow("Diff frame did not render within image size limits.");
expect(pages).toHaveLength(1);
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
});
it("wraps browser launch failures with Chromium installation guidance", async () => {
launchMock.mockRejectedValue(new Error("launch failed"));
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
});
await expect(
screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
}),
).rejects.toThrow("requires a Chromium-compatible browser");
});
it("wraps new-page failures with Chromium installation guidance", async () => {
const browser = createMockBrowser([]);
browser.newPage.mockRejectedValue(new Error("page creation failed"));
launchMock.mockResolvedValue(browser);
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
});
await expect(
screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
}),
).rejects.toThrow("requires a Chromium-compatible browser");
});
it("preserves render errors after a browser page has opened", async () => {
const browser = createMockBrowser([]);
const page = createMockPage();
page.waitForFunction.mockRejectedValue(new Error("hydration timeout"));
browser.newPage.mockResolvedValue(page);
launchMock.mockResolvedValue(browser);
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
});
await expect(
screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
}),
).rejects.toThrow("hydration timeout");
});
});
describe("diffs plugin registration", () => {
it("uses live runtime tool config through the registered tool factory", async () => {
type RegisteredTool = {
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
};
type HttpRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
) => boolean | Promise<boolean>;
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
let registeredToolFactory:
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
| undefined;
let registeredHttpRouteHandler: HttpRouteHandler | undefined;
let configFile: OpenClawConfig = {
gateway: {
port: 18789,
bind: "loopback",
},
plugins: {
entries: {
diffs: {
config: {
viewerBaseUrl: "https://startup.example.com/openclaw",
defaults: {
mode: "view",
theme: "light",
background: false,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
lineSpacing: 2,
},
},
},
},
},
} as OpenClawConfig;
const blobStore = createMemoryBlobStore();
const api = createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
pluginConfig: {
viewerBaseUrl: "https://startup.example.com/openclaw",
defaults: {
mode: "view",
theme: "light",
background: false,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
lineSpacing: 2,
},
},
runtime: {
config: {
current: () => configFile,
},
state: { openBlobStore: () => blobStore },
} as never,
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
},
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler as HttpRouteHandler;
},
on: vi.fn(),
});
registerDiffsPlugin(api as unknown as OpenClawPluginApi);
configFile = {
...configFile,
plugins: {
entries: {
diffs: {
config: {
viewerBaseUrl: "https://live.example.com/gateway",
defaults: {
mode: "view",
theme: "dark",
background: true,
layout: "unified",
showLineNumbers: true,
diffIndicators: "bars",
lineSpacing: 1.6,
},
},
},
},
},
} as OpenClawConfig;
const registeredTool = registeredToolFactory?.({
agentId: "main",
sessionId: "session-456",
messageChannel: "discord",
agentAccountId: "default",
}) as RegisteredTool | undefined;
const result = await registeredTool?.execute?.("tool-1", {
before: "one\n",
after: "two\n",
});
const details = (result as { details?: Record<string, unknown> } | undefined)?.details;
const viewerPath = String(details?.viewerPath);
const res = createMockServerResponse();
const handled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,
}),
res,
);
expect(handled).toBe(true);
expect(String(details?.viewerUrl)).toContain("https://live.example.com/gateway");
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain('body data-theme="dark"');
expect(String(res.body)).toContain('"backgroundEnabled":true');
expect(String(res.body)).toContain('"diffStyle":"unified"');
expect(String(res.body)).toContain('"disableLineNumbers":false');
expect(String(res.body)).toContain('"diffIndicators":"bars"');
expect(String(res.body)).toContain("--diffs-line-height: 24px;");
});
it("uses live runtime viewer-access config through the registered HTTP handler", async () => {
type RegisteredTool = {
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
};
type HttpRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
) => boolean | Promise<boolean>;
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
let registeredToolFactory:
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
| undefined;
let registeredHttpRouteHandler: HttpRouteHandler | undefined;
const on = vi.fn();
let configFile: OpenClawConfig = {
gateway: {
port: 18789,
bind: "loopback",
},
plugins: {
entries: {
diffs: {
config: {
security: {
allowRemoteViewer: true,
},
},
},
},
},
} as OpenClawConfig;
const blobStore = createMemoryBlobStore();
const api = createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
pluginConfig: {
defaults: {
mode: "view",
theme: "light",
background: false,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
lineSpacing: 2,
},
security: {
allowRemoteViewer: true,
},
},
runtime: {
config: {
current: () => configFile,
},
state: { openBlobStore: () => blobStore },
} as never,
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
},
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler as HttpRouteHandler;
},
on,
});
registerDiffsPlugin(api as unknown as OpenClawPluginApi);
expect(on).toHaveBeenCalledTimes(1);
const [hookName, beforePromptBuild] = firstMockCall(on, "plugin hook registration");
expect(hookName).toBe("before_prompt_build");
if (typeof beforePromptBuild !== "function") {
throw new Error("expected before_prompt_build callback");
}
const promptResult = await beforePromptBuild({}, {});
expect(promptResult?.prependSystemContext).toBe(
[
"When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.",
"It accepts either `before` + `after` text or a unified `patch`.",
"Check `details.changed`: identical before/after input returns `false` without creating an artifact; rendered results return `true`.",
"`mode=view` returns `details.viewerUrl` for canvas use; `mode=file` returns `details.filePath`; `mode=both` returns both.",
"If you need to send the rendered file, use the `message` tool with `path` or `filePath`.",
"Include `path` when you know the filename, and omit presentation overrides unless needed.",
].join("\n"),
);
expect(promptResult?.prependContext).toBeUndefined();
const registeredTool = registeredToolFactory?.({
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
}) as RegisteredTool | undefined;
const result = await registeredTool?.execute?.("tool-1", {
before: "one\n",
after: "two\n",
});
const viewerPath = String(
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
);
const res = createMockServerResponse();
const handled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
{
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
);
configFile = {
...configFile,
plugins: {
entries: {
diffs: {
config: {
security: {
allowRemoteViewer: false,
},
},
},
},
},
} as OpenClawConfig;
const proxiedRes = createMockServerResponse();
const proxiedHandled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,
headers: {
"x-forwarded-for": "203.0.113.10",
},
}),
proxiedRes,
);
expect(proxiedHandled).toBe(true);
expect(proxiedRes.statusCode).toBe(404);
});
it("fails closed for remote viewer access when the live diffs plugin entry is removed", async () => {
type RegisteredTool = {
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
};
type HttpRouteHandler = (
req: IncomingMessage,
res: ServerResponse,
) => boolean | Promise<boolean>;
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
let registeredToolFactory:
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
| undefined;
let registeredHttpRouteHandler: HttpRouteHandler | undefined;
let configFile: OpenClawConfig = {
gateway: {
port: 18789,
bind: "loopback",
},
plugins: {
entries: {
diffs: {
config: {
security: {
allowRemoteViewer: true,
},
},
},
},
},
} as OpenClawConfig;
const blobStore = createMemoryBlobStore();
const api = createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
pluginConfig: {
security: {
allowRemoteViewer: true,
},
},
runtime: {
config: {
current: () => configFile,
},
state: { openBlobStore: () => blobStore },
} as never,
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
},
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler as HttpRouteHandler;
},
on: vi.fn(),
});
registerDiffsPlugin(api as unknown as OpenClawPluginApi);
const registeredTool = registeredToolFactory?.({
agentId: "main",
sessionId: "session-789",
messageChannel: "discord",
agentAccountId: "default",
}) as RegisteredTool | undefined;
const result = await registeredTool?.execute?.("tool-1", {
before: "one\n",
after: "two\n",
});
const viewerPath = String(
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
);
configFile = {
...configFile,
plugins: {
entries: {},
},
} as OpenClawConfig;
const proxiedRes = createMockServerResponse();
const proxiedHandled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,
headers: {
"x-forwarded-for": "203.0.113.10",
},
}),
proxiedRes,
);
expect(proxiedHandled).toBe(true);
expect(proxiedRes.statusCode).toBe(404);
});
});
function createMemoryBlobStore<TMetadata>(): PluginBlobStore<TMetadata> {
const entries = new Map<
string,
{
bytes: Uint8Array;
metadata: TMetadata;
createdAt: number;
expiresAt?: number;
}
>();
const read = (key: string): PluginBlobEntry<TMetadata> | undefined => {
const entry = entries.get(key);
if (!entry) {
return undefined;
}
if (entry.expiresAt !== undefined && entry.expiresAt <= Date.now()) {
entries.delete(key);
return undefined;
}
return {
key,
bytes: entry.bytes.slice(),
metadata: entry.metadata,
sizeBytes: entry.bytes.byteLength,
createdAt: entry.createdAt,
...(entry.expiresAt !== undefined ? { expiresAt: entry.expiresAt } : {}),
};
};
const register: PluginBlobStore<TMetadata>["register"] = async (key, bytes, metadata, opts) => {
const createdAt = Date.now();
entries.set(key, {
bytes: bytes.slice(),
metadata,
createdAt,
...(opts?.ttlMs ? { expiresAt: createdAt + opts.ttlMs } : {}),
});
};
return {
register,
async registerIfAbsent(key, bytes, metadata, opts) {
if (read(key)) {
return false;
}
await register(key, bytes, metadata, opts);
return true;
},
async lookup(key) {
return read(key);
},
async entries() {
return [...entries.keys()].flatMap((key) => {
const entry = read(key);
if (!entry) {
return [];
}
const { bytes: _bytes, ...info } = entry;
return [info];
});
},
async delete(key) {
return entries.delete(key);
},
async deleteExpiredKey(key) {
const entry = entries.get(key);
if (!entry || entry.expiresAt === undefined || entry.expiresAt > Date.now()) {
return undefined;
}
entries.delete(key);
return {
key,
metadata: entry.metadata,
sizeBytes: entry.bytes.byteLength,
createdAt: entry.createdAt,
expiresAt: entry.expiresAt,
};
},
async deleteExpired() {
const expired: PluginBlobEntryInfo<TMetadata>[] = [];
for (const [key, entry] of entries) {
if (entry.expiresAt === undefined || entry.expiresAt > Date.now()) {
continue;
}
entries.delete(key);
expired.push({
key,
metadata: entry.metadata,
sizeBytes: entry.bytes.byteLength,
createdAt: entry.createdAt,
expiresAt: entry.expiresAt,
});
}
return expired;
},
async clear() {
entries.clear();
},
};
}
function createConfig(): OpenClawConfig {
return {
browser: {
executablePath: process.execPath,
},
} as OpenClawConfig;
}
function localReq(input: {
method: string;
url: string;
headers?: IncomingMessage["headers"];
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}
async function createScreenshotterHarness(options?: {
boundingBox?: { x: number; y: number; width: number; height: number };
}) {
const pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}> = [];
const browser = createMockBrowser(pages, options);
launchMock.mockResolvedValue(browser);
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
});
return { pages, browser, screenshotter };
}
function createMockBrowser(
pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}>,
options?: { boundingBox?: { x: number; y: number; width: number; height: number } },
) {
const browser = {
newPage: vi.fn(async (_options?: unknown) => {
const page = createMockPage(options);
pages.push(page);
return page;
}),
close: vi.fn(async () => {}),
on: vi.fn(),
};
return browser;
}
function createMockPage(options?: {
boundingBox?: { x: number; y: number; width: number; height: number };
}) {
const box = options?.boundingBox ?? { x: 40, y: 40, width: 640, height: 240 };
const screenshot = vi.fn(async ({ path: screenshotPath }: { path: string }) => {
await fs.writeFile(screenshotPath, Buffer.from("png"));
});
const pdf = vi.fn(async ({ path: pdfPath }: { path: string }) => {
await fs.writeFile(pdfPath, "%PDF-1.7 mock");
});
return {
route: vi.fn(async () => {}),
setContent: vi.fn(async () => {}),
waitForFunction: vi.fn(async () => {}),
evaluate: vi.fn(async () => 1),
emulateMedia: vi.fn(async () => {}),
locator: vi.fn(() => ({
waitFor: vi.fn(async () => {}),
boundingBox: vi.fn(async () => box),
})),
setViewportSize: vi.fn(async () => {}),
screenshot,
pdf,
close: vi.fn(async () => {}),
};
}