mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-23 15:11:42 +00:00
test: collapse utility plugin suites
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
|
||||
import { createTestPluginApi } from "../../../test/helpers/extensions/plugin-api.js";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
|
||||
import plugin from "../index.js";
|
||||
import { createTempDiffRoot } from "./test-helpers.js";
|
||||
|
||||
const { launchMock } = vi.hoisted(() => ({
|
||||
@@ -187,6 +192,128 @@ describe("PlaywrightDiffScreenshotter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffs plugin registration", () => {
|
||||
it("registers the tool, http route, and system-prompt guidance hook", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const registerHttpRoute = vi.fn();
|
||||
const on = vi.fn();
|
||||
|
||||
plugin.register?.(
|
||||
createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: {} as never,
|
||||
registerTool,
|
||||
registerHttpRoute,
|
||||
on,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(registerTool).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
|
||||
path: "/plugins/diffs",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
});
|
||||
expect(on).toHaveBeenCalledTimes(1);
|
||||
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
|
||||
const beforePromptBuild = on.mock.calls[0]?.[1];
|
||||
const result = await beforePromptBuild?.({}, {});
|
||||
expect(result).toMatchObject({
|
||||
prependSystemContext: expect.stringContaining("prefer the `diffs` tool"),
|
||||
});
|
||||
expect(result?.prependContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
|
||||
type RegisteredTool = {
|
||||
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
|
||||
|
||||
let registeredToolFactory:
|
||||
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
|
||||
| undefined;
|
||||
let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
runtime: {} as never,
|
||||
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
|
||||
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
|
||||
},
|
||||
registerHttpRoute(params: RegisteredHttpRouteParams) {
|
||||
registeredHttpRouteHandler = params.handler;
|
||||
},
|
||||
});
|
||||
|
||||
plugin.register?.(api as unknown as OpenClawPluginApi);
|
||||
|
||||
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(String(res.body)).toContain('body data-theme="light"');
|
||||
expect(String(res.body)).toContain('"backgroundEnabled":false');
|
||||
expect(String(res.body)).toContain('"diffStyle":"split"');
|
||||
expect(String(res.body)).toContain('"disableLineNumbers":true');
|
||||
expect(String(res.body)).toContain('"diffIndicators":"classic"');
|
||||
expect(String(res.body)).toContain("--diffs-line-height: 30px;");
|
||||
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createConfig(): OpenClawConfig {
|
||||
return {
|
||||
browser: {
|
||||
@@ -195,6 +322,18 @@ function createConfig(): OpenClawConfig {
|
||||
} 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 };
|
||||
}) {
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
resolveDiffsPluginDefaults,
|
||||
resolveDiffsPluginSecurity,
|
||||
} from "./config.js";
|
||||
import { renderDiffDocument } from "./render.js";
|
||||
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
|
||||
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
|
||||
import { parseViewerPayloadJson } from "./viewer-payload.js";
|
||||
|
||||
const FULL_DEFAULTS = {
|
||||
fontFamily: "JetBrains Mono",
|
||||
@@ -177,3 +181,232 @@ describe("diffs plugin schema surfaces", () => {
|
||||
expect(diffsPluginConfigSchema.jsonSchema).toEqual(manifest.configSchema);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffs viewer URL helpers", () => {
|
||||
it("defaults to loopback for lan/tailnet bind modes", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "lan", port: 18789 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
|
||||
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "tailnet", port: 24444 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("uses custom bind host when provided", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: { enabled: true },
|
||||
},
|
||||
},
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("joins viewer path under baseUrl pathname", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {},
|
||||
baseUrl: "https://example.com/openclaw",
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("rejects base URLs with query/hash", () => {
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDiffDocument", () => {
|
||||
it("renders before/after input into a complete viewer document", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "const value = 1;\n",
|
||||
after: "const value = 2;\n",
|
||||
path: "src/example.ts",
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("src/example.ts");
|
||||
expect(rendered.fileCount).toBe(1);
|
||||
expect(rendered.html).toContain("data-openclaw-diff-root");
|
||||
expect(rendered.html).toContain("src/example.ts");
|
||||
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("max-width: 960px;");
|
||||
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
|
||||
expect(rendered.html).toContain("min-height: 100vh;");
|
||||
expect(rendered.html).toContain('"diffIndicators":"bars"');
|
||||
expect(rendered.html).toContain('"disableLineNumbers":false');
|
||||
expect(rendered.html).toContain("--diffs-line-height: 24px;");
|
||||
expect(rendered.html).toContain("--diffs-font-size: 15px;");
|
||||
expect(rendered.html).not.toContain("fonts.googleapis.com");
|
||||
});
|
||||
|
||||
it("renders multi-file patch input", async () => {
|
||||
const patch = [
|
||||
"diff --git a/a.ts b/a.ts",
|
||||
"--- a/a.ts",
|
||||
"+++ b/a.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const a = 1;",
|
||||
"+const a = 2;",
|
||||
"diff --git a/b.ts b/b.ts",
|
||||
"--- a/b.ts",
|
||||
"+++ b/b.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const b = 1;",
|
||||
"+const b = 2;",
|
||||
].join("\n");
|
||||
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
title: "Workspace patch",
|
||||
},
|
||||
{
|
||||
presentation: {
|
||||
...DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
layout: "split",
|
||||
theme: "dark",
|
||||
},
|
||||
image: resolveDiffImageRenderOptions({
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
fileQuality: "hq",
|
||||
fileMaxWidth: 1180,
|
||||
}),
|
||||
expandUnchanged: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("Workspace patch");
|
||||
expect(rendered.fileCount).toBe(2);
|
||||
expect(rendered.html).toContain("Workspace patch");
|
||||
expect(rendered.imageHtml).toContain("max-width: 1180px;");
|
||||
});
|
||||
|
||||
it("rejects patches that exceed file-count limits", async () => {
|
||||
const patch = Array.from({ length: 129 }, (_, i) => {
|
||||
return [
|
||||
`diff --git a/f${i}.ts b/f${i}.ts`,
|
||||
`--- a/f${i}.ts`,
|
||||
`+++ b/f${i}.ts`,
|
||||
"@@ -1 +1 @@",
|
||||
"-const x = 1;",
|
||||
"+const x = 2;",
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
|
||||
await expect(
|
||||
renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("too many files");
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewer assets", () => {
|
||||
it("serves a stable loader that points at the current runtime bundle", async () => {
|
||||
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
|
||||
|
||||
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
|
||||
});
|
||||
|
||||
it("serves the runtime bundle body", async () => {
|
||||
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
|
||||
|
||||
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(runtime?.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it("returns null for unknown asset paths", async () => {
|
||||
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseViewerPayloadJson", () => {
|
||||
function buildValidPayload(): Record<string, unknown> {
|
||||
return {
|
||||
prerenderedHTML: "<div>ok</div>",
|
||||
langs: ["text"],
|
||||
oldFile: {
|
||||
name: "README.md",
|
||||
contents: "before",
|
||||
},
|
||||
newFile: {
|
||||
name: "README.md",
|
||||
contents: "after",
|
||||
},
|
||||
options: {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: ":host{}",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("accepts valid payload JSON", () => {
|
||||
const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
|
||||
expect(parsed.options.diffStyle).toBe("unified");
|
||||
expect(parsed.options.diffIndicators).toBe("bars");
|
||||
});
|
||||
|
||||
it("rejects payloads with invalid shape", () => {
|
||||
const broken = buildValidPayload();
|
||||
broken.options = {
|
||||
...(broken.options as Record<string, unknown>),
|
||||
diffIndicators: "invalid",
|
||||
};
|
||||
|
||||
expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
|
||||
"Diff payload has invalid shape.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
|
||||
import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffStoreHarness } from "./test-helpers.js";
|
||||
|
||||
describe("createDiffsHttpHandler", () => {
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
async function handleLocalGet(url: string) {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
return { handled, res };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("serves a stored diff document", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(artifact.viewerPath);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
|
||||
});
|
||||
|
||||
it("rejects invalid tokens", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(
|
||||
artifact.viewerPath.replace(artifact.token, "bad-token"),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects malformed artifact ids before reading from disk", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("serves the shared viewer asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
|
||||
});
|
||||
|
||||
it("serves the shared viewer runtime asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer-runtime.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "blocks non-loopback viewer access by default",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "blocks loopback requests that carry proxy forwarding headers by default",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "allows remote access when allowRemoteViewer is enabled",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
request({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
headers,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(expectedStatusCode);
|
||||
if (expectedStatusCode === 200) {
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
}
|
||||
});
|
||||
|
||||
it("rate-limits repeated remote misses", async () => {
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const miss = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
miss,
|
||||
);
|
||||
expect(miss.statusCode).toBe(404);
|
||||
}
|
||||
|
||||
const limited = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
limited,
|
||||
);
|
||||
expect(limited.statusCode).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
async function createViewerArtifact(store: DiffArtifactStore) {
|
||||
return await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function remoteReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "203.0.113.10" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
|
||||
import { renderDiffDocument } from "./render.js";
|
||||
|
||||
describe("renderDiffDocument", () => {
|
||||
it("renders before/after input into a complete viewer document", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "const value = 1;\n",
|
||||
after: "const value = 2;\n",
|
||||
path: "src/example.ts",
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("src/example.ts");
|
||||
expect(rendered.fileCount).toBe(1);
|
||||
expect(rendered.html).toContain("data-openclaw-diff-root");
|
||||
expect(rendered.html).toContain("src/example.ts");
|
||||
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("max-width: 960px;");
|
||||
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
|
||||
expect(rendered.html).toContain("min-height: 100vh;");
|
||||
expect(rendered.html).toContain('"diffIndicators":"bars"');
|
||||
expect(rendered.html).toContain('"disableLineNumbers":false');
|
||||
expect(rendered.html).toContain("--diffs-line-height: 24px;");
|
||||
expect(rendered.html).toContain("--diffs-font-size: 15px;");
|
||||
expect(rendered.html).not.toContain("fonts.googleapis.com");
|
||||
});
|
||||
|
||||
it("renders multi-file patch input", async () => {
|
||||
const patch = [
|
||||
"diff --git a/a.ts b/a.ts",
|
||||
"--- a/a.ts",
|
||||
"+++ b/a.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const a = 1;",
|
||||
"+const a = 2;",
|
||||
"diff --git a/b.ts b/b.ts",
|
||||
"--- a/b.ts",
|
||||
"+++ b/b.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const b = 1;",
|
||||
"+const b = 2;",
|
||||
].join("\n");
|
||||
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
title: "Workspace patch",
|
||||
},
|
||||
{
|
||||
presentation: {
|
||||
...DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
layout: "split",
|
||||
theme: "dark",
|
||||
},
|
||||
image: resolveDiffImageRenderOptions({
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
fileQuality: "hq",
|
||||
fileMaxWidth: 1180,
|
||||
}),
|
||||
expandUnchanged: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("Workspace patch");
|
||||
expect(rendered.fileCount).toBe(2);
|
||||
expect(rendered.html).toContain("Workspace patch");
|
||||
expect(rendered.imageHtml).toContain("max-width: 1180px;");
|
||||
});
|
||||
|
||||
it("rejects patches that exceed file-count limits", async () => {
|
||||
const patch = Array.from({ length: 129 }, (_, i) => {
|
||||
return [
|
||||
`diff --git a/f${i}.ts b/f${i}.ts`,
|
||||
`--- a/f${i}.ts`,
|
||||
`+++ b/f${i}.ts`,
|
||||
"@@ -1 +1 @@",
|
||||
"-const x = 1;",
|
||||
"+const x = 2;",
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
|
||||
await expect(
|
||||
renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("too many files");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
|
||||
import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffStoreHarness } from "./test-helpers.js";
|
||||
|
||||
@@ -211,3 +214,203 @@ describe("DiffArtifactStore", () => {
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDiffsHttpHandler", () => {
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
async function handleLocalGet(url: string) {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
return { handled, res };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("serves a stored diff document", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(artifact.viewerPath);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
|
||||
});
|
||||
|
||||
it("rejects invalid tokens", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(
|
||||
artifact.viewerPath.replace(artifact.token, "bad-token"),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects malformed artifact ids before reading from disk", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("serves the shared viewer asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
|
||||
});
|
||||
|
||||
it("serves the shared viewer runtime asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer-runtime.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "blocks non-loopback viewer access by default",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "blocks loopback requests that carry proxy forwarding headers by default",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "allows remote access when allowRemoteViewer is enabled",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
request({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
headers,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(expectedStatusCode);
|
||||
if (expectedStatusCode === 200) {
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
}
|
||||
});
|
||||
|
||||
it("rate-limits repeated remote misses", async () => {
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const miss = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
miss,
|
||||
);
|
||||
expect(miss.statusCode).toBe(404);
|
||||
}
|
||||
|
||||
const limited = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
limited,
|
||||
);
|
||||
expect(limited.statusCode).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
async function createViewerArtifact(store: DiffArtifactStore) {
|
||||
return await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function remoteReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "203.0.113.10" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
|
||||
|
||||
describe("diffs viewer URL helpers", () => {
|
||||
it("defaults to loopback for lan/tailnet bind modes", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "lan", port: 18789 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
|
||||
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "tailnet", port: 24444 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("uses custom bind host when provided", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: { enabled: true },
|
||||
},
|
||||
},
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("joins viewer path under baseUrl pathname", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {},
|
||||
baseUrl: "https://example.com/openclaw",
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("rejects base URLs with query/hash", () => {
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
|
||||
|
||||
describe("viewer assets", () => {
|
||||
it("serves a stable loader that points at the current runtime bundle", async () => {
|
||||
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
|
||||
|
||||
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
|
||||
});
|
||||
|
||||
it("serves the runtime bundle body", async () => {
|
||||
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
|
||||
|
||||
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(runtime?.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it("returns null for unknown asset paths", async () => {
|
||||
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseViewerPayloadJson } from "./viewer-payload.js";
|
||||
|
||||
function buildValidPayload(): Record<string, unknown> {
|
||||
return {
|
||||
prerenderedHTML: "<div>ok</div>",
|
||||
langs: ["text"],
|
||||
oldFile: {
|
||||
name: "README.md",
|
||||
contents: "before",
|
||||
},
|
||||
newFile: {
|
||||
name: "README.md",
|
||||
contents: "after",
|
||||
},
|
||||
options: {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: ":host{}",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseViewerPayloadJson", () => {
|
||||
it("accepts valid payload JSON", () => {
|
||||
const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
|
||||
expect(parsed.options.diffStyle).toBe("unified");
|
||||
expect(parsed.options.diffIndicators).toBe("bars");
|
||||
});
|
||||
|
||||
it("rejects payloads with invalid shape", () => {
|
||||
const broken = buildValidPayload();
|
||||
broken.options = {
|
||||
...(broken.options as Record<string, unknown>),
|
||||
diffIndicators: "invalid",
|
||||
};
|
||||
|
||||
expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
|
||||
"Diff payload has invalid shape.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user