mirror of https://github.com/openclaw/openclaw.git
514 lines
14 KiB
TypeScript
514 lines
14 KiB
TypeScript
import fs from "node:fs";
|
|
import AjvPkg from "ajv";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
DEFAULT_DIFFS_PLUGIN_SECURITY,
|
|
DEFAULT_DIFFS_TOOL_DEFAULTS,
|
|
diffsPluginConfigSchema,
|
|
resolveDiffImageRenderOptions,
|
|
resolveDiffsPluginDefaults,
|
|
resolveDiffsPluginSecurity,
|
|
resolveDiffsPluginViewerBaseUrl,
|
|
} from "./config.js";
|
|
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
|
|
import {
|
|
getServedViewerAsset,
|
|
resolveViewerRuntimeFileUrl,
|
|
VIEWER_LOADER_PATH,
|
|
VIEWER_RUNTIME_PATH,
|
|
} from "./viewer-assets.js";
|
|
import { parseViewerPayloadJson } from "./viewer-payload.js";
|
|
|
|
const FULL_DEFAULTS = {
|
|
fontFamily: "JetBrains Mono",
|
|
fontSize: 17,
|
|
lineSpacing: 1.8,
|
|
layout: "split",
|
|
showLineNumbers: false,
|
|
diffIndicators: "classic",
|
|
wordWrap: false,
|
|
background: false,
|
|
theme: "light",
|
|
fileFormat: "pdf",
|
|
fileQuality: "hq",
|
|
fileScale: 2.6,
|
|
fileMaxWidth: 1280,
|
|
mode: "file",
|
|
} as const;
|
|
|
|
function compileManifestConfigSchema() {
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
|
|
) as { configSchema: Record<string, unknown> };
|
|
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
|
|
const ajv = new Ajv({ allErrors: true, strict: false, useDefaults: true });
|
|
return ajv.compile(manifest.configSchema);
|
|
}
|
|
|
|
describe("resolveDiffsPluginDefaults", () => {
|
|
it("returns built-in defaults when config is missing", () => {
|
|
expect(resolveDiffsPluginDefaults(undefined)).toEqual(DEFAULT_DIFFS_TOOL_DEFAULTS);
|
|
});
|
|
|
|
it("applies configured defaults from plugin config", () => {
|
|
expect(
|
|
resolveDiffsPluginDefaults({
|
|
defaults: FULL_DEFAULTS,
|
|
}),
|
|
).toEqual(FULL_DEFAULTS);
|
|
});
|
|
|
|
it("clamps and falls back for invalid line spacing and indicators", () => {
|
|
expect(
|
|
resolveDiffsPluginDefaults({
|
|
defaults: {
|
|
lineSpacing: -5,
|
|
diffIndicators: "unknown",
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
lineSpacing: 1,
|
|
diffIndicators: "bars",
|
|
});
|
|
|
|
expect(
|
|
resolveDiffsPluginDefaults({
|
|
defaults: {
|
|
lineSpacing: 9,
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
lineSpacing: 3,
|
|
});
|
|
|
|
expect(
|
|
resolveDiffsPluginDefaults({
|
|
defaults: {
|
|
lineSpacing: Number.NaN,
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
lineSpacing: DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing,
|
|
});
|
|
});
|
|
|
|
it("derives file defaults from quality preset and clamps explicit overrides", () => {
|
|
expect(
|
|
resolveDiffsPluginDefaults({
|
|
defaults: {
|
|
fileQuality: "print",
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
fileQuality: "print",
|
|
fileScale: 3,
|
|
fileMaxWidth: 1400,
|
|
});
|
|
|
|
expect(
|
|
resolveDiffsPluginDefaults({
|
|
defaults: {
|
|
fileQuality: "hq",
|
|
fileScale: 99,
|
|
fileMaxWidth: 99999,
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
fileQuality: "hq",
|
|
fileScale: 4,
|
|
fileMaxWidth: 2400,
|
|
});
|
|
});
|
|
|
|
it("falls back to png for invalid file format defaults", () => {
|
|
expect(
|
|
resolveDiffsPluginDefaults({
|
|
defaults: {
|
|
fileFormat: "invalid" as "png",
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
fileFormat: "png",
|
|
});
|
|
});
|
|
|
|
it("resolves file render format from defaults and explicit overrides", () => {
|
|
const defaults = resolveDiffsPluginDefaults({
|
|
defaults: {
|
|
fileFormat: "pdf",
|
|
},
|
|
});
|
|
|
|
expect(resolveDiffImageRenderOptions({ defaults }).format).toBe("pdf");
|
|
expect(resolveDiffImageRenderOptions({ defaults, fileFormat: "png" }).format).toBe("png");
|
|
expect(resolveDiffImageRenderOptions({ defaults, format: "png" }).format).toBe("png");
|
|
});
|
|
|
|
it("accepts format as a config alias for fileFormat", () => {
|
|
expect(
|
|
resolveDiffsPluginDefaults({
|
|
defaults: {
|
|
format: "pdf",
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
fileFormat: "pdf",
|
|
});
|
|
});
|
|
|
|
it("accepts image* config aliases for backward compatibility", () => {
|
|
expect(
|
|
resolveDiffsPluginDefaults({
|
|
defaults: {
|
|
imageFormat: "pdf",
|
|
imageQuality: "hq",
|
|
imageScale: 2.2,
|
|
imageMaxWidth: 1024,
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
fileFormat: "pdf",
|
|
fileQuality: "hq",
|
|
fileScale: 2.2,
|
|
fileMaxWidth: 1024,
|
|
});
|
|
});
|
|
|
|
it("keeps loader-applied schema defaults from shadowing aliases and quality-derived defaults", () => {
|
|
const validate = compileManifestConfigSchema();
|
|
|
|
const aliasOnly = {
|
|
defaults: {
|
|
format: "pdf",
|
|
imageQuality: "hq",
|
|
},
|
|
};
|
|
expect(validate(aliasOnly)).toBe(true);
|
|
expect(resolveDiffsPluginDefaults(aliasOnly)).toMatchObject({
|
|
fileFormat: "pdf",
|
|
fileQuality: "hq",
|
|
fileScale: 2.5,
|
|
fileMaxWidth: 1200,
|
|
});
|
|
|
|
const qualityOnly = {
|
|
defaults: {
|
|
fileQuality: "hq",
|
|
},
|
|
};
|
|
expect(validate(qualityOnly)).toBe(true);
|
|
expect(resolveDiffsPluginDefaults(qualityOnly)).toMatchObject({
|
|
fileQuality: "hq",
|
|
fileScale: 2.5,
|
|
fileMaxWidth: 1200,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveDiffsPluginSecurity", () => {
|
|
it("defaults to local-only viewer access", () => {
|
|
expect(resolveDiffsPluginSecurity(undefined)).toEqual(DEFAULT_DIFFS_PLUGIN_SECURITY);
|
|
});
|
|
|
|
it("allows opt-in remote viewer access", () => {
|
|
expect(resolveDiffsPluginSecurity({ security: { allowRemoteViewer: true } })).toEqual({
|
|
allowRemoteViewer: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveDiffsPluginViewerBaseUrl", () => {
|
|
it("defaults to undefined when config is missing", () => {
|
|
expect(resolveDiffsPluginViewerBaseUrl(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it("normalizes configured viewer base URLs", () => {
|
|
expect(
|
|
resolveDiffsPluginViewerBaseUrl({
|
|
viewerBaseUrl: "https://example.com/openclaw/",
|
|
}),
|
|
).toBe("https://example.com/openclaw");
|
|
});
|
|
});
|
|
|
|
describe("diffs plugin schema surfaces", () => {
|
|
it("rejects invalid viewerBaseUrl values at manifest-validation time too", () => {
|
|
const validate = compileManifestConfigSchema();
|
|
|
|
expect(validate({ viewerBaseUrl: "javascript:alert(1)" })).toBe(false);
|
|
expect(validate({ viewerBaseUrl: "https://example.com/openclaw?x=1" })).toBe(false);
|
|
expect(validate({ viewerBaseUrl: "https://example.com/openclaw#frag" })).toBe(false);
|
|
expect(validate({ viewerBaseUrl: "https://example.com/openclaw/" })).toBe(true);
|
|
});
|
|
|
|
it("preserves defaults and security for direct safeParse callers", () => {
|
|
expect(
|
|
diffsPluginConfigSchema.safeParse?.({
|
|
viewerBaseUrl: "https://example.com/openclaw/",
|
|
defaults: {
|
|
theme: "light",
|
|
},
|
|
security: {
|
|
allowRemoteViewer: true,
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
success: true,
|
|
data: {
|
|
viewerBaseUrl: "https://example.com/openclaw",
|
|
defaults: {
|
|
fontFamily: "Fira Code",
|
|
fontSize: 15,
|
|
lineSpacing: 1.6,
|
|
layout: "unified",
|
|
showLineNumbers: true,
|
|
diffIndicators: "bars",
|
|
wordWrap: true,
|
|
background: true,
|
|
theme: "light",
|
|
fileFormat: "png",
|
|
fileQuality: "standard",
|
|
fileScale: 2,
|
|
fileMaxWidth: 960,
|
|
mode: "both",
|
|
},
|
|
security: {
|
|
allowRemoteViewer: true,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("canonicalizes alias-driven defaults for direct safeParse callers", () => {
|
|
expect(
|
|
diffsPluginConfigSchema.safeParse?.({
|
|
defaults: {
|
|
format: "pdf",
|
|
imageQuality: "hq",
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
success: true,
|
|
data: {
|
|
defaults: {
|
|
fileFormat: "pdf",
|
|
fileQuality: "hq",
|
|
fileScale: 2.5,
|
|
fileMaxWidth: 1200,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects invalid viewerBaseUrl config values", () => {
|
|
expect(
|
|
diffsPluginConfigSchema.safeParse?.({
|
|
viewerBaseUrl: "javascript:alert(1)",
|
|
}),
|
|
).toMatchObject({
|
|
success: false,
|
|
error: {
|
|
issues: [
|
|
{
|
|
path: ["viewerBaseUrl"],
|
|
message: "viewerBaseUrl must use http or https: javascript:alert(1)",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("keeps the runtime json schema in sync with the manifest config schema", () => {
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
|
|
) as { configSchema?: unknown };
|
|
|
|
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("prefers normalized viewerBaseUrl strings too", () => {
|
|
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",
|
|
);
|
|
});
|
|
|
|
it("uses the configured field name in viewerBaseUrl validation errors", () => {
|
|
expect(() => normalizeViewerBaseUrl("https://example.com?a=1", "viewerBaseUrl")).toThrow(
|
|
"viewerBaseUrl must not include query/hash",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("viewer assets", () => {
|
|
it("prefers the built plugin asset layout when present", async () => {
|
|
const stat = vi.fn(async (path: string) => {
|
|
if (path === "/repo/dist/extensions/diffs/assets/viewer-runtime.js") {
|
|
return { mtimeMs: 1 };
|
|
}
|
|
const error = Object.assign(new Error(`missing: ${path}`), { code: "ENOENT" });
|
|
throw error;
|
|
});
|
|
|
|
await expect(
|
|
resolveViewerRuntimeFileUrl({
|
|
baseUrl: "file:///repo/dist/extensions/diffs/index.js",
|
|
stat,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
pathname: "/repo/dist/extensions/diffs/assets/viewer-runtime.js",
|
|
});
|
|
expect(stat).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("falls back to the source asset layout when the built artifact is absent", async () => {
|
|
const stat = vi.fn(async (path: string) => {
|
|
if (path === "/repo/extensions/diffs/assets/viewer-runtime.js") {
|
|
return { mtimeMs: 1 };
|
|
}
|
|
const error = Object.assign(new Error(`missing: ${path}`), { code: "ENOENT" });
|
|
throw error;
|
|
});
|
|
|
|
await expect(
|
|
resolveViewerRuntimeFileUrl({
|
|
baseUrl: "file:///repo/extensions/diffs/src/viewer-assets.js",
|
|
stat,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
pathname: "/repo/extensions/diffs/assets/viewer-runtime.js",
|
|
});
|
|
expect(stat).toHaveBeenNthCalledWith(1, "/repo/extensions/diffs/src/assets/viewer-runtime.js");
|
|
expect(stat).toHaveBeenNthCalledWith(2, "/repo/extensions/diffs/assets/viewer-runtime.js");
|
|
});
|
|
|
|
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.js?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");
|
|
expect(String(runtime?.body)).toContain('style.width="24px"');
|
|
expect(String(runtime?.body)).toContain('style.gap="6px"');
|
|
});
|
|
|
|
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.");
|
|
});
|
|
});
|