diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aee6947..72d1b6e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,4 +13,17 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm -r run build - run: pnpm -r run typecheck - - run: pnpm --filter @amicode/amico-run test # includes the S31 grep-rule test + - run: pnpm -r run test # amico-run suite (incl. S31 grep rule) + extension unit suite + boot-smoke: + strategy: + matrix: + os: [ubuntu-latest, macos-14] # linux-x64 + darwin-arm64 — both pins machine-gated (spec §5) + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: { node-version: 20, cache: pnpm } + - run: pnpm install --frozen-lockfile + - run: pnpm --filter amicode-v2 fetch:opencode + - run: pnpm --filter amicode-v2 test:smoke diff --git a/.gitignore b/.gitignore index 4fe02caf..4992a94e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ *.vsix .vscode-test/ *.log +packages/extension/vendor/ diff --git a/packages/extension/.vscodeignore b/packages/extension/.vscodeignore new file mode 100644 index 00000000..5d5d6c65 --- /dev/null +++ b/packages/extension/.vscodeignore @@ -0,0 +1,11 @@ +# This file's EXISTENCE stops vsce falling back to .gitignore (which excludes vendor/). +# The explicit negation keeps the vendored opencode shipping even if a future +# exclusion pattern would otherwise swallow it — that's the trap β.2 exists to kill. +!vendor/** +src/** +test/** +scripts/** +node_modules/** +esbuild.config.mjs +tsconfig.json +**/*.map diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json new file mode 100644 index 00000000..aeb4282b --- /dev/null +++ b/packages/extension/opencode.lock.json @@ -0,0 +1,7 @@ +{ + "version": "1.17.3", + "platforms": { + "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", "sha256": "b49948f96d8e92c577d54854e2f038389d03c3dfbbeacc44643b73123210fd13" }, + "linux-x64": { "asset": "opencode-linux-x64.tar.gz", "sha256": "d4bd238a2c1ff56aca1cd3397d21a0a317f5992234517a7f8e2afbbd72010a7d" } + } +} diff --git a/packages/extension/package.json b/packages/extension/package.json index 516206b0..79064581 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -55,8 +55,8 @@ "properties": { "amicode.opencodeBinary": { "type": "string", - "default": "opencode", - "description": "Path or command name for the opencode CLI." + "default": "", + "description": "Override path to the opencode CLI (dev only). Empty = use the vendored binary." }, "amicode.juliaProject": { "type": "string", @@ -75,13 +75,16 @@ "build": "node esbuild.config.mjs", "watch": "node esbuild.config.mjs --watch", "typecheck": "tsc --noEmit", - "test": "echo 'extension: spike smoke tests in test/ need opencode+julia (not headless); skipped'" + "test": "vitest run --passWithNoTests", + "test:smoke": "node test/boot_smoke.mjs", + "fetch:opencode": "node scripts/fetch_opencode.mjs" }, "devDependencies": { "@amicode/amico-run": "workspace:*", "@types/node": "^22.0.0", "@types/vscode": "^1.95.0", "esbuild": "^0.24.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^2.1.0" } } diff --git a/packages/extension/scripts/fetch_opencode.mjs b/packages/extension/scripts/fetch_opencode.mjs new file mode 100644 index 00000000..c0ae9aab --- /dev/null +++ b/packages/extension/scripts/fetch_opencode.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// Download-at-build vendoring of the opencode chat-server binary, pinned by +// opencode.lock.json (spec §2/§3). Importable module + CLI in one file. +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { + chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, + renameSync, rmSync, writeFileSync, +} from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') + +export function loadManifest(root = PKG_ROOT) { + const m = JSON.parse(readFileSync(join(root, 'opencode.lock.json'), 'utf8')) + if (typeof m.version !== 'string' || m.version === '') throw new Error('manifest: version must be a non-empty string') + const platforms = m.platforms ?? {} + if (Object.keys(platforms).length === 0) throw new Error('manifest: platforms missing') + for (const [key, p] of Object.entries(platforms)) { + if (typeof p.asset !== 'string' || p.asset === '') throw new Error(`manifest: ${key}.asset missing`) + if (!/^[0-9a-f]{64}$/.test(p.sha256 ?? '')) throw new Error(`manifest: ${key}.sha256 must be 64 hex chars`) + } + return m +} + +export function resolvePlatform(manifest, flag) { + const key = flag ?? `${process.platform}-${process.arch}` + if (!(key in manifest.platforms)) { + throw new Error(`platform ${key} not supported (supported: ${Object.keys(manifest.platforms).join(', ')})`) + } + return key +} + +export function assetUrl(manifest, platform) { + return `https://github.com/sst/opencode/releases/download/v${manifest.version}/${manifest.platforms[platform].asset}` +} + +export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex') + +async function defaultDownload(url) { + let r + try { r = await fetch(url) } catch (e) { + throw new Error(`download failed: ${e.message} for ${url}`) // spec §6: URL on connection failures too + } + if (!r.ok) throw new Error(`download failed: HTTP ${r.status} for ${url}`) + return Buffer.from(await r.arrayBuffer()) +} + +export async function fetchOpencode({ root = PKG_ROOT, platform, download = defaultDownload } = {}) { + const manifest = loadManifest(root) + const key = resolvePlatform(manifest, platform) + const { asset, sha256: want } = manifest.platforms[key] + const destDir = join(root, 'vendor', 'opencode', key) + const bin = join(destDir, 'opencode') + const stamp = join(destDir, '.sha256') + + if (existsSync(bin) && existsSync(stamp) && readFileSync(stamp, 'utf8').trim() === want) { + return { skipped: true, path: bin } // offline repeat builds + } + + const bytes = await download(assetUrl(manifest, key)) + const got = sha256(bytes) + if (got !== want) { + // Possible supply-chain signal: no retry, no override (spec §3 step 4). + throw new Error(`SHA256 mismatch for ${asset}: expected ${want}, actual ${got}`) + } + + mkdirSync(destDir, { recursive: true }) + const work = mkdtempSync(join(destDir, '.unpack-')) // same fs → rename is atomic + try { + const archive = join(work, asset) + writeFileSync(archive, bytes) + if (asset.endsWith('.zip')) execFileSync('unzip', ['-oq', archive, '-d', work]) + else execFileSync('tar', ['-xzf', archive, '-C', work]) + if (!existsSync(join(work, 'opencode'))) throw new Error(`archive ${asset} did not contain a flat 'opencode' binary`) + renameSync(join(work, 'opencode'), bin) + chmodSync(bin, 0o755) + writeFileSync(stamp, got + '\n') // stamp last (spec §3 step 5) + } finally { + rmSync(work, { recursive: true, force: true }) + } + return { skipped: false, path: bin } +} + +async function main(argv) { + const flagIdx = argv.indexOf('--platform') + const platform = flagIdx >= 0 ? argv[flagIdx + 1] : undefined + if (argv.includes('--record')) { // pin-time only (spec §3 step 6) + const manifest = loadManifest() + for (const key of Object.keys(manifest.platforms)) { + const bytes = await defaultDownload(assetUrl(manifest, key)) + console.log(`${key} ${sha256(bytes)}`) + } + return 0 + } + const r = await fetchOpencode({ platform }) + console.log(r.skipped ? `[fetch-opencode] up to date: ${r.path}` : `[fetch-opencode] installed: ${r.path}`) + return 0 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).then(c => { process.exitCode = c }, e => { console.error(`[fetch-opencode] ${e.message}`); process.exitCode = 1 }) +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 84746f12..25e7ce46 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import * as path from "node:path"; import * as fs from "node:fs"; import { ServerManager } from "./server_manager"; +import { resolveOpencodeBinary, OpencodeMissingError } from "./opencode_binary"; import { ChatPanel } from "./chat_panel"; import { registerRunInspector } from "./run_inspector"; import { registerTrees } from "./trees"; @@ -53,39 +54,60 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeChannel.appendLine(`[boot] opencode project dir: ${opencodeProject.projectDir}`); opencodeChannel.appendLine(`[boot] AGENTS.md: ${opencodeProject.agentsPath}`); - // 4. Spawn opencode. PATH=binDir:$PATH so `amico-run` resolves; env vars - // also tell amico-run where to find julia+script in case the user has - // multiple checkouts. - const binary = vscode.workspace.getConfiguration("amicode").get("opencodeBinary", "opencode"); - const juliaScript = resolveJuliaScript(ctx); - const juliaProject = resolveJuliaProject(); - serverManager = new ServerManager({ - binary, - cwd: opencodeProject.projectDir, - env: { - PATH: `${binDir}:${process.env.PATH ?? ""}`, - AMICO_JULIA_SCRIPT: juliaScript, - AMICO_JULIA_PROJECT: juliaProject, - AMICO_RUNS_ROOT: runsRoot, - }, - channel: opencodeChannel, - }); - ctx.subscriptions.push({ dispose: () => serverManager?.stop() }); - - // SSE event channel — opens once opencode is healthy. - sseClient = new OpencodeEventClient({ channel: opencodeChannel, statusBar }); - ctx.subscriptions.push(sseClient); - - serverManager.onReady((url) => { - opencodeReadyUrl = url; - statusBar?.setServerReady(true); - sseClient?.connect(url); - }); - - serverManager.start().catch((err) => { - vscode.window.showErrorMessage(`Amicode: opencode failed to start — ${err.message}`); - opencodeChannel.appendLine(`[boot] start failed: ${err.stack ?? err.message}`); - }); + // 4. Spawn opencode — the VENDORED binary by default (spec §4; S35, kills + // Assumption 4). Config override is a dev-only escape hatch. On a missing + // binary, chat is disabled but the rest of the extension (inspector, + // watcher, commands) still activates. + let binary: string | undefined; + try { + const resolved = resolveOpencodeBinary( + ctx.extensionPath, + vscode.workspace.getConfiguration("amicode").get("opencodeBinary", ""), + ); + binary = resolved.path; + opencodeChannel.appendLine( + resolved.source === "config-override" + ? `[boot] OVERRIDE: amicode.opencodeBinary = ${binary}` + : `[boot] vendored opencode: ${binary}`, + ); + } catch (e) { + if (e instanceof OpencodeMissingError) { + opencodeChannel.appendLine(`[boot] ${e.message} — chat disabled`); + void vscode.window.showErrorMessage(`Amicode: ${e.message}`); + } else throw e; + } + + if (binary !== undefined) { + const juliaScript = resolveJuliaScript(ctx); + const juliaProject = resolveJuliaProject(); + serverManager = new ServerManager({ + binary, + cwd: opencodeProject.projectDir, + env: { + PATH: `${binDir}:${process.env.PATH ?? ""}`, + AMICO_JULIA_SCRIPT: juliaScript, + AMICO_JULIA_PROJECT: juliaProject, + AMICO_RUNS_ROOT: runsRoot, + }, + channel: opencodeChannel, + }); + ctx.subscriptions.push({ dispose: () => serverManager?.stop() }); + + // SSE event channel — opens once opencode is healthy. + sseClient = new OpencodeEventClient({ channel: opencodeChannel, statusBar }); + ctx.subscriptions.push(sseClient); + + serverManager.onReady((url) => { + opencodeReadyUrl = url; + statusBar?.setServerReady(true); + sseClient?.connect(url); + }); + + serverManager.start().catch((err) => { + vscode.window.showErrorMessage(`Amicode: opencode failed to start — ${err.message}`); + opencodeChannel.appendLine(`[boot] start failed: ${err.stack ?? err.message}`); + }); + } // 5. Commands ctx.subscriptions.push( diff --git a/packages/extension/src/opencode_binary.ts b/packages/extension/src/opencode_binary.ts new file mode 100644 index 00000000..126949a0 --- /dev/null +++ b/packages/extension/src/opencode_binary.ts @@ -0,0 +1,28 @@ +import { accessSync, constants } from "node:fs"; +import { join } from "node:path"; + +export class OpencodeMissingError extends Error {} + +export interface ResolvedBinary { path: string; source: "config-override" | "vendored" } + +const SUPPORTED = ["darwin-arm64", "linux-x64"] as const; + +/** Spec §4: config override → vendored → hard error. NO $PATH fallback (Assumption 4 stays dead). */ +export function resolveOpencodeBinary(extensionRoot: string, configValue: string): ResolvedBinary { + const override = (configValue ?? "").trim(); + if (override !== "") return { path: override, source: "config-override" }; + + const key = `${process.platform}-${process.arch}`; + if (!(SUPPORTED as readonly string[]).includes(key)) { + throw new OpencodeMissingError(`platform ${key} not supported (supported: ${SUPPORTED.join(", ")})`); + } + const vendored = join(extensionRoot, "vendor", "opencode", key, "opencode"); + try { + accessSync(vendored, constants.X_OK); + } catch { + throw new OpencodeMissingError( + `vendored opencode missing at ${vendored} — run \`pnpm --filter amicode-v2 fetch:opencode\` (dev) or reinstall the extension`, + ); + } + return { path: vendored, source: "vendored" }; +} diff --git a/packages/extension/test/boot_smoke.mjs b/packages/extension/test/boot_smoke.mjs new file mode 100644 index 00000000..4fd7447d --- /dev/null +++ b/packages/extension/test/boot_smoke.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// β.2 boot-smoke gate (spec §5): the VENDORED opencode binary must serve +// GET /event as an SSE stream (HTTP 200, text/event-stream) against a +// synthesized project, with no LLM creds. Exit 0 = pass. +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const key = `${process.platform}-${process.arch}` +const bin = join(PKG_ROOT, 'vendor', 'opencode', key, 'opencode') + +const fail = (msg, code = 1) => { console.error(`[smoke] FAIL: ${msg}`); process.exit(code) } + +if (!existsSync(bin)) fail(`vendored binary missing at ${bin} — run \`pnpm --filter amicode-v2 fetch:opencode\``, 10) + +// Synthesize a minimal opencode project (inline port of the spike's prepareOpencodeProject). +const proj = mkdtempSync(join(tmpdir(), 'amicode-smoke-')) +mkdirSync(join(proj, '.opencode'), { recursive: true }) +writeFileSync(join(proj, 'AGENTS.md'), '# Amicode boot smoke\n') +writeFileSync(join(proj, '.opencode', 'opencode.json'), + JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2)) + +const port = await new Promise((res, rej) => { + const srv = createServer() + srv.listen(0, '127.0.0.1', () => { const p = srv.address().port; srv.close(() => res(p)) }) + srv.on('error', rej) +}) + +console.log(`[smoke] spawning ${bin} serve --port ${port}`) +let done = false +let serverLog = '' +const child = spawn(bin, ['serve', '--port', String(port)], { cwd: proj, stdio: ['ignore', 'pipe', 'pipe'] }) +child.on('error', e => fail(`spawn failed: ${e.message}`)) +child.stdout.on('data', d => { serverLog += d }) +child.stderr.on('data', d => { serverLog += d }) +child.on('exit', (c, s) => { if (!done) fail(`opencode exited early (code=${c} signal=${s})\n--- server output ---\n${serverLog}`) }) + +// Poll until the server answers, 30s cap. +const deadline = Date.now() + 30_000 +let up = false +while (Date.now() < deadline && !up) { + try { + const r = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) }) + if (r.status < 500) up = true + } catch { /* not up yet */ } + if (!up) await new Promise(r => setTimeout(r, 200)) +} +if (!up) { done = true; child.kill('SIGKILL'); fail(`server not up within 30s\n--- server output ---\n${serverLog}`) } + +// THE gate: /event answers 200 with an SSE content-type. fetch resolves on +// headers (the SSE body streaming doesn't block us); 10s cap so a header-less +// hang fails fast instead of eating the CI job timeout. +const ctrl = new AbortController() +const gateTimer = setTimeout(() => ctrl.abort(), 10_000) +const ev = await fetch(`http://127.0.0.1:${port}/event`, { signal: ctrl.signal }) +clearTimeout(gateTimer) +const ctype = ev.headers.get('content-type') ?? '' +console.log(`[smoke] GET /event → ${ev.status} (${ctype})`) +if (ev.status !== 200) { done = true; child.kill('SIGKILL'); fail(`/event status ${ev.status}, want 200`) } +if (!ctype.includes('text/event-stream')) { done = true; child.kill('SIGKILL'); fail(`/event content-type "${ctype}", want text/event-stream`) } +ctrl.abort() + +// Clean shutdown: SIGTERM, exit within 5s. +done = true +const exited = new Promise(res => child.on('exit', res)) +child.kill('SIGTERM') +const t = setTimeout(() => child.kill('SIGKILL'), 5_000) +await exited +clearTimeout(t) +console.log('[smoke] PASS') diff --git a/packages/extension/test/fetch_opencode.test.ts b/packages/extension/test/fetch_opencode.test.ts new file mode 100644 index 00000000..2ce32ba3 --- /dev/null +++ b/packages/extension/test/fetch_opencode.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest' +import { execFileSync } from 'node:child_process' +import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fetchOpencode, loadManifest, resolvePlatform, sha256 } from '../scripts/fetch_opencode.mjs' + +function rootWith(manifest: unknown): string { + const root = mkdtempSync(join(tmpdir(), 'oc-test-')) + writeFileSync(join(root, 'opencode.lock.json'), JSON.stringify(manifest)) + return root +} + +const GOOD = { + version: '1.17.3', + platforms: { + 'darwin-arm64': { asset: 'a.zip', sha256: 'ab'.repeat(32) }, + 'linux-x64': { asset: 'a.tar.gz', sha256: 'cd'.repeat(32) }, + }, +} + +describe('loadManifest', () => { + it('accepts a well-formed manifest', () => { + expect(loadManifest(rootWith(GOOD)).version).toBe('1.17.3') + }) + it('the COMMITTED manifest parses and pins exactly the two supported platforms', () => { + const m = loadManifest() // defaults to the real packages/extension root + expect(Object.keys(m.platforms).sort()).toEqual(['darwin-arm64', 'linux-x64']) + }) + it('rejects missing version and short hashes', () => { + expect(() => loadManifest(rootWith({ ...GOOD, version: '' }))).toThrow(/version/) + expect(() => loadManifest(rootWith({ + ...GOOD, platforms: { ...GOOD.platforms, 'linux-x64': { asset: 'a', sha256: 'beef' } }, + }))).toThrow(/sha256/) + }) +}) + +describe('resolvePlatform', () => { + it('honors an explicit valid key and rejects unknown ones', () => { + expect(resolvePlatform(GOOD, 'linux-x64')).toBe('linux-x64') + expect(() => resolvePlatform(GOOD, 'windows-x64')).toThrow(/supported/) + }) + it('detects the current machine when no flag given', () => { + const key = `${process.platform}-${process.arch}` + if (key in GOOD.platforms) expect(resolvePlatform(GOOD)).toBe(key) + else expect(() => resolvePlatform(GOOD)).toThrow(/supported/) + }) +}) + +function fixtureArchive(): { bytes: Buffer; hash: string } { + const dir = mkdtempSync(join(tmpdir(), 'oc-fixture-')) + writeFileSync(join(dir, 'opencode'), '#!/bin/sh\necho fake-opencode\n') + chmodSync(join(dir, 'opencode'), 0o755) + execFileSync('tar', ['-czf', join(dir, 'a.tar.gz'), '-C', dir, 'opencode']) + const bytes = readFileSync(join(dir, 'a.tar.gz')) + return { bytes, hash: sha256(bytes) } +} + +describe('fetchOpencode', () => { + it('downloads, verifies, unpacks, stamps — then skips on re-run', async () => { + const { bytes, hash } = fixtureArchive() + const root = rootWith({ version: '9.9.9', platforms: { 'linux-x64': { asset: 'a.tar.gz', sha256: hash } } }) + let calls = 0 + const download = async () => { calls++; return bytes } + const r1 = await fetchOpencode({ root, platform: 'linux-x64', download }) + expect(r1.skipped).toBe(false) + const bin = join(root, 'vendor', 'opencode', 'linux-x64', 'opencode') + expect(existsSync(bin)).toBe(true) + expect(readFileSync(join(root, 'vendor', 'opencode', 'linux-x64', '.sha256'), 'utf8').trim()).toBe(hash) + const r2 = await fetchOpencode({ root, platform: 'linux-x64', download }) + expect(r2.skipped).toBe(true) + expect(calls).toBe(1) // idempotent: no second download + }) + it('hard-fails on hash mismatch, printing expected vs actual, installing nothing', async () => { + const { bytes } = fixtureArchive() + const root = rootWith({ version: '9.9.9', platforms: { 'linux-x64': { asset: 'a.tar.gz', sha256: 'ee'.repeat(32) } } }) + await expect(fetchOpencode({ root, platform: 'linux-x64', download: async () => bytes })) + .rejects.toThrow(/expected ee.*actual/s) + expect(existsSync(join(root, 'vendor', 'opencode', 'linux-x64', 'opencode'))).toBe(false) + }) +}) diff --git a/packages/extension/test/opencode_binary.test.ts b/packages/extension/test/opencode_binary.test.ts new file mode 100644 index 00000000..fe3a3af9 --- /dev/null +++ b/packages/extension/test/opencode_binary.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resolveOpencodeBinary, OpencodeMissingError } from '../src/opencode_binary' + +const platformKey = `${process.platform}-${process.arch}` + +function rootWithVendored(): string { + const root = mkdtempSync(join(tmpdir(), 'ocbin-')) + const dir = join(root, 'vendor', 'opencode', platformKey) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'opencode'), '#!/bin/sh\n') + chmodSync(join(dir, 'opencode'), 0o755) + return root +} + +describe('resolveOpencodeBinary', () => { + it('config override wins, verbatim', () => { + expect(resolveOpencodeBinary(rootWithVendored(), '/custom/opencode')) + .toEqual({ path: '/custom/opencode', source: 'config-override' }) + }) + it('falls through to the vendored binary when config is empty', () => { + const root = rootWithVendored() + const r = resolveOpencodeBinary(root, '') + expect(r.source).toBe('vendored') + expect(r.path).toBe(join(root, 'vendor', 'opencode', platformKey, 'opencode')) + }) + it('missing vendored binary → actionable hard error, never $PATH', () => { + const empty = mkdtempSync(join(tmpdir(), 'ocbin-empty-')) + expect(() => resolveOpencodeBinary(empty, '')).toThrow(OpencodeMissingError) + expect(() => resolveOpencodeBinary(empty, '')).toThrow(/fetch:opencode|reinstall/) + }) +}) diff --git a/packages/extension/test/opencode_boot_harness.mjs b/packages/extension/test/opencode_boot_harness.mjs deleted file mode 100644 index 8869b5e6..00000000 --- a/packages/extension/test/opencode_boot_harness.mjs +++ /dev/null @@ -1,17 +0,0 @@ -// Small standalone harness that invokes prepareOpencodeProject and prints -// the resulting project dir on stdout. - -import * as path from "node:path"; -import { prepareOpencodeProject } from "../src/opencode_config.ts"; - -// bundle rebases import.meta.url to /tmp; harness gets repo via env. -const repo = process.env.AMICODE_V2_REPO; -if (!repo) { process.stderr.write("AMICODE_V2_REPO unset\n"); process.exit(2); } - -const { projectDir, agentsPath } = prepareOpencodeProject({ - binDir: path.resolve(repo, "bin"), - agentsSrc: path.resolve(repo, "AGENTS.md"), -}); - -process.stdout.write(`PROJECT=${projectDir}\n`); -process.stderr.write(`agents=${agentsPath}\n`); diff --git a/packages/extension/test/smoke_cli.mjs b/packages/extension/test/smoke_cli.mjs deleted file mode 100644 index 251b3aa5..00000000 --- a/packages/extension/test/smoke_cli.mjs +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -// CLI-direct smoke test: invokes bin/amico-run as opencode/the LLM would -// (via bash), asserts the run dir lifecycle matches what the extension's -// RunsRootWatcher will observe. -// -// Asserts: -// - exit code 0 -// - /tmp/amicode-runs/latest symlink points at the new run dir -// - run dir contains: .start, iter_NNNN.png (≥2), result.toml, FINISHED -// - result.toml fidelity is > 0.99 -// -// Usage: node test/smoke_cli.mjs - -import * as cp from "node:child_process"; -import * as path from "node:path"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const repo = path.resolve(here, ".."); -const amicoRun = path.join(repo, "bin", "amico-run"); - -if (!fs.existsSync(amicoRun)) { - console.error("[cli] FAIL: amico-run missing at", amicoRun); - process.exit(10); -} - -// Use an isolated runs root so we don't clobber whatever the user has. -const runsRoot = fs.mkdtempSync(path.join(os.tmpdir(), "amicode-smoke-cli-")); -console.log(`[cli] runs root: ${runsRoot}`); - -const before = Date.now(); -const child = cp.spawnSync( - amicoRun, - ["--system", "qubit", "--gate", "X", "--pulse", "zero-order", "--max-iter", "50"], - { - cwd: repo, - encoding: "utf8", - env: { ...process.env, AMICO_RUNS_ROOT: runsRoot }, - stdio: ["ignore", "pipe", "pipe"], - timeout: 180_000, - }, -); -const elapsed = ((Date.now() - before) / 1000).toFixed(1); - -if (child.status !== 0) { - console.error(`[cli] FAIL: amico-run exited ${child.status} in ${elapsed}s`); - console.error("[cli] stderr tail:", (child.stderr ?? "").split("\n").slice(-15).join("\n")); - process.exit(11); -} -console.log(`[cli] OK: amico-run exited 0 in ${elapsed}s`); - -const latest = path.join(runsRoot, "latest"); -if (!fs.existsSync(latest)) { console.error("[cli] FAIL: latest symlink missing"); process.exit(12); } -const target = fs.realpathSync(latest); -console.log(`[cli] OK: latest → ${target}`); - -const want = [".start", "result.toml", "FINISHED"]; -for (const f of want) { - if (!fs.existsSync(path.join(target, f))) { - console.error(`[cli] FAIL: missing ${f} in ${target}`); - process.exit(13); - } - console.log(`[cli] OK: ${f} present`); -} - -const iterPngs = fs.readdirSync(target).filter((f) => /^iter_\d{4}\.png$/.test(f)).sort(); -if (iterPngs.length < 2) { - console.error(`[cli] FAIL: expected >=2 iter PNGs, got ${iterPngs.length}`); - process.exit(14); -} -console.log(`[cli] OK: ${iterPngs.length} iter PNGs (first=${iterPngs[0]}, last=${iterPngs[iterPngs.length-1]})`); - -const result = fs.readFileSync(path.join(target, "result.toml"), "utf8"); -const fid = parseFloat((result.match(/fidelity\s*=\s*([\d.eE+-]+)/) ?? [])[1] ?? ""); -if (!Number.isFinite(fid) || fid < 0.99) { - console.error(`[cli] FAIL: fidelity ${fid} below 0.99`); - process.exit(15); -} -console.log(`[cli] OK: F=${fid.toFixed(6)}`); - -// Sanity: last line of stdout should contain DONE summary. -const stdout = child.stdout ?? ""; -const doneLine = stdout.split("\n").reverse().find((l) => l.includes("amico-run: DONE")); -if (!doneLine) { - console.error("[cli] FAIL: no 'amico-run: DONE' in stdout"); - console.error("[cli] stdout tail:", stdout.split("\n").slice(-5).join("\n")); - process.exit(16); -} -console.log(`[cli] OK: ${doneLine.trim()}`); - -console.log("[cli] ALL GREEN"); diff --git a/packages/extension/test/smoke_opencode_boot.mjs b/packages/extension/test/smoke_opencode_boot.mjs deleted file mode 100644 index c46312f5..00000000 --- a/packages/extension/test/smoke_opencode_boot.mjs +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env node -// Smoke test: synthesize an opencode project dir via prepareOpencodeProject, -// boot opencode serve against it, confirm /event SSE returns 200 OK and the -// amico MCP shows up in the registered tools listing (via /config or /mcp -// endpoints — opencode 1.3.3 exposes these). -// -// Usage: node test/smoke_opencode_boot.mjs - -import * as cp from "node:child_process"; -import * as path from "node:path"; -import * as fs from "node:fs"; -import * as net from "node:net"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const repo = path.resolve(here, ".."); - -// Bundle prepareOpencodeProject standalone. -const harnessOut = path.join(os.tmpdir(), "amicode-smoke-bootharness.mjs"); -const buildResult = cp.spawnSync( - "npx", - [ - "esbuild", - path.join(here, "opencode_boot_harness.mjs"), - "--bundle", - "--format=esm", - "--platform=node", - `--outfile=${harnessOut}`, - ], - { encoding: "utf8" }, -); -if (buildResult.status !== 0) { - console.error("[smoke] bundle failed", buildResult.stdout, buildResult.stderr); - process.exit(10); -} - -// Run the harness — it prints PROJECT= on stdout. -const harnessChild = cp.spawnSync("node", [harnessOut], { - encoding: "utf8", - env: { ...process.env, AMICODE_V2_REPO: repo }, -}); -if (harnessChild.status !== 0) { - console.error("[smoke] harness failed:", harnessChild.stderr); - process.exit(11); -} -const projectDir = harnessChild.stdout.match(/PROJECT=(.+)/)?.[1]?.trim(); -if (!projectDir) { console.error("[smoke] no PROJECT in harness output:", harnessChild.stdout); process.exit(12); } -console.log(`[smoke] project dir: ${projectDir}`); - -// Confirm config landed. -const configPath = path.join(projectDir, ".opencode", "opencode.json"); -if (!fs.existsSync(configPath)) { - console.error("[smoke] FAIL: config.json missing at " + configPath); - process.exit(13); -} -console.log("[smoke] OK: config.json written"); - -// v2 CLI-direct: no MCP, no plugin. Just AGENTS.md. -const agentsPath = path.join(projectDir, "AGENTS.md"); -if (!fs.existsSync(agentsPath)) { - console.error("[smoke] FAIL: AGENTS.md missing at " + agentsPath); - process.exit(15); -} -const agentsTxt = fs.readFileSync(agentsPath, "utf8"); -if (!/amico-run/.test(agentsTxt)) { - console.error("[smoke] FAIL: AGENTS.md does not mention amico-run"); - process.exit(16); -} -console.log("[smoke] OK: AGENTS.md present and references amico-run"); - -// Pick a free port for opencode. -const port = await new Promise((resolve, reject) => { - const s = net.createServer(); - s.listen(0, "127.0.0.1", () => { - const p = s.address().port; - s.close(() => resolve(p)); - }); - s.on("error", reject); -}); - -console.log(`[smoke] booting opencode serve --port=${port} in ${projectDir}`); -const oc = cp.spawn("opencode", ["serve", "--port", String(port)], { - cwd: projectDir, - stdio: ["ignore", "pipe", "pipe"], -}); - -let log = ""; -oc.stdout.on("data", (b) => { const s = b.toString(); log += s; process.stdout.write(`[opencode] ${s}`); }); -oc.stderr.on("data", (b) => { const s = b.toString(); log += s; process.stderr.write(`[opencode!] ${s}`); }); - -// Wait for opencode to become healthy. -async function waitHealthy(timeoutMs) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const r = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) }); - if (r.ok || (r.status >= 200 && r.status < 400)) return true; - } catch {} - await new Promise((r) => setTimeout(r, 200)); - } - return false; -} - -let exitCode = 0; -const fails = []; - -const healthy = await waitHealthy(15000); -if (!healthy) { - fails.push("opencode failed to become healthy within 15s"); - exitCode = 1; -} else { - console.log("[smoke] OK: opencode is healthy"); -} - -if (healthy) { - // Try /event with a short read. - try { - const ctrl = new AbortController(); - const resp = await fetch(`http://127.0.0.1:${port}/event`, { - headers: { Accept: "text/event-stream" }, - signal: ctrl.signal, - }); - if (resp.status !== 200) { - fails.push(`/event returned ${resp.status}`); - exitCode = 1; - } else { - console.log("[smoke] OK: /event returns 200 (SSE handshake)"); - } - setTimeout(() => ctrl.abort(), 200); - try { for await (const _ of resp.body) { break; } } catch {} - } catch (e) { - fails.push(`/event probe failed: ${e.message}`); - exitCode = 1; - } - - // Probe a few likely "what tools are loaded" endpoints in opencode 1.3.3. - // Result is informational — opencode's HTTP surface isn't fully stable. - for (const ep of ["/config", "/mcp", "/tool", "/tools"]) { - try { - const r = await fetch(`http://127.0.0.1:${port}${ep}`, { signal: AbortSignal.timeout(500) }); - const text = r.status === 200 ? (await r.text()).slice(0, 200) : ""; - console.log(`[smoke] info: ${ep} → ${r.status} ${text ? "→ " + text : ""}`); - } catch { /* endpoint not present */ } - } -} - -// Inspect log for amico-mcp/plugin loaded markers. -if (/amico/i.test(log)) { - console.log("[smoke] OK: opencode log mentions 'amico' (mcp or plugin loaded)"); -} else { - console.log("[smoke] info: opencode log does not yet reference amico (may load lazily)"); -} - -if (fails.length > 0) { - console.error("[smoke] FAILURES:"); - fails.forEach((f) => console.error(" - " + f)); -} - -oc.kill("SIGTERM"); -setTimeout(() => oc.kill("SIGKILL"), 2000); -process.exit(exitCode); diff --git a/packages/extension/test/vscode_shim.mjs b/packages/extension/test/vscode_shim.mjs deleted file mode 100644 index 9e00e535..00000000 --- a/packages/extension/test/vscode_shim.mjs +++ /dev/null @@ -1,38 +0,0 @@ -// Minimal vscode API surface used by callback_server.ts / run_inspector.ts / -// file_watcher.ts. Used in isolation smoke tests only. - -const calls = []; -export const __calls = calls; - -const log = (kind, args) => calls.push({ kind, args }); - -export const window = { - showInformationMessage: (...a) => { log("info", a); return Promise.resolve(undefined); }, - showWarningMessage: (...a) => { log("warn", a); return Promise.resolve(undefined); }, - showErrorMessage: (...a) => { log("error", a); return Promise.resolve(undefined); }, - showQuickPick: (items, opts) => { log("quickPick", [items, opts]); return Promise.resolve(items[0]); }, - showTextDocument: (uri, opts) => { log("openText", [uri, opts]); return Promise.resolve({}); }, - createOutputChannel: (name) => ({ - appendLine: (s) => log("channel", [name, s]), - append: (s) => log("channel", [name, s]), - dispose: () => {}, - }), -}; - -export const commands = { - executeCommand: (...a) => { log("cmd", a); return Promise.resolve(undefined); }, -}; - -export const Uri = { - file: (p) => ({ fsPath: p, toString: () => "file://" + p }), -}; - -export const ViewColumn = { Active: 1, Beside: 2 }; - -export class EventEmitter { - constructor() { this.listeners = []; } - get event() { return (l) => { this.listeners.push(l); return { dispose: () => {} }; }; } - fire(v) { for (const l of this.listeners) try { l(v); } catch {} } -} - -export default { window, commands, Uri, ViewColumn, EventEmitter }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa0a6a85..806c9a64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: typescript: specifier: ^5.6.0 version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.19.19) packages: