diff --git a/docs/install/docker.md b/docs/install/docker.md index 6ed1fec16ebe..ebdd2af9d2f6 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -230,10 +230,11 @@ OPENCLAW_DOCKER_BUILD_NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_DOCKER_BUI `OPENCLAW_EXTENSIONS` selects plugin manifest ids from the source checkout; existing source-directory names are also accepted when they differ. The Docker build resolves the selection to source directories once, installs production -dependencies, and, when a selected plugin is published separately with -`openclaw.build.bundledDist: false`, compiles its runtime into the root bundled -dist. This Docker-only packaging does not change the plugin's npm or ClawHub -artifact contract. Unknown, invalid, or ambiguous ids fail the image build. +dependencies, and includes the selected plugin runtime in the image. Source +checkouts also compile first-party plugins published separately with +`openclaw.build.bundledDist: false`; that marker still preserves the plugin's +external npm or ClawHub ownership and does not change either artifact contract. +Unknown, invalid, or ambiguous ids fail the image build. Known dependency/source-only ids keep their existing source and dependency staging without gaining a compiled root dist entry. A selected plugin with unified build entries must compile successfully; unselected external plugin diff --git a/scripts/build-all.mts b/scripts/build-all.mts index 11e739592eef..5961f471609b 100644 --- a/scripts/build-all.mts +++ b/scripts/build-all.mts @@ -235,6 +235,7 @@ export const BUILD_ALL_STEPS: BuildAllStep[] = [ }, }, }, + tsxStep("external-plugins:local-dist", "scripts/build-external-plugin-local-dist.mts"), tsxStep("check-cli-bootstrap-imports", "scripts/check-cli-bootstrap-imports.mts"), { label: "plugins:assets:copy", @@ -289,6 +290,7 @@ export const BUILD_ALL_PROFILES: Record = { "tsdown-ai", "tsdown-packages", "tsdown-unified", + "external-plugins:local-dist", "check-cli-bootstrap-imports", "plugins:assets:copy", "runtime-postbuild", @@ -304,6 +306,7 @@ export const BUILD_ALL_PROFILES: Record = { ciArtifacts: [ "plugins:assets:build", "tsdown", + "external-plugins:local-dist", "check-cli-bootstrap-imports", "plugins:assets:copy", "runtime-postbuild", @@ -318,6 +321,7 @@ export const BUILD_ALL_PROFILES: Record = { ], gatewayWatch: [ "tsdown", + "external-plugins:local-dist", "check-cli-bootstrap-imports", "runtime-postbuild", "build-stamp", @@ -326,6 +330,7 @@ export const BUILD_ALL_PROFILES: Record = { qaRuntime: [ "plugins:assets:build", "tsdown", + "external-plugins:local-dist", "check-cli-bootstrap-imports", "plugins:assets:copy", "runtime-postbuild", @@ -335,6 +340,7 @@ export const BUILD_ALL_PROFILES: Record = { sourcePerformance: [ "plugins:assets:build", "tsdown", + "external-plugins:local-dist", "check-cli-bootstrap-imports", "plugins:assets:copy", "runtime-postbuild", @@ -345,6 +351,7 @@ export const BUILD_ALL_PROFILES: Record = { ], cliStartup: [ "tsdown", + "external-plugins:local-dist", "check-cli-bootstrap-imports", "runtime-postbuild", "build-stamp", diff --git a/scripts/build-external-plugin-local-dist.mts b/scripts/build-external-plugin-local-dist.mts new file mode 100644 index 000000000000..66ecc9021551 --- /dev/null +++ b/scripts/build-external-plugin-local-dist.mts @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// Builds source-checkout runtime output for externally published first-party plugins. +import fs from "node:fs"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { pathToFileURL } from "node:url"; +import { DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV } from "./lib/bundled-plugin-build-entries.mjs"; +import { shouldBuildBundledCluster } from "./lib/optional-bundled-clusters.mjs"; +import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; +import { + buildPluginNpmRuntime, + listPublishablePluginPackageDirs, + type PluginPackageJson, +} from "./lib/plugin-npm-runtime-build.mts"; + +type ExternalPluginLocalDistParams = { + repoRoot?: string; + env?: NodeJS.ProcessEnv; + logLevel?: "silent" | "warn"; +}; + +function readPluginPackageJson(repoRoot: string, packageDir: string): PluginPackageJson { + return JSON.parse(fs.readFileSync(path.join(repoRoot, packageDir, "package.json"), "utf8")); +} + +/** Lists external first-party packages that need source-checkout dist output. */ +export function listExternalPluginLocalDistPackageDirs( + params: Pick = {}, +): string[] { + const repoRoot = path.resolve(params.repoRoot ?? "."); + const env = params.env ?? process.env; + if (env[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]?.trim()) { + return []; + } + return listPublishablePluginPackageDirs({ repoRoot }).filter((packageDir) => { + const packageJson = readPluginPackageJson(repoRoot, packageDir); + return ( + packageJson.openclaw?.build?.bundledDist === false && + shouldBuildBundledCluster(path.basename(packageDir), env, { packageJson }) + ); + }); +} + +/** Builds isolated plugin graphs, then stages every output below its excluded root dist path. */ +export async function buildExternalPluginLocalDist( + params: ExternalPluginLocalDistParams = {}, +): Promise<{ durationMs: number; pluginDirs: string[] }> { + const repoRoot = path.resolve(params.repoRoot ?? "."); + const packageDirs = listExternalPluginLocalDistPackageDirs({ + repoRoot, + env: params.env, + }); + const startedAt = performance.now(); + const pluginDirs: string[] = []; + + for (const packageDir of packageDirs) { + const result = await buildPluginNpmRuntime({ + repoRoot, + packageDir, + // Standalone package validation owns its existing bundler warnings; this + // root build still surfaces errors and validates every emitted host import. + logLevel: params.logLevel ?? "error", + }); + if (!result) { + throw new Error(`${packageDir} did not produce source-checkout runtime output`); + } + const targetDir = path.join(repoRoot, "dist", "extensions", result.pluginDir); + assertRealOutputRoot(targetDir); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.cpSync(result.outDir, targetDir, { recursive: true }); + fs.rmSync(result.outDir, { recursive: true, force: true }); + pluginDirs.push(result.pluginDir); + } + + return { + durationMs: performance.now() - startedAt, + pluginDirs, + }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + const result = await buildExternalPluginLocalDist(); + console.log( + `[external-plugin-local-dist] built ${result.pluginDirs.length} plugins in ${Math.round(result.durationMs)}ms`, + ); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/copy-bundled-plugin-metadata.mts b/scripts/copy-bundled-plugin-metadata.mts index d7dbcc3d46d8..e79aeadc5151 100644 --- a/scripts/copy-bundled-plugin-metadata.mts +++ b/scripts/copy-bundled-plugin-metadata.mts @@ -290,7 +290,15 @@ export function copyBundledPluginMetadata(params: CopyMetadataParams = {}): void : undefined; const packageJson = isRecord(parsedPackageJson) ? parsedPackageJson : undefined; const topLevelPublicSurfaceEntries = collectTopLevelPublicSurfaceEntries(pluginDir); - if (!shouldCopyBundledPluginMetadata(dirent.name, env, buildablePluginDirs)) { + const hasExternalLocalDist = + isRecord(packageJson?.openclaw) && + isRecord(packageJson.openclaw.build) && + packageJson.openclaw.build.bundledDist === false && + fs.existsSync(distPluginDir); + if ( + !hasExternalLocalDist && + !shouldCopyBundledPluginMetadata(dirent.name, env, buildablePluginDirs) + ) { removePathIfExists(distPluginDir); continue; } diff --git a/scripts/lib/plugin-npm-runtime-build.mts b/scripts/lib/plugin-npm-runtime-build.mts index e2b76c2a9709..5379f5582794 100644 --- a/scripts/lib/plugin-npm-runtime-build.mts +++ b/scripts/lib/plugin-npm-runtime-build.mts @@ -24,7 +24,7 @@ export type PluginPackageJson = JsonRecord & { dependencies?: JsonRecord; openclaw?: { assetScripts?: { build?: unknown }; - build?: { openclawVersion?: unknown; runtimeFormat?: unknown }; + build?: { bundledDist?: unknown; openclawVersion?: unknown; runtimeFormat?: unknown }; compat?: { pluginApi?: unknown }; release?: { bundleRuntimeDependencies?: unknown; diff --git a/scripts/test-built-plugin-singleton.mts b/scripts/test-built-plugin-singleton.mts index daa20c90410d..bb7bf912c90c 100644 --- a/scripts/test-built-plugin-singleton.mts +++ b/scripts/test-built-plugin-singleton.mts @@ -14,12 +14,18 @@ const repoRoot = resolveRepoRoot(import.meta.url); const smokeEntryPath = path.join(repoRoot, "dist", "plugins", "build-smoke-entry.js"); assert.ok(fs.existsSync(smokeEntryPath), `missing build output: ${smokeEntryPath}`); -const { clearPluginCommands, getPluginCommandSpecs, loadOpenClawPlugins, matchPluginCommand } = - await import(pathToFileURL(smokeEntryPath).href); +const { + clearPluginCommands, + getPluginCommandSpecs, + getPluginModuleLoaderStats, + loadOpenClawPlugins, + matchPluginCommand, +} = await import(pathToFileURL(smokeEntryPath).href); assert.equal(typeof loadOpenClawPlugins, "function", "built loader export missing"); assert.equal(typeof clearPluginCommands, "function", "clearPluginCommands missing"); assert.equal(typeof getPluginCommandSpecs, "function", "getPluginCommandSpecs missing"); +assert.equal(typeof getPluginModuleLoaderStats, "function", "plugin loader stats missing"); assert.equal(typeof matchPluginCommand, "function", "matchPluginCommand missing"); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-smoke-")); @@ -105,6 +111,12 @@ stageBundledPluginRuntime({ repoRoot }); const runtimeEntryPath = path.join(runtimePluginDir, "index.js"); assert.ok(fs.existsSync(runtimeEntryPath), "runtime overlay entry missing"); +const smsRuntimeEntryPath = path.join(repoRoot, "dist-runtime", "extensions", "sms", "index.js"); +assert.ok(fs.existsSync(smsRuntimeEntryPath), "compiled SMS runtime entry missing"); +assert.ok( + fs.existsSync(path.join(repoRoot, "dist-runtime", "extensions", "mxc", "mxc-spawn-launcher.mjs")), + "compiled MXC runtime asset missing", +); assert.equal( fs.existsSync(path.join(repoRoot, "dist-runtime", "plugins", "commands.js")), false, @@ -113,6 +125,54 @@ assert.equal( clearPluginCommands(); +const smsStatsBefore = getPluginModuleLoaderStats(); +const smsRegistry = loadOpenClawPlugins({ + cache: false, + preferBuiltPluginArtifacts: true, + workspaceDir: tempRoot, + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "dist-runtime", "extensions"), + }, + config: { + plugins: { + enabled: true, + allow: ["sms"], + entries: { + sms: { enabled: true }, + }, + }, + }, +}); +const smsRecord = smsRegistry.plugins.find((entry: { id: string }) => entry.id === "sms"); +assert.ok(smsRecord, "SMS plugin missing from registry"); +assert.equal(smsRecord.status, "loaded", smsRecord.error ?? "SMS plugin failed to load"); +const smsStatsAfter = getPluginModuleLoaderStats(); +assert.ok( + smsStatsAfter.nativeHits > smsStatsBefore.nativeHits, + "compiled SMS runtime did not use native loading", +); +for (const counter of [ + "nativeMisses", + "sourceTransformForced", + "sourceTransformFallbacks", +] as const) { + assert.equal( + smsStatsAfter[counter], + smsStatsBefore[counter], + `compiled SMS runtime changed ${counter}`, + ); +} +assert.equal( + smsStatsAfter.topSourceTransformTargets.some(({ target }: { target: string }) => + target.replaceAll("\\", "/").includes("/extensions/sms/"), + ), + false, + "compiled SMS runtime reached the source transformer", +); + +clearPluginCommands(); + const registry = loadOpenClawPlugins({ cache: false, workspaceDir: tempRoot, diff --git a/src/plugins/build-smoke-entry.ts b/src/plugins/build-smoke-entry.ts index 1f7ac4108337..348c7db77128 100644 --- a/src/plugins/build-smoke-entry.ts +++ b/src/plugins/build-smoke-entry.ts @@ -2,3 +2,4 @@ export { clearPluginCommands, executePluginCommand, matchPluginCommand } from "./commands.js"; export { getPluginCommandSpecs } from "./command-specs.js"; export { loadOpenClawPlugins, loadPluginRegistryHandle } from "./loader.js"; +export { getPluginModuleLoaderStats } from "./plugin-module-loader-cache.js"; diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 03c264993a89..af427cc0c069 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -445,6 +445,28 @@ describe("copyBundledPluginMetadata", () => { expect(fs.existsSync(staleDistDir)).toBe(false); }); + it("preserves isolated source-checkout output for an external plugin", () => { + const repoRoot = makeRepoRoot("openclaw-external-plugin-local-dist-meta-"); + createPlugin(repoRoot, { + id: "sms", + packageName: "@openclaw/sms", + packageOpenClaw: { + extensions: ["./index.ts"], + build: { bundledDist: false }, + release: { publishToNpm: true }, + }, + }); + const distPluginDir = path.join(repoRoot, "dist", "extensions", "sms"); + fs.mkdirSync(distPluginDir, { recursive: true }); + fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {};\n", "utf8"); + + copyBundledPluginMetadata({ repoRoot }); + + expect(fs.existsSync(path.join(distPluginDir, "index.js"))).toBe(true); + expect(fs.existsSync(path.join(distPluginDir, "openclaw.plugin.json"))).toBe(true); + expect(readBundledPackageJson(repoRoot, "sms").openclaw?.extensions).toEqual(["./index.js"]); + }); + it("preserves manifest-less runtime support package outputs and copies package metadata", () => { const repoRoot = makeRepoRoot("openclaw-bundled-runtime-support-"); const pluginDir = path.join(repoRoot, "extensions", "image-generation-core"); diff --git a/src/plugins/loader.hooks-and-runtime.test-utils.ts b/src/plugins/loader.hooks-and-runtime.test-utils.ts index 754be9a0a548..fdce26afe987 100644 --- a/src/plugins/loader.hooks-and-runtime.test-utils.ts +++ b/src/plugins/loader.hooks-and-runtime.test-utils.ts @@ -200,6 +200,75 @@ function loadBuiltArtifactScenario(scenario: BuiltArtifactScenario) { return registry.plugins.find((entry) => entry.id === scenario.id)?.status; } +function loadSourceExternalArtifactScenario(params: { + sourceBody: string; + packageLocalBody: string; + rootBuildBody?: string; + runtimeOverlayBody?: string; +}) { + const id = "source-external-artifact-test"; + const repoRoot = makePluginLoaderTempDir(); + const sourceDir = path.join(repoRoot, "extensions", id); + const rootBuildDir = path.join(repoRoot, "dist", "extensions", id); + mkdirSafe(path.join(repoRoot, ".git")); + mkdirSafe(path.join(repoRoot, "src")); + writeFixtureText(repoRoot, "pnpm-workspace.yaml", "packages: []\n"); + writeFixtureJson(sourceDir, "openclaw.plugin.json", pluginManifest(id)); + writeFixtureJson(sourceDir, "package.json", { + openclaw: { + extensions: ["./index.ts"], + build: { bundledDist: false }, + }, + }); + writeFixtureText(sourceDir, "index.ts", params.sourceBody); + writeFixtureText(sourceDir, "dist/index.js", params.packageLocalBody); + if (params.rootBuildBody) { + mkdirSafe(rootBuildDir); + fs.copyFileSync( + path.join(sourceDir, "openclaw.plugin.json"), + path.join(rootBuildDir, "openclaw.plugin.json"), + ); + writeFixtureJson(rootBuildDir, "package.json", { + openclaw: { extensions: ["./index.js"] }, + }); + writeFixtureText(rootBuildDir, "index.js", params.rootBuildBody); + } + if (params.runtimeOverlayBody) { + writeFixtureText( + repoRoot, + path.join("dist-runtime", "extensions", id, "index.js"), + params.runtimeOverlayBody, + ); + } + + const config = { + plugins: { + allow: [id], + entries: { [id]: { enabled: true } }, + }, + }; + const registry = withEnv( + { + OPENCLAW_BUNDLED_PLUGINS_DIR: params.rootBuildBody + ? path.join(repoRoot, "dist", "extensions") + : path.join(repoRoot, "extensions"), + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + }, + () => { + const manifestRegistry = loadPluginManifestRegistryCore({ config }); + return loadOpenClawPlugins({ + cache: false, + preferBuiltPluginArtifacts: true, + onlyPluginIds: [id], + config, + manifestRegistry, + }); + }, + ); + return registry.plugins.find((entry) => entry.id === id)?.status; +} + describe("loadOpenClawPlugins", () => { it("setup-loads a trusted global channel plugin when the caller scopes to it", () => { useNoBundledPlugins(); @@ -863,80 +932,25 @@ ${channelPluginSource({ ).toBe("loaded"); }); - it("ignores built artifacts when the bundled source plugin opts out of core dist", () => { - const repoRoot = makePluginLoaderTempDir(); - const sourceDir = path.join(repoRoot, "extensions", "source-only-artifact-test"); - const builtPluginDir = path.join(repoRoot, "dist", "extensions", "source-only-artifact-test"); - mkdirSafe(path.join(repoRoot, ".git")); - mkdirSafe(path.join(repoRoot, "src")); - writeFixtureText(repoRoot, "pnpm-workspace.yaml", "packages: []\n"); - writeFixtureJson( - sourceDir, - "openclaw.plugin.json", - pluginManifest("source-only-artifact-test"), - ); - writeFixtureJson(sourceDir, "package.json", { - openclaw: { - extensions: ["./index.ts"], - build: { bundledDist: false }, - }, - }); - writeFixtureText( - sourceDir, - "index.ts", - 'export default { id: "source-only-artifact-test", register() {} };\n', - ); - writeFixtureText( - sourceDir, - "dist/index.js", - 'throw new Error("stale package-local dist should not load");\n', - ); - mkdirSafe(builtPluginDir); - fs.copyFileSync( - path.join(sourceDir, "openclaw.plugin.json"), - path.join(builtPluginDir, "openclaw.plugin.json"), - ); - writeFixtureJson(builtPluginDir, "package.json", { - openclaw: { extensions: ["./index.js"] }, - }); - writeFixtureText( - builtPluginDir, - "index.js", - 'throw new Error("stale discovered core dist should not load");\n', - ); - writeFixtureText( - repoRoot, - "dist-runtime/extensions/source-only-artifact-test/index.js", - 'throw new Error("stale core dist should not load");\n', - ); + it("ignores package-local dist when a bundled source plugin opts out of core dist", () => { + expect( + loadSourceExternalArtifactScenario({ + sourceBody: 'export default { id: "source-external-artifact-test", register() {} };\n', + packageLocalBody: 'throw new Error("stale package-local dist should not load");\n', + }), + ).toBe("loaded"); + }); - const config = { - plugins: { - allow: ["source-only-artifact-test"], - entries: { "source-only-artifact-test": { enabled: true } }, - }, - }; - const registry = withEnv( - { - OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "dist", "extensions"), - OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", - OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, - }, - () => { - const manifestRegistry = loadPluginManifestRegistryCore({ config }); - return loadOpenClawPlugins({ - cache: false, - preferBuiltPluginArtifacts: true, - onlyPluginIds: ["source-only-artifact-test"], - config, - manifestRegistry, - }); - }, - ); - - expect(registry.plugins.find((entry) => entry.id === "source-only-artifact-test")?.status).toBe( - "loaded", - ); + it("prefers the root build when a bundled source plugin opts out of core dist", () => { + expect( + loadSourceExternalArtifactScenario({ + sourceBody: 'throw new Error("source should not load when root build exists");\n', + packageLocalBody: 'throw new Error("stale package-local dist should not load");\n', + rootBuildBody: 'module.exports = { id: "source-external-artifact-test", register() {} };\n', + runtimeOverlayBody: + 'throw new Error("staged runtime should canonicalize to the root build");\n', + }), + ).toBe("loaded"); }); it("prefers package-local dist artifacts over workspace source TS when requested", () => { diff --git a/src/plugins/plugin-runtime-artifact-resolution.test.ts b/src/plugins/plugin-runtime-artifact-resolution.test.ts index d7abbad9753c..f775357d4bf6 100644 --- a/src/plugins/plugin-runtime-artifact-resolution.test.ts +++ b/src/plugins/plugin-runtime-artifact-resolution.test.ts @@ -87,6 +87,26 @@ describe("resolvePluginRuntimeArtifact", () => { }, ); + it("prefers the root build for source-external plugins without using package-local output", () => { + const fixture = createBundledPluginFixture(); + const packageLocalSource = path.join(fixture.rootDir, "dist", "index.js"); + fs.mkdirSync(path.dirname(packageLocalSource), { recursive: true }); + fs.writeFileSync(packageLocalSource, 'module.exports = { id: "stale" };\n'); + + const resolved = resolvePluginRuntimeArtifact({ + pluginId: "fixture", + entryKind: "runtime", + rootDir: fixture.rootDir, + source: fixture.source, + origin: "bundled", + preferBuiltPluginArtifacts: true, + packageManifest: { build: { bundledDist: false } }, + }); + + expect(resolved.source).toBe(fixture.builtSource); + expect(resolved.source).not.toBe(fs.realpathSync(packageLocalSource)); + }); + it("aliases different physical inputs for the same logical runtime entry", () => { const fixture = createBundledPluginFixture(); const first = resolveFixture({ diff --git a/src/plugins/plugin-runtime-artifact-resolution.ts b/src/plugins/plugin-runtime-artifact-resolution.ts index 2c8ffd9283b5..9bbe0f2977f9 100644 --- a/src/plugins/plugin-runtime-artifact-resolution.ts +++ b/src/plugins/plugin-runtime-artifact-resolution.ts @@ -117,10 +117,13 @@ function resolvePreferredBuiltRuntimeArtifact(params: { } return { source, rootDir }; } - if (params.packageManifest?.build?.bundledDist === false) { - return { source, rootDir }; - } - const packageLocalArtifactSource = resolvePackageLocalDistRuntimeArtifact({ source, rootDir }); + // Source-external plugins can leave package-local npm build output behind. + // Keep source authoritative over that output, but allow the lifecycle-owned root + // build to provide the fresh JavaScript artifact used by source checkouts. + const packageLocalArtifactSource = + params.packageManifest?.build?.bundledDist === false + ? null + : resolvePackageLocalDistRuntimeArtifact({ source, rootDir }); if (packageLocalArtifactSource) { return { source: packageLocalArtifactSource, rootDir }; } @@ -137,6 +140,8 @@ function resolvePreferredBuiltRuntimeArtifact(params: { return { source, rootDir }; } const artifactRelativePath = rewriteBundledRuntimeArtifactRelativePath(relativeSource); + // The runtime overlay is a staging fallback. Final canonicalization maps it + // to the matching root build when both exist, so one build owns execution. for (const artifactRootName of ["dist-runtime", "dist"] as const) { const artifactRoot = path.join( packageRoot, diff --git a/test/scripts/build-all.test.ts b/test/scripts/build-all.test.ts index db4e35b00864..0a12982563d6 100644 --- a/test/scripts/build-all.test.ts +++ b/test/scripts/build-all.test.ts @@ -362,6 +362,7 @@ describe("resolveBuildAllSteps", () => { "tsdown-ai", "tsdown-packages", "tsdown-unified", + "external-plugins:local-dist", "check-cli-bootstrap-imports", "plugins:assets:copy", "runtime-postbuild", @@ -436,6 +437,7 @@ describe("resolveBuildAllSteps", () => { expect(resolveBuildAllSteps("ciArtifacts").map((step) => step.label)).toEqual([ "plugins:assets:build", "tsdown", + "external-plugins:local-dist", "check-cli-bootstrap-imports", "plugins:assets:copy", "runtime-postbuild", @@ -568,6 +570,7 @@ describe("resolveBuildAllSteps", () => { it("uses a minimal built runtime profile for gateway watch regression", () => { expect(resolveBuildAllSteps("gatewayWatch").map((step) => step.label)).toEqual([ "tsdown", + "external-plugins:local-dist", "check-cli-bootstrap-imports", "runtime-postbuild", "build-stamp", @@ -579,6 +582,7 @@ describe("resolveBuildAllSteps", () => { expect(resolveBuildAllSteps("qaRuntime").map((step) => step.label)).toEqual([ "plugins:assets:build", "tsdown", + "external-plugins:local-dist", "check-cli-bootstrap-imports", "plugins:assets:copy", "runtime-postbuild", @@ -591,6 +595,7 @@ describe("resolveBuildAllSteps", () => { expect(resolveBuildAllSteps("sourcePerformance").map((step) => step.label)).toEqual([ "plugins:assets:build", "tsdown", + "external-plugins:local-dist", "check-cli-bootstrap-imports", "plugins:assets:copy", "runtime-postbuild", @@ -604,6 +609,7 @@ describe("resolveBuildAllSteps", () => { it("uses a CLI startup profile without generated plugin assets", () => { expect(resolveBuildAllSteps("cliStartup").map((step) => step.label)).toEqual([ "tsdown", + "external-plugins:local-dist", "check-cli-bootstrap-imports", "runtime-postbuild", "build-stamp", @@ -676,6 +682,19 @@ describe("resolveBuildAllSteps", () => { } }); + it("builds isolated external plugin output after tsdown and before runtime postbuild", () => { + for (const profile of Object.keys(BUILD_ALL_PROFILES)) { + const labels = resolveBuildAllSteps(profile).map((step) => step.label); + const lastTsdown = profile === "full" ? "tsdown-unified" : "tsdown"; + expect(labels.indexOf("external-plugins:local-dist")).toBeGreaterThan( + labels.indexOf(lastTsdown), + ); + expect(labels.indexOf("external-plugins:local-dist")).toBeLessThan( + labels.indexOf("runtime-postbuild"), + ); + } + }); + it("writes the runtime postbuild stamp after the build stamp", () => { const labels = resolveBuildAllSteps("full").map((step) => step.label); expect(labels).toContain("runtime-postbuild"); diff --git a/test/scripts/build-external-plugin-local-dist.test.ts b/test/scripts/build-external-plugin-local-dist.test.ts new file mode 100644 index 000000000000..4f0bfc2aa575 --- /dev/null +++ b/test/scripts/build-external-plugin-local-dist.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + buildExternalPluginLocalDist, + listExternalPluginLocalDistPackageDirs, +} from "../../scripts/build-external-plugin-local-dist.mts"; +import { + collectRootPackageExcludedExtensionDirs, + DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV, +} from "../../scripts/lib/bundled-plugin-build-entries.mjs"; + +describe("external plugin local dist build", () => { + it("selects every externalized first-party plugin behind a package exclusion", () => { + const packageDirs = listExternalPluginLocalDistPackageDirs(); + const excludedPluginIds = collectRootPackageExcludedExtensionDirs(); + + expect(packageDirs).toHaveLength(61); + expect(packageDirs).toEqual( + expect.arrayContaining(["extensions/slack", "extensions/sms", "extensions/mxc"]), + ); + expect(packageDirs).not.toContain("extensions/whatsapp"); + expect( + packageDirs.every((packageDir) => excludedPluginIds.has(packageDir.split("/").at(-1) ?? "")), + ).toBe(true); + }); + + it("leaves Docker-selected external plugin compilation on the unified build path", () => { + expect( + listExternalPluginLocalDistPackageDirs({ + env: { + ...process.env, + [DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "slack", + }, + }), + ).toEqual([]); + }); + + it("performs no writes when Docker owns the selected build", async () => { + await expect( + buildExternalPluginLocalDist({ + env: { + ...process.env, + [DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "slack", + }, + logLevel: "silent", + }), + ).resolves.toMatchObject({ pluginDirs: [] }); + }); +});