feat: runtime smoke test, gitea actions workflows, runtime-checks record
check / check (push) Successful in 49s

This commit is contained in:
haylan committed 2026-10-14 12:34:57 +02:00
1 parent d301ef5a62
commit c92fe4db73
15 files changed
+847 -137

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
// 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)
})
}