mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 02:01:12 +00:00
* 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
869 lines
26 KiB
TypeScript
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 () => {}),
|
|
};
|
|
}
|