Files
openclaw-zero-token/plugins/marketplace.test.ts
sjhu 571e14a236 feat: upgrade to upstream v2026.3.28
Major upgrade from e26988a38 to upstream v2026.3.28 (f9b107928).
Key changes:
- Upstream src/, ui/, extensions/ (89 bundled extensions)
- Zero-token web providers preserved in src/zero-token/
- AskOnce plugin restored and registered as CLI command
- Added missing packages: @anthropic-ai/vertex-sdk, @modelcontextprotocol/sdk
- Fixed tsconfig rootDir, skipLibCheck for plugin-sdk DTS build
- Added askonce to bundled plugin metadata and package.json exports
- Fixed AskOnce CLI command registration (missing commands metadata)
- Restored AskOnce adapter imports (correct 5-level relative paths)
- Removed stale migration artifacts from root directory
2026-03-30 17:58:12 +08:00

356 lines
11 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
const installPluginFromPathMock = vi.fn();
const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn());
vi.mock("./install.js", () => ({
installPluginFromPath: (...args: unknown[]) => installPluginFromPathMock(...args),
}));
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
}));
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-test-"));
try {
return await fn(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
async function writeMarketplaceManifest(rootDir: string, manifest: unknown): Promise<string> {
const manifestPath = path.join(rootDir, ".claude-plugin", "marketplace.json");
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
await fs.writeFile(manifestPath, JSON.stringify(manifest));
return manifestPath;
}
async function writeRemoteMarketplaceFixture(params: {
repoDir: string;
manifest: unknown;
pluginDir?: string;
}) {
await fs.mkdir(path.join(params.repoDir, ".claude-plugin"), { recursive: true });
if (params.pluginDir) {
await fs.mkdir(path.join(params.repoDir, params.pluginDir), { recursive: true });
}
await fs.writeFile(
path.join(params.repoDir, ".claude-plugin", "marketplace.json"),
JSON.stringify(params.manifest),
);
}
async function writeLocalMarketplaceFixture(params: {
rootDir: string;
manifest: unknown;
pluginDir?: string;
}) {
if (params.pluginDir) {
await fs.mkdir(params.pluginDir, { recursive: true });
}
return writeMarketplaceManifest(params.rootDir, params.manifest);
}
function mockRemoteMarketplaceClone(params: { manifest: unknown; pluginDir?: string }) {
runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => {
const repoDir = argv.at(-1);
expect(typeof repoDir).toBe("string");
await writeRemoteMarketplaceFixture({
repoDir: repoDir as string,
manifest: params.manifest,
...(params.pluginDir ? { pluginDir: params.pluginDir } : {}),
});
return { code: 0, stdout: "", stderr: "", killed: false };
});
}
async function expectRemoteMarketplaceError(params: { manifest: unknown; expectedError: string }) {
mockRemoteMarketplaceClone({ manifest: params.manifest });
const { listMarketplacePlugins } = await import("./marketplace.js");
const result = await listMarketplacePlugins({ marketplace: "owner/repo" });
expect(result).toEqual({
ok: false,
error: params.expectedError,
});
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
}
function expectRemoteMarketplaceInstallResult(result: unknown) {
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(
["git", "clone", "--depth", "1", "https://github.com/owner/repo.git", expect.any(String)],
{ timeoutMs: 120_000 },
);
expect(installPluginFromPathMock).toHaveBeenCalledWith(
expect.objectContaining({
path: expect.stringMatching(/[\\/]repo[\\/]plugins[\\/]frontend-design$/),
}),
);
expect(result).toMatchObject({
ok: true,
pluginId: "frontend-design",
marketplacePlugin: "frontend-design",
marketplaceSource: "owner/repo",
});
}
function expectMarketplaceManifestListing(
result: Awaited<ReturnType<typeof import("./marketplace.js").listMarketplacePlugins>>,
) {
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error("expected marketplace listing to succeed");
}
expect(result.sourceLabel.replaceAll("\\", "/")).toContain(".claude-plugin/marketplace.json");
expect(result.manifest).toEqual({
name: "Example Marketplace",
version: "1.0.0",
plugins: [
{
name: "frontend-design",
version: "0.1.0",
description: "Design system bundle",
source: { kind: "path", path: "./plugins/frontend-design" },
},
],
});
}
function expectLocalMarketplaceInstallResult(params: {
result: unknown;
pluginDir: string;
marketplaceSource: string;
}) {
expect(installPluginFromPathMock).toHaveBeenCalledWith(
expect.objectContaining({
path: params.pluginDir,
}),
);
expect(params.result).toMatchObject({
ok: true,
pluginId: "frontend-design",
marketplacePlugin: "frontend-design",
marketplaceSource: params.marketplaceSource,
});
}
describe("marketplace plugins", () => {
afterEach(() => {
installPluginFromPathMock.mockReset();
runCommandWithTimeoutMock.mockReset();
vi.unstubAllGlobals();
});
it("lists plugins from a local marketplace root", async () => {
await withTempDir(async (rootDir) => {
await writeMarketplaceManifest(rootDir, {
name: "Example Marketplace",
version: "1.0.0",
plugins: [
{
name: "frontend-design",
version: "0.1.0",
description: "Design system bundle",
source: "./plugins/frontend-design",
},
],
});
const { listMarketplacePlugins } = await import("./marketplace.js");
expectMarketplaceManifestListing(await listMarketplacePlugins({ marketplace: rootDir }));
});
});
it("resolves relative plugin paths against the marketplace root", async () => {
await withTempDir(async (rootDir) => {
const pluginDir = path.join(rootDir, "plugins", "frontend-design");
const manifestPath = await writeLocalMarketplaceFixture({
rootDir,
pluginDir,
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
},
});
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
const { installPluginFromMarketplace } = await import("./marketplace.js");
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expectLocalMarketplaceInstallResult({
result,
pluginDir,
marketplaceSource: path.join(rootDir, ".claude-plugin", "marketplace.json"),
});
});
});
it("resolves Claude-style plugin@marketplace shortcuts from known_marketplaces.json", async () => {
await withTempDir(async (homeDir) => {
const openClawHome = path.join(homeDir, "openclaw-home");
await fs.mkdir(path.join(homeDir, ".claude", "plugins"), { recursive: true });
await fs.mkdir(openClawHome, { recursive: true });
await fs.writeFile(
path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"),
JSON.stringify({
"claude-plugins-official": {
source: {
source: "github",
repo: "anthropics/claude-plugins-official",
},
installLocation: path.join(homeDir, ".claude", "plugins", "marketplaces", "official"),
},
}),
);
const { resolveMarketplaceInstallShortcut } = await import("./marketplace.js");
const shortcut = await withEnvAsync(
{ HOME: homeDir, OPENCLAW_HOME: openClawHome },
async () => await resolveMarketplaceInstallShortcut("superpowers@claude-plugins-official"),
);
expect(shortcut).toEqual({
ok: true,
plugin: "superpowers",
marketplaceName: "claude-plugins-official",
marketplaceSource: "claude-plugins-official",
});
});
});
it("installs remote marketplace plugins from relative paths inside the cloned repo", async () => {
mockRemoteMarketplaceClone({
pluginDir: path.join("plugins", "frontend-design"),
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
},
});
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
const { installPluginFromMarketplace } = await import("./marketplace.js");
const result = await installPluginFromMarketplace({
marketplace: "owner/repo",
plugin: "frontend-design",
});
expectRemoteMarketplaceInstallResult(result);
});
it("returns a structured error for archive downloads with an empty response body", async () => {
await withTempDir(async (rootDir) => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 200 })),
);
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://example.com/frontend-design.tgz",
},
],
});
const { installPluginFromMarketplace } = await import("./marketplace.js");
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expect(result).toEqual({
ok: false,
error: "failed to download https://example.com/frontend-design.tgz: empty response body",
});
expect(installPluginFromPathMock).not.toHaveBeenCalled();
});
});
it.each([
{
name: "rejects remote marketplace git plugin sources before cloning nested remotes",
manifest: {
plugins: [
{
name: "frontend-design",
source: {
type: "git",
url: "https://evil.example/repo.git",
},
},
],
},
expectedError:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"remote marketplaces may not use git plugin sources",
},
{
name: "rejects remote marketplace absolute plugin paths",
manifest: {
plugins: [
{
name: "frontend-design",
source: {
type: "path",
path: "/tmp/frontend-design",
},
},
],
},
expectedError:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"remote marketplaces may only use relative plugin paths",
},
{
name: "rejects remote marketplace HTTP plugin paths",
manifest: {
plugins: [
{
name: "frontend-design",
source: {
type: "path",
path: "https://evil.example/plugin.tgz",
},
},
],
},
expectedError:
'invalid marketplace entry "frontend-design" in owner/repo: ' +
"remote marketplaces may not use HTTP(S) plugin paths",
},
] as const)("$name", async ({ manifest, expectedError }) => {
await expectRemoteMarketplaceError({ manifest, expectedError });
});
});