import fs from "node:fs/promises" ;
import path from "node:path" ;
import { beforeEach, describe, expect, it, vi } from "vitest" ;
import type { OpenClawConfig } from "../config/types.openclaw.js" ;
import type { PluginEnableResult } from "../plugins/enable.js" ;
import { withTempDir } from "../test-helpers/temp-dir.js" ;
const resolveBundledInstallPlanForCatalogEntry = vi.hoisted(() => vi.fn(() => undefined));
vi.mock("../cli/plugin-install-plan.js" , () => ({
resolveBundledInstallPlanForCatalogEntry,
}));
const resolveBundledPluginSources = vi.hoisted(() => vi.fn(() => new Map()));
const findBundledPluginSourceInMap = vi.hoisted(() => vi.fn(() => null ));
vi.mock("../plugins/bundled-sources.js" , () => ({
resolveBundledPluginSources,
findBundledPluginSourceInMap,
}));
const installPluginFromNpmSpec = vi.hoisted(() => vi.fn());
vi.mock("../plugins/install.js" , () => ({
installPluginFromNpmSpec,
}));
const enablePluginInConfig = vi.hoisted(() =>
vi.fn<(cfg: OpenClawConfig, pluginId: string) => PluginEnableResult>((cfg) => ({
config: cfg,
enabled: true ,
})),
);
vi.mock("../plugins/enable.js" , () => ({
enablePluginInConfig,
}));
const recordPluginInstall = vi.hoisted(() => vi.fn((cfg) => cfg));
const buildNpmResolutionInstallFields = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../plugins/installs.js" , () => ({
recordPluginInstall,
buildNpmResolutionInstallFields,
}));
const withTimeout = vi.hoisted(() => vi.fn(async <T>(promise: Promise<T>) => await promise));
vi.mock("../utils/with-timeout.js" , () => ({
withTimeout,
}));
import { ensureOnboardingPluginInstalled } from "./onboarding-plugin-install.js" ;
describe("ensureOnboardingPluginInstalled" , () => {
beforeEach(() => {
vi.clearAllMocks();
withTimeout.mockImplementation(async <T>(promise: Promise<T>) => await promise);
});
it("passes npm specs and optional expected integrity to npm installs with progress" , async () => {
const npmResolution = {
name: "@wecom/wecom-openclaw-plugin" ,
version: "1.2.3" ,
resolvedSpec: "@wecom/wecom-openclaw-plugin@1.2.3" ,
integrity: "sha512-wecom" ,
shasum: "deadbeef" ,
resolvedAt: "2026-04-24T00:00:00.000Z" ,
};
const installFields = {
resolvedName: npmResolution.name,
resolvedVersion: npmResolution.version,
resolvedSpec: npmResolution.resolvedSpec,
integrity: npmResolution.integrity,
shasum: npmResolution.shasum,
resolvedAt: npmResolution.resolvedAt,
};
buildNpmResolutionInstallFields.mockReturnValueOnce(installFields);
installPluginFromNpmSpec.mockImplementation(async (params) => {
params.logger?.info?.("Downloading demo-plugin…" );
return {
ok: true ,
pluginId: "demo-plugin" ,
targetDir: "/tmp/demo-plugin" ,
version: "1.2.3" ,
npmResolution,
};
});
const stop = vi.fn();
const update = vi.fn();
const result = await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "WeCom" ,
install: {
npmSpec: "@wecom/wecom-openclaw-plugin@1.2.3" ,
expectedIntegrity: "sha512-wecom" ,
},
},
prompter: {
select: vi.fn(async () => "npm" ),
progress: vi.fn(() => ({ update, stop })),
} as never,
runtime: {} as never,
});
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@wecom/wecom-openclaw-plugin@1.2.3" ,
expectedIntegrity: "sha512-wecom" ,
timeoutMs: 300 _000 ,
}),
);
expect(update).toHaveBeenCalledWith("Downloading demo-plugin…" );
expect(stop).toHaveBeenCalledWith("Installed WeCom plugin" );
expect(buildNpmResolutionInstallFields).toHaveBeenCalledWith(npmResolution);
expect(recordPluginInstall).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
pluginId: "demo-plugin" ,
source: "npm" ,
spec: "@wecom/wecom-openclaw-plugin@1.2.3" ,
installPath: "/tmp/demo-plugin" ,
version: "1.2.3" ,
...installFields,
}),
);
expect(result.installed).toBe(true );
expect(result.status).toBe("installed" );
});
it("returns a timed out status and notes the retry path when npm install hangs" , async () => {
const note = vi.fn(async () => {});
const stop = vi.fn();
withTimeout.mockRejectedValue(new Error("timeout" ));
const result = await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "Demo Plugin" ,
install: {
npmSpec: "@demo/plugin@1.2.3" ,
expectedIntegrity: "sha512-demo" ,
},
},
prompter: {
select: vi.fn(async () => "npm" ),
note,
progress: vi.fn(() => ({ update: vi.fn(), stop })),
} as never,
runtime: {
error: vi.fn(),
} as never,
});
expect(result).toEqual({
cfg: {},
installed: false ,
pluginId: "demo-plugin" ,
status: "timed_out" ,
});
expect(stop).toHaveBeenCalledWith("Install timed out: Demo Plugin" );
expect(note).toHaveBeenCalledWith(
"Installing @demo/plugin@1.2.3 timed out after 5 minutes.\nReturning to selection." ,
"Plugin install" ,
);
});
it("offers registry npm specs without requiring an exact version or integrity pin" , async () => {
let captured:
| {
options: Array<{ value: "npm" | "local" | "skip" ; label: string; hint?: string }>;
initialValue: "npm" | "local" | "skip" ;
}
| undefined;
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "Demo Plugin" ,
install: {
npmSpec: "@demo/plugin" ,
},
},
prompter: {
select: vi.fn(async (input) => {
captured = input;
return "skip" ;
}),
} as never,
runtime: {} as never,
});
expect(captured?.options).toEqual([
{ value: "npm" , label: "Download from npm (@demo/plugin)" },
{ value: "skip" , label: "Skip for now" },
]);
expect(captured?.initialValue).toBe("npm" );
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
});
it("does not offer local installs when the workspace only has a spoofed .git marker" , async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-spoofed-git-" }, async (temp) => {
const workspaceDir = path.join(temp, "workspace" );
const cwdDir = path.join(temp, "cwd" );
const pluginDir = path.join(workspaceDir, "plugins" , "demo" );
await fs.mkdir(pluginDir, { recursive: true });
await fs.mkdir(cwdDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, ".git" ), "not-a-gitdir-pointer\n" , "utf8" );
let captured:
| {
message: string;
options: Array<{ value: "npm" | "local" | "skip" ; label: string; hint?: string }>;
initialValue: "npm" | "local" | "skip" ;
}
| undefined;
const cwdSpy = vi.spyOn(process, "cwd" ).mockReturnValue(cwdDir);
let result: Awaited<ReturnType<typeof ensureOnboardingPluginInstalled>> | undefined;
try {
result = await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "Demo Plugin" ,
install: {
localPath: "plugins/demo" ,
},
},
prompter: {
select: vi.fn(async (input) => {
captured = input;
return "skip" ;
}),
} as never,
runtime: {} as never,
workspaceDir,
});
} finally {
cwdSpy.mockRestore();
}
expect(captured).toBeDefined();
expect(captured?.message).toBe("Install Demo Plugin plugin?" );
expect(captured?.options).toEqual([{ value: "skip" , label: "Skip for now" }]);
expect(result).toEqual({
cfg: {},
installed: false ,
pluginId: "demo-plugin" ,
status: "skipped" ,
});
});
});
it("allows local installs for real gitdir checkouts and sanitizes prompt text" , async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-gitdir-" }, async (temp) => {
const workspaceDir = path.join(temp, "workspace" );
const pluginDir = path.join(workspaceDir, "plugins" , "demo" );
await fs.mkdir(pluginDir, { recursive: true });
await fs.mkdir(path.join(workspaceDir, ".git" ), { recursive: true });
let captured:
| {
message: string;
options: Array<{ value: "npm" | "local" | "skip" ; label: string; hint?: string }>;
initialValue: "npm" | "local" | "skip" ;
}
| undefined;
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "Demo\x1b[31m Plugin\n" ,
install: {
npmSpec: "@demo/plugin@1.2.3" ,
expectedIntegrity: "sha512-demo" ,
localPath: "plugins/demo" ,
},
},
prompter: {
select: vi.fn(async (input) => {
captured = input;
return "skip" ;
}),
} as never,
runtime: {} as never,
workspaceDir,
});
const realPluginDir = await fs.realpath(pluginDir);
expect(captured).toBeDefined();
expect(captured?.message).toBe("Install Demo Plugin\\n plugin?" );
expect(captured?.options).toEqual([
{ value: "npm" , label: "Download from npm (@demo/plugin@1.2.3)" },
{
value: "local" ,
label: "Use local plugin path" ,
hint: realPluginDir,
},
{ value: "skip" , label: "Skip for now" },
]);
expect(captured?.message).not.toContain("\x1b" );
expect(captured?.options[0 ]?.label).not.toContain("\x1b" );
});
});
it("does not add local plugin paths when enablement is blocked by policy" , async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-blocked-enable-" }, async (temp) => {
const workspaceDir = path.join(temp, "workspace" );
const pluginDir = path.join(workspaceDir, "plugins" , "demo" );
await fs.mkdir(pluginDir, { recursive: true });
await fs.mkdir(path.join(workspaceDir, ".git" ), { recursive: true });
enablePluginInConfig.mockReturnValueOnce({
config: {},
enabled: false ,
reason: "blocked by allowlist" ,
});
const note = vi.fn(async () => {});
const error = vi.fn();
const result = await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "Demo Plugin" ,
install: {
localPath: "plugins/demo" ,
},
},
prompter: {
select: vi.fn(async () => "local" ),
note,
} as never,
runtime: { error } as never,
workspaceDir,
});
expect(result).toEqual({
cfg: {},
installed: false ,
pluginId: "demo-plugin" ,
status: "failed" ,
});
expect(note).toHaveBeenCalledWith(
"Cannot enable Demo Plugin: blocked by allowlist." ,
"Plugin install" ,
);
expect(error).toHaveBeenCalledWith(
"Plugin install failed: demo-plugin is disabled (blocked by allowlist)." ,
);
});
});
it("allows local installs for linked git worktrees" , async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-worktree-" }, async (temp) => {
const workspaceDir = path.join(temp, "workspace" );
const pluginDir = path.join(workspaceDir, "plugins" , "demo" );
const commonGitDir = path.join(temp, "repo.git" );
await fs.mkdir(pluginDir, { recursive: true });
await fs.mkdir(commonGitDir, { recursive: true });
const realCommonGitDir = await fs.realpath(commonGitDir);
await fs.writeFile(path.join(workspaceDir, ".git" ), `gitdir: ${realCommonGitDir}\n`, "utf8" );
let captured:
| {
message: string;
options: Array<{ value: "npm" | "local" | "skip" ; label: string; hint?: string }>;
initialValue: "npm" | "local" | "skip" ;
}
| undefined;
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "Demo Plugin" ,
install: {
localPath: "plugins/demo" ,
},
},
prompter: {
select: vi.fn(async (input) => {
captured = input;
return "skip" ;
}),
} as never,
runtime: {} as never,
workspaceDir,
});
const realPluginDir = await fs.realpath(pluginDir);
expect(captured?.options).toEqual([
{
value: "local" ,
label: "Use local plugin path" ,
hint: realPluginDir,
},
{ value: "skip" , label: "Skip for now" },
]);
expect(captured?.initialValue).toBe("local" );
});
});
it("records local install source metadata when a local path is selected" , async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-local-record-" }, async (temp) => {
const workspaceDir = path.join(temp, "workspace" );
const pluginDir = path.join(workspaceDir, "plugins" , "demo" );
await fs.mkdir(path.join(workspaceDir, ".git" ), { recursive: true });
await fs.mkdir(pluginDir, { recursive: true });
const result = await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "Demo Plugin" ,
install: {
npmSpec: "@demo/plugin@1.2.3" ,
localPath: "plugins/demo" ,
},
},
prompter: {
select: vi.fn(async () => "local" ),
} as never,
runtime: {} as never,
workspaceDir,
});
const realPluginDir = await fs.realpath(pluginDir);
expect(recordPluginInstall).toHaveBeenCalledWith(
expect.objectContaining({
plugins: {
load: {
paths: [realPluginDir],
},
},
}),
{
pluginId: "demo-plugin" ,
source: "path" ,
sourcePath: "./plugins/demo" ,
spec: "@demo/plugin@1.2.3" ,
},
);
expect(result.installed).toBe(true );
expect(result.status).toBe("installed" );
});
});
it("records local install source metadata when npm install falls back to local" , async () => {
await withTempDir(
{ prefix: "openclaw-onboarding-install-npm-fallback-record-" },
async (temp) => {
const workspaceDir = path.join(temp, "workspace" );
const pluginDir = path.join(workspaceDir, "plugins" , "demo" );
await fs.mkdir(path.join(workspaceDir, ".git" ), { recursive: true });
await fs.mkdir(pluginDir, { recursive: true });
installPluginFromNpmSpec.mockResolvedValueOnce({
ok: false ,
error: "registry unavailable" ,
});
const note = vi.fn(async () => {});
const result = await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "Demo Plugin" ,
install: {
npmSpec: "@demo/plugin@1.2.3" ,
localPath: "plugins/demo" ,
},
},
prompter: {
select: vi.fn(async () => "npm" ),
note,
confirm: vi.fn(async () => true ),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
} as never,
runtime: {} as never,
workspaceDir,
});
const realPluginDir = await fs.realpath(pluginDir);
expect(note).toHaveBeenCalledWith(
"Failed to install @demo/plugin@1.2.3: registry unavailable\nReturning to selection." ,
"Plugin install" ,
);
expect(recordPluginInstall).toHaveBeenCalledWith(
expect.objectContaining({
plugins: {
load: {
paths: [realPluginDir],
},
},
}),
{
pluginId: "demo-plugin" ,
source: "path" ,
sourcePath: "./plugins/demo" ,
spec: "@demo/plugin@1.2.3" ,
},
);
expect(result.installed).toBe(true );
expect(result.status).toBe("installed" );
},
);
});
it("records absolute local catalog paths as workspace-relative source metadata" , async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-portable-record-" }, async (temp) => {
const workspaceDir = path.join(temp, "workspace" );
const pluginDir = path.join(workspaceDir, "plugins" , "demo" );
await fs.mkdir(path.join(workspaceDir, ".git" ), { recursive: true });
await fs.mkdir(pluginDir, { recursive: true });
const realPluginDir = await fs.realpath(pluginDir);
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "Demo Plugin" ,
install: {
localPath: realPluginDir,
},
},
prompter: {
select: vi.fn(async () => "local" ),
} as never,
runtime: {} as never,
workspaceDir,
});
expect(recordPluginInstall).toHaveBeenCalledWith(expect.anything(), {
pluginId: "demo-plugin" ,
source: "path" ,
sourcePath: "./plugins/demo" ,
});
});
});
it("keeps local installs available when cwd is a git repo but workspaceDir is not" , async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-cwd-git-" }, async (temp) => {
const repoDir = path.join(temp, "repo" );
const workspaceDir = path.join(temp, "workspace" );
const pluginDir = path.join(repoDir, "demo-plugin" );
await fs.mkdir(path.join(repoDir, ".git" ), { recursive: true });
await fs.mkdir(pluginDir, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
let captured:
| {
options: Array<{ value: "npm" | "local" | "skip" ; label: string; hint?: string }>;
}
| undefined;
const cwdSpy = vi.spyOn(process, "cwd" ).mockReturnValue(repoDir);
try {
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "Demo Plugin" ,
install: {
localPath: pluginDir,
},
},
prompter: {
select: vi.fn(async (input) => {
captured = input;
return "skip" ;
}),
} as never,
runtime: {} as never,
workspaceDir,
});
} finally {
cwdSpy.mockRestore();
}
const realPluginDir = await fs.realpath(pluginDir);
expect(captured?.options).toEqual([
{
value: "local" ,
label: "Use local plugin path" ,
hint: realPluginDir,
},
{ value: "skip" , label: "Skip for now" },
]);
});
});
it("rejects local install paths outside the trusted workspace roots" , async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-outside-root-" }, async (temp) => {
const workspaceDir = path.join(temp, "workspace" );
const pluginDir = path.join(temp, "external-plugin" );
await fs.mkdir(path.join(workspaceDir, ".git" ), { recursive: true });
await fs.mkdir(pluginDir, { recursive: true });
let captured:
| {
options: Array<{ value: "npm" | "local" | "skip" ; label: string; hint?: string }>;
}
| undefined;
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "Demo Plugin" ,
install: {
localPath: pluginDir,
},
},
prompter: {
select: vi.fn(async (input) => {
captured = input;
return "skip" ;
}),
} as never,
runtime: {} as never,
workspaceDir,
});
expect(captured?.options).toEqual([{ value: "skip" , label: "Skip for now" }]);
});
});
it("rejects local install paths when relative resolution looks cross-drive" , async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-cross-drive-" }, async (temp) => {
const workspaceDir = path.join(temp, "workspace" );
const pluginDir = path.join(workspaceDir, "plugins" , "demo" );
await fs.mkdir(path.join(workspaceDir, ".git" ), { recursive: true });
await fs.mkdir(pluginDir, { recursive: true });
const realWorkspaceDir = await fs.realpath(workspaceDir);
const originalRelative = path.relative;
const originalIsAbsolute = path.isAbsolute;
const relativeSpy = vi.spyOn(path, "relative" ).mockImplementation((from, to) => {
if (
typeof from === "string" &&
typeof to === "string" &&
from === realWorkspaceDir &&
to === path.join(realWorkspaceDir, "plugins" , "demo" )
) {
return "D:\\evil" ;
}
return originalRelative(from, to);
});
const isAbsoluteSpy = vi.spyOn(path, "isAbsolute" ).mockImplementation((value) => {
if (value === "D:\\evil" ) {
return true ;
}
return originalIsAbsolute(value);
});
try {
let captured:
| {
options: Array<{ value: "npm" | "local" | "skip" ; label: string; hint?: string }>;
}
| undefined;
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin" ,
label: "Demo Plugin" ,
install: {
localPath: "plugins/demo" ,
},
},
prompter: {
select: vi.fn(async (input) => {
captured = input;
return "skip" ;
}),
} as never,
runtime: {} as never,
workspaceDir,
});
expect(captured?.options).toEqual([{ value: "skip" , label: "Skip for now" }]);
} finally {
relativeSpy.mockRestore();
isAbsoluteSpy.mockRestore();
}
});
});
});
Messung V0.5 in Prozent C=100 H=100 G=100
¤ Dauer der Verarbeitung: 0.13 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland