openclaw/extensions/diffs/src/tool.test.ts

491 lines
15 KiB
TypeScript
Raw Normal View History

2026-02-28 18:38:00 -05:00
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs";
2026-02-28 18:38:00 -05:00
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DiffScreenshotter } from "./browser.js";
2026-02-28 19:20:07 -05:00
import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js";
2026-02-28 18:38:00 -05:00
import { DiffArtifactStore } from "./store.js";
import { createDiffsTool } from "./tool.js";
import type { DiffRenderOptions } from "./types.js";
2026-02-28 18:38:00 -05:00
describe("diffs tool", () => {
let rootDir: string;
let store: DiffArtifactStore;
beforeEach(async () => {
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-tool-"));
store = new DiffArtifactStore({ rootDir });
});
afterEach(async () => {
await fs.rm(rootDir, { recursive: true, force: true });
});
it("returns a viewer URL in view mode", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
2026-02-28 19:20:07 -05:00
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
2026-02-28 18:38:00 -05:00
});
const result = await tool.execute?.("tool-1", {
before: "one\n",
after: "two\n",
path: "README.md",
mode: "view",
});
const text = readTextContent(result, 0);
expect(text).toContain("http://127.0.0.1:18789/plugins/diffs/view/");
expect((result?.details as Record<string, unknown>).viewerUrl).toBeDefined();
});
it("does not expose reserved format in the tool schema", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
const parameters = tool.parameters as { properties?: Record<string, unknown> };
expect(parameters.properties).toBeDefined();
expect(parameters.properties).not.toHaveProperty("format");
});
2026-02-28 18:38:00 -05:00
it("returns an image artifact in image mode", async () => {
const cleanupSpy = vi.spyOn(store, "scheduleCleanup");
const screenshotter = createPngScreenshotter({
assertHtml: (html) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
},
assertImage: (image) => {
expect(image).toMatchObject({
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
});
},
2026-02-28 18:38:00 -05:00
});
const tool = createToolWithScreenshotter(store, screenshotter);
2026-02-28 18:38:00 -05:00
const result = await tool.execute?.("tool-2", {
before: "one\n",
after: "two\n",
mode: "image",
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect(readTextContent(result, 0)).toContain("Diff PNG generated at:");
2026-02-28 18:38:00 -05:00
expect(readTextContent(result, 0)).toContain("Use the `message` tool");
expect(result?.content).toHaveLength(1);
expect((result?.details as Record<string, unknown>).filePath).toBeDefined();
2026-02-28 18:38:00 -05:00
expect((result?.details as Record<string, unknown>).imagePath).toBeDefined();
expect((result?.details as Record<string, unknown>).format).toBe("png");
expect((result?.details as Record<string, unknown>).fileQuality).toBe("standard");
expect((result?.details as Record<string, unknown>).imageQuality).toBe("standard");
expect((result?.details as Record<string, unknown>).fileScale).toBe(2);
expect((result?.details as Record<string, unknown>).imageScale).toBe(2);
expect((result?.details as Record<string, unknown>).fileMaxWidth).toBe(960);
expect((result?.details as Record<string, unknown>).imageMaxWidth).toBe(960);
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
expect(cleanupSpy).toHaveBeenCalledTimes(1);
2026-02-28 18:38:00 -05:00
});
it("renders PDF output when fileFormat is pdf", async () => {
const screenshotter = createPdfScreenshotter({
assertOutputPath: (outputPath) => {
expect(outputPath).toMatch(/preview\.pdf$/);
},
});
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2b", {
before: "one\n",
after: "two\n",
mode: "image",
fileFormat: "pdf",
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect(readTextContent(result, 0)).toContain("Diff PDF generated at:");
expect((result?.details as Record<string, unknown>).format).toBe("pdf");
expect((result?.details as Record<string, unknown>).filePath).toMatch(/preview\.pdf$/);
});
it("accepts mode=file as an alias for file artifact rendering", async () => {
const screenshotter = createPngScreenshotter({
assertOutputPath: (outputPath) => {
expect(outputPath).toMatch(/preview\.png$/);
},
});
const tool = createToolWithScreenshotter(store, screenshotter);
const result = await tool.execute?.("tool-2c", {
before: "one\n",
after: "two\n",
mode: "file",
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect((result?.details as Record<string, unknown>).mode).toBe("file");
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
});
it("honors ttlSeconds for artifact-only file output", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
vi.setSystemTime(now);
try {
const screenshotter = createPngScreenshotter();
const tool = createToolWithScreenshotter(store, screenshotter);
const result = await tool.execute?.("tool-2c-ttl", {
before: "one\n",
after: "two\n",
mode: "file",
ttlSeconds: 1,
});
const filePath = (result?.details as Record<string, unknown>).filePath as string;
await expect(fs.stat(filePath)).resolves.toBeDefined();
vi.setSystemTime(new Date(now.getTime() + 2_000));
await store.cleanupExpired();
await expect(fs.stat(filePath)).rejects.toMatchObject({
code: "ENOENT",
});
} finally {
vi.useRealTimers();
}
});
it("accepts image* tool options for backward compatibility", async () => {
const screenshotter = createPngScreenshotter({
assertImage: (image) => {
expect(image).toMatchObject({
qualityPreset: "hq",
scale: 2.4,
maxWidth: 1100,
});
},
});
const tool = createToolWithScreenshotter(store, screenshotter);
const result = await tool.execute?.("tool-2legacy", {
before: "one\n",
after: "two\n",
mode: "file",
imageQuality: "hq",
imageScale: 2.4,
imageMaxWidth: 1100,
});
expect((result?.details as Record<string, unknown>).fileQuality).toBe("hq");
expect((result?.details as Record<string, unknown>).fileScale).toBe(2.4);
expect((result?.details as Record<string, unknown>).fileMaxWidth).toBe(1100);
});
it("accepts deprecated format alias for fileFormat", async () => {
const screenshotter = createPdfScreenshotter();
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2format", {
before: "one\n",
after: "two\n",
mode: "file",
format: "pdf",
});
expect((result?.details as Record<string, unknown>).fileFormat).toBe("pdf");
expect((result?.details as Record<string, unknown>).filePath).toMatch(/preview\.pdf$/);
});
it("honors defaults.mode=file when mode is omitted", async () => {
const screenshotter = createPngScreenshotter();
const tool = createToolWithScreenshotter(store, screenshotter, {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
mode: "file",
});
const result = await tool.execute?.("tool-2d", {
before: "one\n",
after: "two\n",
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect((result?.details as Record<string, unknown>).mode).toBe("file");
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
});
2026-02-28 18:38:00 -05:00
it("falls back to view output when both mode cannot render an image", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
2026-02-28 19:20:07 -05:00
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
2026-02-28 18:38:00 -05:00
screenshotter: {
screenshotHtml: vi.fn(async () => {
throw new Error("browser missing");
}),
},
});
const result = await tool.execute?.("tool-3", {
before: "one\n",
after: "two\n",
mode: "both",
});
expect(result?.content).toHaveLength(1);
expect(readTextContent(result, 0)).toContain("File rendering failed");
expect((result?.details as Record<string, unknown>).fileError).toBe("browser missing");
2026-02-28 18:38:00 -05:00
expect((result?.details as Record<string, unknown>).imageError).toBe("browser missing");
});
it("rejects invalid base URLs as tool input errors", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
2026-02-28 19:20:07 -05:00
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
2026-02-28 18:38:00 -05:00
});
await expect(
tool.execute?.("tool-4", {
before: "one\n",
after: "two\n",
mode: "view",
baseUrl: "javascript:alert(1)",
}),
).rejects.toThrow("Invalid baseUrl");
});
2026-02-28 19:20:07 -05:00
it("rejects oversized patch payloads", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
await expect(
tool.execute?.("tool-oversize-patch", {
patch: "x".repeat(2_100_000),
mode: "view",
}),
).rejects.toThrow("patch exceeds maximum size");
});
it("rejects oversized before/after payloads", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
const large = "x".repeat(600_000);
await expect(
tool.execute?.("tool-oversize-before", {
before: large,
after: "ok",
mode: "view",
}),
).rejects.toThrow("before exceeds maximum size");
});
2026-02-28 19:20:07 -05:00
it("uses configured defaults when tool params omit them", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
mode: "view",
theme: "light",
layout: "split",
wordWrap: false,
background: false,
fontFamily: "JetBrains Mono",
fontSize: 17,
},
});
const result = await tool.execute?.("tool-5", {
before: "one\n",
after: "two\n",
path: "README.md",
});
expect(readTextContent(result, 0)).toContain("Diff viewer ready.");
expect((result?.details as Record<string, unknown>).mode).toBe("view");
const viewerPath = String((result?.details as Record<string, unknown>).viewerPath);
const [id] = viewerPath.split("/").filter(Boolean).slice(-2);
const html = await store.readHtml(id);
expect(html).toContain('body data-theme="light"');
expect(html).toContain("--diffs-font-size: 17px;");
expect(html).toContain('--diffs-font-family: "JetBrains Mono"');
});
it("prefers explicit tool params over configured defaults", async () => {
const screenshotter = createPngScreenshotter({
assertHtml: (html) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
},
assertImage: (image) => {
expect(image).toMatchObject({
format: "png",
qualityPreset: "print",
scale: 2.75,
maxWidth: 1320,
});
2026-02-28 19:20:07 -05:00
},
});
const tool = createToolWithScreenshotter(store, screenshotter, {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
mode: "view",
theme: "light",
layout: "split",
fileQuality: "hq",
fileScale: 2.2,
fileMaxWidth: 1180,
2026-02-28 19:20:07 -05:00
});
const result = await tool.execute?.("tool-6", {
before: "one\n",
after: "two\n",
mode: "both",
theme: "dark",
layout: "unified",
fileQuality: "print",
fileScale: 2.75,
fileMaxWidth: 1320,
2026-02-28 19:20:07 -05:00
});
expect((result?.details as Record<string, unknown>).mode).toBe("both");
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect((result?.details as Record<string, unknown>).format).toBe("png");
expect((result?.details as Record<string, unknown>).fileQuality).toBe("print");
expect((result?.details as Record<string, unknown>).fileScale).toBe(2.75);
expect((result?.details as Record<string, unknown>).fileMaxWidth).toBe(1320);
2026-02-28 19:20:07 -05:00
const viewerPath = String((result?.details as Record<string, unknown>).viewerPath);
const [id] = viewerPath.split("/").filter(Boolean).slice(-2);
const html = await store.readHtml(id);
expect(html).toContain('body data-theme="dark"');
});
2026-02-28 18:38:00 -05:00
});
function createApi(): OpenClawPluginApi {
return {
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
runtime: {} as OpenClawPluginApi["runtime"],
logger: {
info() {},
warn() {},
error() {},
},
registerTool() {},
registerHook() {},
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
registerCommand() {},
feature(context): extend plugin system to support custom context management (#22201) * feat(context-engine): add ContextEngine interface and registry Introduce the pluggable ContextEngine abstraction that allows external plugins to register custom context management strategies. - ContextEngine interface with lifecycle methods: bootstrap, ingest, ingestBatch, afterTurn, assemble, compact, prepareSubagentSpawn, onSubagentEnded, dispose - Module-level singleton registry with registerContextEngine() and resolveContextEngine() (config-driven slot selection) - LegacyContextEngine: pass-through implementation wrapping existing compaction behavior for 100% backward compatibility - ensureContextEnginesInitialized() guard for safe one-time registration - 19 tests covering contract, registry, resolution, and legacy parity * feat(plugins): add context-engine slot and registerContextEngine API Wire the ContextEngine abstraction into the plugin system so external plugins can register context engines via the standard plugin API. - Add 'context-engine' to PluginKind union type - Add 'contextEngine' slot to PluginSlotsConfig (default: 'legacy') - Wire registerContextEngine() through OpenClawPluginApi - Export ContextEngine types from plugin-sdk for external consumers - Restore proper slot-based resolution in registry * feat(context-engine): wire ContextEngine into agent run lifecycle Integrate the ContextEngine abstraction into the core agent run path: - Resolve context engine once per run (reused across retries) - Bootstrap: hydrate canonical store from session file on first run - Assemble: route context assembly through pluggable engine - Auto-compaction guard: disable built-in auto-compaction when the engine declares ownsCompaction (prevents double-compaction) - AfterTurn: post-turn lifecycle hook for ingest + background compaction decisions - Overflow compaction: route through contextEngine.compact() - Dispose: clean up engine resources in finally block - Notify context engine on subagent lifecycle events Legacy engine: all lifecycle methods are pass-through/no-op, preserving 100% backward compatibility for users without a context engine plugin. * feat(plugins): add scoped subagent methods and gateway request scope Expose runtime.subagent.{run, waitForRun, getSession, deleteSession} so external plugins can spawn sub-agent sessions without raw gateway dispatch access. Uses AsyncLocalStorage request-scope bridge to dispatch internally via handleGatewayRequest with a synthetic operator client. Methods are only available during gateway request handling. - Symbol.for-backed global singleton for cross-module-reload safety - Fallback gateway context for non-WS dispatch paths (Telegram/WhatsApp) - Set gateway request scope for all handlers, not just plugin handlers - 3 staleness tests for fallback context hardening * feat(context-engine): route /compact and sessions.get through context engine Wire the /compact command and sessions.get handler through the pluggable ContextEngine interface. - Thread tokenBudget and force parameters to context engine compact - Route /compact through contextEngine.compact() when registered - Wire sessions.get as runtime alias for plugin subagent dispatch - Add .pebbles/ to .gitignore * style: format with oxfmt 0.33.0 Fix duplicate import (ControlUiRootState in server.impl.ts) and import ordering across all changed files. * fix: update extension test mocks for context-engine types Add missing subagent property to bluebubbles PluginRuntime mock. Add missing registerContextEngine to lobster OpenClawPluginApi mock. * fix(subagents): keep deferred delete cleanup retryable * style: format run attempt for CI * fix(rebase): remove duplicate embedded-run imports * test: add missing gateway context mock export * fix: pass resolved auth profile into afterTurn compaction Ensure the embedded runner forwards resolved auth profile context into legacy context-engine compaction params on the normal afterTurn path, matching overflow compaction behavior. This allows downstream LCM summarization to use the intended provider auth/profile consistently. Also fix strict TS typing in external-link token dedupe and align an attempt unit test reasoningLevel value with the current ReasoningLevel enum. Regeneration-Prompt: | We were debugging context-engine compaction where downstream summary calls were missing the right auth/profile context in normal afterTurn flow, while overflow compaction already propagated it. Preserve current behavior and keep changes additive: thread the resolved authProfileId through run -> attempt -> legacy compaction param builder without broad refactors. Add tests that prove the auth profile is included in afterTurn legacy params and that overflow compaction still passes it through run attempts. Keep existing APIs stable, and only adjust small type issues needed for strict compilation. * fix: remove duplicate imports from rebase * feat: add context-engine system prompt additions * fix(rebase): dedupe attempt import declarations * test: fix fetch mock typing in ollama autodiscovery * fix(test): add registerContextEngine to diffs extension mock APIs * test(windows): use path.delimiter in ios-team-id fixture PATH * test(cron): add model formatting and precedence edge case tests Covers: - Provider/model string splitting (whitespace, nested paths, empty segments) - Provider normalization (casing, aliases like bedrock→amazon-bedrock) - Anthropic model alias normalization (opus-4.5→claude-opus-4-5) - Precedence: job payload > session override > config default - Sequential runs with different providers (CI flake regression pattern) - forceNew session preserving stored model overrides - Whitespace/empty model string edge cases - Config model as string vs object format * test(cron): fix model formatting test config types * test(phone-control): add registerContextEngine to mock API * fix: re-export ChannelKind from config-reload-plan * fix: add subagent mock to plugin-runtime-mock test util * docs: add changelog fragment for context engine PR #22201
2026-03-06 05:31:59 -08:00
registerContextEngine() {},
2026-02-28 18:38:00 -05:00
resolvePath(input: string) {
return input;
},
on() {},
};
}
function createToolWithScreenshotter(
store: DiffArtifactStore,
screenshotter: DiffScreenshotter,
defaults = DEFAULT_DIFFS_TOOL_DEFAULTS,
) {
return createDiffsTool({
api: createApi(),
store,
defaults,
screenshotter,
});
}
function createPngScreenshotter(
params: {
assertHtml?: (html: string) => void;
assertImage?: (image: DiffRenderOptions["image"]) => void;
assertOutputPath?: (outputPath: string) => void;
} = {},
): DiffScreenshotter {
const screenshotHtml: DiffScreenshotter["screenshotHtml"] = vi.fn(
async ({
html,
outputPath,
image,
}: {
html: string;
outputPath: string;
image: DiffRenderOptions["image"];
}) => {
params.assertHtml?.(html);
params.assertImage?.(image);
params.assertOutputPath?.(outputPath);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
},
);
return {
screenshotHtml,
};
}
function createPdfScreenshotter(
params: {
assertOutputPath?: (outputPath: string) => void;
} = {},
): DiffScreenshotter {
const screenshotHtml: DiffScreenshotter["screenshotHtml"] = vi.fn(
async ({ outputPath, image }: { outputPath: string; image: DiffRenderOptions["image"] }) => {
expect(image.format).toBe("pdf");
params.assertOutputPath?.(outputPath);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("%PDF-1.7"));
return outputPath;
},
);
return { screenshotHtml };
}
2026-02-28 18:38:00 -05:00
function readTextContent(result: unknown, index: number): string {
const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)
?.content;
const entry = content?.[index];
return entry?.type === "text" ? (entry.text ?? "") : "";
}