Files
openwork/apps/server/src/claude-plugin-bundle.e2e.test.ts
T

288 lines
11 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { startServer } from "./server.js";
import { parseClaudePluginSource } from "./claude-plugin-bundle.js";
import type { ServerConfig } from "./types.js";
type Served = { port: number; stop: (closeActiveConnections?: boolean) => void | Promise<void> };
const stops: Array<() => void | Promise<void>> = [];
const roots: string[] = [];
let previousEnv: Record<string, string | undefined> = {};
afterEach(async () => {
while (stops.length) await stops.pop()?.();
while (roots.length) await rm(roots.pop()!, { recursive: true, force: true });
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
previousEnv = {};
});
function setEnv(key: string, value: string) {
if (!(key in previousEnv)) previousEnv[key] = process.env[key];
process.env[key] = value;
}
const PLUGIN_FILES: Record<string, string> = {
".claude-plugin/plugin.json": JSON.stringify({
name: "slack",
displayName: "Slack",
description: "Slack integration for searching messages and sending communications",
version: "1.0.0",
}),
".mcp.json": JSON.stringify({
mcpServers: {
slack: { url: "https://mcp.slack.com/mcp" },
"local-helper": { command: "${CLAUDE_PLUGIN_ROOT}/bin/run" },
},
}),
"skills/slack-search/SKILL.md": [
"---",
"name: slack-search",
"description: Search Slack messages effectively",
"---",
"",
"Use the slack MCP tools to search.",
].join("\n"),
"skills/slack-search/references/tips.md": "Extra reference file.",
"commands/standup.md": [
"---",
"description: Compile a standup update from Slack",
"---",
"",
"Summarize recent messages.",
].join("\n"),
"README.md": "# Slack plugin",
};
function startMockGithub(options?: { branch?: string }) {
const branch = options?.branch ?? "main";
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url);
// API: repo info
if (url.pathname === "/repos/slackapi/slack-mcp-plugin") {
return Response.json({ default_branch: branch });
}
// API: recursive tree (ref may arrive %2F-encoded for slash branches)
const treePrefix = "/repos/slackapi/slack-mcp-plugin/git/trees/";
if (url.pathname.startsWith(treePrefix)) {
const ref = decodeURIComponent(url.pathname.slice(treePrefix.length));
if (ref !== branch) return Response.json({ message: "not found" }, { status: 404 });
return Response.json({
tree: Object.keys(PLUGIN_FILES).map((path) => ({ path, type: "blob", sha: `sha-${path}` })),
});
}
// Raw files (slash-branch refs appear as literal path segments)
const rawPrefix = `/slackapi/slack-mcp-plugin/${branch}/`;
if (url.pathname.startsWith(rawPrefix)) {
const path = decodeURIComponent(url.pathname.slice(rawPrefix.length));
const content = PLUGIN_FILES[path];
if (content !== undefined) return new Response(content);
}
return Response.json({ message: "not found" }, { status: 404 });
},
}) as Served;
stops.push(() => server.stop(true));
return server;
}
function startMockOpencode() {
const requests: Array<{ method: string; pathname: string }> = [];
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const url = new URL(request.url);
requests.push({ method: request.method, pathname: url.pathname });
if (request.method === "POST" && url.pathname === "/mcp") {
const body = await request.json().catch(() => null) as { name?: unknown } | null;
const name = typeof body?.name === "string" ? body.name : "";
return Response.json(name ? { [name]: { status: "connected" } } : {});
}
return Response.json({});
},
}) as Served;
stops.push(() => server.stop(true));
return { server, requests };
}
async function startOpenwork(options?: { branch?: string }) {
const workspaceRoot = await mkdtemp(join(tmpdir(), "openwork-claude-plugin-"));
roots.push(workspaceRoot);
setEnv("OPENWORK_RUNTIME_DB", join(workspaceRoot, "runtime.sqlite"));
const github = startMockGithub(options);
setEnv("OPENWORK_GITHUB_API_BASE", `http://127.0.0.1:${github.port}`);
setEnv("OPENWORK_GITHUB_RAW_BASE", `http://127.0.0.1:${github.port}`);
const engine = startMockOpencode();
const config: ServerConfig = {
host: "127.0.0.1",
port: 0,
token: "owt_test_token",
hostToken: "owt_host_token",
approval: { mode: "auto", timeoutMs: 1000 },
corsOrigins: ["*"],
workspaces: [
{
id: "ws_1",
name: "Workspace",
path: workspaceRoot,
preset: "starter",
workspaceType: "local",
baseUrl: `http://127.0.0.1:${engine.server.port}`,
},
],
authorizedRoots: [workspaceRoot],
readOnly: false,
startedAt: Date.now(),
tokenSource: "cli",
hostTokenSource: "cli",
logFormat: "pretty",
logRequests: false,
};
const server = await startServer(config) as Served;
stops.push(() => server.stop(true));
return {
base: `http://127.0.0.1:${server.port}`,
headers: { Authorization: "Bearer owt_test_token", "Content-Type": "application/json" },
workspaceRoot,
engine,
};
}
describe("parseClaudePluginSource", () => {
test("parses URL variants", () => {
expect(parseClaudePluginSource("https://github.com/slackapi/slack-mcp-plugin")).toEqual({
owner: "slackapi",
repo: "slack-mcp-plugin",
ref: null,
dir: null,
treeSegments: null,
});
expect(parseClaudePluginSource("github.com/slackapi/slack-mcp-plugin.git")).toEqual({
owner: "slackapi",
repo: "slack-mcp-plugin",
ref: null,
dir: null,
treeSegments: null,
});
expect(parseClaudePluginSource("https://github.com/a/b/tree/dev/plugins/x")).toEqual({
owner: "a",
repo: "b",
ref: "dev",
dir: "plugins/x",
treeSegments: ["dev", "plugins", "x"],
});
// Query strings and hash fragments are ignored.
expect(parseClaudePluginSource("https://github.com/a/b?tab=readme-ov-file#readme")).toEqual({
owner: "a",
repo: "b",
ref: null,
dir: null,
treeSegments: null,
});
expect(parseClaudePluginSource("https://github.com/a/b/tree/dev/plugins/x?x=1")).toEqual({
owner: "a",
repo: "b",
ref: "dev",
dir: "plugins/x",
treeSegments: ["dev", "plugins", "x"],
});
expect(() => parseClaudePluginSource("https://gitlab.com/a/b")).toThrow();
expect(() => parseClaudePluginSource("not a url")).toThrow();
});
});
describe("claude plugin bundles", () => {
test("dryRun returns the Will-install preview with warnings", async () => {
const openwork = await startOpenwork();
const response = await fetch(`${openwork.base}/workspace/ws_1/claude-plugins`, {
method: "POST",
headers: openwork.headers,
body: JSON.stringify({ url: "https://github.com/slackapi/slack-mcp-plugin", dryRun: true }),
});
expect(response.status).toBe(200);
const body = await response.json() as { preview: { name: string; version: string | null; components: Array<{ type: string; name: string }>; warnings: string[] } };
expect(body.preview.name).toBe("Slack");
expect(body.preview.version).toBe("1.0.0");
const byType = (type: string) => body.preview.components.filter((entry) => entry.type === type).map((entry) => entry.name);
expect(byType("mcp")).toEqual(["slack"]);
expect(byType("skill")).toEqual(["slack-search"]);
expect(byType("command")).toEqual(["standup"]);
// local-helper uses ${CLAUDE_PLUGIN_ROOT} and must be skipped with a warning.
expect(body.preview.warnings.some((warning) => warning.includes("local-helper"))).toBe(true);
// Nothing installed on dryRun.
expect(existsSync(join(openwork.workspaceRoot, ".opencode/skills/slack-plugin"))).toBe(false);
});
test("resolves branch names containing slashes in /tree/ URLs", async () => {
const openwork = await startOpenwork({ branch: "release/v1" });
const response = await fetch(`${openwork.base}/workspace/ws_1/claude-plugins`, {
method: "POST",
headers: openwork.headers,
body: JSON.stringify({
url: "https://github.com/slackapi/slack-mcp-plugin/tree/release/v1",
dryRun: true,
}),
});
expect(response.status).toBe(200);
const body = await response.json() as { preview: { name: string; source: { ref: string; dir: string | null } } };
// "release" fails as a ref, so the resolver falls through to "release/v1".
expect(body.preview.source.ref).toBe("release/v1");
expect(body.preview.source.dir).toBeNull();
expect(body.preview.name).toBe("Slack");
});
test("installs skills, commands, and MCP servers; uninstall cleans up", async () => {
const openwork = await startOpenwork();
const installResponse = await fetch(`${openwork.base}/workspace/ws_1/claude-plugins`, {
method: "POST",
headers: openwork.headers,
body: JSON.stringify({ url: "https://github.com/slackapi/slack-mcp-plugin" }),
});
expect(installResponse.status).toBe(200);
const installBody = await installResponse.json() as { item: { pluginId: string; files: Array<{ objectType: string; path: string }> } };
expect(installBody.item.pluginId).toBe("github:slackapi/slack-mcp-plugin");
// Skill and command land namespaced under .opencode/.
const skillPath = join(openwork.workspaceRoot, ".opencode/skills/slack-plugin/slack-search/SKILL.md");
const commandPath = join(openwork.workspaceRoot, ".opencode/commands/slack-plugin/standup.md");
expect(existsSync(skillPath)).toBe(true);
expect(existsSync(commandPath)).toBe(true);
expect(await readFile(skillPath, "utf8")).toContain("Search Slack messages effectively");
// MCP registered in the runtime DB and pushed to the engine.
const listResponse = await fetch(`${openwork.base}/workspace/ws_1/mcp`, { headers: openwork.headers });
const listBody = await listResponse.json() as { items: Array<{ name: string; source: string }> };
const slackEntry = listBody.items.find((entry) => entry.name === "slack");
expect(slackEntry?.source).toBe("config.remote");
expect(openwork.engine.requests.some((entry) => entry.method === "POST" && entry.pathname === "/mcp")).toBe(true);
// Uninstall through the shared cloud-plugins route.
const removeResponse = await fetch(
`${openwork.base}/workspace/ws_1/cloud-plugins/${encodeURIComponent(installBody.item.pluginId)}`,
{ method: "DELETE", headers: openwork.headers },
);
expect(removeResponse.status).toBe(200);
expect(existsSync(skillPath)).toBe(false);
expect(existsSync(commandPath)).toBe(false);
const afterRemove = await fetch(`${openwork.base}/workspace/ws_1/mcp`, { headers: openwork.headers });
const afterRemoveBody = await afterRemove.json() as { items: Array<{ name: string }> };
expect(afterRemoveBody.items.some((entry) => entry.name === "slack")).toBe(false);
});
});