openclaw/test/plugin-clawhub-release.test.ts

363 lines
10 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
collectClawHubPublishablePluginPackages,
collectClawHubVersionGateErrors,
collectPluginClawHubReleasePathsFromGitRange,
collectPluginClawHubReleasePlan,
resolveChangedClawHubPublishablePluginPackages,
resolveSelectedClawHubPublishablePluginPackages,
type PublishablePluginPackage,
} from "../scripts/lib/plugin-clawhub-release.ts";
const tempDirs: string[] = [];
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
describe("resolveChangedClawHubPublishablePluginPackages", () => {
const publishablePlugins: PublishablePluginPackage[] = [
{
extensionId: "feishu",
packageDir: "extensions/feishu",
packageName: "@openclaw/feishu",
version: "2026.4.1",
channel: "stable",
publishTag: "latest",
},
{
extensionId: "zalo",
packageDir: "extensions/zalo",
packageName: "@openclaw/zalo",
version: "2026.4.1-beta.1",
channel: "beta",
publishTag: "beta",
},
];
it("ignores shared release-tooling changes", () => {
expect(
resolveChangedClawHubPublishablePluginPackages({
plugins: publishablePlugins,
changedPaths: ["pnpm-lock.yaml"],
}),
).toEqual([]);
});
});
describe("collectClawHubPublishablePluginPackages", () => {
it("requires the ClawHub external plugin contract", () => {
const repoDir = createTempPluginRepo({
includeClawHubContract: false,
});
expect(() => collectClawHubPublishablePluginPackages(repoDir)).toThrow(
"openclaw.compat.pluginApi is required for external code plugins published to ClawHub.",
);
});
it("rejects unsafe extension directory names", () => {
const repoDir = createTempPluginRepo({
extensionId: "Demo Plugin",
});
expect(() => collectClawHubPublishablePluginPackages(repoDir)).toThrow(
"Demo Plugin: extension directory name must match",
);
});
});
describe("collectClawHubVersionGateErrors", () => {
it("requires a version bump when a publishable plugin changes", () => {
const repoDir = createTempPluginRepo();
const baseRef = git(repoDir, ["rev-parse", "HEAD"]);
writeFileSync(
join(repoDir, "extensions", "demo-plugin", "index.ts"),
"export const demo = 2;\n",
);
git(repoDir, ["add", "."]);
git(repoDir, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"change plugin",
]);
const headRef = git(repoDir, ["rev-parse", "HEAD"]);
const errors = collectClawHubVersionGateErrors({
rootDir: repoDir,
plugins: collectClawHubPublishablePluginPackages(repoDir),
gitRange: { baseRef, headRef },
});
expect(errors).toEqual([
"@openclaw/demo-plugin@2026.4.1: changed publishable plugin still has the same version in package.json.",
]);
});
it("does not require a version bump for the first ClawHub opt-in", () => {
const repoDir = createTempPluginRepo({
publishToClawHub: false,
});
const baseRef = git(repoDir, ["rev-parse", "HEAD"]);
writeFileSync(
join(repoDir, "extensions", "demo-plugin", "package.json"),
JSON.stringify(
{
name: "@openclaw/demo-plugin",
version: "2026.4.1",
openclaw: {
extensions: ["./index.ts"],
compat: {
pluginApi: ">=2026.4.1",
},
build: {
openclawVersion: "2026.4.1",
},
release: {
publishToClawHub: true,
},
},
},
null,
2,
),
);
git(repoDir, ["add", "."]);
git(repoDir, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"opt in",
]);
const headRef = git(repoDir, ["rev-parse", "HEAD"]);
const errors = collectClawHubVersionGateErrors({
rootDir: repoDir,
plugins: collectClawHubPublishablePluginPackages(repoDir),
gitRange: { baseRef, headRef },
});
expect(errors).toEqual([]);
});
it("does not require a version bump for shared release-tooling changes", () => {
const repoDir = createTempPluginRepo();
const baseRef = git(repoDir, ["rev-parse", "HEAD"]);
mkdirSync(join(repoDir, "scripts"), { recursive: true });
writeFileSync(join(repoDir, "scripts", "plugin-clawhub-publish.sh"), "#!/usr/bin/env bash\n");
git(repoDir, ["add", "."]);
git(repoDir, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"shared tooling",
]);
const headRef = git(repoDir, ["rev-parse", "HEAD"]);
const errors = collectClawHubVersionGateErrors({
rootDir: repoDir,
plugins: collectClawHubPublishablePluginPackages(repoDir),
gitRange: { baseRef, headRef },
});
expect(errors).toEqual([]);
});
});
describe("resolveSelectedClawHubPublishablePluginPackages", () => {
it("selects all publishable plugins when shared release tooling changes", () => {
const repoDir = createTempPluginRepo({
extraExtensionIds: ["demo-two"],
});
const baseRef = git(repoDir, ["rev-parse", "HEAD"]);
mkdirSync(join(repoDir, "scripts"), { recursive: true });
writeFileSync(join(repoDir, "scripts", "plugin-clawhub-publish.sh"), "#!/usr/bin/env bash\n");
git(repoDir, ["add", "."]);
git(repoDir, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"shared tooling",
]);
const headRef = git(repoDir, ["rev-parse", "HEAD"]);
const selected = resolveSelectedClawHubPublishablePluginPackages({
rootDir: repoDir,
plugins: collectClawHubPublishablePluginPackages(repoDir),
gitRange: { baseRef, headRef },
});
expect(selected.map((plugin) => plugin.extensionId)).toEqual(["demo-plugin", "demo-two"]);
});
it("selects all publishable plugins when the shared setup action changes", () => {
const repoDir = createTempPluginRepo({
extraExtensionIds: ["demo-two"],
});
const baseRef = git(repoDir, ["rev-parse", "HEAD"]);
mkdirSync(join(repoDir, ".github", "actions", "setup-node-env"), { recursive: true });
writeFileSync(
join(repoDir, ".github", "actions", "setup-node-env", "action.yml"),
"name: setup-node-env\n",
);
git(repoDir, ["add", "."]);
git(repoDir, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"shared helpers",
]);
const headRef = git(repoDir, ["rev-parse", "HEAD"]);
const selected = resolveSelectedClawHubPublishablePluginPackages({
rootDir: repoDir,
plugins: collectClawHubPublishablePluginPackages(repoDir),
gitRange: { baseRef, headRef },
});
expect(selected.map((plugin) => plugin.extensionId)).toEqual(["demo-plugin", "demo-two"]);
});
});
describe("collectPluginClawHubReleasePlan", () => {
it("skips versions that already exist on ClawHub", async () => {
const repoDir = createTempPluginRepo();
const plan = await collectPluginClawHubReleasePlan({
rootDir: repoDir,
selection: ["@openclaw/demo-plugin"],
fetchImpl: async () => new Response("{}", { status: 200 }),
registryBaseUrl: "https://clawhub.ai",
});
expect(plan.candidates).toEqual([]);
expect(plan.skippedPublished).toHaveLength(1);
expect(plan.skippedPublished[0]).toMatchObject({
packageName: "@openclaw/demo-plugin",
version: "2026.4.1",
});
});
});
describe("collectPluginClawHubReleasePathsFromGitRange", () => {
it("rejects unsafe git refs", () => {
const repoDir = createTempPluginRepo();
const headRef = git(repoDir, ["rev-parse", "HEAD"]);
expect(() =>
collectPluginClawHubReleasePathsFromGitRange({
rootDir: repoDir,
gitRange: {
baseRef: "--not-a-ref",
headRef,
},
}),
).toThrow("baseRef must be a normal git ref or commit SHA.");
});
});
function createTempPluginRepo(
options: {
extensionId?: string;
extraExtensionIds?: string[];
publishToClawHub?: boolean;
includeClawHubContract?: boolean;
} = {},
) {
const repoDir = mkdtempSync(join(tmpdir(), "openclaw-clawhub-release-"));
tempDirs.push(repoDir);
const extensionId = options.extensionId ?? "demo-plugin";
const extensionIds = [extensionId, ...(options.extraExtensionIds ?? [])];
writeFileSync(
join(repoDir, "package.json"),
JSON.stringify({ name: "openclaw-test-root" }, null, 2),
);
writeFileSync(join(repoDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
for (const currentExtensionId of extensionIds) {
mkdirSync(join(repoDir, "extensions", currentExtensionId), { recursive: true });
writeFileSync(
join(repoDir, "extensions", currentExtensionId, "package.json"),
JSON.stringify(
{
name: `@openclaw/${currentExtensionId}`,
version: "2026.4.1",
openclaw: {
extensions: ["./index.ts"],
...(options.includeClawHubContract === false
? {}
: {
compat: {
pluginApi: ">=2026.4.1",
},
build: {
openclawVersion: "2026.4.1",
},
}),
release: {
publishToClawHub: options.publishToClawHub ?? true,
},
},
},
null,
2,
),
);
writeFileSync(
join(repoDir, "extensions", currentExtensionId, "index.ts"),
`export const ${currentExtensionId.replaceAll(/[-.]/g, "_")} = 1;\n`,
);
}
git(repoDir, ["init", "-b", "main"]);
git(repoDir, ["add", "."]);
git(repoDir, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"init",
]);
return repoDir;
}
function git(cwd: string, args: string[]) {
return execFileSync("git", ["-C", cwd, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}