From 412434a450be301785bbd1c6632a7d16215c4db6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 29 Apr 2026 02:48:41 -0700 Subject: [PATCH] test(plugins): extend external install contract coverage --- scripts/e2e/lib/plugins/assertions.mjs | 33 +++++++++++++++ scripts/lib/plugin-prerelease-test-plan.mjs | 9 +++- src/plugins/install.test.ts | 27 ++++++++++++ src/plugins/sdk-alias.test.ts | 41 ++++++++++++++++++- .../plugin-prerelease-test-plan.test.ts | 23 +++++++++++ 5 files changed, 130 insertions(+), 3 deletions(-) diff --git a/scripts/e2e/lib/plugins/assertions.mjs b/scripts/e2e/lib/plugins/assertions.mjs index 5854cadbd79..61634f27b26 100644 --- a/scripts/e2e/lib/plugins/assertions.mjs +++ b/scripts/e2e/lib/plugins/assertions.mjs @@ -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"); } diff --git a/scripts/lib/plugin-prerelease-test-plan.mjs b/scripts/lib/plugin-prerelease-test-plan.mjs index e0a1d80cba6..54d6fc962e4 100644 --- a/scripts/lib/plugin-prerelease-test-plan.mjs +++ b/scripts/lib/plugin-prerelease-test-plan.mjs @@ -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", diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 75ee032809f..8fa0388110f 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -2799,6 +2799,7 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { function writePluginWithPeerDeps( pluginDir: string, peerDependencies: Record, + dependencies?: Record, ): 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()); diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index c5b423cba99..e115dbe9cd5 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -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, }); }); diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 1ea45b75b63..bad03042ea1 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -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;