import { execFileSync } from
"node:child_process" ;
import { mkdirSync, writeFileSync } from
"node:fs" ;
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" ;
import { cleanupTempDirs, makeTempRepoRoot } from
"./helpers/temp-repo.js" ;
const tempDirs: string[] = [];
afterEach(() => {
cleanupTempDirs(tempDirs);
});
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, headRef } = commitSharedReleaseToolingChange(repoDir);
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, headRef } = commitSharedReleaseToolingChange(repoDir);
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 = makeTempRepoRoot(tempDirs,
"openclaw-clawhub-release-" );
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 commitSharedReleaseToolingChange(repoDir: string) {
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" ]);
return { baseRef, headRef };
}
function git(cwd: string, args: string[]) {
return execFileSync(
"git" , [
"-C" , cwd, ...args], {
encoding:
"utf8" ,
stdio: [
"ignore" ,
"pipe" ,
"pipe" ],
}).trim();
}
Messung V0.5 in Prozent C=97 H=97 G=96
¤ Dauer der Verarbeitung: 0.5 Sekunden
¤
*© Formatika GbR, Deutschland