openclaw/scripts/test-built-plugin-singleton.mts
Peter Steinberger 2bfa3ebc98
fix(update): complete plugin runtime artifacts on first Git update (#145664)
* fix(update): prevent stale runtime modules after Git updates

Promote root and nested dist-runtime outputs through the existing candidate runtime transaction so activation and rollback keep every runtime generation aligned. Extend real-Git execution probes for runtime overlays and consolidate repeated fixture writes.

* fix(update): complete source runtime artifacts on first hop

Complete canonical plugin overlays and SDK aliases before target-owned plugin
convergence when an older published updater omitted those outputs. Preserve
exact online retries and legacy target contracts, and require current offline
authority for changed physical runtime publication and rollback.

Share artifact ownership with build/postbuild writers, join smoke cancellation,
and keep each directory swap synchronous after its authority check.

* fix(daemon): preserve proven systemd service absence

Recognize the native systemd missing-unit-file response during strict
non-loading inspection. Carry affirmative user-unit absence to the Linux
absence owner, which separately excludes system-scope ownership before
reporting an offline service. Keep failed manager inspection and other
platforms conservative.

This allows first-hop Git runtime artifact completion on CLI-only Linux
installations without weakening publication authority.

* fix(update): preserve canonical artifacts in smoke consumers

Keep the built singleton smoke on canonical runtime preparation and verify
its cleanup leaves a no-op. Release the QA fixture port reservation before
packaged maintenance so offline publication can inspect the configured port.
Align update routing and lifecycle fixtures with their real target identity
and lease contracts while retaining consent, cleanup, and restart assertions.

* test: rebalance plugin state type checks
2026-09-12 07:46:15 -07:00

491 lines
18 KiB
TypeScript

// Smoke-tests the built plugin loader singleton and bundled plugin runtime overlay.
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { withDistArtifactOwnership } from "./lib/dist-artifact-ownership.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import { installProcessWarningFilter } from "./process-warning-filter.mts";
import { prepareBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mts";
installProcessWarningFilter();
const repoRoot = resolveRepoRoot(import.meta.url);
async function runBuiltPluginSingletonSmoke(signal: AbortSignal) {
signal.throwIfAborted();
const smokeEntryPath = path.join(repoRoot, "dist", "plugins", "build-smoke-entry.js");
assert.ok(fs.existsSync(smokeEntryPath), `missing build output: ${smokeEntryPath}`);
const {
buildPluginRuntimeLoadOptions,
clearPluginCommands,
getPluginCommandSpecs,
getPluginModuleLoaderStats,
loadOpenClawPlugins,
matchPluginCommand,
resolvePluginRuntimeLoadContext,
} = await import(pathToFileURL(smokeEntryPath).href);
signal.throwIfAborted();
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-"));
const pluginId = "build-smoke-plugin";
const distPluginDir = path.join(repoRoot, "dist", "extensions", pluginId);
const runtimePluginDir = path.join(repoRoot, "dist-runtime", "extensions", pluginId);
function cleanup() {
clearPluginCommands();
fs.rmSync(distPluginDir, { recursive: true, force: true });
fs.rmSync(runtimePluginDir, { recursive: true, force: true });
fs.rmSync(tempRoot, { recursive: true, force: true });
}
process.on("exit", cleanup);
try {
fs.mkdirSync(distPluginDir, { recursive: true });
fs.writeFileSync(
path.join(distPluginDir, "package.json"),
JSON.stringify(
{
name: "@openclaw/build-smoke-plugin",
type: "module",
openclaw: {
extensions: ["./index.js"],
},
},
null,
2,
),
"utf8",
);
fs.writeFileSync(
path.join(distPluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: pluginId,
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
null,
2,
),
"utf8",
);
fs.writeFileSync(
path.join(distPluginDir, "index.js"),
[
"import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk/plugin-entry';",
"",
"export default {",
` id: ${JSON.stringify(pluginId)},`,
" configSchema: emptyPluginConfigSchema(),",
" register(api) {",
" api.registerCommand({",
" name: 'pair',",
" description: 'Pair a device',",
" acceptsArgs: true,",
" nativeNames: { telegram: 'pair', discord: 'pair' },",
" async handler({ args }) {",
" return { text: `paired:${args ?? ''}` };",
" },",
" });",
" },",
"};",
"",
].join("\n"),
"utf8",
);
const runtime = prepareBundledPluginRuntime({ repoRoot });
try {
await runtime.publish(() => signal.throwIfAborted());
} catch (error) {
try {
await runtime.cleanup();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Smoke runtime setup and cleanup failed.", {
cause: cleanupError,
});
}
throw error;
}
await runtime.cleanup();
signal.throwIfAborted();
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,
"dist-runtime must not stage a duplicate commands module",
);
clearPluginCommands();
const smsStatsBefore = getPluginModuleLoaderStats();
// Prepared runtimes carry this context into late, plugin-scoped loads. Prove that the load-options
// projection retains the built-artifact choice instead of reopening source transformation.
const smsRegistry = loadOpenClawPlugins(
buildPluginRuntimeLoadOptions(
resolvePluginRuntimeLoadContext({
config: {
plugins: {
enabled: true,
allow: ["sms"],
entries: { sms: { enabled: true } },
},
},
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "extensions"),
},
workspaceDir: tempRoot,
}),
{ cache: false, onlyPluginIds: ["sms"] },
),
);
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",
);
// Exercise the real built load owner with omitted preferences, as provider and
// tool callers do. Source-owned metadata must not force source execution.
const { resolvePluginDiscoveryProvidersRuntime } = await import(
pathToFileURL(path.join(repoRoot, "dist", "plugins", "provider-discovery.runtime.js")).href
);
signal.throwIfAborted();
const { createPluginMetadataSnapshotFixture } =
await import("../src/plugins/plugin-metadata.test-support.js");
signal.throwIfAborted();
const { withPluginRuntimeGenerationScope } =
await import("../src/plugins/runtime/generation-scope.js");
signal.throwIfAborted();
const { setPluginRuntimeLoadContext } = await import("../src/plugins/runtime/load-context.js");
signal.throwIfAborted();
const artifactPluginId = "build-artifact-selection";
const artifactSourceRoot = path.join(tempRoot, "extensions", artifactPluginId);
const artifactBuiltRoot = path.join(tempRoot, "dist", "extensions", artifactPluginId);
fs.mkdirSync(artifactSourceRoot, { recursive: true });
fs.mkdirSync(artifactBuiltRoot, { recursive: true });
fs.mkdirSync(path.join(artifactSourceRoot, "dist"));
const artifactEntry = (label: string) => `export default {
id: ${JSON.stringify(artifactPluginId)},
register(api) {
api.registerProvider({ id: ${JSON.stringify(artifactPluginId)}, label: ${JSON.stringify(label)}, auth: [] });
api.registerTool({ name: "artifact_probe", label: "Artifact", description: ${JSON.stringify(label)},
parameters: { type: "object", properties: {} },
async execute() { return { content: [{ type: "text", text: ${JSON.stringify(label)} }] }; }
});
}
};\n`;
const artifactSource = path.join(artifactSourceRoot, "index.ts");
fs.writeFileSync(artifactSource, artifactEntry("source"));
fs.writeFileSync(path.join(artifactBuiltRoot, "index.js"), artifactEntry("compiled"));
fs.writeFileSync(
path.join(artifactSourceRoot, "dist", "index.js"),
artifactEntry("package-local"),
);
fs.writeFileSync(
path.join(artifactSourceRoot, "package.json"),
JSON.stringify({ type: "module" }),
);
for (const [rootDir, extension, label] of [
[artifactSourceRoot, ".ts", "source"],
[artifactBuiltRoot, ".js", "compiled"],
] as const) {
fs.writeFileSync(
path.join(rootDir, `provider-discovery${extension}`),
`export default {
id: ${JSON.stringify(artifactPluginId)}, label: ${JSON.stringify(label)}, auth: [],
catalog: { run: async () => ({ providers: {} }) }
};\n`,
);
}
fs.writeFileSync(
path.join(artifactBuiltRoot, "package.json"),
JSON.stringify({
type: "module",
openclaw: { extensions: ["./index.js"] },
}),
);
const artifactConfig = {
plugins: { allow: [artifactPluginId], entries: { [artifactPluginId]: { enabled: true } } },
};
const artifactManifest = {
id: artifactPluginId,
rootDir: artifactSourceRoot,
source: artifactSource,
origin: "bundled" as const,
channels: [],
providers: [artifactPluginId],
cliBackends: [],
skills: [],
hooks: [],
contracts: { tools: ["artifact_probe"] },
manifestPath: path.join(artifactSourceRoot, "openclaw.plugin.json"),
providerDiscoverySource: path.join(artifactSourceRoot, "provider-discovery.ts"),
configSchema: { type: "object", properties: {}, additionalProperties: false },
packageManifest: { extensions: ["./index.ts"], build: { bundledDist: false } },
};
const artifactManifestRegistry = {
plugins: [artifactManifest],
diagnostics: [],
};
const artifactMetadataSnapshot = createPluginMetadataSnapshotFixture(artifactManifestRegistry);
for (const toolDiscovery of [false, true]) {
for (const preferBuiltPluginArtifacts of [undefined, false]) {
const values = {
config: artifactConfig,
env: { ...process.env, OPENCLAW_STATE_DIR: path.join(tempRoot, "artifact-state") },
manifestRegistry: artifactManifestRegistry,
installRecords: {},
preferBuiltPluginArtifacts,
workspaceDir: tempRoot,
};
const options = toolDiscovery
? buildPluginRuntimeLoadOptions(resolvePluginRuntimeLoadContext(values))
: values;
const selected = loadOpenClawPlugins({
...options,
cache: false,
activate: false,
toolDiscovery,
onlyPluginIds: [artifactPluginId],
});
const label = preferBuiltPluginArtifacts === false ? "source" : "compiled";
assert.equal(selected.plugins[0]?.status, "loaded", selected.plugins[0]?.error);
assert.equal(selected.providers[0]?.provider.label, label);
const tool = selected.tools[0]?.factory({ config: artifactConfig });
assert.equal(tool?.description, label);
if (!toolDiscovery) {
setPluginRuntimeLoadContext(selected, resolvePluginRuntimeLoadContext(values));
const providers = withPluginRuntimeGenerationScope(
{ metadataSnapshot: artifactMetadataSnapshot, pluginRegistry: selected },
() =>
resolvePluginDiscoveryProvidersRuntime({
config: artifactConfig,
pluginMetadataSnapshot: artifactMetadataSnapshot,
onlyPluginIds: [artifactPluginId],
discoveryEntriesOnly: true,
}),
);
assert.equal(
providers[0]?.label,
label,
"provider discovery chose a different artifact policy",
);
}
}
}
// Implicit built-host policy leaves installed source alone; explicit true can
// use its package-local output. Neither cache call order may contaminate the other.
for (const [orderIndex, preferences] of [
[undefined, true],
[true, undefined],
].entries()) {
const options = {
config: artifactConfig,
env: { ...process.env, OPENCLAW_STATE_DIR: path.join(tempRoot, "artifact-state") },
workspaceDir: path.join(tempRoot, `cache-order-${orderIndex}`),
manifestRegistry: {
...artifactManifestRegistry,
plugins: [{ ...artifactManifest, origin: "global" }],
},
installRecords: {},
onlyPluginIds: [artifactPluginId],
cache: true,
activate: false,
};
const registries = preferences.map((preferBuiltPluginArtifacts) => {
const selected = loadOpenClawPlugins({ ...options, preferBuiltPluginArtifacts });
const label = preferBuiltPluginArtifacts ? "package-local" : "source";
assert.equal(selected.plugins[0]?.status, "loaded", selected.plugins[0]?.error);
assert.equal(
selected.providers[0]?.provider.label,
label,
`cache call order ${orderIndex}`,
);
assert.equal(selected.tools[0]?.factory({ config: artifactConfig })?.description, label);
return selected;
});
for (const [index, preferBuiltPluginArtifacts] of preferences.entries()) {
assert.equal(
loadOpenClawPlugins({ ...options, preferBuiltPluginArtifacts }),
registries[index],
"repeated selection did not reuse its own cached registry",
);
}
}
clearPluginCommands();
const registry = loadOpenClawPlugins({
cache: false,
workspaceDir: tempRoot,
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "dist-runtime", "extensions"),
},
config: {
plugins: {
enabled: true,
allow: [pluginId],
entries: {
[pluginId]: { enabled: true },
},
},
},
});
const record = registry.plugins.find((entry: { id: string }) => entry.id === pluginId);
assert.ok(record, "smoke plugin missing from registry");
assert.equal(record.status, "loaded", record.error ?? "smoke plugin failed to load");
assert.deepEqual(
getPluginCommandSpecs().filter((command: { name: string }) => command.name === "pair"),
[{ name: "pair", description: "Pair a device", acceptsArgs: true }],
);
const match = matchPluginCommand("/pair now");
assert.ok(match, "canonical built command registry did not receive the command");
assert.equal(match.args, "now");
const result = await match.command.handler({ args: match.args });
signal.throwIfAborted();
assert.deepEqual(result, { text: "paired:now" });
// Keep these imports after the cold native checks so they cannot prewarm the loader.
const { buildBundleMcpToolsFromCatalog } = await import(
pathToFileURL(path.join(repoRoot, "dist", "agents", "agent-bundle-mcp-materialize.js")).href
);
signal.throwIfAborted();
const { getPluginToolMeta } = await import(
pathToFileURL(path.join(repoRoot, "dist", "plugins", "tool-metadata.js")).href
);
signal.throwIfAborted();
const { getPluginToolMeta: getSdkPluginToolMeta } = await import(
pathToFileURL(path.join(repoRoot, "dist", "plugin-sdk", "agent-harness-runtime.js")).href
);
signal.throwIfAborted();
const [mcpTool] = buildBundleMcpToolsFromCatalog({
catalog: {
version: 1,
generatedAt: 0,
servers: {
"build-smoke-mcp": {
serverName: "build-smoke-mcp",
safeServerName: "build-smoke-mcp",
launchSummary: "build smoke inventory fixture",
toolCount: 1,
},
},
tools: [
{
serverName: "build-smoke-mcp",
safeServerName: "build-smoke-mcp",
toolName: "lookup",
inputSchema: { type: "object", properties: {} },
fallbackDescription: "Look up a build smoke inventory item",
},
],
},
});
assert.ok(mcpTool, "compiled MCP materializer did not produce a tool");
const mcpMetadata = getPluginToolMeta(mcpTool);
assert.ok(mcpMetadata, "canonical built metadata owner did not receive MCP tool metadata");
assert.equal(mcpMetadata.pluginId, "bundle-mcp");
assert.equal(mcpMetadata.mcp?.serverName, "build-smoke-mcp");
assert.equal(mcpMetadata.mcp?.safeServerName, "build-smoke-mcp");
assert.equal(mcpMetadata.mcp?.toolName, "lookup");
assert.equal(mcpMetadata.mcp?.operation, "tool");
assert.strictEqual(
getSdkPluginToolMeta(mcpTool),
mcpMetadata,
"public agent-harness-runtime SDK did not read the canonical MCP metadata record",
);
} finally {
process.off("exit", cleanup);
cleanup();
}
signal.throwIfAborted();
const remaining = prepareBundledPluginRuntime({ repoRoot });
const changed = remaining.changed;
await remaining.cleanup();
assert.equal(changed, false, "singleton smoke left noncanonical runtime artifacts");
process.stdout.write("[build-smoke] built plugin singleton smoke passed\n");
}
const controller = new AbortController();
let exitCode = 0;
const cancel = (code: number) => {
if (!controller.signal.aborted) {
exitCode = code;
controller.abort(new Error("Built plugin singleton smoke canceled."));
}
};
const onSigint = () => cancel(130);
const onSigterm = () => cancel(143);
try {
await withDistArtifactOwnership(repoRoot, async () => {
process.on("SIGINT", onSigint);
process.on("SIGTERM", onSigterm);
// Imports cannot be interrupted; keep ownership until each pending read joins.
await runBuiltPluginSingletonSmoke(controller.signal);
});
controller.signal.throwIfAborted();
} catch (error) {
if (!controller.signal.aborted || error !== controller.signal.reason) {
throw error;
}
process.exitCode = exitCode;
} finally {
process.off("SIGINT", onSigint);
process.off("SIGTERM", onSigterm);
}