// Test harness for .opencode/tools/pipeline-status.ts. // // Why this file: there is no bun/tsx/esbuild on the CI runner (only node + // pytest). The pipeline-status.ts tool is a TypeScript module (Bun runtime // for opencode, not runnable with plain `node`). To exercise the tool's // execute() function in pytest, we strip TS-only syntax (import type // annotations + ESM import -> CJS require) and load the resulting JS as a // CommonJS module. The tool() factory from @opencode-ai/plugin is identity // (returns its argument), so we can replace it with a passthrough shim // without changing the tool semantics. // // This file is consumed by tests/test_pipeline_status_tool.py via: // node tests/_ts_loader.mjs [args...] // where is one of: load, exec, exec_real. // // - load: print the {description, args keys} of the tool — sanity check. // - exec: call execute({pr_number: }) with a stubbed spawnSync that // returns whatever argv it received, plus a fixture stdout. Used by unit // tests to verify args passed and output trimming. // - exec_real: call execute({pr_number: }) with the REAL spawnSync, // used by the integration test against the real pipeline-status.py. import { readFileSync } from "node:fs" import * as nodeFs from "node:fs" import { fileURLToPath } from "node:url" import path from "node:path" import { spawnSync } from "node:child_process" const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), "..", "..") const DEFAULT_TS_FILE = path.join(REPO_ROOT, ".opencode", "tools", "pipeline-status.ts") // Parameterize via env var TS_FILE (relative to REPO_ROOT) so other TS tool // wrappers (e.g. spec-status.ts) can be loaded by the same harness without // breaking existing callers that don't set TS_FILE (default: pipeline-status.ts). const TS_FILE = process.env.TS_FILE ? path.resolve(REPO_ROOT, process.env.TS_FILE) : DEFAULT_TS_FILE // Minimal zod shim covering the methods the tool actually uses: // tool.schema.number().describe("...") // At runtime (in opencode/Bun) `tool.schema` is the real zod. Here we // only need a chainable builder that returns an object with .describe() // — no validation occurs in execute(). function makeZodShim() { const chain = () => { const obj = { describe() { return obj }, optional() { return obj }, // Add more methods as future tool definitions need them. } return obj } return { number: chain, string: chain, boolean: chain, array: chain, object: chain, // enum(values) — used by post-review.ts for // verdict validation. Like the other methods, the shim returns a // chainable builder without actually validating the value (validation // happens at the opencode zod layer, not inside execute()). enum: () => chain(), } } const zodShim = makeZodShim() function stripTs(src) { // Minimal TS -> JS for this specific file: // 1) `import { spawnSync } from "child_process"` -> `const { spawnSync } = require("child_process")` // 2) `import { tool } from "@opencode-ai/plugin"` -> `const tool = (x) => x` // 3) `import path from "path"` -> `const path = require("path")` // 4) `import.meta.dir` -> a stub pointing at .opencode/tools (so the script // path resolves to .opencode/scripts/pipeline-status.py) // 5) `args: z.ZodObject` -> the args are referenced inside execute as // `args.pr_number`; the schema itself is unused at runtime here. // 6) Strip `: type` annotations and `async execute(args)` stays. // 7) Strip `as const` assertions (TS-only, used by post-review.ts // for tuple literal types) -> plain array literal. // 8) Strip `type = ...;` type alias declarations (TS-only) -> removed. // 9) Strip relative imports (`import { X } from "./_shared"`) — the // referenced module is inlined by loadTool() via inlineShared(). The // import line is removed here; the bindings come from the prepended // shared-module code. // 10) Strip `export ` keyword on top-level `function`/`const` declarations // (shared modules use `export function parseRepo`). After stripping, // the declarations become plain statements in the sandbox scope. // 11) Strip type annotations on function parameters and return types: // `function foo(a: Type, b?: Type2): RetType {` -> `function foo(a, b) {`. // Needed for _shared.ts which declares typed function params. let out = src out = out.replace(/^import\s+\{\s*spawnSync\s*\}\s+from\s+["']child_process["'];?\s*$/m, 'const { spawnSync } = require("child_process");') out = out.replace(/^import\s+path\s+from\s+["']path["'];?\s*$/m, 'const path = require("path");') out = out.replace(/^import\s+\{\s*tool\s*\}\s+from\s+["']@opencode-ai\/plugin["'];?\s*$/m, 'const tool = (x) => x;') // fs imports (create-readme.ts uses readFileSync/writeFileSync in local mode). // Convert to CJS require so the sandbox can inject the fs module. out = out.replace(/^import\s+\{\s*([^}]+)\s*\}\s+from\s+["']fs["'];?\s*$/m, 'const { $1 } = require("fs");') // Strip relative imports (`./_shared`, `../foo`, etc.) — loadTool inlines them. out = out.replace(/^import\s+\{[^}]*\}\s+from\s+["']\.[^"']+["'];?\s*$/m, "") // Replace `import.meta.dir` with the directory of the TS file. out = out.replace(/import\.meta\.dir/g, JSON.stringify(path.dirname(TS_FILE))) // Strip `as const` assertions: `["APPROVE", ...] as const` -> `["APPROVE", ...]` out = out.replace(/\bas\s+const\b/g, "") // Strip `type = ...` type alias declarations. Covers both single-line // (`type Foo = string`) and multi-line object types (`type Bar = {\n a: T\n b?: U\n}`) // used by create-readme.ts (Feature, CustomSection, CreateArgs). Use `gs` // (`.` matches newlines) for the multi-line case anchored at `^type ... = {` // through the closing `}` on its own line. out = out.replace(/^type\s+\w+\s*=\s*\{[^}]*\}\s*;?\s*$/gms, "") out = out.replace(/^type\s+\w+\s*=\s*.+\s*;?\s*$/gm, "") // Strip `export ` keyword on top-level declarations (shared modules). // Supports `export async function` (async helpers added for Forgejo dispatch). out = out.replace(/^export\s+(async\s+)?(function|const|let|var)\b/gm, "$1$2") // Strip type annotations on function params + return type: // `function foo(a: Type, b?: Type2): RetType {` -> `function foo(a, b) {` // Handles single-line function signatures (used by _shared.ts). Object // types like `{ cwd?: string }` are matched non-greedily within a param. // Return type may itself be an object literal type (e.g. create-readme.ts // `validateReadme(content: string): { ok: boolean; issues: string[] }`), // so match greedily from `):` up to the final ` {` that opens the body. out = out.replace(/^(\s*(?:async\s+)?function\s+\w+\s*\()([^)]*)\)(\s*:\s*.+?)?\s*\{/gm, (line, head, params, _ret) => { const cleaned = params .split(",") .map((p) => p.replace(/^\s*\w+/, (n) => n).replace(/:.*/, "").replace(/\?$/, "").trim()) .filter((p) => p.length > 0) .join(", ") return `${head}${cleaned}) {` }) // Strip type annotations on variable declarations: // `const issues: string[] = []` -> `const issues = []` // `let content: string` -> `let content` // Handles common TS types (string, boolean, number, arrays, generics like // Record<...>, union types `A | B`, and `undefined`). Matches the `: ` // segment between the binding name and `=` or end-of-statement. out = out.replace(/^(\s*(?:const|let|var)\s+\w+)\s*:\s*[^=;\n]+?(\s*=|\s*$)/gm, "$1$2") // Strip TS non-null assertions (`foo!`): post-fix `!` after an identifier // or closing bracket, used to assert non-null in TS. Must not strip `!` // in logical operators (`!=`, `!==`, unary `!foo`). Match `!` immediately // after a word char or `]`/`)` that is NOT followed by `=`. out = out.replace(/([\w\])])!(?!=)/g, "$1") return out } function inlineShared(rawSrc, spawnSyncImpl) { // Detect relative imports (`./_shared`, `../foo`, etc.) in the ORIGINAL // source (before stripTs blanks the line) and inline the shared module so // its exported functions are in scope inside the new Function sandbox. We // load and strip the shared module the same way as the tool, then strip its // own `require("child_process")` / `require("path")` lines so they pick up // the sandbox bindings instead of re-declaring identifiers. const sharedMatch = rawSrc.match(/^import\s+\{[^}]*\}\s+from\s+["'](\.[^"']+)["'];?\s*$/m) if (!sharedMatch) return { sharedCode: "" } const rel = sharedMatch[1] // Resolve the shared module relative to the tool's directory. The import // specifier in TS omits the `.ts` extension (e.g. `./_shared`), so append it // when the specifier has no extension. const spec = rel.endsWith(".ts") ? rel : rel + ".ts" const sharedPath = path.resolve(path.dirname(TS_FILE), spec) let sharedSrc = stripTs(readFileSync(sharedPath, "utf-8")) // Strip the same sandbox-replaced declarations so no duplicate identifiers // clash with the new Function parameters / require shim. sharedSrc = sharedSrc.replace(/^const\s+\{\s*spawnSync\s*\}\s*=\s*require\(["']child_process["']\);?\s*$/m, "") sharedSrc = sharedSrc.replace(/^const\s+path\s*=\s*require\(["']path["']\);?\s*$/m, "") sharedSrc = sharedSrc.replace(/^const\s+tool\s*=\s*\(x\)\s*=>\s*x;?\s*$/m, "") // Remove any relative import line from the shared module (defensive — _shared // has none today, but keeps the path open for deeper nesting). sharedSrc = sharedSrc.replace(/^import\s+\{[^}]*\}\s+from\s+["']\.[^"']+["'];?\s*$/m, "") return { sharedCode: sharedSrc + "\n" } } function loadTool(spawnSyncImpl, fsImpl) { // Provide a CommonJS module sandbox so the tool file's `export default` // becomes accessible via `module.exports.default`. const rawSrc = readFileSync(TS_FILE, "utf-8") let src = stripTs(rawSrc) // Inline relative imports (e.g. `./_shared`) so the tool's imported // functions (parseRepo/runGh/formatResult) are in scope inside the sandbox. const { sharedCode } = inlineShared(rawSrc, spawnSyncImpl) // Strip the require/const declarations we replace with sandbox args, so // we don't get "Identifier already declared" between Function args and // the in-source `const { spawnSync } = require(...)` lines. src = src.replace(/^const\s+\{\s*spawnSync\s*\}\s*=\s*require\(["']child_process["']\);?\s*$/m, "") src = src.replace(/^const\s+path\s*=\s*require\(["']path["']\);?\s*$/m, "") src = src.replace(/^const\s+tool\s*=\s*\(x\)\s*=>\s*x;?\s*$/m, "") // NOTE: do NOT strip `const { ... } = require("fs")` — the require shim below // returns the fs module, so the destructuring binds readFileSync/writeFileSync // in-scope inside the sandbox. Stripping would drop the bindings. // Convert `export default tool({...})` into `module.exports.default = tool({...})` const cjs = src.replace(/^export default /m, "module.exports.default = ") // tool shim with .schema = zodShim (since pipeline-status.ts uses tool.schema.number()) const toolShim = (x) => x toolShim.schema = zodShim const fs = fsImpl || nodeFs const fn = new Function( "module", "require", "spawnSync", "path", "tool", "fs", sharedCode + cjs + "\nreturn module.exports.default;", ) return fn({ exports: {} }, (name) => { if (name === "child_process") return { spawnSync: spawnSyncImpl } if (name === "path") return path if (name === "fs") return fs throw new Error("unexpected require: " + name) }, spawnSyncImpl, path, toolShim, fs) } function buildExecArgs(tool, rawValue) { // Interpret ``rawValue`` (argv string) as the tool's declared args. // - pipeline-status.ts: ``pr_number`` (int) → parseInt // - spec-status.ts: ``validate`` (bool) → /true/i match // - project-status.ts: ``check`` (bool) + ``fast`` (bool) + ``repo`` (str) — // multi-arg tool. ``rawValue`` encodes the booleans as "check|fast" and an // optional third pipe-separated part carries the ``repo`` string: // "true|false|/path/to/repo". Detected dynamically: if the tool declares // ``check`` AND ``fast``, split on ``|`` and map the first two to booleans; // if a third part exists and the tool declares ``repo``, pass it as string. // Detection is dynamic so the harness works for any single-arg tool // without hardcoding tool names. If the tool declares no args, return {}. const keys = Object.keys(tool.args || {}) if (keys.length === 0) return {} // Multi-arg tool (project-status.ts: check + fast [+ repo]). if (keys.includes("check") && keys.includes("fast")) { const parts = String(rawValue || "").split("|") const args = { check: /^true$/i.test(parts[0] || ""), fast: /^true$/i.test(parts[1] || ""), } if (keys.includes("repo") && parts[2] !== undefined && parts[2] !== "") { args.repo = parts[2] } return args } const first = keys[0] if (first === "pr_number") return { pr_number: parseInt(rawValue, 10) } if (first === "validate") return { validate: /^true$/i.test(rawValue || "") } // Fallback heuristic: numeric → int, else bool-ish. return { [first]: rawValue } } // Multi-arg tools (commit.ts, create-pr.ts, create-issue.ts) declare several // args (message, title, body, issue_number, labels). The single-arg // ``buildExecArgs`` can't handle them. ``exec_stub_json`` mode passes the // full args object as a JSON string + a JSON array of sequential stub // responses (one per spawnSync call — commit.ts makes 2: git diff, git commit). function buildExecArgsFromJson(rawValue) { return JSON.parse(rawValue) } function buildStubSequencer(responses) { // Return a stub function that returns responses[callIndex] for each call, // cycling through the list if there are more calls than responses. let idx = 0 return (cmd, args, opts) => { const r = responses[idx % responses.length] idx++ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" } } } function main() { const mode = process.argv[2] if (!mode) { console.error("usage: node _ts_loader.mjs [args...]") process.exit(2) } if (mode === "load") { const t = loadTool(spawnSync) console.log(JSON.stringify({ description: t.description, args: Object.keys(t.args) })) return } if (mode === "exec_stub") { // Args: // The first arg is interpreted based on which tool is loaded: // - pipeline-status.ts declares ``pr_number`` (int) // - spec-status.ts declares ``validate`` (bool) // Detected dynamically from ``t.args`` keys so the harness stays generic. const stubStatus = parseInt(process.argv[4], 10) const stubStdout = process.argv[5] const stubStderr = process.argv[6] || "" const callLog = [] const stub = (cmd, args, opts) => { callLog.push({ cmd, args, opts }) return { status: stubStatus, stdout: stubStdout, stderr: stubStderr } } const t = loadTool(stub) const execArgs = buildExecArgs(t, process.argv[3]) t.execute(execArgs, { // ToolContext — only fields the tool actually touches. Our tool // touches none of the ctx fields, so this can be empty-ish. sessionID: "test", messageID: "test", agent: "test", directory: REPO_ROOT, worktree: REPO_ROOT, abort: new AbortController().signal, metadata() {}, async ask() {}, }).then( (result) => { console.log(JSON.stringify({ result, calls: callLog })) }, (err) => { console.log(JSON.stringify({ error: String(err), calls: callLog })) }, ) return } if (mode === "exec_stub_json") { // Multi-arg tools (commit.ts, create-pr.ts, create-issue.ts). // Args: // args_json — JSON string of the args object, e.g. {"message":"feat(x): y"} // responses_json — JSON array of {status, stdout, stderr} stub // responses, returned sequentially per spawnSync call. Tools that // make N spawnSync calls need N entries (extra calls cycle back). const execArgs = buildExecArgsFromJson(process.argv[3]) const responses = JSON.parse(process.argv[4]) const callLog = [] const stub = (cmd, args, opts) => { callLog.push({ cmd, args, opts }) const r = responses[callLog.length - 1] || responses[responses.length - 1] return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" } } const t = loadTool(stub) t.execute(execArgs, { sessionID: "test", messageID: "test", agent: "test", directory: REPO_ROOT, worktree: REPO_ROOT, abort: new AbortController().signal, metadata() {}, async ask() {}, }).then( (result) => { console.log(JSON.stringify({ result, calls: callLog })) }, (err) => { console.log(JSON.stringify({ error: String(err), calls: callLog })) }, ) return } if (mode === "exec_real") { const t = loadTool(spawnSync) // use real spawnSync const execArgs = buildExecArgs(t, process.argv[3]) t.execute(execArgs, { sessionID: "test", messageID: "test", agent: "test", directory: REPO_ROOT, worktree: REPO_ROOT, abort: new AbortController().signal, metadata() {}, async ask() {}, }).then( (result) => console.log(JSON.stringify({ result })), (err) => console.log(JSON.stringify({ error: String(err) })), ) return } console.error("unknown mode: " + mode) process.exit(2) } main()