fix: harden runtime capture mocks

Harden runtime capture mocks with in-place loader capture, TypeScript/external mocks, async output capture, and namespace import coverage.
This commit is contained in:
Peter Steinberger 2026-04-27 11:11:55 +01:00 committed by GitHub
parent acd1f79a9b
commit a1a5bcac20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 740 additions and 25 deletions

View File

@ -166,10 +166,18 @@ function registrationReturnValue(name, args, context) {
}
function createRuntimeContext(options) {
const runtime = options.runtime ?? {};
return {
...runtime,
agent: runtime.agent ?? {},
env: options.env ?? {},
logger: options.logger ?? console,
now: () => new Date(0),
tts: runtime.tts ?? {},
state: {
resolveStateDir: () => options.stateDir ?? process.cwd(),
...(runtime.state ?? {}),
},
};
}

View File

@ -200,7 +200,7 @@ export async function captureEntrypointWithMockSdk(entrypoint, options = {}) {
try {
const { stdout } = await execFileAsync(
process.execPath,
["--preserve-symlinks", runnerPath, JSON.stringify(payload)],
["--no-warnings", "--preserve-symlinks", runnerPath, JSON.stringify(payload)],
{
cwd: options.cwd ?? process.cwd(),
env: {

View File

@ -1,5 +1,6 @@
#!/usr/bin/env node
import { mkdtemp, rm, symlink } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import { register } from "node:module";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@ -7,15 +8,16 @@ import { createCaptureApi } from "./capture-api.js";
import { createMockSdkPackage } from "./sdk-mock.js";
const options = JSON.parse(process.argv[2] ?? "{}");
let activeOutputCapture = null;
try {
const result = await run(options);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
writeRunnerStdout(`${JSON.stringify(result, null, 2)}\n`);
} catch (error) {
if (error.failureClass) {
process.stderr.write(`[plugin-inspector:${error.failureClass}]\n`);
writeRunnerStderr(`[plugin-inspector:${error.failureClass}]\n`);
}
process.stderr.write(`${error.stack ?? error.message}\n`);
writeRunnerStderr(`${error.stack ?? error.message}\n`);
process.exitCode = 1;
}
@ -25,40 +27,49 @@ async function run(options) {
const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-mock-sdk-"));
try {
await createMockSdkPackage(workspace);
const linkedPluginRoot = path.join(workspace, "plugin");
await symlink(pluginRoot, linkedPluginRoot, "junction");
const linkedEntrypoint = path.join(linkedPluginRoot, path.relative(pluginRoot, entrypoint));
return await captureLinkedEntrypoint(linkedEntrypoint, options);
const { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
register(pathToFileURL(loaderPath));
return await captureLinkedEntrypoint(entrypoint, options);
} finally {
await rm(workspace, { force: true, recursive: true });
}
}
async function captureLinkedEntrypoint(entrypoint, options) {
const outputCapture = installProcessOutputCapture();
activeOutputCapture = outputCapture;
let module;
try {
module = await import(pathToFileURL(entrypoint).href);
} catch (error) {
await drainAsyncOutput();
throw capturePhaseError(error, "entrypoint-import-error");
}
const register = findRegisterExport(module);
if (!register) {
return {
status: "no-register-export",
entrypoint: options.entrypoint,
mockSdk: true,
captured: [],
};
await drainAsyncOutput();
return withProcessOutput(
{
status: "no-register-export",
entrypoint: options.entrypoint,
mockSdk: true,
captured: [],
},
outputCapture,
);
}
const api = createCaptureApi(options.apiOptions);
try {
await register(api);
} catch (error) {
await drainAsyncOutput();
throw capturePhaseError(error, "registration-execution-error");
}
await drainAsyncOutput();
const result = {
status: "captured",
entrypoint: options.entrypoint,
@ -68,7 +79,22 @@ async function captureLinkedEntrypoint(entrypoint, options) {
if (options.apiOptions?.retainHandlers === true) {
result.retained = api.getRetainedContracts();
}
return result;
return withProcessOutput(result, outputCapture);
}
function withProcessOutput(result, outputCapture) {
const stdout = outputCapture.stdout();
const stderr = outputCapture.stderr();
if (stdout.length === 0 && stderr.length === 0) {
return result;
}
return {
...result,
processOutput: {
stdout,
stderr,
},
};
}
function capturePhaseError(error, failureClass) {
@ -88,3 +114,49 @@ function findRegisterExport(module) {
}
return null;
}
function installProcessOutputCapture() {
const stdoutChunks = [];
const stderrChunks = [];
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
process.stdout.write = (chunk, encoding, callback) => {
stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk));
invokeWriteCallback(encoding, callback);
return true;
};
process.stderr.write = (chunk, encoding, callback) => {
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk));
invokeWriteCallback(encoding, callback);
return true;
};
return {
originalStdoutWrite,
originalStderrWrite,
stdout: () => stdoutChunks.join(""),
stderr: () => stderrChunks.join(""),
};
}
function invokeWriteCallback(encoding, callback) {
if (typeof encoding === "function") {
encoding();
} else if (typeof callback === "function") {
callback();
}
}
async function drainAsyncOutput() {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setImmediate(resolve));
}
function writeRunnerStdout(text) {
(activeOutputCapture?.originalStdoutWrite ?? process.stdout.write.bind(process.stdout))(text);
}
function writeRunnerStderr(text) {
(activeOutputCapture?.originalStderrWrite ?? process.stderr.write.bind(process.stderr))(text);
}

View File

@ -1,6 +1,9 @@
import { mkdir, writeFile } from "node:fs/promises";
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const SOURCE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]);
const SKIP_DIRS = new Set([".git", "coverage", "node_modules", "reports"]);
export const mockSdkSubpathExports = {
"plugin-entry": [
"buildPluginConfigSchema",
@ -212,10 +215,13 @@ export const mockSdkExportNames = [
]),
].sort();
export async function createMockSdkPackage(rootDir) {
export async function createMockSdkPackage(rootDir, options = {}) {
const packageDir = path.join(rootDir, "node_modules", "openclaw");
const pluginSdkDir = path.join(packageDir, "plugin-sdk");
const externalDir = path.join(rootDir, "mock-modules", "external");
await mkdir(pluginSdkDir, { recursive: true });
await mkdir(externalDir, { recursive: true });
const imports = options.pluginRoot ? await collectRuntimeImports(options.pluginRoot) : emptyRuntimeImports();
await writeFile(
path.join(packageDir, "package.json"),
`${JSON.stringify(
@ -237,7 +243,474 @@ export async function createMockSdkPackage(rootDir) {
for (const [subpath, exportNames] of Object.entries(mockSdkSubpathExports)) {
await writeFile(path.join(pluginSdkDir, `${subpath}.js`), mockSdkSubpathSource(exportNames), "utf8");
}
return packageDir;
for (const specifier of imports.openclawSdkSpecifiers) {
if (specifier === "openclaw/plugin-sdk") {
continue;
}
const relative = specifier.slice("openclaw/plugin-sdk/".length);
if (mockSdkSubpathExports[relative]) {
continue;
}
const targetPath = path.join(pluginSdkDir, `${relative}.js`);
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(
targetPath,
dynamicMockModuleSource(imports.bySpecifier.get(specifier) ?? new Set(), {
includeSdkRuntime: true,
zod: relative === "zod",
}),
"utf8",
);
}
const externalMap = {};
for (const specifier of imports.bareSpecifiers) {
const fileName = `${safeModuleFileName(specifier)}.js`;
externalMap[specifier] = path.join(externalDir, fileName);
await writeFile(
path.join(externalDir, fileName),
externalMockModuleSource(specifier, imports.bySpecifier.get(specifier) ?? new Set()),
"utf8",
);
}
const fallbackExternalPath = path.join(externalDir, "__fallback__.js");
await writeFile(fallbackExternalPath, externalMockModuleSource("__fallback__", new Set()), "utf8");
const loaderPath = path.join(rootDir, "mock-loader.mjs");
await writeFile(
loaderPath,
mockLoaderSource({
externalMap,
fallbackExternalPath,
pluginSdkDir,
}),
"utf8",
);
return { packageDir, loaderPath, pluginSdkDir };
}
function emptyRuntimeImports() {
return {
bySpecifier: new Map(),
openclawSdkSpecifiers: new Set(["openclaw/plugin-sdk"]),
bareSpecifiers: new Set(),
};
}
async function collectRuntimeImports(pluginRoot) {
const bySpecifier = new Map();
const openclawSdkSpecifiers = new Set(["openclaw/plugin-sdk"]);
const bareSpecifiers = new Set();
for (const filePath of await listSourceFiles(pluginRoot)) {
const text = await readFile(filePath, "utf8");
for (const entry of parseModuleImports(text)) {
if (entry.specifier.startsWith("openclaw/plugin-sdk")) {
openclawSdkSpecifiers.add(entry.specifier);
} else if (isMockableBareSpecifier(entry.specifier)) {
bareSpecifiers.add(entry.specifier);
} else {
continue;
}
const names = bySpecifier.get(entry.specifier) ?? new Set();
for (const name of entry.names) {
names.add(name);
}
bySpecifier.set(entry.specifier, names);
}
}
return { bySpecifier, openclawSdkSpecifiers, bareSpecifiers };
}
async function listSourceFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (entry.name.startsWith(".") && entry.name !== ".clawhub") {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) {
files.push(...(await listSourceFiles(fullPath)));
}
continue;
}
if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
files.push(fullPath);
}
}
return files;
}
function parseModuleImports(text) {
const entries = [];
const patterns = [
/\bimport\s+([\s\S]*?)\s+from\s+["']([^"']+)["']/g,
/\bexport\s+(?:type\s+)?(?:\*\s+from|\{([\s\S]*?)\}\s+from)\s+["']([^"']+)["']/g,
];
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
const specifier = match[2];
if (specifier) {
entries.push({ specifier, names: parseNamedImports(match[1] ?? "") });
}
}
}
for (const match of text.matchAll(/\bimport\s+["']([^"']+)["']/g)) {
entries.push({ specifier: match[1], names: new Set() });
}
return entries;
}
function parseNamedImports(clause) {
const names = new Set();
const named = /\{([\s\S]*?)\}/.exec(clause)?.[1] ?? clause;
for (const rawPart of named.split(",")) {
const part = rawPart.trim();
if (!part || part.startsWith("type ")) {
continue;
}
const sourceName = part.replace(/^type\s+/u, "").split(/\s+as\s+/u)[0]?.trim();
if (sourceName && /^[A-Za-z_$][\w$]*$/u.test(sourceName)) {
names.add(sourceName);
}
}
return names;
}
function isMockableBareSpecifier(specifier) {
return (
!specifier.startsWith(".") &&
!specifier.startsWith("/") &&
!specifier.startsWith("node:") &&
!specifier.startsWith("data:") &&
!specifier.startsWith("file:")
);
}
function safeModuleFileName(specifier) {
return specifier.replace(/[^A-Za-z0-9._-]+/gu, "__");
}
function mockLoaderSource({ externalMap, fallbackExternalPath, pluginSdkDir }) {
return `import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { builtinModules, stripTypeScriptTypes } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const externalMap = new Map(Object.entries(${JSON.stringify(externalMap)}));
const fallbackExternalPath = ${JSON.stringify(fallbackExternalPath)};
const pluginSdkDir = ${JSON.stringify(pluginSdkDir)};
const builtins = new Set([...builtinModules, ...builtinModules.map((name) => \`node:\${name}\`)]);
export async function resolve(specifier, context, nextResolve) {
if (specifier === "openclaw/plugin-sdk") {
return moduleUrl(path.join(pluginSdkDir, "index.js"));
}
if (specifier.startsWith("openclaw/plugin-sdk/")) {
const subpath = specifier.slice("openclaw/plugin-sdk/".length);
return moduleUrl(path.join(pluginSdkDir, \`\${subpath}.js\`));
}
try {
return await nextResolve(specifier, context);
} catch (error) {
const resolved = resolveExtensionless(specifier, context.parentURL);
if (resolved) {
return moduleUrl(resolved);
}
if (isMockableBareSpecifier(specifier)) {
return moduleUrl(externalMap.get(specifier) ?? fallbackExternalPath);
}
throw error;
}
}
export async function load(url, context, nextLoad) {
if (url.startsWith("file:") && /\\.[cm]?ts$/u.test(fileURLToPath(url))) {
const rawSource = await readFile(fileURLToPath(url), "utf8");
return { format: "module", source: stripTypeScriptTypes(rawSource, { mode: "transform" }), shortCircuit: true };
}
return nextLoad(url, context);
}
function moduleUrl(filePath) {
return { url: pathToFileURL(filePath).href, shortCircuit: true };
}
function resolveExtensionless(specifier, parentURL) {
if (!parentURL || (!specifier.startsWith(".") && !specifier.startsWith("/"))) {
return null;
}
const parentDir = path.dirname(fileURLToPath(parentURL));
const base = specifier.startsWith("/") ? specifier : path.resolve(parentDir, specifier);
const parsed = path.parse(base);
const withoutJsExtension = [".js", ".mjs", ".cjs"].includes(parsed.ext) ? path.join(parsed.dir, parsed.name) : null;
const candidates = [
base,
...(withoutJsExtension ? [\`\${withoutJsExtension}.ts\`, \`\${withoutJsExtension}.mts\`, \`\${withoutJsExtension}.cts\`] : []),
\`\${base}.js\`,
\`\${base}.mjs\`,
\`\${base}.ts\`,
path.join(base, "index.js"),
path.join(base, "index.mjs"),
path.join(base, "index.ts"),
];
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
function isMockableBareSpecifier(specifier) {
return !builtins.has(specifier) &&
!specifier.startsWith(".") &&
!specifier.startsWith("/") &&
!specifier.startsWith("data:") &&
!specifier.startsWith("file:");
}
`;
}
function dynamicMockModuleSource(exportNames, options = {}) {
const names = new Set([...exportNames].filter(isValidExportName));
if (options.zod) {
addZodExports(names);
}
return `${genericMockRuntimeSource(options)}
${[...names].map(genericExportStatement).join("\n")}
export default ${options.zod ? "createZNamespace()" : 'createMockValue("default")'};
`;
}
function externalMockModuleSource(specifier, exportNames) {
const names = new Set([...exportNames].filter(isValidExportName));
if (specifier === "zod") {
addZodExports(names);
}
return dynamicMockModuleSource(names, { zod: specifier === "zod" });
}
function addZodExports(names) {
for (const name of ["z", "any", "array", "boolean", "enum", "literal", "number", "object", "record", "string", "unknown"]) {
names.add(name);
}
}
function isValidExportName(name) {
return name !== "default" && /^[A-Za-z_$][\w$]*$/u.test(name);
}
function genericExportStatement(name) {
if (name === "z") {
return "export const z = createZNamespace();";
}
if (name === "Type") {
return "export const Type = createTypeNamespace();";
}
if (["any", "array", "boolean", "enum", "literal", "number", "object", "record", "string", "unknown"].includes(name)) {
if (name === "enum") {
return "const zodEnum = createZNamespace().enum;\nexport { zodEnum as enum };";
}
return `export const ${name} = createZNamespace().${name};`;
}
if (["createChatChannelPlugin", "createPlugin", "defineChannelPluginEntry", "definePlugin", "definePluginEntry", "defineSetupPluginEntry"].includes(name)) {
return name === "definePluginEntry" ? "export { definePluginEntry };" : `export const ${name} = definePluginEntry;`;
}
if (/^[A-Z].*Schema$/u.test(name)) {
return `export const ${name} = createSchema();`;
}
return `export const ${name} = createMockValue(${JSON.stringify(name)});`;
}
function genericMockRuntimeSource(options = {}) {
return `${options.includeSdkRuntime ? `function definePluginEntry(entry) {
if (entry && typeof entry === "object" && typeof entry.register === "function") {
return entry;
}
if (entry && typeof entry === "object" && typeof entry.registerFull === "function") {
return { ...entry, register: entry.registerFull };
}
return typeof entry === "function" ? { register: entry } : entry;
}
` : ""}
function createMockValue(name) {
function fn(...args) {
if (name.startsWith("normalize")) {
return typeof args[0] === "string" ? args[0] : "";
}
if (name === "jsonResult") {
return { type: "json", value: args[0] };
}
if (name === "readStringParam") {
return typeof args[0] === "string" ? args[0] : "";
}
return createMockValue(name);
}
return new Proxy(fn, {
get(_target, property) {
if (property === "then") {
return undefined;
}
if (property === Symbol.toPrimitive) {
return () => name;
}
if (property === "toString") {
return () => name;
}
if (property === "valueOf") {
return () => name;
}
return createMockValue(\`\${name}.\${String(property)}\`);
},
construct() {
return createMockValue(name);
},
});
}
function createZNamespace() {
const namespace = {
any: () => createSchema(),
array: () => createSchema([]),
boolean: () => createSchema(),
enum: (values) => createSchema(Array.isArray(values) ? values[0] : undefined),
literal: (value) => createSchema(value),
number: () => createSchema(),
object: (shape = {}) => createSchema(undefined, shape),
record: () => createSchema({}),
string: () => createSchema(),
unknown: () => createSchema(),
};
return new Proxy(namespace, {
get(target, property) {
if (property in target) {
return target[property];
}
return () => createSchema();
},
});
}
function createSchema(defaultValue, shape) {
const schema = {
parse(value) {
if (shape && isPlainObject(value ?? defaultValue ?? {})) {
const source = isPlainObject(value) ? value : {};
const output = isPlainObject(defaultValue) ? clonePlain(defaultValue) : {};
for (const [key, fieldSchema] of Object.entries(shape)) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
output[key] = parseWithSchema(fieldSchema, source[key]);
continue;
}
const parsed = parseWithSchema(fieldSchema, undefined);
if (parsed !== undefined) {
output[key] = parsed;
}
}
return output;
}
return value === undefined ? clonePlain(defaultValue) : value;
},
default(value) {
return createSchema(value, shape);
},
optional() {
return this;
},
nullable() {
return this;
},
nullish() {
return this;
},
strict() {
return this;
},
passthrough() {
return this;
},
regex() {
return this;
},
min() {
return this;
},
max() {
return this;
},
int() {
return this;
},
positive() {
return this;
},
nonnegative() {
return this;
},
url() {
return this;
},
describe() {
return this;
},
refine() {
return this;
},
superRefine() {
return this;
},
transform() {
return this;
},
};
return new Proxy(schema, {
get(target, property) {
if (property in target) {
return target[property];
}
return () => target;
},
});
}
function parseWithSchema(schema, value) {
return schema && typeof schema.parse === "function" ? schema.parse(value) : value;
}
function clonePlain(value) {
if (value === undefined || value === null) {
return value;
}
return JSON.parse(JSON.stringify(value));
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function createTypeNamespace() {
const namespace = {
Any: () => ({}),
Array: (items = {}) => ({ type: "array", items }),
Boolean: () => ({ type: "boolean" }),
Literal: (value) => ({ const: value }),
Number: () => ({ type: "number" }),
Object: (properties = {}) => ({ type: "object", properties }),
Optional: (schema) => schema,
String: (options = {}) => ({ type: "string", ...options }),
Union: (schemas = []) => ({ anyOf: schemas }),
Unknown: () => ({}),
};
return new Proxy(namespace, {
get(target, property) {
if (property in target) {
return target[property];
}
return (...args) => ({ kind: String(property), args });
},
});
}
`;
}
function mockSdkSource() {

View File

@ -52,7 +52,7 @@ test("cold import readiness preserves combined blocker evidence", () => {
dependencies: ["left-pad"],
sdkImportDetails: [
{
specifier: "openclaw/plugin-sdk/discord",
specifier: "openclaw/plugin-sdk/legacy-helper",
ref: "index.js:1",
},
],

View File

@ -13,7 +13,7 @@ test("contract probes map issue findings to executable backlog rows", () => {
{
fixture: "codex-app-server",
code: "sdk-export-missing",
evidence: ["openclaw/plugin-sdk/discord"],
evidence: ["openclaw/plugin-sdk/legacy-helper"],
},
{
fixture: "agentchat",

View File

@ -8,7 +8,7 @@ test("issue ids are stable fingerprints", () => {
code: "sdk-export-missing",
severity: "P1",
compatRecord: "plugin-sdk-export-aliases",
evidence: ["openclaw/plugin-sdk/discord @ plugins/codex-app-server/src/controller.ts:104"],
evidence: ["openclaw/plugin-sdk/legacy-helper @ plugins/sample-plugin/src/controller.ts:104"],
};
assert.equal(issueId(finding), issueId({ ignored: "field", ...finding }));
@ -73,7 +73,7 @@ test("issue builder applies metadata and class summaries", () => {
level: "warning",
message: "missing sdk export",
compatRecord: "plugin-sdk-export-aliases",
evidence: ["openclaw/plugin-sdk/discord"],
evidence: ["openclaw/plugin-sdk/legacy-helper"],
},
{
fixture: "agentchat",

View File

@ -150,3 +150,165 @@ test("runtime capture report classifies registration execution failures", async
assert.equal(captureReport.results[0].failureClass, "registration-execution-error");
assert.match(captureReport.results[0].error, /register exploded/);
});
test("runtime capture supports TypeScript entrypoints, SDK subpaths, external mocks, and noisy output", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-runtime-ts-"));
await mkdir(path.join(rootDir, "src"), { recursive: true });
await writeFile(
path.join(rootDir, "package.json"),
`${JSON.stringify(
{
name: "openclaw-typescript-weather",
version: "1.0.0",
type: "module",
openclaw: {
extensions: ["src/index.ts"],
compat: { pluginApi: "^1.0.0" },
},
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
path.join(rootDir, "src", "helper.ts"),
["export type WeatherConfig = { city: string };", "export const toolName: string = 'weather_ts';"].join("\n"),
"utf8",
);
await writeFile(
path.join(rootDir, "src", "sdk-barrel.ts"),
'export { ReexportedConfigSchema, reexportedSdkHelper } from "openclaw/plugin-sdk/reexported";\n',
"utf8",
);
await writeFile(
path.join(rootDir, "src", "index.ts"),
[
'import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";',
'import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core";',
'import { z } from "openclaw/plugin-sdk/zod";',
'import defaultZod, { type ZodType } from "zod";',
'import { externalHelper } from "missing-runtime-dependency";',
'import { MemoryStore } from "missing-class-dependency";',
'import { toolName } from "./helper";',
'import { ReexportedConfigSchema, reexportedSdkHelper } from "./sdk-barrel.js";',
"",
"class FileStore extends MemoryStore {}",
"new FileStore();",
"const typedSchema: ZodType | undefined = undefined;",
"const defaultConfig = z",
" .object({ enabled: z.boolean().default(false), reexported: ReexportedConfigSchema })",
" .parse({});",
"defaultZod.object({ city: defaultZod.string().default('sf') }).parse({});",
"structuredClone(defaultConfig);",
"buildChannelConfigSchema(z.object({})).parse?.({});",
"console.log('plugin startup noise', Boolean(typedSchema));",
"setTimeout(() => console.log('late plugin noise'), 0);",
"externalHelper?.();",
"reexportedSdkHelper?.();",
"export default definePluginEntry((api) => {",
" api.runtime.state.resolveStateDir();",
" api.on('before_tool_call', () => undefined);",
" api.registerTool({ name: toolName, inputSchema: z.object({}), run() {} });",
"});",
].join("\n"),
"utf8",
);
const config = await loadPluginRootConfig(null, { cwd: rootDir });
const compatibilityReport = await inspectCompatibilityFixtureSet(config, { openclawPath: false });
const captureReport = await buildRuntimeCaptureReport({ report: compatibilityReport, rootDir });
assert.equal(captureReport.summary.failedCount, 0);
assert.equal(captureReport.summary.capturedCount, 1);
assert.equal(captureReport.summary.registrationCount, 1);
assert.equal(captureReport.summary.hookCount, 1);
assert.match(captureReport.results[0].processOutput.stdout, /plugin startup noise/);
assert.match(captureReport.results[0].processOutput.stdout, /late plugin noise/);
});
test("runtime capture supports namespace imports from mocked externals", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-runtime-zod-namespace-"));
await mkdir(path.join(rootDir, "src"), { recursive: true });
await writeFile(
path.join(rootDir, "package.json"),
`${JSON.stringify(
{
name: "openclaw-zod-namespace",
version: "1.0.0",
type: "module",
openclaw: {
extensions: ["src/index.mjs"],
compat: { pluginApi: "^1.0.0" },
},
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
path.join(rootDir, "src", "index.mjs"),
[
'import { definePluginEntry } from "openclaw/plugin-sdk";',
'import * as z from "zod";',
"const schema = z.object({ city: z.string().default('sf') });",
"export default definePluginEntry((api) => {",
" api.registerTool({ name: schema.parse({}).city, inputSchema: {}, run() {} });",
"});",
].join("\n"),
"utf8",
);
const config = await loadPluginRootConfig(null, { cwd: rootDir });
const compatibilityReport = await inspectCompatibilityFixtureSet(config, { openclawPath: false });
const captureReport = await buildRuntimeCaptureReport({ report: compatibilityReport, rootDir });
assert.equal(captureReport.summary.failedCount, 0);
assert.equal(captureReport.summary.registrationCount, 1);
});
test("runtime capture keeps dist chunk imports rooted at their original package", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-runtime-dist-"));
await mkdir(path.join(rootDir, "dist", "extensions", "weather"), { recursive: true });
await writeFile(path.join(rootDir, "dist", "package-shared.js"), "export const toolName = 'weather_dist';\n", "utf8");
const packageRoot = path.join(rootDir, "dist", "extensions", "weather");
await writeFile(
path.join(packageRoot, "package.json"),
`${JSON.stringify(
{
name: "openclaw-dist-weather",
version: "1.0.0",
type: "module",
openclaw: {
extensions: ["index.js"],
compat: { pluginApi: "^1.0.0" },
},
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(path.join(packageRoot, "index.js"), 'export { default } from "./src/index.js";\n', "utf8");
await mkdir(path.join(packageRoot, "src"), { recursive: true });
await writeFile(
path.join(packageRoot, "src", "index.js"),
[
'import { definePluginEntry } from "openclaw/plugin-sdk";',
'import { toolName } from "../../../package-shared.js";',
"",
"export default definePluginEntry((api) => {",
" api.registerTool({ name: toolName, inputSchema: { type: 'object' }, run() {} });",
"});",
].join("\n"),
"utf8",
);
const config = await loadPluginRootConfig(null, { cwd: packageRoot });
const compatibilityReport = await inspectCompatibilityFixtureSet(config, { openclawPath: false });
const captureReport = await buildRuntimeCaptureReport({ report: compatibilityReport, rootDir: packageRoot });
assert.equal(captureReport.summary.failedCount, 0);
assert.equal(captureReport.summary.registrationCount, 1);
});

View File

@ -146,7 +146,7 @@ function readinessReport() {
priority: "high",
sdkImportDetails: [
{
specifier: "openclaw/plugin-sdk/discord",
specifier: "openclaw/plugin-sdk/legacy-helper",
ref: "plugins/fixture/src/index.ts:1",
},
],