mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
refactor: eliminate jscpd clones and boost tests
This commit is contained in:
@@ -1,30 +1,12 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
|
||||
import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js";
|
||||
import { buildDockerExecArgs } from "./bash-tools.shared.js";
|
||||
import { sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
import { resolveShellFromPath, sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const resolveShellFromPath = (name: string) => {
|
||||
const envPath = process.env.PATH ?? "";
|
||||
if (!envPath) {
|
||||
return undefined;
|
||||
}
|
||||
const entries = envPath.split(path.delimiter).filter(Boolean);
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(entry, name);
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// ignore missing or non-executable entries
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const defaultShell = isWin
|
||||
? undefined
|
||||
: process.env.OPENCLAW_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh";
|
||||
|
||||
@@ -49,7 +49,7 @@ export function getShellConfig(): { shell: string; args: string[] } {
|
||||
return { shell, args: ["-c"] };
|
||||
}
|
||||
|
||||
function resolveShellFromPath(name: string): string | undefined {
|
||||
export function resolveShellFromPath(name: string): string | undefined {
|
||||
const envPath = process.env.PATH ?? "";
|
||||
if (!envPath) {
|
||||
return undefined;
|
||||
|
||||
@@ -38,6 +38,20 @@ describe("cdp", () => {
|
||||
return wsPort;
|
||||
};
|
||||
|
||||
const startVersionHttpServer = async (versionBody: Record<string, unknown>) => {
|
||||
httpServer = createServer((req, res) => {
|
||||
if (req.url === "/json/version") {
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify(versionBody));
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
|
||||
return (httpServer.address() as { port: number }).port;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!httpServer) {
|
||||
@@ -68,23 +82,10 @@ describe("cdp", () => {
|
||||
);
|
||||
});
|
||||
|
||||
httpServer = createServer((req, res) => {
|
||||
if (req.url === "/json/version") {
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
const httpPort = await startVersionHttpServer({
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
|
||||
const httpPort = (httpServer.address() as { port: number }).port;
|
||||
|
||||
const created = await createTargetViaCdp({
|
||||
cdpUrl: `http://127.0.0.1:${httpPort}`,
|
||||
url: "https://example.com",
|
||||
@@ -122,23 +123,10 @@ describe("cdp", () => {
|
||||
);
|
||||
});
|
||||
|
||||
httpServer = createServer((req, res) => {
|
||||
if (req.url === "/json/version") {
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
const httpPort = await startVersionHttpServer({
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
|
||||
const httpPort = (httpServer.address() as { port: number }).port;
|
||||
|
||||
const created = await createTargetViaCdp({
|
||||
cdpUrl: `http://127.0.0.1:${httpPort}`,
|
||||
url: "http://127.0.0.1:8080",
|
||||
@@ -174,6 +162,16 @@ describe("cdp", () => {
|
||||
expect(res.result.value).toBe(2);
|
||||
});
|
||||
|
||||
it("fails when /json/version omits webSocketDebuggerUrl", async () => {
|
||||
const httpPort = await startVersionHttpServer({});
|
||||
await expect(
|
||||
createTargetViaCdp({
|
||||
cdpUrl: `http://127.0.0.1:${httpPort}`,
|
||||
url: "https://example.com",
|
||||
}),
|
||||
).rejects.toThrow("CDP /json/version missing webSocketDebuggerUrl");
|
||||
});
|
||||
|
||||
it("captures an aria snapshot via CDP", async () => {
|
||||
const wsPort = await startWsServerWithMessages((msg, socket) => {
|
||||
if (msg.method === "Accessibility.enable") {
|
||||
|
||||
@@ -39,6 +39,14 @@ export function getBrowserControlServerBaseUrl(): string {
|
||||
return `http://127.0.0.1:${state.testPort}`;
|
||||
}
|
||||
|
||||
export function restoreGatewayPortEnv(prevGatewayPort: string | undefined): void {
|
||||
if (prevGatewayPort === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_PORT;
|
||||
return;
|
||||
}
|
||||
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
|
||||
}
|
||||
|
||||
export function setBrowserControlServerCreateTargetId(targetId: string | null): void {
|
||||
state.createTargetId = targetId;
|
||||
}
|
||||
@@ -332,11 +340,7 @@ export function installBrowserControlServerHooks() {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (state.prevGatewayPort === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_PORT = state.prevGatewayPort;
|
||||
}
|
||||
restoreGatewayPortEnv(state.prevGatewayPort);
|
||||
if (state.prevGatewayToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
installBrowserControlServerHooks,
|
||||
makeResponse,
|
||||
getPwMocks,
|
||||
restoreGatewayPortEnv,
|
||||
startBrowserControlServerFromConfig,
|
||||
stopBrowserControlServer,
|
||||
} from "./server.control-server.test-harness.js";
|
||||
@@ -80,11 +81,7 @@ describe("profile CRUD endpoints", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
if (state.prevGatewayPort === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_PORT = state.prevGatewayPort;
|
||||
}
|
||||
restoreGatewayPortEnv(state.prevGatewayPort);
|
||||
await stopBrowserControlServer();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { createChannelTestPluginBase } from "../test-utils/channel-plugins.js";
|
||||
import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js";
|
||||
import { createRegistry } from "./server.e2e-registry-helpers.js";
|
||||
import {
|
||||
@@ -95,22 +96,15 @@ const createStubChannelPlugin = (params: {
|
||||
label: string;
|
||||
resolveAllowFrom?: (cfg: Record<string, unknown>) => string[];
|
||||
}): ChannelPlugin => ({
|
||||
id: params.id,
|
||||
meta: {
|
||||
...createChannelTestPluginBase({
|
||||
id: params.id,
|
||||
label: params.label,
|
||||
selectionLabel: params.label,
|
||||
docsPath: `/channels/${params.id}`,
|
||||
blurb: "test stub.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
resolveAllowFrom: params.resolveAllowFrom
|
||||
? ({ cfg }) => params.resolveAllowFrom?.(cfg as Record<string, unknown>) ?? []
|
||||
: undefined,
|
||||
},
|
||||
config: {
|
||||
resolveAllowFrom: params.resolveAllowFrom
|
||||
? ({ cfg }) => params.resolveAllowFrom?.(cfg as Record<string, unknown>) ?? []
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
resolveTarget: ({ to, allowFrom }) => {
|
||||
|
||||
@@ -7,11 +7,11 @@ import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js";
|
||||
import { BARE_SESSION_RESET_PROMPT } from "../auto-reply/reply/session-reset-prompt.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js";
|
||||
import { createRegistry } from "./server.e2e-registry-helpers.js";
|
||||
import {
|
||||
agentCommand,
|
||||
connectWebchatClient,
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
@@ -367,18 +367,7 @@ describe("gateway server agent", () => {
|
||||
test("agent events stream to webchat clients when run context is registered", async () => {
|
||||
await writeMainSessionEntry({ sessionId: "sess-main" });
|
||||
|
||||
const webchatWs = new WebSocket(`ws://127.0.0.1:${port}`, {
|
||||
headers: { origin: `http://127.0.0.1:${port}` },
|
||||
});
|
||||
await new Promise<void>((resolve) => webchatWs.once("open", resolve));
|
||||
await connectOk(webchatWs, {
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.WEBCHAT,
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
},
|
||||
});
|
||||
const webchatWs = await connectWebchatClient({ port });
|
||||
|
||||
registerAgentRunContext("run-auto-1", { sessionKey: "main" });
|
||||
|
||||
|
||||
@@ -83,6 +83,16 @@ function scopedCanvasPath(capability: string, path: string): string {
|
||||
return `${CANVAS_CAPABILITY_PATH_PREFIX}/${encodeURIComponent(capability)}${path}`;
|
||||
}
|
||||
|
||||
const allowCanvasHostHttp: CanvasHostHandler["handleHttpRequest"] = async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (url.pathname !== CANVAS_HOST_PATH && !url.pathname.startsWith(`${CANVAS_HOST_PATH}/`)) {
|
||||
return false;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("ok");
|
||||
return true;
|
||||
};
|
||||
async function withCanvasGatewayHarness(params: {
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
listenHost?: string;
|
||||
@@ -162,19 +172,7 @@ describe("gateway canvas host auth", () => {
|
||||
run: async () => {
|
||||
await withCanvasGatewayHarness({
|
||||
resolvedAuth,
|
||||
handleHttpRequest: async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (
|
||||
url.pathname !== CANVAS_HOST_PATH &&
|
||||
!url.pathname.startsWith(`${CANVAS_HOST_PATH}/`)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("ok");
|
||||
return true;
|
||||
},
|
||||
handleHttpRequest: allowCanvasHostHttp,
|
||||
run: async ({ listener, clients }) => {
|
||||
const host = "127.0.0.1";
|
||||
const operatorOnlyCapability = "operator-only";
|
||||
@@ -287,19 +285,7 @@ describe("gateway canvas host auth", () => {
|
||||
run: async () => {
|
||||
await withCanvasGatewayHarness({
|
||||
resolvedAuth,
|
||||
handleHttpRequest: async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (
|
||||
url.pathname !== CANVAS_HOST_PATH &&
|
||||
!url.pathname.startsWith(`${CANVAS_HOST_PATH}/`)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("ok");
|
||||
return true;
|
||||
},
|
||||
handleHttpRequest: allowCanvasHostHttp,
|
||||
run: async ({ listener, clients }) => {
|
||||
clients.add(
|
||||
makeWsClient({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { PluginRegistry } from "../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createChannelTestPluginBase } from "../test-utils/channel-plugins.js";
|
||||
import { createRegistry } from "./server.e2e-registry-helpers.js";
|
||||
import {
|
||||
connectOk,
|
||||
@@ -48,20 +49,11 @@ const createStubChannelPlugin = (params: {
|
||||
summary?: Record<string, unknown>;
|
||||
logoutCleared?: boolean;
|
||||
}): ChannelPlugin => ({
|
||||
id: params.id,
|
||||
meta: {
|
||||
...createChannelTestPluginBase({
|
||||
id: params.id,
|
||||
label: params.label,
|
||||
selectionLabel: params.label,
|
||||
docsPath: `/channels/${params.id}`,
|
||||
blurb: "test stub.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
isConfigured: async () => false,
|
||||
},
|
||||
config: { isConfigured: async () => false },
|
||||
}),
|
||||
status: {
|
||||
buildChannelSummary: async () => ({
|
||||
configured: false,
|
||||
|
||||
@@ -573,6 +573,43 @@ export async function connectOk(ws: WebSocket, opts?: Parameters<typeof connectR
|
||||
return res.payload as { type: "hello-ok" };
|
||||
}
|
||||
|
||||
export async function connectWebchatClient(params: {
|
||||
port: number;
|
||||
origin?: string;
|
||||
client?: NonNullable<Parameters<typeof connectReq>[1]>["client"];
|
||||
}): Promise<WebSocket> {
|
||||
const origin = params.origin ?? `http://127.0.0.1:${params.port}`;
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${params.port}`, {
|
||||
headers: { origin },
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000);
|
||||
const onOpen = () => {
|
||||
clearTimeout(timer);
|
||||
ws.off("error", onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (err: Error) => {
|
||||
clearTimeout(timer);
|
||||
ws.off("open", onOpen);
|
||||
reject(err);
|
||||
};
|
||||
ws.once("open", onOpen);
|
||||
ws.once("error", onError);
|
||||
});
|
||||
await connectOk(ws, {
|
||||
client:
|
||||
params.client ??
|
||||
({
|
||||
id: GATEWAY_CLIENT_NAMES.WEBCHAT,
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
} as NonNullable<Parameters<typeof connectReq>[1]>["client"]),
|
||||
});
|
||||
return ws;
|
||||
}
|
||||
|
||||
export async function rpcReq<T extends Record<string, unknown>>(
|
||||
ws: WebSocket,
|
||||
method: string,
|
||||
|
||||
@@ -13,11 +13,10 @@ import { resolveSafeInstallDir, unscopedPackageName } from "../infra/install-saf
|
||||
import {
|
||||
type NpmIntegrityDrift,
|
||||
type NpmSpecResolution,
|
||||
packNpmSpecToArchive,
|
||||
resolveArchiveSourcePath,
|
||||
withTempDir,
|
||||
} from "../infra/install-source-utils.js";
|
||||
import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js";
|
||||
import { installFromNpmSpecArchive } from "../infra/npm-pack-install.js";
|
||||
import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
||||
import { isPathInside, isPathInsideWithRealpath } from "../security/scan-paths.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
@@ -415,58 +414,38 @@ export async function installHooksFromNpmSpec(params: {
|
||||
return { ok: false, error: specError };
|
||||
}
|
||||
|
||||
return await withTempDir("openclaw-hook-pack-", async (tmpDir) => {
|
||||
logger.info?.(`Downloading ${spec}…`);
|
||||
const packedResult = await packNpmSpecToArchive({
|
||||
spec,
|
||||
timeoutMs,
|
||||
cwd: tmpDir,
|
||||
});
|
||||
if (!packedResult.ok) {
|
||||
return packedResult;
|
||||
}
|
||||
|
||||
const npmResolution: NpmSpecResolution = {
|
||||
...packedResult.metadata,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({
|
||||
spec,
|
||||
expectedIntegrity: params.expectedIntegrity,
|
||||
resolution: npmResolution,
|
||||
onIntegrityDrift: params.onIntegrityDrift,
|
||||
warn: (message) => {
|
||||
logger.warn?.(message);
|
||||
},
|
||||
});
|
||||
const integrityDrift = driftResult.integrityDrift;
|
||||
if (driftResult.error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: driftResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const installResult = await installHooksFromArchive({
|
||||
archivePath: packedResult.archivePath,
|
||||
hooksDir: params.hooksDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
mode,
|
||||
dryRun,
|
||||
expectedHookPackId,
|
||||
});
|
||||
if (!installResult.ok) {
|
||||
return installResult;
|
||||
}
|
||||
|
||||
return {
|
||||
...installResult,
|
||||
npmResolution,
|
||||
integrityDrift,
|
||||
};
|
||||
logger.info?.(`Downloading ${spec}…`);
|
||||
const flowResult = await installFromNpmSpecArchive({
|
||||
tempDirPrefix: "openclaw-hook-pack-",
|
||||
spec,
|
||||
timeoutMs,
|
||||
expectedIntegrity: params.expectedIntegrity,
|
||||
onIntegrityDrift: params.onIntegrityDrift,
|
||||
warn: (message) => {
|
||||
logger.warn?.(message);
|
||||
},
|
||||
installFromArchive: async ({ archivePath }) =>
|
||||
await installHooksFromArchive({
|
||||
archivePath,
|
||||
hooksDir: params.hooksDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
mode,
|
||||
dryRun,
|
||||
expectedHookPackId,
|
||||
}),
|
||||
});
|
||||
if (!flowResult.ok) {
|
||||
return flowResult;
|
||||
}
|
||||
if (!flowResult.installResult.ok) {
|
||||
return flowResult.installResult;
|
||||
}
|
||||
return {
|
||||
...flowResult.installResult,
|
||||
npmResolution: flowResult.npmResolution,
|
||||
integrityDrift: flowResult.integrityDrift,
|
||||
};
|
||||
}
|
||||
|
||||
export async function installHooksFromPath(params: {
|
||||
|
||||
96
src/infra/npm-pack-install.test.ts
Normal file
96
src/infra/npm-pack-install.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { packNpmSpecToArchive, withTempDir } from "./install-source-utils.js";
|
||||
import { installFromNpmSpecArchive } from "./npm-pack-install.js";
|
||||
|
||||
vi.mock("./install-source-utils.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./install-source-utils.js")>();
|
||||
return {
|
||||
...actual,
|
||||
withTempDir: vi.fn(async (_prefix: string, fn: (tmpDir: string) => Promise<unknown>) => {
|
||||
return await fn("/tmp/openclaw-npm-pack-install-test");
|
||||
}),
|
||||
packNpmSpecToArchive: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("installFromNpmSpecArchive", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(packNpmSpecToArchive).mockReset();
|
||||
vi.mocked(withTempDir).mockClear();
|
||||
});
|
||||
|
||||
it("returns pack errors without invoking installer", async () => {
|
||||
vi.mocked(packNpmSpecToArchive).mockResolvedValue({ ok: false, error: "pack failed" });
|
||||
const installFromArchive = vi.fn(async () => ({ ok: true as const }));
|
||||
|
||||
const result = await installFromNpmSpecArchive({
|
||||
tempDirPrefix: "openclaw-test-",
|
||||
spec: "@openclaw/test@1.0.0",
|
||||
timeoutMs: 1000,
|
||||
installFromArchive,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, error: "pack failed" });
|
||||
expect(installFromArchive).not.toHaveBeenCalled();
|
||||
expect(withTempDir).toHaveBeenCalledWith("openclaw-test-", expect.any(Function));
|
||||
});
|
||||
|
||||
it("returns resolution metadata and installer result on success", async () => {
|
||||
vi.mocked(packNpmSpecToArchive).mockResolvedValue({
|
||||
ok: true,
|
||||
archivePath: "/tmp/openclaw-test.tgz",
|
||||
metadata: {
|
||||
name: "@openclaw/test",
|
||||
version: "1.0.0",
|
||||
resolvedSpec: "@openclaw/test@1.0.0",
|
||||
integrity: "sha512-same",
|
||||
},
|
||||
});
|
||||
const installFromArchive = vi.fn(async () => ({ ok: true as const, target: "done" }));
|
||||
|
||||
const result = await installFromNpmSpecArchive({
|
||||
tempDirPrefix: "openclaw-test-",
|
||||
spec: "@openclaw/test@1.0.0",
|
||||
timeoutMs: 1000,
|
||||
expectedIntegrity: "sha512-same",
|
||||
installFromArchive,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.installResult).toEqual({ ok: true, target: "done" });
|
||||
expect(result.integrityDrift).toBeUndefined();
|
||||
expect(result.npmResolution.resolvedSpec).toBe("@openclaw/test@1.0.0");
|
||||
expect(result.npmResolution.resolvedAt).toBeTruthy();
|
||||
expect(installFromArchive).toHaveBeenCalledWith({ archivePath: "/tmp/openclaw-test.tgz" });
|
||||
});
|
||||
|
||||
it("aborts when integrity drift callback rejects drift", async () => {
|
||||
vi.mocked(packNpmSpecToArchive).mockResolvedValue({
|
||||
ok: true,
|
||||
archivePath: "/tmp/openclaw-test.tgz",
|
||||
metadata: {
|
||||
resolvedSpec: "@openclaw/test@1.0.0",
|
||||
integrity: "sha512-new",
|
||||
},
|
||||
});
|
||||
const installFromArchive = vi.fn(async () => ({ ok: true as const }));
|
||||
|
||||
const result = await installFromNpmSpecArchive({
|
||||
tempDirPrefix: "openclaw-test-",
|
||||
spec: "@openclaw/test@1.0.0",
|
||||
timeoutMs: 1000,
|
||||
expectedIntegrity: "sha512-old",
|
||||
onIntegrityDrift: async () => false,
|
||||
installFromArchive,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "aborted: npm package integrity drift detected for @openclaw/test@1.0.0",
|
||||
});
|
||||
expect(installFromArchive).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
73
src/infra/npm-pack-install.ts
Normal file
73
src/infra/npm-pack-install.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
type NpmIntegrityDrift,
|
||||
type NpmSpecResolution,
|
||||
packNpmSpecToArchive,
|
||||
withTempDir,
|
||||
} from "./install-source-utils.js";
|
||||
import {
|
||||
type NpmIntegrityDriftPayload,
|
||||
resolveNpmIntegrityDriftWithDefaultMessage,
|
||||
} from "./npm-integrity.js";
|
||||
|
||||
export type NpmSpecArchiveInstallFlowResult<TResult extends { ok: boolean }> =
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
installResult: TResult;
|
||||
npmResolution: NpmSpecResolution;
|
||||
integrityDrift?: NpmIntegrityDrift;
|
||||
};
|
||||
|
||||
export async function installFromNpmSpecArchive<TResult extends { ok: boolean }>(params: {
|
||||
tempDirPrefix: string;
|
||||
spec: string;
|
||||
timeoutMs: number;
|
||||
expectedIntegrity?: string;
|
||||
onIntegrityDrift?: (payload: NpmIntegrityDriftPayload) => boolean | Promise<boolean>;
|
||||
warn?: (message: string) => void;
|
||||
installFromArchive: (params: { archivePath: string }) => Promise<TResult>;
|
||||
}): Promise<NpmSpecArchiveInstallFlowResult<TResult>> {
|
||||
return await withTempDir(params.tempDirPrefix, async (tmpDir) => {
|
||||
const packedResult = await packNpmSpecToArchive({
|
||||
spec: params.spec,
|
||||
timeoutMs: params.timeoutMs,
|
||||
cwd: tmpDir,
|
||||
});
|
||||
if (!packedResult.ok) {
|
||||
return packedResult;
|
||||
}
|
||||
|
||||
const npmResolution: NpmSpecResolution = {
|
||||
...packedResult.metadata,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({
|
||||
spec: params.spec,
|
||||
expectedIntegrity: params.expectedIntegrity,
|
||||
resolution: npmResolution,
|
||||
onIntegrityDrift: params.onIntegrityDrift,
|
||||
warn: params.warn,
|
||||
});
|
||||
if (driftResult.error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: driftResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const installResult = await params.installFromArchive({
|
||||
archivePath: packedResult.archivePath,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
installResult,
|
||||
npmResolution,
|
||||
integrityDrift: driftResult.integrityDrift,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -17,11 +17,10 @@ import {
|
||||
import {
|
||||
type NpmIntegrityDrift,
|
||||
type NpmSpecResolution,
|
||||
packNpmSpecToArchive,
|
||||
resolveArchiveSourcePath,
|
||||
withTempDir,
|
||||
} from "../infra/install-source-utils.js";
|
||||
import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js";
|
||||
import { installFromNpmSpecArchive } from "../infra/npm-pack-install.js";
|
||||
import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
||||
import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js";
|
||||
import * as skillScanner from "../security/skill-scanner.js";
|
||||
@@ -443,58 +442,38 @@ export async function installPluginFromNpmSpec(params: {
|
||||
return { ok: false, error: specError };
|
||||
}
|
||||
|
||||
return await withTempDir("openclaw-npm-pack-", async (tmpDir) => {
|
||||
logger.info?.(`Downloading ${spec}…`);
|
||||
const packedResult = await packNpmSpecToArchive({
|
||||
spec,
|
||||
timeoutMs,
|
||||
cwd: tmpDir,
|
||||
});
|
||||
if (!packedResult.ok) {
|
||||
return packedResult;
|
||||
}
|
||||
|
||||
const npmResolution: NpmSpecResolution = {
|
||||
...packedResult.metadata,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({
|
||||
spec,
|
||||
expectedIntegrity: params.expectedIntegrity,
|
||||
resolution: npmResolution,
|
||||
onIntegrityDrift: params.onIntegrityDrift,
|
||||
warn: (message) => {
|
||||
logger.warn?.(message);
|
||||
},
|
||||
});
|
||||
const integrityDrift = driftResult.integrityDrift;
|
||||
if (driftResult.error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: driftResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const installResult = await installPluginFromArchive({
|
||||
archivePath: packedResult.archivePath,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
mode,
|
||||
dryRun,
|
||||
expectedPluginId,
|
||||
});
|
||||
if (!installResult.ok) {
|
||||
return installResult;
|
||||
}
|
||||
|
||||
return {
|
||||
...installResult,
|
||||
npmResolution,
|
||||
integrityDrift,
|
||||
};
|
||||
logger.info?.(`Downloading ${spec}…`);
|
||||
const flowResult = await installFromNpmSpecArchive({
|
||||
tempDirPrefix: "openclaw-npm-pack-",
|
||||
spec,
|
||||
timeoutMs,
|
||||
expectedIntegrity: params.expectedIntegrity,
|
||||
onIntegrityDrift: params.onIntegrityDrift,
|
||||
warn: (message) => {
|
||||
logger.warn?.(message);
|
||||
},
|
||||
installFromArchive: async ({ archivePath }) =>
|
||||
await installPluginFromArchive({
|
||||
archivePath,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
mode,
|
||||
dryRun,
|
||||
expectedPluginId,
|
||||
}),
|
||||
});
|
||||
if (!flowResult.ok) {
|
||||
return flowResult;
|
||||
}
|
||||
if (!flowResult.installResult.ok) {
|
||||
return flowResult.installResult;
|
||||
}
|
||||
return {
|
||||
...flowResult.installResult,
|
||||
npmResolution: flowResult.npmResolution,
|
||||
integrityDrift: flowResult.integrityDrift,
|
||||
};
|
||||
}
|
||||
|
||||
export async function installPluginFromPath(params: {
|
||||
|
||||
50
src/test-utils/channel-plugins.test.ts
Normal file
50
src/test-utils/channel-plugins.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createChannelTestPluginBase, createOutboundTestPlugin } from "./channel-plugins.js";
|
||||
|
||||
describe("createChannelTestPluginBase", () => {
|
||||
it("builds a plugin base with defaults", () => {
|
||||
const cfg = {} as never;
|
||||
const base = createChannelTestPluginBase({ id: "telegram", label: "Telegram" });
|
||||
expect(base.id).toBe("telegram");
|
||||
expect(base.meta.label).toBe("Telegram");
|
||||
expect(base.meta.selectionLabel).toBe("Telegram");
|
||||
expect(base.meta.docsPath).toBe("/channels/telegram");
|
||||
expect(base.capabilities.chatTypes).toEqual(["direct"]);
|
||||
expect(base.config.listAccountIds(cfg)).toEqual(["default"]);
|
||||
expect(base.config.resolveAccount(cfg)).toEqual({});
|
||||
});
|
||||
|
||||
it("honors config and metadata overrides", async () => {
|
||||
const cfg = {} as never;
|
||||
const base = createChannelTestPluginBase({
|
||||
id: "discord",
|
||||
label: "Discord Bot",
|
||||
docsPath: "/custom/discord",
|
||||
capabilities: { chatTypes: ["group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["acct-1"],
|
||||
isConfigured: async () => true,
|
||||
},
|
||||
});
|
||||
expect(base.meta.docsPath).toBe("/custom/discord");
|
||||
expect(base.capabilities.chatTypes).toEqual(["group"]);
|
||||
expect(base.config.listAccountIds(cfg)).toEqual(["acct-1"]);
|
||||
const account = base.config.resolveAccount(cfg);
|
||||
await expect(base.config.isConfigured?.(account, cfg)).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOutboundTestPlugin", () => {
|
||||
it("keeps outbound test plugin account list behavior", () => {
|
||||
const cfg = {} as never;
|
||||
const plugin = createOutboundTestPlugin({
|
||||
id: "signal",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
resolveTarget: () => ({ ok: true, to: "target" }),
|
||||
sendText: async () => ({ channel: "signal", messageId: "m1" }),
|
||||
},
|
||||
});
|
||||
expect(plugin.config.listAccountIds(cfg)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -28,13 +28,13 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
export const createOutboundTestPlugin = (params: {
|
||||
export const createChannelTestPluginBase = (params: {
|
||||
id: ChannelId;
|
||||
outbound: ChannelOutboundAdapter;
|
||||
label?: string;
|
||||
docsPath?: string;
|
||||
capabilities?: ChannelCapabilities;
|
||||
}): ChannelPlugin => ({
|
||||
config?: Partial<ChannelPlugin["config"]>;
|
||||
}): Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config"> => ({
|
||||
id: params.id,
|
||||
meta: {
|
||||
id: params.id,
|
||||
@@ -45,8 +45,25 @@ export const createOutboundTestPlugin = (params: {
|
||||
},
|
||||
capabilities: params.capabilities ?? { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
...params.config,
|
||||
},
|
||||
});
|
||||
|
||||
export const createOutboundTestPlugin = (params: {
|
||||
id: ChannelId;
|
||||
outbound: ChannelOutboundAdapter;
|
||||
label?: string;
|
||||
docsPath?: string;
|
||||
capabilities?: ChannelCapabilities;
|
||||
}): ChannelPlugin => ({
|
||||
...createChannelTestPluginBase({
|
||||
id: params.id,
|
||||
label: params.label,
|
||||
docsPath: params.docsPath,
|
||||
capabilities: params.capabilities,
|
||||
config: { listAccountIds: () => [] },
|
||||
}),
|
||||
outbound: params.outbound,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user