test: reduce remaining clone seams

This commit is contained in:
Peter Steinberger
2026-03-26 19:59:48 +00:00
parent b20ae13c6b
commit 2fc017788c
9 changed files with 260 additions and 376 deletions

View File

@@ -10,6 +10,19 @@ import {
describe("telegram stickers", () => {
const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000;
async function createStaticStickerHarness() {
const proxyFetch = vi.fn().mockResolvedValue(
new Response(Buffer.from(new Uint8Array([0x52, 0x49, 0x46, 0x46])), {
status: 200,
headers: { "content-type": "image/webp" },
}),
);
const handlerContext = await createBotHandlerWithOptions({
proxyFetch: proxyFetch as unknown as typeof fetch,
});
return { proxyFetch, ...handlerContext };
}
beforeEach(() => {
cacheStickerSpy.mockClear();
getCachedStickerSpy.mockClear();
@@ -23,15 +36,7 @@ describe("telegram stickers", () => {
it.skip(
"downloads static sticker (WEBP) and includes sticker metadata",
async () => {
const proxyFetch = vi.fn().mockResolvedValue(
new Response(Buffer.from(new Uint8Array([0x52, 0x49, 0x46, 0x46])), {
status: 200,
headers: { "content-type": "image/webp" },
}),
);
const { handler, replySpy, runtimeError } = await createBotHandlerWithOptions({
proxyFetch: proxyFetch as unknown as typeof fetch,
});
const { handler, proxyFetch, replySpy, runtimeError } = await createStaticStickerHarness();
await handler({
message: {
@@ -74,15 +79,7 @@ describe("telegram stickers", () => {
it.skip(
"refreshes cached sticker metadata on cache hit",
async () => {
const proxyFetch = vi.fn().mockResolvedValue(
new Response(Buffer.from(new Uint8Array([0x52, 0x49, 0x46, 0x46])), {
status: 200,
headers: { "content-type": "image/webp" },
}),
);
const { handler, replySpy, runtimeError } = await createBotHandlerWithOptions({
proxyFetch: proxyFetch as unknown as typeof fetch,
});
const { handler, proxyFetch, replySpy, runtimeError } = await createStaticStickerHarness();
getCachedStickerSpy.mockReturnValue({
fileId: "old_file_id",

View File

@@ -1,5 +1,4 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
@@ -7,37 +6,22 @@ import {
clearPluginManifestRegistryCache,
type PluginManifestRegistry,
} from "../plugins/manifest-registry.js";
import {
cleanupTrackedTempDirs,
makeTrackedTempDir,
mkdirSafeDir,
} from "../plugins/test-helpers/fs-fixtures.js";
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
import { validateConfigObject } from "./validation.js";
const tempDirs: string[] = [];
function chmodSafeDir(dir: string) {
if (process.platform === "win32") {
return;
}
fs.chmodSync(dir, 0o755);
}
function mkdtempSafe(prefix: string) {
const dir = fs.mkdtempSync(prefix);
chmodSafeDir(dir);
return dir;
}
function mkdirSafe(dir: string) {
fs.mkdirSync(dir, { recursive: true });
chmodSafeDir(dir);
}
function makeTempDir() {
const dir = mkdtempSafe(path.join(os.tmpdir(), "openclaw-plugin-auto-enable-"));
tempDirs.push(dir);
return dir;
return makeTrackedTempDir("openclaw-plugin-auto-enable", tempDirs);
}
function writePluginManifestFixture(params: { rootDir: string; id: string; channels: string[] }) {
mkdirSafe(params.rootDir);
mkdirSafeDir(params.rootDir);
fs.writeFileSync(
path.join(params.rootDir, "openclaw.plugin.json"),
JSON.stringify(
@@ -122,9 +106,7 @@ function applyWithBluebubblesImessageConfig(extra?: {
afterEach(() => {
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
cleanupTrackedTempDirs(tempDirs);
});
describe("applyPluginAutoEnable", () => {
@@ -306,7 +288,7 @@ describe("applyPluginAutoEnable", () => {
it("uses env-scoped catalog metadata for preferOver auto-enable decisions", () => {
const stateDir = makeTempDir();
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
mkdirSafe(path.dirname(catalogPath));
mkdirSafeDir(path.dirname(catalogPath));
fs.writeFileSync(
catalogPath,
JSON.stringify({

View File

@@ -55,6 +55,24 @@ function makeMockMessage(role: "user" | "assistant" = "user", text = "hello"): A
return { role, content: text, timestamp: Date.now() } as AgentMessage;
}
function registerPromptTrackingEngine(engineId: string) {
const calls: Array<Record<string, unknown>> = [];
registerContextEngine(engineId, () => ({
info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" },
async ingest() {
return { ingested: false };
},
async assemble(params) {
calls.push({ ...params });
return { messages: params.messages, estimatedTokens: 0 };
},
async compact() {
return { ok: true, compacted: false };
},
}));
return calls;
}
/** A minimal mock engine that satisfies the ContextEngine interface. */
class MockContextEngine implements ContextEngine {
readonly info: ContextEngineInfo = {
@@ -700,20 +718,7 @@ describe("LegacyContextEngine parity", () => {
describe("assemble() prompt forwarding", () => {
it("forwards prompt to the underlying engine", async () => {
const engineId = `prompt-fwd-${Date.now().toString(36)}`;
const calls: Array<Record<string, unknown>> = [];
registerContextEngine(engineId, () => ({
info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" },
async ingest() {
return { ingested: false };
},
async assemble(params) {
calls.push({ ...params });
return { messages: params.messages, estimatedTokens: 0 };
},
async compact() {
return { ok: true, compacted: false };
},
}));
const calls = registerPromptTrackingEngine(engineId);
const engine = await resolveContextEngine(configWithSlot(engineId));
await engine.assemble({
@@ -728,20 +733,7 @@ describe("assemble() prompt forwarding", () => {
it("omits prompt when not provided", async () => {
const engineId = `prompt-omit-${Date.now().toString(36)}`;
const calls: Array<Record<string, unknown>> = [];
registerContextEngine(engineId, () => ({
info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" },
async ingest() {
return { ingested: false };
},
async assemble(params) {
calls.push({ ...params });
return { messages: params.messages, estimatedTokens: 0 };
},
async compact() {
return { ok: true, compacted: false };
},
}));
const calls = registerPromptTrackingEngine(engineId);
const engine = await resolveContextEngine(configWithSlot(engineId));
await engine.assemble({
@@ -758,20 +750,7 @@ describe("assemble() prompt forwarding", () => {
// is undefined — JavaScript keeps the key present with value undefined,
// which breaks engines that guard with `'prompt' in params`.
const engineId = `prompt-undef-${Date.now().toString(36)}`;
const calls: Array<Record<string, unknown>> = [];
registerContextEngine(engineId, () => ({
info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" },
async ingest() {
return { ingested: false };
},
async assemble(params) {
calls.push({ ...params });
return { messages: params.messages, estimatedTokens: 0 };
},
async compact() {
return { ok: true, compacted: false };
},
}));
const calls = registerPromptTrackingEngine(engineId);
const engine = await resolveContextEngine(configWithSlot(engineId));
// Simulate the attempt.ts call-site pattern: conditional spread

View File

@@ -4,22 +4,48 @@ import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { runStartupMatrixMigration } from "./server-startup-matrix-migration.js";
async function seedLegacyMatrixState(home: string) {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
}
function makeMatrixStartupConfig(includeCredentials = true) {
return {
channels: {
matrix: includeCredentials
? {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
}
: {
homeserver: "https://matrix.example.org",
},
},
} as never;
}
function createSuccessfulMatrixMigrationDeps() {
return {
maybeCreateMatrixMigrationSnapshot: vi.fn(async () => ({
created: true,
archivePath: "/tmp/snapshot.tar.gz",
markerPath: "/tmp/migration-snapshot.json",
})),
autoMigrateLegacyMatrixState: vi.fn(async () => ({
migrated: true,
changes: [],
warnings: [],
})),
};
}
describe("runStartupMatrixMigration", () => {
it("creates a snapshot before actionable startup migration", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
const maybeCreateMatrixMigrationSnapshotMock = vi.fn(async () => ({
created: true,
archivePath: "/tmp/snapshot.tar.gz",
markerPath: "/tmp/migration-snapshot.json",
}));
const autoMigrateLegacyMatrixStateMock = vi.fn(async () => ({
migrated: true,
changes: [],
warnings: [],
}));
await seedLegacyMatrixState(home);
const deps = createSuccessfulMatrixMigrationDeps();
const autoPrepareLegacyMatrixCryptoMock = vi.fn(async () => ({
migrated: false,
changes: [],
@@ -27,50 +53,34 @@ describe("runStartupMatrixMigration", () => {
}));
await runStartupMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
} as never,
cfg: makeMatrixStartupConfig(),
env: process.env,
deps: {
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock,
autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock,
maybeCreateMatrixMigrationSnapshot: deps.maybeCreateMatrixMigrationSnapshot,
autoMigrateLegacyMatrixState: deps.autoMigrateLegacyMatrixState,
autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock,
},
log: {},
});
expect(maybeCreateMatrixMigrationSnapshotMock).toHaveBeenCalledWith(
expect(deps.maybeCreateMatrixMigrationSnapshot).toHaveBeenCalledWith(
expect.objectContaining({ trigger: "gateway-startup" }),
);
expect(autoMigrateLegacyMatrixStateMock).toHaveBeenCalledOnce();
expect(deps.autoMigrateLegacyMatrixState).toHaveBeenCalledOnce();
expect(autoPrepareLegacyMatrixCryptoMock).toHaveBeenCalledOnce();
});
});
it("skips snapshot creation when startup only has warning-only migration state", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
await seedLegacyMatrixState(home);
const maybeCreateMatrixMigrationSnapshotMock = vi.fn();
const autoMigrateLegacyMatrixStateMock = vi.fn();
const autoPrepareLegacyMatrixCryptoMock = vi.fn();
const info = vi.fn();
await runStartupMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as never,
cfg: makeMatrixStartupConfig(false),
env: process.env,
deps: {
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock as never,
@@ -91,9 +101,7 @@ describe("runStartupMatrixMigration", () => {
it("skips startup migration when snapshot creation fails", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
await seedLegacyMatrixState(home);
const maybeCreateMatrixMigrationSnapshotMock = vi.fn(async () => {
throw new Error("backup failed");
});
@@ -102,15 +110,7 @@ describe("runStartupMatrixMigration", () => {
const warn = vi.fn();
await runStartupMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
} as never,
cfg: makeMatrixStartupConfig(),
env: process.env,
deps: {
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock,
@@ -130,19 +130,8 @@ describe("runStartupMatrixMigration", () => {
it("downgrades migration step failures to warnings so startup can continue", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
const maybeCreateMatrixMigrationSnapshotMock = vi.fn(async () => ({
created: true,
archivePath: "/tmp/snapshot.tar.gz",
markerPath: "/tmp/migration-snapshot.json",
}));
const autoMigrateLegacyMatrixStateMock = vi.fn(async () => ({
migrated: true,
changes: [],
warnings: [],
}));
await seedLegacyMatrixState(home);
const deps = createSuccessfulMatrixMigrationDeps();
const autoPrepareLegacyMatrixCryptoMock = vi.fn(async () => {
throw new Error("disk full");
});
@@ -150,27 +139,19 @@ describe("runStartupMatrixMigration", () => {
await expect(
runStartupMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
} as never,
cfg: makeMatrixStartupConfig(),
env: process.env,
deps: {
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock,
autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock,
maybeCreateMatrixMigrationSnapshot: deps.maybeCreateMatrixMigrationSnapshot,
autoMigrateLegacyMatrixState: deps.autoMigrateLegacyMatrixState,
autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock,
},
log: { warn },
}),
).resolves.toBeUndefined();
expect(maybeCreateMatrixMigrationSnapshotMock).toHaveBeenCalledOnce();
expect(autoMigrateLegacyMatrixStateMock).toHaveBeenCalledOnce();
expect(deps.maybeCreateMatrixMigrationSnapshot).toHaveBeenCalledOnce();
expect(deps.autoMigrateLegacyMatrixState).toHaveBeenCalledOnce();
expect(autoPrepareLegacyMatrixCryptoMock).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalledWith(
"gateway: legacy Matrix encrypted-state preparation failed during Matrix migration; continuing startup: Error: disk full",

View File

@@ -120,6 +120,40 @@ describe("resolveAllowAlwaysPatterns", () => {
expect(second.allowlistSatisfied).toBe(true);
}
function expectPositionalArgvCarrierResult(params: {
command: string;
expectPersisted: boolean;
}) {
const dir = makeTempDir();
const touch = makeExecutable(dir, "touch");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const safeBins = resolveSafeBins(undefined);
const marker = path.join(dir, "marker");
const command = params.command.replaceAll("{marker}", marker);
const { persisted } = resolvePersistedPatterns({
command,
dir,
env,
safeBins,
});
if (params.expectPersisted) {
expect(persisted).toEqual([touch]);
} else {
expect(persisted).not.toContain(touch);
}
const second = evaluateShellAllowlist({
command,
allowlist: [{ pattern: touch }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(params.expectPersisted);
}
it("returns direct executable paths for non-shell segments", () => {
const exe = path.join("/tmp", "openclaw-tool");
const patterns = resolveAllowAlwaysPatterns({
@@ -268,88 +302,31 @@ describe("resolveAllowAlwaysPatterns", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const touch = makeExecutable(dir, "touch");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const safeBins = resolveSafeBins(undefined);
const { persisted } = resolvePersistedPatterns({
command: `sh -lc 'exec -- "$0" "$1"' touch ${path.join(dir, "marker")}`,
dir,
env,
safeBins,
expectPositionalArgvCarrierResult({
command: `sh -lc 'exec -- "$0" "$1"' touch {marker}`,
expectPersisted: true,
});
expect(persisted).toEqual([touch]);
const second = evaluateShellAllowlist({
command: `sh -lc 'exec -- "$0" "$1"' touch ${path.join(dir, "second-marker")}`,
allowlist: [{ pattern: touch }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(true);
});
it("rejects positional argv carriers when $0 is single-quoted", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const touch = makeExecutable(dir, "touch");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const safeBins = resolveSafeBins(undefined);
const marker = path.join(dir, "marker");
const { persisted } = resolvePersistedPatterns({
command: `sh -lc "'$0' "$1"" touch ${marker}`,
dir,
env,
safeBins,
expectPositionalArgvCarrierResult({
command: `sh -lc "'$0' "$1"" touch {marker}`,
expectPersisted: false,
});
expect(persisted).not.toContain(touch);
const second = evaluateShellAllowlist({
command: `sh -lc "'$0' "$1"" touch ${marker}`,
allowlist: [{ pattern: touch }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(false);
});
it("rejects positional argv carriers when exec is separated from $0 by a newline", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const touch = makeExecutable(dir, "touch");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const safeBins = resolveSafeBins(undefined);
const marker = path.join(dir, "marker");
const { persisted } = resolvePersistedPatterns({
expectPositionalArgvCarrierResult({
command: `sh -lc "exec
$0 \\"$1\\"" touch ${marker}`,
dir,
env,
safeBins,
$0 \\"$1\\"" touch {marker}`,
expectPersisted: false,
});
expect(persisted).not.toContain(touch);
const second = evaluateShellAllowlist({
command: `sh -lc "exec
$0 \\"$1\\"" touch ${marker}`,
allowlist: [{ pattern: touch }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(false);
});
it("rejects positional argv carriers when inline command contains extra shell operations", () => {

View File

@@ -7,6 +7,8 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { runMessageAction } from "./message-action-runner.js";
type ChannelActionHandler = NonNullable<NonNullable<ChannelPlugin["actions"]>["handleAction"]>;
function createAlwaysConfiguredPluginConfig(account: Record<string, unknown> = { enabled: true }) {
return {
listAccountIds: () => ["default"],
@@ -15,6 +17,36 @@ function createAlwaysConfiguredPluginConfig(account: Record<string, unknown> = {
};
}
function createPollForwardingPlugin(params: {
pluginId: string;
label: string;
blurb: string;
handleAction: ChannelActionHandler;
}): ChannelPlugin {
return {
id: params.pluginId,
meta: {
id: params.pluginId,
label: params.label,
selectionLabel: params.label,
docsPath: `/channels/${params.pluginId}`,
blurb: params.blurb,
},
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig(),
messaging: {
targetResolver: {
looksLikeId: () => true,
},
},
actions: {
describeMessageTool: () => ({ actions: ["poll"] }),
supportsAction: ({ action }) => action === "poll",
handleAction: params.handleAction,
},
};
}
describe("runMessageAction plugin dispatch", () => {
describe("alias-based plugin action dispatch", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
@@ -382,28 +414,12 @@ describe("runMessageAction plugin dispatch", () => {
}),
);
const telegramPollPlugin: ChannelPlugin = {
id: "telegram",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram poll forwarding test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig(),
messaging: {
targetResolver: {
looksLikeId: () => true,
},
},
actions: {
describeMessageTool: () => ({ actions: ["poll"] }),
supportsAction: ({ action }) => action === "poll",
handleAction,
},
};
const telegramPollPlugin = createPollForwardingPlugin({
pluginId: "telegram",
label: "Telegram",
blurb: "Telegram poll forwarding test plugin.",
handleAction,
});
beforeEach(() => {
setActivePluginRegistry(
@@ -489,28 +505,12 @@ describe("runMessageAction plugin dispatch", () => {
}),
);
const discordPollPlugin: ChannelPlugin = {
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord",
docsPath: "/channels/discord",
blurb: "Discord plugin-owned poll test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig(),
messaging: {
targetResolver: {
looksLikeId: () => true,
},
},
actions: {
describeMessageTool: () => ({ actions: ["poll"] }),
supportsAction: ({ action }) => action === "poll",
handleAction,
},
};
const discordPollPlugin = createPollForwardingPlugin({
pluginId: "discord",
label: "Discord",
blurb: "Discord plugin-owned poll test plugin.",
handleAction,
});
beforeEach(() => {
setActivePluginRegistry(

View File

@@ -126,6 +126,31 @@ function loadSingleCandidateRegistry(params: {
]);
}
function loadRegistryForMinHostVersionCase(params: {
rootDir: string;
minHostVersion: string;
env?: NodeJS.ProcessEnv;
}) {
return loadPluginManifestRegistry({
cache: false,
...(params.env ? { env: params.env } : {}),
candidates: [
createPluginCandidate({
idHint: "synology-chat",
rootDir: params.rootDir,
packageDir: params.rootDir,
origin: "global",
packageManifest: {
install: {
npmSpec: "@openclaw/synology-chat",
minHostVersion: params.minHostVersion,
},
},
}),
],
});
}
function hasUnsafeManifestDiagnostic(registry: ReturnType<typeof loadPluginManifestRegistry>) {
return registry.diagnostics.some((diag) => diag.message.includes("unsafe plugin manifest path"));
}
@@ -263,23 +288,10 @@ describe("loadPluginManifestRegistry", () => {
const dir = makeTempDir();
writeManifest(dir, { id: "synology-chat", configSchema: { type: "object" } });
const registry = loadPluginManifestRegistry({
cache: false,
const registry = loadRegistryForMinHostVersionCase({
rootDir: dir,
minHostVersion: ">=2026.3.22",
env: { OPENCLAW_VERSION: "2026.3.21" },
candidates: [
createPluginCandidate({
idHint: "synology-chat",
rootDir: dir,
packageDir: dir,
origin: "global",
packageManifest: {
install: {
npmSpec: "@openclaw/synology-chat",
minHostVersion: ">=2026.3.22",
},
},
}),
],
});
expect(registry.plugins).toEqual([]);
@@ -294,22 +306,9 @@ describe("loadPluginManifestRegistry", () => {
const dir = makeTempDir();
writeManifest(dir, { id: "synology-chat", configSchema: { type: "object" } });
const registry = loadPluginManifestRegistry({
cache: false,
candidates: [
createPluginCandidate({
idHint: "synology-chat",
rootDir: dir,
packageDir: dir,
origin: "global",
packageManifest: {
install: {
npmSpec: "@openclaw/synology-chat",
minHostVersion: "2026.3.22",
},
},
}),
],
const registry = loadRegistryForMinHostVersionCase({
rootDir: dir,
minHostVersion: "2026.3.22",
});
expect(registry.plugins).toEqual([]);
@@ -324,23 +323,10 @@ describe("loadPluginManifestRegistry", () => {
const dir = makeTempDir();
writeManifest(dir, { id: "synology-chat", configSchema: { type: "object" } });
const registry = loadPluginManifestRegistry({
cache: false,
const registry = loadRegistryForMinHostVersionCase({
rootDir: dir,
minHostVersion: ">=2026.3.22",
env: { OPENCLAW_VERSION: "unknown" },
candidates: [
createPluginCandidate({
idHint: "synology-chat",
rootDir: dir,
packageDir: dir,
origin: "global",
packageManifest: {
install: {
npmSpec: "@openclaw/synology-chat",
minHostVersion: ">=2026.3.22",
},
},
}),
],
});
expect(registry.plugins).toEqual([]);

View File

@@ -1,5 +1,4 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, describe, expect, it, vi } from "vitest";
@@ -14,6 +13,7 @@ import {
resolvePluginSdkAliasFile,
shouldPreferNativeJiti,
} from "./sdk-alias.js";
import { makeTrackedTempDir, mkdirSafeDir } from "./test-helpers/fs-fixtures.js";
type CreateJiti = typeof import("jiti").createJiti;
@@ -24,30 +24,13 @@ async function getCreateJiti() {
return createJitiPromise;
}
function chmodSafeDir(dir: string) {
if (process.platform === "win32") {
return;
}
fs.chmodSync(dir, 0o755);
}
function mkdtempSafe(prefix: string) {
const dir = fs.mkdtempSync(prefix);
chmodSafeDir(dir);
return dir;
}
function mkdirSafe(dir: string) {
fs.mkdirSync(dir, { recursive: true });
chmodSafeDir(dir);
}
const fixtureRoot = mkdtempSafe(path.join(os.tmpdir(), "openclaw-sdk-alias-"));
const fixtureTempDirs: string[] = [];
const fixtureRoot = makeTrackedTempDir("openclaw-sdk-alias-root", fixtureTempDirs);
let tempDirIndex = 0;
function makeTempDir() {
const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`);
mkdirSafe(dir);
mkdirSafeDir(dir);
return dir;
}
@@ -73,8 +56,8 @@ function createPluginSdkAliasFixture(params?: {
const root = makeTempDir();
const srcFile = path.join(root, "src", "plugin-sdk", params?.srcFile ?? "index.ts");
const distFile = path.join(root, "dist", "plugin-sdk", params?.distFile ?? "index.js");
mkdirSafe(path.dirname(srcFile));
mkdirSafe(path.dirname(distFile));
mkdirSafeDir(path.dirname(srcFile));
mkdirSafeDir(path.dirname(distFile));
const trustedRootIndicatorMode =
params?.trustedRootIndicatorMode ??
(params?.trustedRootIndicators === false ? "none" : "bin+marker");
@@ -111,8 +94,8 @@ function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?:
const root = makeTempDir();
const srcFile = path.join(root, "src", "extensionAPI.ts");
const distFile = path.join(root, "dist", "extensionAPI.js");
mkdirSafe(path.dirname(srcFile));
mkdirSafe(path.dirname(distFile));
mkdirSafeDir(path.dirname(srcFile));
mkdirSafeDir(path.dirname(distFile));
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
@@ -128,8 +111,8 @@ function createPluginRuntimeAliasFixture(params?: { srcBody?: string; distBody?:
const root = makeTempDir();
const srcFile = path.join(root, "src", "plugins", "runtime", "index.ts");
const distFile = path.join(root, "dist", "plugins", "runtime", "index.js");
mkdirSafe(path.dirname(srcFile));
mkdirSafe(path.dirname(distFile));
mkdirSafeDir(path.dirname(srcFile));
mkdirSafeDir(path.dirname(distFile));
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
@@ -148,6 +131,23 @@ function createPluginRuntimeAliasFixture(params?: { srcBody?: string; distBody?:
return { root, srcFile, distFile };
}
function createUserInstalledPluginSdkAliasFixture() {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs");
fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8");
const externalPluginRoot = path.join(makeTempDir(), ".openclaw", "extensions", "demo");
const externalPluginEntry = path.join(externalPluginRoot, "index.ts");
mkdirSafeDir(externalPluginRoot);
fs.writeFileSync(externalPluginEntry, 'export const plugin = "demo";\n', "utf-8");
return { externalPluginEntry, externalPluginRoot, fixture, sourceRootAlias };
}
function resolvePluginSdkAlias(params: {
srcFile: string;
distFile: string;
@@ -470,19 +470,8 @@ describe("plugin sdk alias helpers", () => {
});
it("resolves plugin-sdk aliases for user-installed plugins via the running openclaw argv hint", () => {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs");
fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8");
const externalPluginRoot = path.join(makeTempDir(), ".openclaw", "extensions", "demo");
const externalPluginEntry = path.join(externalPluginRoot, "index.ts");
mkdirSafe(externalPluginRoot);
fs.writeFileSync(externalPluginEntry, 'export const plugin = "demo";\n', "utf-8");
const { externalPluginEntry, externalPluginRoot, fixture, sourceRootAlias } =
createUserInstalledPluginSdkAliasFixture();
const aliases = withCwd(externalPluginRoot, () =>
withEnv({ NODE_ENV: undefined }, () =>
@@ -499,19 +488,8 @@ describe("plugin sdk alias helpers", () => {
});
it("resolves plugin-sdk aliases for user-installed plugins via moduleUrl hint", () => {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs");
fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8");
const externalPluginRoot = path.join(makeTempDir(), ".openclaw", "extensions", "demo");
const externalPluginEntry = path.join(externalPluginRoot, "index.ts");
mkdirSafe(externalPluginRoot);
fs.writeFileSync(externalPluginEntry, 'export const plugin = "demo";\n', "utf-8");
const { externalPluginEntry, externalPluginRoot, fixture, sourceRootAlias } =
createUserInstalledPluginSdkAliasFixture();
// Simulate loader.ts passing its own import.meta.url as the moduleUrl hint.
// This covers installations where argv1 does not resolve to the openclaw root
@@ -585,8 +563,8 @@ describe("plugin sdk alias helpers", () => {
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "discord");
const copiedSourceDir = path.join(copiedExtensionRoot, "src");
const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk");
mkdirSafe(copiedSourceDir);
mkdirSafe(copiedPluginSdkDir);
mkdirSafeDir(copiedSourceDir);
mkdirSafeDir(copiedPluginSdkDir);
const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs");
fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8");
fs.writeFileSync(

View File

@@ -96,6 +96,10 @@ const formatPluginCompatibilityNotice = vi.hoisted(() =>
vi.fn((notice: PluginCompatibilityNotice) => `${notice.pluginId} ${notice.message}`),
);
function getWizardNoteCalls(note: WizardPrompter["note"]) {
return (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
}
vi.mock("../commands/onboard-channels.js", () => ({
setupChannels,
}));
@@ -398,7 +402,7 @@ describe("runSetupWizard", () => {
prompter,
);
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
const calls = getWizardNoteCalls(note);
expect(calls.length).toBeGreaterThan(0);
expect(calls.some((call) => call?.[1] === "Web search")).toBe(true);
} finally {
@@ -488,7 +492,7 @@ describe("runSetupWizard", () => {
prompter,
);
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
const calls = getWizardNoteCalls(note);
expect(calls.some((call) => call?.[1] === "Plugin compatibility")).toBe(true);
expect(
calls.some((call) => {