160 lines
6.9 KiB
TypeScript
160 lines
6.9 KiB
TypeScript
// Runtime smoke test against a real `opencode serve` (v2). No model calls, no tokens spent.
|
|
//
|
|
// npm run build && node scripts/smoke.ts [--keep]
|
|
//
|
|
// Builds a temp fixture project whose opencode.json loads this repo's dist/ directory as a local
|
|
// plugin (local plugin entries must be DIRECTORIES with a root index file; file entries are
|
|
// ignored), installs the agents with our own init, boots a private server, and asserts over the
|
|
// HTTP API (Basic auth user `opencode`, password printed by serve):
|
|
// - plugin haylan.pocock reaches state.status "active"
|
|
// - every manifest skill id is registered and user-invoked ones have autoinvoke=false
|
|
// - agents pocock (primary), pocock-worker / pocock-reviewer (subagent) are discovered and the
|
|
// worker's skill deny + allow-list survived frontmatter decoding
|
|
// Env: OPENCODE_BIN (default "opencode"), SMOKE_PORT (default random).
|
|
import { spawn, spawnSync } from "node:child_process"
|
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync } from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import path from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
import { runInit } from "../src/init.ts"
|
|
import { loadManifest, packageRoot } from "../src/manifest.ts"
|
|
|
|
const BIN = process.env.OPENCODE_BIN ?? "opencode"
|
|
const PORT = Number(process.env.SMOKE_PORT ?? 40000 + Math.floor(Math.random() * 20000))
|
|
const BASE = `http://127.0.0.1:${PORT}`
|
|
|
|
function assert(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new Error(`smoke FAIL: ${message}`)
|
|
}
|
|
|
|
async function waitReady(timeoutMs = 90_000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const res = await fetch(BASE + "/")
|
|
if (res.ok) return
|
|
} catch {
|
|
/* not up yet */
|
|
}
|
|
await new Promise((r) => setTimeout(r, 500))
|
|
}
|
|
throw new Error("smoke FAIL: server did not come up in time")
|
|
}
|
|
|
|
async function main() {
|
|
if (!existsSync(path.join(packageRoot, "dist", "index.js"))) {
|
|
throw new Error("smoke FAIL: dist/index.js missing - run `npm run build` first")
|
|
}
|
|
const manifest = loadManifest()
|
|
const fixture = mkdtempSync(path.join(tmpdir(), "pocock-smoke-"))
|
|
let server: ReturnType<typeof spawn> | undefined
|
|
let out = ""
|
|
try {
|
|
mkdirSync(path.join(fixture, ".opencode"), { recursive: true })
|
|
writeFileSync(
|
|
path.join(fixture, "opencode.json"),
|
|
JSON.stringify(
|
|
{ $schema: "https://opencode.ai/config.json", plugins: [path.join(packageRoot, "dist")] },
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
runInit({ argv: ["init"], cwd: fixture, pkgRoot: packageRoot })
|
|
|
|
server = spawn(BIN, ["serve", "--hostname", "127.0.0.1", "--port", String(PORT)], {
|
|
cwd: fixture,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
shell: process.platform === "win32",
|
|
})
|
|
const collect = (chunk: Buffer) => (out += chunk.toString())
|
|
server.stdout?.on("data", collect)
|
|
server.stderr?.on("data", collect)
|
|
await waitReady()
|
|
|
|
const pw = /server password (\S+)/.exec(out)?.[1]
|
|
assert(pw, `server password not found in output:\n${out.slice(0, 400)}`)
|
|
const auth = { Authorization: "Basic " + Buffer.from(`opencode:${pw}`).toString("base64") }
|
|
const get = async (p: string) => {
|
|
const res = await fetch(BASE + "/api" + p, { headers: auth })
|
|
assert(res.ok, `GET /api${p} -> ${res.status}`)
|
|
return res.json() as Promise<{ data?: unknown } & Record<string, unknown>>
|
|
}
|
|
|
|
// Plugin loads (lazy: poll until it appears)
|
|
let plugin: { id: string; state?: { status?: string } } | undefined
|
|
const deadline = Date.now() + 60_000
|
|
while (!plugin && Date.now() < deadline) {
|
|
const body = (await get("/plugin")) as { data: { id: string; state?: { status: string } }[] }
|
|
plugin = body.data.find((p) => p.id === "haylan.pocock")
|
|
if (!plugin) await new Promise((r) => setTimeout(r, 1000))
|
|
}
|
|
assert(plugin, "plugin haylan.pocock never appeared in /api/plugin")
|
|
assert(plugin!.state?.status === "active", `plugin state: ${JSON.stringify(plugin!.state)}`)
|
|
console.log("ok plugin haylan.pocock active")
|
|
|
|
// Skills registered with correct autoinvoke flags (ids may also exist from global installs)
|
|
const skillBody = (await get("/skill")) as { data: { id: string; autoinvoke?: boolean }[] }
|
|
const byId = new Map(skillBody.data.map((s) => [s.id, s]))
|
|
for (const entry of manifest) {
|
|
const found = byId.get(entry.id)
|
|
assert(found, `skill '${entry.id}' not registered`)
|
|
// the API omits autoinvoke when it defaults to true
|
|
const actual = found!.autoinvoke ?? true
|
|
assert(
|
|
actual === entry.autoinvoke,
|
|
`skill '${entry.id}' autoinvoke=${actual}, want ${entry.autoinvoke}`,
|
|
)
|
|
}
|
|
console.log(
|
|
`ok ${manifest.length} skills registered (${manifest.filter((m) => !m.autoinvoke).length} hidden from model)`,
|
|
)
|
|
|
|
// Agents discovered with correct modes and the worker's permission decoding intact
|
|
const agentBody = (await get("/agent")) as {
|
|
data: {
|
|
id: string
|
|
mode: string
|
|
permissions: { action: string; resource: string; effect: string }[]
|
|
}[]
|
|
}
|
|
const agents = new Map(agentBody.data.map((a) => [a.id, a]))
|
|
assert(agents.get("pocock")?.mode === "primary", "pocock is not primary")
|
|
assert(agents.get("pocock-reviewer")?.mode === "subagent", "pocock-reviewer is not subagent")
|
|
const worker = agents.get("pocock-worker")
|
|
assert(worker?.mode === "subagent", "pocock-worker is not subagent")
|
|
const rules = worker!.permissions
|
|
const has = (action: string, resource: string, effect: string) =>
|
|
rules.some((r) => r.action === action && r.resource === resource && r.effect === effect)
|
|
assert(has("skill", "*", "deny"), "worker skill deny * missing")
|
|
assert(has("skill", "tdd", "allow"), "worker tdd allow missing")
|
|
assert(has("shell", "git push*", "deny"), "worker git push deny missing")
|
|
assert(has("subagent", "*", "deny"), "worker subagent deny missing")
|
|
console.log("ok agents pocock/pocock-worker/pocock-reviewer with permissions decoded")
|
|
|
|
console.log("smoke PASS")
|
|
} finally {
|
|
// shell:true on Windows means kill() only reaches cmd.exe; kill the whole tree.
|
|
if (server?.pid) {
|
|
if (process.platform === "win32")
|
|
spawnSync("taskkill", ["/PID", String(server.pid), "/T", "/F"])
|
|
else server.kill()
|
|
}
|
|
// Windows holds locks briefly after kill: retry, and never let cleanup mask the real error.
|
|
try {
|
|
if (process.argv.includes("--keep")) console.log(`fixture kept at ${fixture}`)
|
|
else rmSync(fixture, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 })
|
|
} catch (cleanupErr) {
|
|
console.log(`warning: could not remove fixture ${fixture}: ${(cleanupErr as Error).message}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
main()
|
|
.then(() => process.exit(0))
|
|
.catch((err) => {
|
|
console.error(err.message)
|
|
process.exit(1)
|
|
})
|
|
}
|