diff --git a/src/capture-api.js b/src/capture-api.js index 09710f1..f9676c1 100644 --- a/src/capture-api.js +++ b/src/capture-api.js @@ -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 ?? {}), + }, }; } diff --git a/src/inspector.js b/src/inspector.js index 3d38735..95e58b4 100644 --- a/src/inspector.js +++ b/src/inspector.js @@ -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: { diff --git a/src/mock-sdk-capture-runner.js b/src/mock-sdk-capture-runner.js index c6a1256..65d7d8a 100644 --- a/src/mock-sdk-capture-runner.js +++ b/src/mock-sdk-capture-runner.js @@ -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); +} diff --git a/src/sdk-mock.js b/src/sdk-mock.js index 7ea7e87..76827f2 100644 --- a/src/sdk-mock.js +++ b/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() { diff --git a/test/cold-import-readiness.test.js b/test/cold-import-readiness.test.js index 2199634..6b41136 100644 --- a/test/cold-import-readiness.test.js +++ b/test/cold-import-readiness.test.js @@ -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", }, ], diff --git a/test/contract-probes.test.js b/test/contract-probes.test.js index c596035..a95e063 100644 --- a/test/contract-probes.test.js +++ b/test/contract-probes.test.js @@ -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", diff --git a/test/issues.test.js b/test/issues.test.js index 909f16a..6497444 100644 --- a/test/issues.test.js +++ b/test/issues.test.js @@ -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", diff --git a/test/runtime-capture-report.test.js b/test/runtime-capture-report.test.js index 3c0b124..0410e91 100644 --- a/test/runtime-capture-report.test.js +++ b/test/runtime-capture-report.test.js @@ -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); +}); diff --git a/test/workspace-plan.test.js b/test/workspace-plan.test.js index b010cc6..76d5478 100644 --- a/test/workspace-plan.test.js +++ b/test/workspace-plan.test.js @@ -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", }, ],