From f14cdf15a0d0d1a1b53f117888eb192bf87ffdf9 Mon Sep 17 00:00:00 2001 From: Mateusz Tymek Date: Thu, 8 Jan 2026 11:16:41 +0100 Subject: [PATCH] Testing and CI/CD --- .github/workflows/ci.yml | 54 +++++++++ bun.lock | 5 + main.js | 5 +- package.json | 4 +- src/ProcessManager.ts | 2 +- src/types.ts | 2 + tests/ProcessManager.test.ts | 225 +++++++++++++++++++++++++++++++++++ 7 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/ProcessManager.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a4a968b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Cache OpenCode binary + id: cache-opencode + uses: actions/cache@v4 + with: + path: ~/.bun/bin/opencode + key: ${{ runner.os }}-opencode-${{ hashFiles('.github/workflows/ci.yml') }} + restore-keys: | + ${{ runner.os }}-opencode- + + - name: Install dependencies + run: bun install + + - name: Install OpenCode CLI + if: steps.cache-opencode.outputs.cache-hit != 'true' + run: bun install -g opencode-ai + + - name: Verify OpenCode installation + run: opencode --version + + - name: Type check + run: bun run tsc -noEmit -skipLibCheck + + - name: Build plugin + run: bun run build + + - name: Run tests + run: bun test diff --git a/bun.lock b/bun.lock index 7807e36..29a4d4a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "obsidian-opencode", "devDependencies": { + "@types/bun": "^1.3.5", "@types/node": "^20.11.0", "builtin-modules": "^3.3.0", "esbuild": "^0.21.5", @@ -67,6 +68,8 @@ "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@types/codemirror": ["@types/codemirror@5.60.8", "", { "dependencies": { "@types/tern": "*" } }, "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -77,6 +80,8 @@ "builtin-modules": ["builtin-modules@3.3.0", "", {}, "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw=="], + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], diff --git a/main.js b/main.js index ca7e670..24fd5a9 100644 --- a/main.js +++ b/main.js @@ -35,7 +35,8 @@ var DEFAULT_SETTINGS = { hostname: "127.0.0.1", autoStart: false, opencodePath: "opencode", - projectDirectory: "" + projectDirectory: "", + startupTimeout: 15e3 }; var OPENCODE_VIEW_TYPE = "opencode-view"; @@ -498,7 +499,7 @@ var ProcessManager = class { this.setError(`Failed to start: ${err.message}`); } }); - const ready = await this.waitForServerOrExit(15e3); + const ready = await this.waitForServerOrExit(this.settings.startupTimeout); if (ready) { this.setState("running"); return true; diff --git a/package.json b/package.json index 78489a2..c0211ca 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", - "build": "bun run tsc -noEmit -skipLibCheck && bun run esbuild.config.mjs production" + "build": "bun run tsc -noEmit -skipLibCheck && bun run esbuild.config.mjs production", + "test": "bun test" }, "keywords": [ "obsidian", @@ -16,6 +17,7 @@ "author": "mat", "license": "MIT", "devDependencies": { + "@types/bun": "^1.3.5", "@types/node": "^20.11.0", "builtin-modules": "^3.3.0", "esbuild": "^0.21.5", diff --git a/src/ProcessManager.ts b/src/ProcessManager.ts index 6ab35b5..7e84aec 100644 --- a/src/ProcessManager.ts +++ b/src/ProcessManager.ts @@ -129,7 +129,7 @@ export class ProcessManager { }); // Wait for server to be ready - const ready = await this.waitForServerOrExit(15000); + const ready = await this.waitForServerOrExit(this.settings.startupTimeout); if (ready) { this.setState("running"); return true; diff --git a/src/types.ts b/src/types.ts index 89769f5..60a3eb2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ export interface OpenCodeSettings { autoStart: boolean; opencodePath: string; projectDirectory: string; + startupTimeout: number; } export const DEFAULT_SETTINGS: OpenCodeSettings = { @@ -12,6 +13,7 @@ export const DEFAULT_SETTINGS: OpenCodeSettings = { autoStart: false, opencodePath: "opencode", projectDirectory: "", + startupTimeout: 15000, }; export const OPENCODE_VIEW_TYPE = "opencode-view"; diff --git a/tests/ProcessManager.test.ts b/tests/ProcessManager.test.ts new file mode 100644 index 0000000..0b012cc --- /dev/null +++ b/tests/ProcessManager.test.ts @@ -0,0 +1,225 @@ +import { describe, test, expect, beforeAll, afterEach } from "bun:test"; +import { ProcessManager, ProcessState } from "../src/ProcessManager"; +import { OpenCodeSettings } from "../src/types"; + +// Test configuration +const TEST_PORT_BASE = 15000; +const TEST_TIMEOUT_MS = 10000; // 10 seconds for server startup in tests +const PROJECT_DIR = process.cwd(); + +let currentPort = TEST_PORT_BASE; + +function getNextPort(): number { + return currentPort++; +} + +function createTestSettings(port: number): OpenCodeSettings { + return { + port, + hostname: "127.0.0.1", + autoStart: false, + opencodePath: "opencode", + projectDirectory: "", + startupTimeout: TEST_TIMEOUT_MS, + }; +} + +// Track current manager for cleanup +let currentManager: ProcessManager | null = null; + +// Verify opencode binary is available before running tests +beforeAll(async () => { + const proc = Bun.spawn(["opencode", "--version"], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + throw new Error( + "opencode binary not found or not executable. " + + "Please ensure 'opencode' is installed and available in PATH." + ); + } +}); + +// Cleanup after each test +afterEach(async () => { + if (currentManager) { + currentManager.stop(); + // Give process time to fully terminate + await new Promise((resolve) => setTimeout(resolve, 500)); + currentManager = null; + } +}); + +describe("ProcessManager", () => { + describe("happy path", () => { + test("starts server and transitions to running state", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + const stateHistory: ProcessState[] = []; + + currentManager = new ProcessManager( + settings, + PROJECT_DIR, + PROJECT_DIR, + (state) => stateHistory.push(state) + ); + + expect(currentManager.getState()).toBe("stopped"); + + const success = await currentManager.start(); + + expect(success).toBe(true); + expect(currentManager.getState()).toBe("running"); + expect(stateHistory).toContain("starting"); + expect(stateHistory).toContain("running"); + }); + + test("reports correct server URL with encoded project directory", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + + currentManager = new ProcessManager( + settings, + PROJECT_DIR, + PROJECT_DIR, + () => {} + ); + + const url = currentManager.getUrl(); + const expectedBase = `http://127.0.0.1:${port}`; + const expectedPath = btoa(PROJECT_DIR); + + expect(url).toBe(`${expectedBase}/${expectedPath}`); + }); + + test("stops server gracefully and transitions to stopped state", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + const stateHistory: ProcessState[] = []; + + currentManager = new ProcessManager( + settings, + PROJECT_DIR, + PROJECT_DIR, + (state) => stateHistory.push(state) + ); + + await currentManager.start(); + expect(currentManager.getState()).toBe("running"); + + currentManager.stop(); + + expect(currentManager.getState()).toBe("stopped"); + expect(stateHistory).toContain("stopped"); + }); + + test("state callbacks fire in correct order: starting -> running", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + const stateHistory: ProcessState[] = []; + + currentManager = new ProcessManager( + settings, + PROJECT_DIR, + PROJECT_DIR, + (state) => stateHistory.push(state) + ); + + await currentManager.start(); + + // Verify order: first starting, then running + const startingIndex = stateHistory.indexOf("starting"); + const runningIndex = stateHistory.indexOf("running"); + + expect(startingIndex).toBeGreaterThanOrEqual(0); + expect(runningIndex).toBeGreaterThan(startingIndex); + }); + + test("can restart after stop", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + + currentManager = new ProcessManager( + settings, + PROJECT_DIR, + PROJECT_DIR, + () => {} + ); + + // First start + const firstStart = await currentManager.start(); + expect(firstStart).toBe(true); + expect(currentManager.getState()).toBe("running"); + + // Stop + currentManager.stop(); + expect(currentManager.getState()).toBe("stopped"); + + // Wait for process to fully terminate + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Restart + const secondStart = await currentManager.start(); + expect(secondStart).toBe(true); + expect(currentManager.getState()).toBe("running"); + }); + + test("returns true immediately if already running", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + + currentManager = new ProcessManager( + settings, + PROJECT_DIR, + PROJECT_DIR, + () => {} + ); + + // First start + await currentManager.start(); + expect(currentManager.getState()).toBe("running"); + + // Second start should return true immediately without state changes + const stateHistory: ProcessState[] = []; + const originalOnStateChange = (currentManager as any).onStateChange; + (currentManager as any).onStateChange = (state: ProcessState) => { + stateHistory.push(state); + originalOnStateChange(state); + }; + + const result = await currentManager.start(); + + expect(result).toBe(true); + expect(currentManager.getState()).toBe("running"); + // Should not have triggered any state changes + expect(stateHistory).toEqual([]); + }); + + test("health check endpoint is accessible when running", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + + currentManager = new ProcessManager( + settings, + PROJECT_DIR, + PROJECT_DIR, + () => {} + ); + + await currentManager.start(); + + // Verify we can hit the health endpoint + const url = currentManager.getUrl(); + const healthUrl = `${url}/global/health`; + + const response = await fetch(healthUrl, { + signal: AbortSignal.timeout(2000), + }); + + expect(response.ok).toBe(true); + }); + }); +});