test(plugins): extend external install contract coverage

This commit is contained in:
Vincent Koc
2026-04-29 02:48:41 -07:00
parent 3af661384c
commit 412434a450
5 changed files with 130 additions and 3 deletions

View File

@@ -213,6 +213,38 @@ function assertMarketplaceRecords() {
}
}
function assertRealPathInside(parentPath, childPath, label) {
const parentRealPath = fs.realpathSync(parentPath);
const childRealPath = fs.realpathSync(childPath);
if (
childRealPath !== parentRealPath &&
!childRealPath.startsWith(`${parentRealPath}${path.sep}`)
) {
throw new Error(`${label} resolved outside ${parentPath}: ${childRealPath}`);
}
}
function assertClawHubExternalInstallContract(installPath) {
const openclawPeerPath = path.join(installPath, "node_modules", "openclaw");
if (!fs.existsSync(openclawPeerPath)) {
throw new Error(`missing ClawHub openclaw peer symlink: ${openclawPeerPath}`);
}
if (!fs.lstatSync(openclawPeerPath).isSymbolicLink()) {
throw new Error(`ClawHub openclaw peer is not a symlink: ${openclawPeerPath}`);
}
const hostRoot = fs.realpathSync(process.cwd());
const linkedHostRoot = fs.realpathSync(openclawPeerPath);
if (linkedHostRoot !== hostRoot) {
throw new Error(`expected ClawHub openclaw peer ${linkedHostRoot} to target ${hostRoot}`);
}
const dependencyPackagePath = path.join(installPath, "node_modules", "is-number", "package.json");
if (!fs.existsSync(dependencyPackagePath)) {
throw new Error(`missing ClawHub isolated dependency: ${dependencyPackagePath}`);
}
assertRealPathInside(installPath, dependencyPackagePath, "ClawHub isolated dependency");
}
function assertMarketplaceUpdated() {
const data = readJson("/tmp/plugins-marketplace-updated.json");
const inspect = readJson("/tmp/plugins-marketplace-updated-inspect.json");
@@ -322,6 +354,7 @@ function assertClawHubInstalled() {
if (!fs.existsSync(installPath)) {
throw new Error(`ClawHub install path missing on disk: ${installPath}`);
}
assertClawHubExternalInstallContract(installPath);
fs.writeFileSync("/tmp/plugins-clawhub-install-path.txt", installPath, "utf8");
}

View File

@@ -10,6 +10,7 @@ export const PLUGIN_PRERELEASE_REQUIRED_SURFACES = Object.freeze([
"config-round-trip",
"gateway-bootstrap",
"sdk-compatibility",
"external-install-boundary",
"status-diagnostics",
"npm-registry-plugin",
"clawhub-registry-plugin",
@@ -40,13 +41,19 @@ const pluginPrereleaseDockerLanes = Object.freeze([
},
{
lane: "plugins",
surfaces: ["external-plugins", "sdk-compatibility", "status-diagnostics"],
surfaces: [
"external-plugins",
"sdk-compatibility",
"external-install-boundary",
"status-diagnostics",
],
},
{
lane: "kitchen-sink-plugin",
surfaces: [
"external-plugins",
"sdk-compatibility",
"external-install-boundary",
"status-diagnostics",
"npm-registry-plugin",
"clawhub-registry-plugin",

View File

@@ -2799,6 +2799,7 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => {
function writePluginWithPeerDeps(
pluginDir: string,
peerDependencies: Record<string, string>,
dependencies?: Record<string, string>,
): void {
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
@@ -2807,6 +2808,7 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => {
name: "peer-dep-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
...(dependencies ? { dependencies } : {}),
peerDependencies,
}),
"utf-8",
@@ -2836,6 +2838,31 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => {
expect(run).not.toHaveBeenCalled();
});
it("keeps the openclaw peer symlink when plugin package dependencies are installed", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
const fakeHostRoot = suiteTempRootTracker.makeTempDir();
const run = vi.mocked(runCommandWithTimeout);
mockSuccessfulCommandRun(run);
resolveRootMock.mockReturnValue(fakeHostRoot);
writePluginWithPeerDeps(pluginDir, { openclaw: "*" }, { "is-number": "7.0.0" });
const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expectSingleNpmInstallIgnoreScriptsCall({
calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>,
expectedTargetDir: result.targetDir,
});
const symlinkPath = path.join(result.targetDir, "node_modules", "openclaw");
expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot));
});
it("does not create a symlink when peerDependencies is empty", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
resolveRootMock.mockReturnValue(suiteTempRootTracker.makeTempDir());

View File

@@ -163,12 +163,25 @@ function createPluginSdkAliasTargetFixture(params?: {
distFile: "channel-runtime.js",
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
"./plugin-sdk/plugin-entry": { default: "./dist/plugin-sdk/plugin-entry.js" },
},
});
const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs");
const distRootAlias = path.join(fixture.root, "dist", "plugin-sdk", "root-alias.cjs");
const sourcePluginEntryPath = path.join(fixture.root, "src", "plugin-sdk", "plugin-entry.ts");
const distPluginEntryPath = path.join(fixture.root, "dist", "plugin-sdk", "plugin-entry.js");
fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8");
fs.writeFileSync(distRootAlias, "module.exports = {};\n", "utf-8");
fs.writeFileSync(
sourcePluginEntryPath,
"export const definePluginEntry = (entry) => entry;\n",
"utf-8",
);
fs.writeFileSync(
distPluginEntryPath,
"export const definePluginEntry = (entry) => entry;\n",
"utf-8",
);
return {
fixture,
sourceRootAlias,
@@ -180,6 +193,8 @@ function createPluginSdkAliasTargetFixture(params?: {
`channel-runtime${sourceChannelRuntimeExtension}`,
),
distChannelRuntimePath: path.join(fixture.root, "dist", "plugin-sdk", "channel-runtime.js"),
sourcePluginEntryPath,
distPluginEntryPath,
};
}
@@ -191,16 +206,25 @@ function writePluginEntry(root: string, relativePath: string) {
}
function createUserInstalledPluginSdkAliasFixture() {
const { fixture, sourceRootAlias, sourceChannelRuntimePath } =
const { fixture, sourcePluginEntryPath, sourceRootAlias, sourceChannelRuntimePath } =
createPluginSdkAliasTargetFixture();
const externalPluginRoot = path.join(makeTempDir(), ".openclaw", "extensions", "demo");
const externalPluginEntry = path.join(externalPluginRoot, "index.ts");
mkdirSafeDir(externalPluginRoot);
fs.writeFileSync(externalPluginEntry, 'export const plugin = "demo";\n', "utf-8");
fs.writeFileSync(
externalPluginEntry,
[
'import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";',
'export default definePluginEntry({ id: "demo", register() {} });',
"",
].join("\n"),
"utf-8",
);
return {
externalPluginEntry,
externalPluginRoot,
fixture,
sourcePluginEntryPath,
sourceRootAlias,
sourceChannelRuntimePath,
};
@@ -251,6 +275,7 @@ function expectPluginSdkAliasTargets(
params: {
rootAliasPath: string;
channelRuntimePath?: string;
pluginEntryPath?: string;
},
) {
expect(fs.realpathSync(aliases["openclaw/plugin-sdk"] ?? "")).toBe(
@@ -267,6 +292,14 @@ function expectPluginSdkAliasTargets(
fs.realpathSync(params.channelRuntimePath),
);
}
if (params.pluginEntryPath) {
expect(fs.realpathSync(aliases["openclaw/plugin-sdk/plugin-entry"] ?? "")).toBe(
fs.realpathSync(params.pluginEntryPath),
);
expect(fs.realpathSync(aliases["@openclaw/plugin-sdk/plugin-entry"] ?? "")).toBe(
fs.realpathSync(params.pluginEntryPath),
);
}
}
function expectPluginSdkAliasResolution(params: {
@@ -798,6 +831,7 @@ describe("plugin sdk alias helpers", () => {
externalPluginEntry,
externalPluginRoot,
fixture,
sourcePluginEntryPath,
sourceRootAlias,
sourceChannelRuntimePath,
} = createUserInstalledPluginSdkAliasFixture();
@@ -811,6 +845,7 @@ describe("plugin sdk alias helpers", () => {
expectPluginSdkAliasTargets(aliases, {
rootAliasPath: sourceRootAlias,
channelRuntimePath: sourceChannelRuntimePath,
pluginEntryPath: sourcePluginEntryPath,
});
});
@@ -819,6 +854,7 @@ describe("plugin sdk alias helpers", () => {
externalPluginEntry,
externalPluginRoot,
fixture,
sourcePluginEntryPath,
sourceRootAlias,
sourceChannelRuntimePath,
} = createUserInstalledPluginSdkAliasFixture();
@@ -850,6 +886,7 @@ describe("plugin sdk alias helpers", () => {
expectPluginSdkAliasTargets(aliases, {
rootAliasPath: sourceRootAlias,
channelRuntimePath: sourceChannelRuntimePath,
pluginEntryPath: sourcePluginEntryPath,
});
});

View File

@@ -121,6 +121,29 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => {
expect(sweepScript).toContain("scan_logs_for_unexpected_errors");
});
it("keeps the generic plugin Docker lane as an external install contract canary", () => {
const lane = findLaneByName("plugins");
const sweepScript = readFileSync("scripts/e2e/lib/plugins/sweep.sh", "utf8");
const clawhubScript = readFileSync("scripts/e2e/lib/plugins/clawhub.sh", "utf8");
const assertionsScript = readFileSync("scripts/e2e/lib/plugins/assertions.mjs", "utf8");
const prereleasePlan = createPluginPrereleaseTestPlan();
expect(lane).toEqual(
expect.objectContaining({
command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins",
name: "plugins",
resources: expect.arrayContaining(["npm"]),
stateScenario: "empty",
}),
);
expect(prereleasePlan.surfaces).toContain("external-install-boundary");
expect(sweepScript).toContain("run_plugins_clawhub_scenario");
expect(clawhubScript).toContain('plugins install "$CLAWHUB_PLUGIN_SPEC"');
expect(assertionsScript).toContain("assertClawHubExternalInstallContract");
expect(assertionsScript).toContain('node_modules", "openclaw');
expect(assertionsScript).toContain('node_modules", "is-number');
});
it("wires the full plugin prerelease plan into its release workflow", () => {
const workflow = readCiWorkflow();
const preflight = workflow.jobs.preflight;