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:
parent
acd1f79a9b
commit
a1a5bcac20
@ -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 ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
479
src/sdk-mock.js
479
src/sdk-mock.js
@ -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() {
|
||||
|
||||
@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user