diff --git a/README.md b/README.md index b468522..df5bc29 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,46 @@ openclaw plugin inspector banner -# 💊 OpenClaw Plugin Inspector +# OpenClaw Plugin Inspector -`plugin-inspector` is the reusable OpenClaw plugin compatibility inspector. It -wraps the static inspection, registration capture, and report model prototyped -in crabpot into an npm-publishable package. +`plugin-inspector` is the offline compatibility check for OpenClaw plugins. Run +it from a plugin root to inspect package metadata, `openclaw.plugin.json`, SDK +imports, `api.on(...)`, `api.register*`, and optional runtime registration +capture. -## Install +## Quick Start -Install it as a dev dependency in a plugin repo: +From a plugin package directory: + +```bash +npx @openclaw/plugin-inspector +``` + +That runs `check`, writes report artifacts to `reports/`, and exits non-zero +when compatibility breakages are found. + +Add a local config and GitHub Actions workflow: + +```bash +npx @openclaw/plugin-inspector init --ci +``` + +Or install it as a dev dependency: ```bash npm install --save-dev @openclaw/plugin-inspector +npx plugin-inspector check ``` -Then run it from the plugin root: +## Commands ```bash npx @openclaw/plugin-inspector check +npx @openclaw/plugin-inspector check --plugin-root ./plugins/weather +npx @openclaw/plugin-inspector init --ci --package-manager pnpm ``` -## CLI - -Run the default plugin-root check from a plugin package directory: - -```bash -plugin-inspector check -``` - -That command reads the current directory as one plugin, inspects package -metadata, `openclaw.plugin.json`, source imports, `api.on(...)`, -`api.register*`, and writes: +`check` reads the current directory as one plugin unless `--plugin-root` is set. +It writes: - `reports/plugin-inspector-report.json` - `reports/plugin-inspector-report.md` @@ -43,8 +53,8 @@ checkout: plugin-inspector check --no-openclaw ``` -Use a simple plugin-root config when you want stable fixture metadata or -expected seams: +Use `plugin-inspector.config.json` when CI needs stable fixture metadata, +expected seams, or runtime capture defaults: ```json { @@ -58,6 +68,9 @@ expected seams: "registrations": ["registerTool"] } }, + "capture": { + "mockSdk": true + }, "openclaw": { "defaultCheckoutPath": "../openclaw" } @@ -70,48 +83,51 @@ Then run: plugin-inspector check --config plugin-inspector.config.json ``` -Copy-ready examples live in `examples/plugin-inspector.config.json` and +`init --ci` writes this shape for you, plus +`.github/workflows/plugin-inspector.yml`. Copy-ready examples also live in +`examples/plugin-inspector.config.json` and `examples/github-actions-plugin-inspector.yml`. -Fixture-set configs are still supported for crabpot-style compatibility suites: +## Runtime Capture + +Runtime capture imports plugin entrypoints in an isolated subprocess and records +the registrations made during `register(api)`. It is opt-in because it executes +plugin code: ```bash -plugin-inspector report --config crabpot.config.json --out reports +PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 npx @openclaw/plugin-inspector check --runtime --mock-sdk ``` -Capture a plugin entrypoint in an explicitly isolated execution lane: +By default, runtime capture uses a generated mock for `openclaw/plugin-sdk` and +common external packages so plugin code can load in clean CI without OpenClaw +installed. Use `--real-sdk` only when the plugin workspace already has real SDK +dependencies installed and you intentionally want to test that path. + +Runtime capture writes: + +- `reports/plugin-inspector-runtime-capture.json` +- `reports/plugin-inspector-runtime-capture.md` + +You can also capture one entrypoint directly: ```bash PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture ./dist/index.js --mock-sdk ``` -Run the optional runtime capture smoke during `check`: +## CI -```bash -PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector check --no-openclaw --capture -``` - -Runtime capture creates a temporary mock `openclaw/plugin-sdk` package, imports -declared OpenClaw package entrypoints, calls their `register(api)` function with -the capture API, and writes: - -- `reports/plugin-inspector-runtime-capture.json` -- `reports/plugin-inspector-runtime-capture.md` - -### CI - -With a dev dependency: +Minimal package scripts: ```json { "scripts": { "plugin:check": "plugin-inspector check --no-openclaw", - "plugin:check:runtime": "PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector check --no-openclaw --capture" + "plugin:check:runtime": "PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector check --no-openclaw --runtime --mock-sdk" } } ``` -GitHub Actions: +GitHub Actions without a local dev dependency: ```yaml name: plugin-inspector @@ -131,8 +147,8 @@ jobs: node-version: 24 cache: npm - run: npm ci - - run: npm run plugin:check - - run: npm run plugin:check:runtime + - run: npx @openclaw/plugin-inspector check --no-openclaw + - run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 npx @openclaw/plugin-inspector check --no-openclaw --runtime --mock-sdk - uses: actions/upload-artifact@v5 if: always() with: @@ -140,6 +156,31 @@ jobs: path: reports/plugin-inspector-* ``` +## Fixture Suites + +Fixture-set configs are still supported for crabpot-style compatibility suites: + +```bash +plugin-inspector report --config crabpot.config.json --out reports +``` + +Use fixture suites when one repo wants to inspect many plugins. Use plugin-root +`check` for normal plugin CI. + +## Mocking Model + +Default inspection is static, offline, and credential-free. Runtime capture is +the only mode that imports plugin code. + +When `--mock-sdk` is enabled, the inspector generates temporary modules for +`openclaw/plugin-sdk` subpaths and unresolved external packages discovered in +the plugin import graph. The mock SDK captures registrations; it does not call +network services, launch OpenClaw, run provider SDKs, or emulate service +lifecycle side effects. + +Use the mock lane for plugin compatibility CI. Keep live provider/service tests +in the plugin repo behind their own credentials and explicit opt-in flags. + ## Scope Default inspection is offline and credential-free. It reads manifests, package @@ -148,5 +189,5 @@ metadata, and source files, then reports observed `api.on(...)`, OpenClaw target checkout parsing is limited to public compatibility registries, SDK package exports, manifest types, hooks, and captured registrar metadata. -Cold import capture and synthetic contract probes are explicit opt-in modes. -Live lanes will stay credential-gated and must never run in default CI. +Cold import capture, synthetic contract probes, and runtime capture are explicit +opt-in modes. Live lanes stay credential-gated and must never run in default CI. diff --git a/examples/github-actions-plugin-inspector.yml b/examples/github-actions-plugin-inspector.yml index cee02a8..1663ea7 100644 --- a/examples/github-actions-plugin-inspector.yml +++ b/examples/github-actions-plugin-inspector.yml @@ -15,8 +15,8 @@ jobs: node-version: 24 cache: npm - run: npm ci - - run: npm run plugin:check - - run: npm run plugin:check:runtime + - run: npx @openclaw/plugin-inspector check --no-openclaw + - run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 npx @openclaw/plugin-inspector check --no-openclaw --runtime --mock-sdk - uses: actions/upload-artifact@v5 if: always() with: diff --git a/examples/plugin-inspector.config.json b/examples/plugin-inspector.config.json index 6ca927f..55ba50f 100644 --- a/examples/plugin-inspector.config.json +++ b/examples/plugin-inspector.config.json @@ -9,6 +9,9 @@ "registrations": ["registerTool"] } }, + "capture": { + "mockSdk": true + }, "openclaw": { "defaultCheckoutPath": "../openclaw" } diff --git a/src/advanced.js b/src/advanced.js index d0e6cb1..6815854 100644 --- a/src/advanced.js +++ b/src/advanced.js @@ -116,12 +116,21 @@ export { defaultPluginRootConfigFiles, fixtureCheckoutPath, fixtureSourceRoot, + inferPluginSeams, loadInspectorConfig, loadPluginRootConfig, normalizeInspectorConfig, normalizePluginRootConfig, + packageId, validateInspectorConfig, } from "./config.js"; +export { + buildPluginInspectorConfig, + defaultInitConfigPath, + defaultInitWorkflowPath, + renderGithubActionsWorkflow, + writePluginInspectorInit, +} from "./init.js"; export { buildPlatformProbes, defaultPlatformTargets, diff --git a/src/api.js b/src/api.js index f90d7ea..8c8c005 100644 --- a/src/api.js +++ b/src/api.js @@ -1,6 +1,7 @@ import path from "node:path"; import { createCaptureApi } from "./capture-api.js"; import { loadInspectorConfig, loadPluginRootConfig } from "./config.js"; +import { writePluginInspectorInit } from "./init.js"; import { captureEntrypoint } from "./inspector.js"; import { renderTextSummary, writeCompatibilityReport } from "./report.js"; import { buildRuntimeCaptureReport, writeRuntimeCaptureReport } from "./runtime-capture-report.js"; @@ -45,24 +46,25 @@ export async function writePluginReports(report, options = {}) { export async function runPluginCheck(options = {}) { const outDir = options.outDir ?? "reports"; - const report = await inspectPluginRoot(options); - const paths = await writePluginReports(report, { ...options, outDir }); + const config = await loadPluginConfig(options); + const report = await inspectPluginRoot({ ...options, config }); + const paths = await writePluginReports(report, { ...options, pluginRoot: config.rootDir, outDir }); const result = { report, paths }; + const capture = options.capture ?? config.capture?.runtime ?? false; + const mockSdk = options.mockSdk ?? config.capture?.mockSdk ?? true; - if (options.capture === true) { + if (capture === true) { if (process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED !== "1") { throw new Error("runtime capture imports plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 in an isolated workspace"); } - const config = await loadPluginConfig(options); const runtimeCapture = await buildRuntimeCaptureReport({ - mockSdk: options.mockSdk ?? true, + mockSdk, report, rootDir: config.rootDir, }); - const outputRoot = options.cwd ?? options.pluginRoot ?? process.cwd(); const runtimeCapturePaths = await writeRuntimeCaptureReport(runtimeCapture, { - jsonPath: path.resolve(outputRoot, outDir, "plugin-inspector-runtime-capture.json"), - markdownPath: path.resolve(outputRoot, outDir, "plugin-inspector-runtime-capture.md"), + jsonPath: path.resolve(config.rootDir, outDir, "plugin-inspector-runtime-capture.json"), + markdownPath: path.resolve(config.rootDir, outDir, "plugin-inspector-runtime-capture.md"), }); result.runtimeCapture = runtimeCapture; result.runtimeCapturePaths = runtimeCapturePaths; @@ -82,4 +84,8 @@ export async function capturePluginEntrypoint(entrypoint, options = {}) { return captureEntrypoint(entrypoint, options); } +export async function setupPluginInspector(options = {}) { + return writePluginInspectorInit(options); +} + export { createCaptureApi, renderTextSummary }; diff --git a/src/cli.js b/src/cli.js index e04b356..8933897 100755 --- a/src/cli.js +++ b/src/cli.js @@ -7,6 +7,7 @@ import { captureEntrypoint, inspectFixtureSet, loadInspectorConfig, + writePluginInspectorInit, writeArtifacts, writeReport, } from "./advanced.js"; @@ -20,6 +21,8 @@ try { printHelp(); } else if (command === "check") { await runCheck(commandArgs); + } else if (command === "init") { + await runInit(commandArgs); } else if (command === "inspect" || command === "report" || command === "ci") { await runReport(command, commandArgs); } else if (command === "capture") { @@ -34,11 +37,13 @@ try { async function runCheck(commandArgs) { const configPath = readFlag(commandArgs, "--config"); + const pluginRoot = readFlag(commandArgs, "--plugin-root") ?? readFlag(commandArgs, "--root"); const outDir = readFlag(commandArgs, "--out") ?? "reports"; const openclawPath = commandArgs.includes("--no-openclaw") ? false : readFlag(commandArgs, "--openclaw"); const json = commandArgs.includes("--json"); - const capture = commandArgs.includes("--capture"); - const { report } = await runPluginCheck({ configPath, outDir, openclawPath, capture }); + const capture = readRuntimeFlag(commandArgs); + const mockSdk = readMockSdkFlag(commandArgs); + const { report } = await runPluginCheck({ configPath, pluginRoot, outDir, openclawPath, capture, mockSdk }); if (json) { console.log(JSON.stringify(report, null, 2)); @@ -51,6 +56,25 @@ async function runCheck(commandArgs) { } } +async function runInit(commandArgs) { + const pluginRoot = readFlag(commandArgs, "--plugin-root") ?? readFlag(commandArgs, "--root"); + const configPath = readFlag(commandArgs, "--config") ?? undefined; + const workflowPath = readFlag(commandArgs, "--workflow") ?? undefined; + const packageManager = readFlag(commandArgs, "--package-manager") ?? "npm"; + const result = await writePluginInspectorInit({ + pluginRoot, + configPath, + workflowPath, + packageManager, + ci: commandArgs.includes("--ci"), + force: commandArgs.includes("--force"), + }); + + for (const filePath of result.written) { + console.log(`wrote ${filePath}`); + } +} + async function runReport(command, commandArgs) { const configPath = readFlag(commandArgs, "--config"); const outDir = readFlag(commandArgs, "--out") ?? "reports"; @@ -75,7 +99,7 @@ async function runCapture(commandArgs) { const entrypoint = commandArgs.find((arg) => !arg.startsWith("-")); const outputPath = readFlag(commandArgs, "--output"); const pluginRoot = readFlag(commandArgs, "--plugin-root"); - const mockSdk = commandArgs.includes("--mock-sdk"); + const mockSdk = readMockSdkFlag(commandArgs) ?? commandArgs.includes("--mock-sdk"); if (!entrypoint) { throw new Error("capture requires an entrypoint path"); } @@ -100,14 +124,49 @@ function readFlag(commandArgs, name) { return commandArgs[index + 1] ?? null; } +function readRuntimeFlag(commandArgs) { + if (commandArgs.includes("--runtime") || commandArgs.includes("--capture")) { + return true; + } + if (commandArgs.includes("--no-runtime") || commandArgs.includes("--no-capture")) { + return false; + } + return undefined; +} + +function readMockSdkFlag(commandArgs) { + const sdk = readFlag(commandArgs, "--sdk"); + if (sdk === "mock") { + return true; + } + if (sdk === "real") { + return false; + } + if (sdk && !["mock", "real"].includes(sdk)) { + throw new Error("--sdk must be mock or real"); + } + if (commandArgs.includes("--mock-sdk")) { + return true; + } + if (commandArgs.includes("--real-sdk")) { + return false; + } + return undefined; +} + function printHelp() { console.log(`plugin-inspector Usage: - plugin-inspector check [--config ] [--out ] [--openclaw ] [--no-openclaw] [--capture] [--json] + plugin-inspector + plugin-inspector check [--plugin-root ] [--config ] [--out ] [--openclaw ] [--no-openclaw] [--runtime] [--mock-sdk|--real-sdk] [--json] + plugin-inspector init [--plugin-root ] [--config ] [--ci] [--package-manager npm|pnpm|yarn|bun] [--force] plugin-inspector report --config [--out ] [--check] [--json] plugin-inspector inspect --config [--out ] [--check] [--json] plugin-inspector ci --config [--out ] - PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture [--mock-sdk] [--plugin-root ] [--output ] + PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture [--mock-sdk|--real-sdk] [--plugin-root ] [--output ] + +Default check runs from the current plugin root and writes reports/ unless --out is set. +Runtime capture is opt-in because it imports plugin code; use --runtime with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1. `); } diff --git a/src/config.js b/src/config.js index 2ca926c..a52128f 100644 --- a/src/config.js +++ b/src/config.js @@ -52,6 +52,19 @@ export function validateInspectorConfig(config) { errors.push("config.fixtures must be a non-empty array"); } + if (config.capture !== undefined) { + if (!config.capture || typeof config.capture !== "object" || Array.isArray(config.capture)) { + errors.push("config.capture must be an object when present"); + } else { + if (config.capture.runtime !== undefined && typeof config.capture.runtime !== "boolean") { + errors.push("config.capture.runtime must be a boolean when present"); + } + if (config.capture.mockSdk !== undefined && typeof config.capture.mockSdk !== "boolean") { + errors.push("config.capture.mockSdk must be a boolean when present"); + } + } + } + const ids = new Set(); const paths = new Set(); for (const fixture of config.fixtures ?? []) { @@ -134,6 +147,7 @@ export async function normalizePluginRootConfig(config, options = {}) { return { version: 1, submoduleRoot: ".", + capture: config.capture, openclaw: config.openclaw, fixtures: [fixture], }; @@ -157,7 +171,7 @@ async function readJsonIfExists(filePath) { return JSON.parse(await readFile(filePath, "utf8")); } -function packageId(packageName) { +export function packageId(packageName) { if (!packageName) { return null; } @@ -170,7 +184,7 @@ function packageId(packageName) { .toLowerCase(); } -function inferPluginSeams(pluginManifest, packageJson) { +export function inferPluginSeams(pluginManifest, packageJson) { const contracts = Object.keys(pluginManifest?.contracts ?? {}); if (contracts.includes("tools")) { return ["dynamic-tool"]; diff --git a/src/index.js b/src/index.js index 19570ac..29bc3ed 100644 --- a/src/index.js +++ b/src/index.js @@ -6,5 +6,6 @@ export { loadPluginConfig, renderTextSummary, runPluginCheck, + setupPluginInspector, writePluginReports, } from "./api.js"; diff --git a/src/init.js b/src/init.js new file mode 100644 index 0000000..c042cd0 --- /dev/null +++ b/src/init.js @@ -0,0 +1,150 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { inferPluginSeams, packageId } from "./config.js"; + +export const defaultInitConfigPath = "plugin-inspector.config.json"; +export const defaultInitWorkflowPath = ".github/workflows/plugin-inspector.yml"; + +export async function writePluginInspectorInit(options = {}) { + const pluginRoot = path.resolve(options.pluginRoot ?? options.cwd ?? process.cwd()); + const configPath = path.resolve(pluginRoot, options.configPath ?? defaultInitConfigPath); + const written = []; + + if (existsSync(configPath) && options.force !== true) { + throw new Error(`${path.relative(pluginRoot, configPath)} already exists; pass --force to overwrite it`); + } + + const config = await buildPluginInspectorConfig({ pluginRoot }); + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + written.push(configPath); + + if (options.ci === true) { + const workflowPath = path.resolve(pluginRoot, options.workflowPath ?? defaultInitWorkflowPath); + if (existsSync(workflowPath) && options.force !== true) { + throw new Error(`${path.relative(pluginRoot, workflowPath)} already exists; pass --force to overwrite it`); + } + await mkdir(path.dirname(workflowPath), { recursive: true }); + await writeFile(workflowPath, renderGithubActionsWorkflow({ packageManager: options.packageManager }), "utf8"); + written.push(workflowPath); + } + + return { pluginRoot, configPath, written }; +} + +export async function buildPluginInspectorConfig(options = {}) { + const pluginRoot = path.resolve(options.pluginRoot ?? options.cwd ?? process.cwd()); + const packageJson = await readJsonIfExists(path.join(pluginRoot, "package.json")); + const pluginManifest = await readJsonIfExists(path.join(pluginRoot, "openclaw.plugin.json")); + const sourceRoot = inferSourceRoot(packageJson); + + const plugin = { + id: pluginManifest?.id ?? packageId(packageJson?.name) ?? "plugin", + priority: "high", + seams: inferPluginSeams(pluginManifest, packageJson), + }; + + if (sourceRoot !== ".") { + plugin.sourceRoot = sourceRoot; + } + + return { + version: 1, + plugin, + capture: { + mockSdk: true, + }, + }; +} + +export function renderGithubActionsWorkflow(options = {}) { + const packageManager = normalizePackageManager(options.packageManager); + const setup = packageManagerSetup(packageManager); + + return `name: plugin-inspector + +on: + pull_request: + push: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: ${setup.cache} +${setup.corepack ? " - run: corepack enable\n" : ""} - run: ${setup.install} + - run: ${setup.exec} @openclaw/plugin-inspector check --no-openclaw + - run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 ${setup.exec} @openclaw/plugin-inspector check --no-openclaw --runtime --mock-sdk + - uses: actions/upload-artifact@v5 + if: always() + with: + name: plugin-inspector-reports + path: reports/plugin-inspector-* +`; +} + +function inferSourceRoot(packageJson) { + const entrypoints = [ + packageJson?.openclaw?.entrypoint, + ...(packageJson?.openclaw?.extensions ?? []), + ...(packageJson?.openclaw?.runtimeExtensions ?? []), + ].filter((value) => typeof value === "string"); + const entrypoint = entrypoints[0] ?? packageJson?.exports?.["."] ?? packageJson?.main ?? "src/index.js"; + if (typeof entrypoint === "string" && entrypoint.startsWith("src/")) { + return "src"; + } + return "."; +} + +async function readJsonIfExists(filePath) { + if (!existsSync(filePath)) { + return null; + } + return JSON.parse(await readFile(filePath, "utf8")); +} + +function normalizePackageManager(packageManager = "npm") { + if (["npm", "pnpm", "yarn", "bun"].includes(packageManager)) { + return packageManager; + } + throw new Error("--package-manager must be npm, pnpm, yarn, or bun"); +} + +function packageManagerSetup(packageManager) { + if (packageManager === "pnpm") { + return { + cache: "pnpm", + corepack: true, + install: "pnpm install --frozen-lockfile", + exec: "pnpm dlx", + }; + } + if (packageManager === "yarn") { + return { + cache: "yarn", + corepack: true, + install: "yarn install --immutable", + exec: "yarn dlx", + }; + } + if (packageManager === "bun") { + return { + cache: "npm", + corepack: false, + install: "bun install --frozen-lockfile", + exec: "bunx", + }; + } + return { + cache: "npm", + corepack: false, + install: "npm ci", + exec: "npx", + }; +} diff --git a/test/api.test.js b/test/api.test.js index 6b3c10f..39767f5 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -9,6 +9,7 @@ import { inspectPluginRoot, loadPluginConfig, runPluginCheck, + setupPluginInspector, } from "../src/index.js"; test("public API runs the plugin-root check and writes reports", async () => { @@ -61,6 +62,41 @@ test("public API exposes capture through an explicit entrypoint helper", async ( ); }); +test("public API can initialize plugin inspector files", async () => { + const pluginRoot = await createPluginRoot(); + + const result = await setupPluginInspector({ pluginRoot, ci: true, packageManager: "npm" }); + const config = JSON.parse(await readFile(path.join(pluginRoot, "plugin-inspector.config.json"), "utf8")); + const workflow = await readFile(path.join(pluginRoot, ".github", "workflows", "plugin-inspector.yml"), "utf8"); + + assert.equal(result.written.length, 2); + assert.equal(config.plugin.id, "weather"); + assert.equal(config.capture.mockSdk, true); + assert.match(workflow, /npx @openclaw\/plugin-inspector check --no-openclaw/); +}); + +test("public API honors config-driven runtime capture", async () => { + const pluginRoot = await createPluginRoot(); + await writeFile( + path.join(pluginRoot, "plugin-inspector.config.json"), + `${JSON.stringify({ version: 1, capture: { runtime: true, mockSdk: true } }, null, 2)}\n`, + "utf8", + ); + + const previous = process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED; + process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED = "1"; + try { + const result = await runPluginCheck({ pluginRoot, outDir: "reports", openclawPath: false }); + assert.equal(result.runtimeCapture.summary.registrationCount, 1); + } finally { + if (previous === undefined) { + delete process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED; + } else { + process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED = previous; + } + } +}); + async function createPluginRoot() { const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-api-root-")); await mkdir(path.join(rootDir, "src"), { recursive: true }); diff --git a/test/cli.test.js b/test/cli.test.js index aacc17f..1da5d99 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -65,3 +65,102 @@ test("check command runs from a plugin root without fixture config", async () => assert.equal(capture.summary.capturedCount, 1); assert.equal(capture.summary.registrationCount, 1); }); + +test("check command can target a plugin root and use runtime aliases", async () => { + const rootDir = await createCliPluginRoot("plugin-inspector-cli-target-"); + const cliPath = path.resolve("src/cli.js"); + + await execFileAsync( + process.execPath, + [cliPath, "--plugin-root", rootDir, "--out", "reports", "--no-openclaw", "--runtime", "--mock-sdk"], + { + cwd: os.tmpdir(), + env: { + ...process.env, + PLUGIN_INSPECTOR_EXECUTE_ISOLATED: "1", + }, + }, + ); + + const report = JSON.parse(await readFile(path.join(rootDir, "reports", "plugin-inspector-report.json"), "utf8")); + const capture = JSON.parse( + await readFile(path.join(rootDir, "reports", "plugin-inspector-runtime-capture.json"), "utf8"), + ); + assert.equal(report.fixtures[0].id, "weather"); + assert.equal(capture.summary.capturedCount, 1); +}); + +test("check command can enable runtime capture from plugin config", async () => { + const rootDir = await createCliPluginRoot("plugin-inspector-cli-config-runtime-"); + await writeFile( + path.join(rootDir, "plugin-inspector.config.json"), + `${JSON.stringify({ version: 1, capture: { runtime: true, mockSdk: true } }, null, 2)}\n`, + "utf8", + ); + const cliPath = path.resolve("src/cli.js"); + + await execFileAsync(process.execPath, [cliPath, "check", "--out", "reports", "--no-openclaw"], { + cwd: rootDir, + env: { + ...process.env, + PLUGIN_INSPECTOR_EXECUTE_ISOLATED: "1", + }, + }); + + const capture = JSON.parse( + await readFile(path.join(rootDir, "reports", "plugin-inspector-runtime-capture.json"), "utf8"), + ); + assert.equal(capture.summary.registrationCount, 1); +}); + +test("init command writes plugin config and CI workflow", async () => { + const rootDir = await createCliPluginRoot("plugin-inspector-cli-init-"); + const cliPath = path.resolve("src/cli.js"); + + const { stdout } = await execFileAsync( + process.execPath, + [cliPath, "init", "--plugin-root", rootDir, "--ci", "--package-manager", "pnpm"], + ); + const config = JSON.parse(await readFile(path.join(rootDir, "plugin-inspector.config.json"), "utf8")); + const workflow = await readFile(path.join(rootDir, ".github", "workflows", "plugin-inspector.yml"), "utf8"); + + assert.match(stdout, /plugin-inspector\.config\.json/); + assert.equal(config.plugin.id, "weather"); + assert.equal(config.plugin.sourceRoot, "src"); + assert.equal(config.capture.mockSdk, true); + assert.match(workflow, /pnpm dlx @openclaw\/plugin-inspector check --no-openclaw/); + assert.match(workflow, /--runtime --mock-sdk/); +}); + +async function createCliPluginRoot(prefix) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), prefix)); + await mkdir(path.join(rootDir, "src"), { recursive: true }); + await writeFile( + path.join(rootDir, "package.json"), + `${JSON.stringify( + { + name: "@example/openclaw-weather", + version: "1.0.0", + type: "module", + openclaw: { + extensions: ["src/index.js"], + compat: { pluginApi: "^1.0.0" }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + await writeFile( + path.join(rootDir, "openclaw.plugin.json"), + `${JSON.stringify({ id: "weather", name: "Weather", version: "1.0.0", contracts: { tools: {} } }, null, 2)}\n`, + "utf8", + ); + await writeFile( + path.join(rootDir, "src", "index.js"), + 'import { definePluginEntry } from "openclaw/plugin-sdk";\nexport default definePluginEntry((api) => api.registerTool({ name: "weather" }));\n', + "utf8", + ); + return rootDir; +}