From 265b9a5228a9c4eaa4a8ac9a6faa845b5470a45a Mon Sep 17 00:00:00 2001 From: sven1103-agent <261423644+sven1103-agent@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:23:01 +0200 Subject: [PATCH 1/3] test: gate releases with opencode smoke test --- .github/workflows/publish.yml | 11 ++ .github/workflows/test.yml | 8 ++ package.json | 4 +- test/integration/opencode-smoke.test.js | 171 ++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 test/integration/opencode-smoke.test.js diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index adbd384..17799d5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -51,6 +51,17 @@ jobs: - name: Install dependencies run: npm install + - name: Run tests + run: npm test + + - name: Install OpenCode CLI + run: npm install -g opencode-linux-x64@1.3.7 + + - name: Run smoke test + env: + OPENCODE_BIN: opencode + run: npm run test:smoke + - name: Pack dry run run: npm pack --dry-run diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ed7663c..791d69a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,3 +21,11 @@ jobs: - name: Run tests run: npm test + + - name: Install OpenCode CLI + run: npm install -g opencode-linux-x64@1.3.7 + + - name: Run smoke test + env: + OPENCODE_BIN: opencode + run: npm run test:smoke diff --git a/package.json b/package.json index 3b90741..d44eded 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ "access": "public" }, "scripts": { - "test": "node --test" + "test": "node --test", + "test:smoke": "node --test test/integration/*.test.js", + "test:smoke:published": "node --test test/integration/*.test.js" }, "dependencies": { "@opencode-ai/plugin": "1.3.0", diff --git a/test/integration/opencode-smoke.test.js b/test/integration/opencode-smoke.test.js new file mode 100644 index 0000000..b50d2a4 --- /dev/null +++ b/test/integration/opencode-smoke.test.js @@ -0,0 +1,171 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import http from "node:http"; +import { spawn } from "node:child_process"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const OPENCODE_BIN = process.env.OPENCODE_BIN || "opencode"; +const PUBLISHED_PLUGIN_SPEC = process.env.OPENCODE_SMOKE_PUBLISHED_SPEC; + +function requestJson(url) { + return new Promise((resolve, reject) => { + const req = http.get(url, (res) => { + let body = ""; + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => { + try { + resolve({ status: res.statusCode, json: JSON.parse(body), body }); + } catch (error) { + reject(new Error(`Failed to parse JSON response from ${url}: ${body}`)); + } + }); + }); + req.on("error", reject); + }); +} + +function isLatestLikeSpecifier(spec) { + if (!spec) return false; + if (spec.endsWith("@latest")) return true; + return spec.indexOf("@", 1) === -1; +} + +async function unpackPackedPlugin() { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "worktree-plugin-smoke-")); + const unpackDir = path.join(tempRoot, "unpacked"); + await fs.mkdir(unpackDir, { recursive: true }); + + const packed = JSON.parse((await execFileAsync("npm", ["pack", "--json", "--pack-destination", tempRoot], { cwd: repoRoot })).stdout); + const tarballPath = path.join(tempRoot, packed[0].filename); + await execFileAsync("tar", ["-xzf", tarballPath, "-C", unpackDir]); + await execFileAsync("npm", ["install", "--omit=dev"], { cwd: path.join(unpackDir, "package") }); + + return { + tempRoot, + packageDir: path.join(unpackDir, "package"), + async cleanup() { + await fs.rm(tempRoot, { recursive: true, force: true }); + }, + }; +} + +async function startOpencodeServer(pluginRef) { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-smoke-")); + const projectDir = path.join(tempRoot, "project"); + const homeDir = path.join(tempRoot, "home"); + const xdgDir = path.join(tempRoot, "xdg"); + const configDir = path.join(xdgDir, "opencode"); + const port = 4100 + Math.floor(Math.random() * 1000); + + await fs.mkdir(projectDir, { recursive: true }); + await fs.mkdir(homeDir, { recursive: true }); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(projectDir, "opencode.json"), + `${JSON.stringify({ plugin: [pluginRef] }, null, 2)}\n`, + "utf8", + ); + + const env = { + ...process.env, + HOME: homeDir, + XDG_CONFIG_HOME: xdgDir, + OPENCODE_CONFIG_DIR: configDir, + }; + + const child = spawn(OPENCODE_BIN, ["serve", "--print-logs", "--hostname", "127.0.0.1", "--port", String(port)], { + cwd: projectDir, + env, + }); + + let logs = ""; + child.stdout.on("data", (chunk) => { + logs += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + logs += chunk.toString(); + }); + + return { + port, + projectDir, + readLogs() { + return logs; + }, + async stop() { + if (!child.killed) child.kill("SIGTERM"); + await new Promise((resolve) => child.once("exit", resolve)); + await fs.rm(tempRoot, { recursive: true, force: true }); + }, + }; +} + +async function waitForToolIDs(port, attempts = 40) { + let lastError = null; + for (let index = 0; index < attempts; index += 1) { + try { + const response = await requestJson(`http://127.0.0.1:${port}/experimental/tool/ids`); + if (response.status === 200 && Array.isArray(response.json)) return response.json; + lastError = new Error(`Unexpected response: ${response.status} ${response.body}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw lastError || new Error("Timed out waiting for OpenCode tool ids"); +} + +test("packed plugin loads in isolated opencode and registers custom tools", { timeout: 60000 }, async () => { + const packed = await unpackPackedPlugin(); + const server = await startOpencodeServer(`file://${packed.packageDir}`); + + try { + const toolIDs = await waitForToolIDs(server.port); + const logs = server.readLogs(); + + assert.match(logs, /loading plugin/); + assert.doesNotMatch(logs, /Failed to load plugin/i); + assert.doesNotMatch(logs, /Plugin export is not a function/i); + assert.ok(toolIDs.includes("worktree_prepare"), `Expected worktree_prepare in tool ids: ${JSON.stringify(toolIDs)}`); + assert.ok(toolIDs.includes("worktree_cleanup"), `Expected worktree_cleanup in tool ids: ${JSON.stringify(toolIDs)}`); + } finally { + await server.stop(); + await packed.cleanup(); + } +}); + +const publishedTest = PUBLISHED_PLUGIN_SPEC ? test : test.skip; + +publishedTest("published plugin spec reproduces current load status in isolated opencode", { timeout: 60000 }, async () => { + const server = await startOpencodeServer(PUBLISHED_PLUGIN_SPEC || ""); + + try { + const toolIDs = await waitForToolIDs(server.port); + const logs = server.readLogs(); + + assert.match(logs, /loading plugin/); + + if (isLatestLikeSpecifier(PUBLISHED_PLUGIN_SPEC)) { + assert.match(logs, /Plugin export is not a function/i); + assert.ok(!toolIDs.includes("worktree_prepare"), `Did not expect worktree_prepare in tool ids: ${JSON.stringify(toolIDs)}`); + assert.ok(!toolIDs.includes("worktree_cleanup"), `Did not expect worktree_cleanup in tool ids: ${JSON.stringify(toolIDs)}`); + return; + } + + assert.doesNotMatch(logs, /Failed to load plugin/i); + assert.doesNotMatch(logs, /Plugin export is not a function/i); + assert.ok(toolIDs.includes("worktree_prepare"), `Expected worktree_prepare in tool ids: ${JSON.stringify(toolIDs)}`); + assert.ok(toolIDs.includes("worktree_cleanup"), `Expected worktree_cleanup in tool ids: ${JSON.stringify(toolIDs)}`); + } finally { + await server.stop(); + } +}); From bcb6e2caa3d345847a7d350fc8ff23740d5572d3 Mon Sep 17 00:00:00 2001 From: sven1103-agent <261423644+sven1103-agent@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:35:22 +0200 Subject: [PATCH 2/3] ci: resolve opencode binary path --- .github/workflows/publish.yml | 5 +++-- .github/workflows/test.yml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 17799d5..8a389cb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -57,9 +57,10 @@ jobs: - name: Install OpenCode CLI run: npm install -g opencode-linux-x64@1.3.7 + - name: Resolve OpenCode binary path + run: echo "OPENCODE_BIN=$(npm root -g)/opencode-linux-x64/bin/opencode" >> "$GITHUB_ENV" + - name: Run smoke test - env: - OPENCODE_BIN: opencode run: npm run test:smoke - name: Pack dry run diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 791d69a..6bd6cc0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,8 @@ jobs: - name: Install OpenCode CLI run: npm install -g opencode-linux-x64@1.3.7 + - name: Resolve OpenCode binary path + run: echo "OPENCODE_BIN=$(npm root -g)/opencode-linux-x64/bin/opencode" >> "$GITHUB_ENV" + - name: Run smoke test - env: - OPENCODE_BIN: opencode run: npm run test:smoke From f54600a1053b66d1553cdc882363bb203bcd363a Mon Sep 17 00:00:00 2001 From: sven1103-agent <261423644+sven1103-agent@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:37:30 +0200 Subject: [PATCH 3/3] test: separate smoke suite from unit tests --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d44eded..ad61c11 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "access": "public" }, "scripts": { - "test": "node --test", + "test": "node --test test/*.test.js", "test:smoke": "node --test test/integration/*.test.js", "test:smoke:published": "node --test test/integration/*.test.js" },