From 8454e9578837911644b82b283cfc96e93820315a Mon Sep 17 00:00:00 2001 From: Linfang Wang Date: Tue, 21 Apr 2026 13:55:40 +0800 Subject: [PATCH 1/2] release: fix bundled channel smoke self-resolution --- ...test-built-bundled-channel-entry-smoke.mjs | 130 ++++++++++++++---- ...-built-bundled-channel-entry-smoke.test.ts | 82 +++++++++++ 2 files changed, 184 insertions(+), 28 deletions(-) create mode 100644 test/scripts/test-built-bundled-channel-entry-smoke.test.ts diff --git a/scripts/test-built-bundled-channel-entry-smoke.mjs b/scripts/test-built-bundled-channel-entry-smoke.mjs index db636ce910d9f..28a7cdbb9552f 100644 --- a/scripts/test-built-bundled-channel-entry-smoke.mjs +++ b/scripts/test-built-bundled-channel-entry-smoke.mjs @@ -8,7 +8,7 @@ installProcessWarningFilter(); process.env.OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK ??= "1"; -function parseArgs(argv) { +export function parseArgs(argv) { let packageRoot = process.env.OPENCLAW_BUNDLED_CHANNEL_SMOKE_ROOT; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -30,33 +30,95 @@ function parseArgs(argv) { }; } -const { packageRoot } = parseArgs(process.argv.slice(2)); -const distExtensionsRoot = path.join(packageRoot, "dist", "extensions"); +function readJson(pathname, fsImpl = fs) { + return JSON.parse(fsImpl.readFileSync(pathname, "utf8")); +} async function importBuiltModule(absolutePath) { return import(pathToFileURL(absolutePath).href); } -function readJson(pathname) { - return JSON.parse(fs.readFileSync(pathname, "utf8")); +function getInstalledPackageRoot(installRoot, packageName) { + return path.join(installRoot, "node_modules", ...packageName.split("/")); } function extensionEntryToDistFilename(entry) { return entry.replace(/^\.\//u, "").replace(/\.[^.]+$/u, ".js"); } -function collectBundledChannelEntryFiles() { +export function createBundledChannelSmokeInstallView({ packageRoot, fs: fsImpl = fs } = {}) { + const resolvedPackageRoot = path.resolve(packageRoot); + const packageJsonPath = path.join(resolvedPackageRoot, "package.json"); + const packageJson = readJson(packageJsonPath, fsImpl); + const packageName = packageJson.name; + if (typeof packageName !== "string" || packageName.trim().length === 0) { + throw new Error(`missing package name in ${packageJsonPath}`); + } + const installedPackageRoot = getInstalledPackageRoot(resolvedPackageRoot, packageName); + const createdPaths = []; + if (fsImpl.existsSync(installedPackageRoot)) { + const resolvedInstalledRoot = fsImpl.realpathSync(installedPackageRoot); + if (path.resolve(resolvedInstalledRoot) !== resolvedPackageRoot) { + throw new Error( + `existing package self-reference does not point at ${resolvedPackageRoot}: ${installedPackageRoot}`, + ); + } + } else { + fsImpl.mkdirSync(path.dirname(installedPackageRoot), { recursive: true }); + try { + fsImpl.symlinkSync( + resolvedPackageRoot, + installedPackageRoot, + process.platform === "win32" ? "junction" : "dir", + ); + } catch (error) { + const symlinkError = error; + if ( + !symlinkError || + typeof symlinkError !== "object" || + !("code" in symlinkError) || + !["EPERM", "EACCES", "ENOTSUP"].includes(symlinkError.code) + ) { + throw error; + } + fsImpl.mkdirSync(installedPackageRoot, { recursive: true }); + fsImpl.writeFileSync( + path.join(installedPackageRoot, "package.json"), + fsImpl.readFileSync(packageJsonPath, "utf8"), + "utf8", + ); + fsImpl.symlinkSync( + path.join(resolvedPackageRoot, "dist"), + path.join(installedPackageRoot, "dist"), + process.platform === "win32" ? "junction" : "dir", + ); + createdPaths.push(path.join(installedPackageRoot, "dist")); + } + createdPaths.push(installedPackageRoot); + } + return { + installedPackageRoot, + cleanup() { + for (const createdPath of createdPaths.toReversed()) { + fsImpl.rmSync(createdPath, { recursive: true, force: true }); + } + }, + }; +} + +export function collectBundledChannelEntryFiles(packageRoot, fsImpl = fs) { + const distExtensionsRoot = path.join(packageRoot, "dist", "extensions"); const files = []; - for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) { + for (const dirent of fsImpl.readdirSync(distExtensionsRoot, { withFileTypes: true })) { if (!dirent.isDirectory()) { continue; } const extensionRoot = path.join(distExtensionsRoot, dirent.name); const packageJsonPath = path.join(extensionRoot, "package.json"); - if (!fs.existsSync(packageJsonPath)) { + if (!fsImpl.existsSync(packageJsonPath)) { continue; } - const packageJson = readJson(packageJsonPath); + const packageJson = readJson(packageJsonPath, fsImpl); if (!packageJson.openclaw?.channel) { continue; } @@ -86,7 +148,7 @@ function collectBundledChannelEntryFiles() { } const channelEntryPath = path.join(extensionRoot, "channel-entry.js"); - if (fs.existsSync(channelEntryPath)) { + if (fsImpl.existsSync(channelEntryPath)) { files.push({ id: dirent.name, kind: "channel", @@ -166,25 +228,37 @@ async function smokeSetupEntry(entryFile) { return true; } -const entryFiles = collectBundledChannelEntryFiles(); -let channelCount = 0; -let setupCount = 0; -let legacySetupCount = 0; +export async function main(argv = process.argv.slice(2)) { + const { packageRoot } = parseArgs(argv); + const smokeInstallView = createBundledChannelSmokeInstallView({ packageRoot }); + try { + const entryFiles = collectBundledChannelEntryFiles(packageRoot); + let channelCount = 0; + let setupCount = 0; + let legacySetupCount = 0; -for (const entryFile of entryFiles) { - if (entryFile.kind === "channel") { - await smokeChannelEntry(entryFile); - channelCount += 1; - continue; - } - if (await smokeSetupEntry(entryFile)) { - setupCount += 1; - } else { - legacySetupCount += 1; + for (const entryFile of entryFiles) { + if (entryFile.kind === "channel") { + await smokeChannelEntry(entryFile); + channelCount += 1; + continue; + } + if (await smokeSetupEntry(entryFile)) { + setupCount += 1; + } else { + legacySetupCount += 1; + } + } + + assert.ok(channelCount > 0, "no bundled channel entries found"); + process.stdout.write( + `[build-smoke] bundled channel entry smoke passed packageRoot=${packageRoot} channel=${channelCount} setup=${setupCount} legacySetup=${legacySetupCount}\n`, + ); + } finally { + smokeInstallView.cleanup(); } } -assert.ok(channelCount > 0, "no bundled channel entries found"); -process.stdout.write( - `[build-smoke] bundled channel entry smoke passed packageRoot=${packageRoot} channel=${channelCount} setup=${setupCount} legacySetup=${legacySetupCount}\n`, -); +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await main(); +} diff --git a/test/scripts/test-built-bundled-channel-entry-smoke.test.ts b/test/scripts/test-built-bundled-channel-entry-smoke.test.ts new file mode 100644 index 0000000000000..79ed68e34794a --- /dev/null +++ b/test/scripts/test-built-bundled-channel-entry-smoke.test.ts @@ -0,0 +1,82 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + collectBundledChannelEntryFiles, + createBundledChannelSmokeInstallView, +} from "../../scripts/test-built-bundled-channel-entry-smoke.mjs"; +import { createScriptTestHarness } from "./test-helpers.ts"; + +const { createTempDir } = createScriptTestHarness(); + +describe("test-built-bundled-channel-entry-smoke", () => { + it("imports packaged channel entries through a temporary installed-package view", async () => { + const packageRoot = createTempDir("openclaw-bundled-channel-smoke-"); + const rootPackageJson = { + name: "@qverisai/qverisbot", + type: "module", + exports: { + "./plugin-sdk/channel-entry-contract": "./dist/plugin-sdk/channel-entry-contract.js", + }, + }; + await fs.writeFile( + path.join(packageRoot, "package.json"), + `${JSON.stringify(rootPackageJson, null, 2)}\n`, + "utf8", + ); + await fs.mkdir(path.join(packageRoot, "dist", "plugin-sdk"), { recursive: true }); + await fs.writeFile( + path.join(packageRoot, "dist", "plugin-sdk", "channel-entry-contract.js"), + "export function defineBundledChannelEntry(entry) { return entry; }\n", + "utf8", + ); + + const extensionRoot = path.join(packageRoot, "dist", "extensions", "discord"); + await fs.mkdir(extensionRoot, { recursive: true }); + await fs.writeFile( + path.join(extensionRoot, "package.json"), + `${JSON.stringify( + { + name: "@openclaw/discord", + type: "module", + openclaw: { + extensions: ["./index.js"], + channel: { + id: "discord", + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + await fs.writeFile( + path.join(extensionRoot, "index.js"), + [ + 'import { defineBundledChannelEntry } from "@qverisai/qverisbot/plugin-sdk/channel-entry-contract";', + "export default defineBundledChannelEntry({", + " kind: 'bundled-channel-entry',", + " loadChannelPlugin() {", + " return { id: 'discord' };", + " },", + "});", + ].join("\n"), + "utf8", + ); + + const smokeInstallView = createBundledChannelSmokeInstallView({ packageRoot }); + try { + const [entry] = collectBundledChannelEntryFiles(packageRoot); + const imported = await import(pathToFileURL(entry.path).href); + expect(imported.default.kind).toBe("bundled-channel-entry"); + expect(imported.default.loadChannelPlugin().id).toBe("discord"); + expect(smokeInstallView.installedPackageRoot).toBe( + path.join(packageRoot, "node_modules", "@qverisai", "qverisbot"), + ); + } finally { + smokeInstallView.cleanup(); + } + }); +}); From 3b5aeb7302f7b8a834983f6c599138ababf318b1 Mon Sep 17 00:00:00 2001 From: Linfang Wang Date: Tue, 21 Apr 2026 15:21:24 +0800 Subject: [PATCH 2/2] release: harden bundled channel smoke install view --- ...test-built-bundled-channel-entry-smoke.mjs | 117 ++++++++++++------ ...-built-bundled-channel-entry-smoke.test.ts | 61 ++++++++- 2 files changed, 136 insertions(+), 42 deletions(-) diff --git a/scripts/test-built-bundled-channel-entry-smoke.mjs b/scripts/test-built-bundled-channel-entry-smoke.mjs index 28a7cdbb9552f..e118c736fca66 100644 --- a/scripts/test-built-bundled-channel-entry-smoke.mjs +++ b/scripts/test-built-bundled-channel-entry-smoke.mjs @@ -42,20 +42,24 @@ function getInstalledPackageRoot(installRoot, packageName) { return path.join(installRoot, "node_modules", ...packageName.split("/")); } +function cleanupCreatedPaths(createdPaths, fsImpl) { + for (const createdPath of [...createdPaths].toReversed()) { + fsImpl.rmSync(createdPath, { recursive: true, force: true }); + } +} + function extensionEntryToDistFilename(entry) { return entry.replace(/^\.\//u, "").replace(/\.[^.]+$/u, ".js"); } -export function createBundledChannelSmokeInstallView({ packageRoot, fs: fsImpl = fs } = {}) { - const resolvedPackageRoot = path.resolve(packageRoot); - const packageJsonPath = path.join(resolvedPackageRoot, "package.json"); - const packageJson = readJson(packageJsonPath, fsImpl); - const packageName = packageJson.name; - if (typeof packageName !== "string" || packageName.trim().length === 0) { - throw new Error(`missing package name in ${packageJsonPath}`); - } - const installedPackageRoot = getInstalledPackageRoot(resolvedPackageRoot, packageName); - const createdPaths = []; +function ensurePackageSelfReference({ + aliasPackageName, + packageJsonPath, + resolvedPackageRoot, + createdPaths, + fsImpl, +}) { + const installedPackageRoot = getInstalledPackageRoot(resolvedPackageRoot, aliasPackageName); if (fsImpl.existsSync(installedPackageRoot)) { const resolvedInstalledRoot = fsImpl.realpathSync(installedPackageRoot); if (path.resolve(resolvedInstalledRoot) !== resolvedPackageRoot) { @@ -64,44 +68,75 @@ export function createBundledChannelSmokeInstallView({ packageRoot, fs: fsImpl = ); } } else { - fsImpl.mkdirSync(path.dirname(installedPackageRoot), { recursive: true }); try { - fsImpl.symlinkSync( - resolvedPackageRoot, - installedPackageRoot, - process.platform === "win32" ? "junction" : "dir", - ); - } catch (error) { - const symlinkError = error; - if ( - !symlinkError || - typeof symlinkError !== "object" || - !("code" in symlinkError) || - !["EPERM", "EACCES", "ENOTSUP"].includes(symlinkError.code) - ) { - throw error; + fsImpl.mkdirSync(path.dirname(installedPackageRoot), { recursive: true }); + try { + fsImpl.symlinkSync( + resolvedPackageRoot, + installedPackageRoot, + process.platform === "win32" ? "junction" : "dir", + ); + createdPaths.push(installedPackageRoot); + } catch (error) { + const symlinkError = error; + if ( + !symlinkError || + typeof symlinkError !== "object" || + !("code" in symlinkError) || + !["EPERM", "EACCES", "ENOTSUP"].includes(symlinkError.code) + ) { + throw error; + } + fsImpl.mkdirSync(installedPackageRoot, { recursive: true }); + createdPaths.push(installedPackageRoot); + fsImpl.writeFileSync( + path.join(installedPackageRoot, "package.json"), + fsImpl.readFileSync(packageJsonPath, "utf8"), + "utf8", + ); + const distLinkPath = path.join(installedPackageRoot, "dist"); + fsImpl.symlinkSync( + path.join(resolvedPackageRoot, "dist"), + distLinkPath, + process.platform === "win32" ? "junction" : "dir", + ); + createdPaths.push(distLinkPath); } - fsImpl.mkdirSync(installedPackageRoot, { recursive: true }); - fsImpl.writeFileSync( - path.join(installedPackageRoot, "package.json"), - fsImpl.readFileSync(packageJsonPath, "utf8"), - "utf8", - ); - fsImpl.symlinkSync( - path.join(resolvedPackageRoot, "dist"), - path.join(installedPackageRoot, "dist"), - process.platform === "win32" ? "junction" : "dir", - ); - createdPaths.push(path.join(installedPackageRoot, "dist")); + } catch (error) { + cleanupCreatedPaths(createdPaths, fsImpl); + throw error; + } + } + return installedPackageRoot; +} + +export function createBundledChannelSmokeInstallView({ packageRoot, fs: fsImpl = fs } = {}) { + const resolvedPackageRoot = path.resolve(packageRoot); + const packageJsonPath = path.join(resolvedPackageRoot, "package.json"); + const packageJson = readJson(packageJsonPath, fsImpl); + const packageName = packageJson.name; + if (typeof packageName !== "string" || packageName.trim().length === 0) { + throw new Error(`missing package name in ${packageJsonPath}`); + } + const createdPaths = []; + const packageAliases = packageName === "openclaw" ? [packageName] : [packageName, "openclaw"]; + let installedPackageRoot = ""; + for (const [index, packageAlias] of packageAliases.entries()) { + const aliasInstalledRoot = ensurePackageSelfReference({ + aliasPackageName: packageAlias, + packageJsonPath, + resolvedPackageRoot, + createdPaths, + fsImpl, + }); + if (index === 0) { + installedPackageRoot = aliasInstalledRoot; } - createdPaths.push(installedPackageRoot); } return { installedPackageRoot, cleanup() { - for (const createdPath of createdPaths.toReversed()) { - fsImpl.rmSync(createdPath, { recursive: true, force: true }); - } + cleanupCreatedPaths(createdPaths, fsImpl); }, }; } diff --git a/test/scripts/test-built-bundled-channel-entry-smoke.test.ts b/test/scripts/test-built-bundled-channel-entry-smoke.test.ts index 79ed68e34794a..e71d427582b54 100644 --- a/test/scripts/test-built-bundled-channel-entry-smoke.test.ts +++ b/test/scripts/test-built-bundled-channel-entry-smoke.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { collectBundledChannelEntryFiles, createBundledChannelSmokeInstallView, @@ -75,8 +75,67 @@ describe("test-built-bundled-channel-entry-smoke", () => { expect(smokeInstallView.installedPackageRoot).toBe( path.join(packageRoot, "node_modules", "@qverisai", "qverisbot"), ); + await expect( + import( + pathToFileURL( + path.join( + packageRoot, + "node_modules", + "openclaw", + "dist", + "extensions", + "discord", + "index.js", + ), + ).href + ), + ).resolves.toMatchObject({ + default: expect.objectContaining({ + kind: "bundled-channel-entry", + }), + }); } finally { smokeInstallView.cleanup(); } }); + + it("cleans up partially created fallback paths when setup fails", () => { + const packageRoot = "/tmp/openclaw-bundled-channel-smoke-fixture"; + const installedPackageRoot = path.join(packageRoot, "node_modules", "@qverisai", "qverisbot"); + const distLinkPath = path.join(installedPackageRoot, "dist"); + const mkdirSync = vi.fn(); + const rmSync = vi.fn(); + const fsMock = { + existsSync: vi.fn().mockReturnValue(false), + readFileSync: vi.fn().mockReturnValue('{"name":"@qverisai/qverisbot"}\n'), + mkdirSync, + writeFileSync: vi.fn(), + rmSync, + symlinkSync: vi + .fn() + .mockImplementationOnce(() => { + const error = new Error("sandbox"); + Object.assign(error, { code: "EPERM" }); + throw error; + }) + .mockImplementationOnce(() => { + throw new Error("dist link failed"); + }), + }; + + expect(() => + createBundledChannelSmokeInstallView({ + packageRoot, + fs: fsMock, + }), + ).toThrow("dist link failed"); + + expect(mkdirSync).toHaveBeenNthCalledWith(1, path.dirname(installedPackageRoot), { + recursive: true, + }); + expect(mkdirSync).toHaveBeenNthCalledWith(2, installedPackageRoot, { recursive: true }); + expect(rmSync).toHaveBeenCalledTimes(1); + expect(rmSync).toHaveBeenCalledWith(installedPackageRoot, { recursive: true, force: true }); + expect(rmSync).not.toHaveBeenCalledWith(distLinkPath, { recursive: true, force: true }); + }); });