Testing and CI/CD
This commit is contained in:
54
.github/workflows/ci.yml
vendored
Normal file
54
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||||
5
bun.lock
5
bun.lock
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "obsidian-opencode",
|
"name": "obsidian-opencode",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.5",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"builtin-modules": "^3.3.0",
|
"builtin-modules": "^3.3.0",
|
||||||
"esbuild": "^0.21.5",
|
"esbuild": "^0.21.5",
|
||||||
@@ -67,6 +68,8 @@
|
|||||||
|
|
||||||
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
5
main.js
5
main.js
@@ -35,7 +35,8 @@ var DEFAULT_SETTINGS = {
|
|||||||
hostname: "127.0.0.1",
|
hostname: "127.0.0.1",
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
opencodePath: "opencode",
|
opencodePath: "opencode",
|
||||||
projectDirectory: ""
|
projectDirectory: "",
|
||||||
|
startupTimeout: 15e3
|
||||||
};
|
};
|
||||||
var OPENCODE_VIEW_TYPE = "opencode-view";
|
var OPENCODE_VIEW_TYPE = "opencode-view";
|
||||||
|
|
||||||
@@ -498,7 +499,7 @@ var ProcessManager = class {
|
|||||||
this.setError(`Failed to start: ${err.message}`);
|
this.setError(`Failed to start: ${err.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const ready = await this.waitForServerOrExit(15e3);
|
const ready = await this.waitForServerOrExit(this.settings.startupTimeout);
|
||||||
if (ready) {
|
if (ready) {
|
||||||
this.setState("running");
|
this.setState("running");
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node esbuild.config.mjs",
|
"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": [
|
"keywords": [
|
||||||
"obsidian",
|
"obsidian",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"author": "mat",
|
"author": "mat",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.5",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"builtin-modules": "^3.3.0",
|
"builtin-modules": "^3.3.0",
|
||||||
"esbuild": "^0.21.5",
|
"esbuild": "^0.21.5",
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export class ProcessManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wait for server to be ready
|
// Wait for server to be ready
|
||||||
const ready = await this.waitForServerOrExit(15000);
|
const ready = await this.waitForServerOrExit(this.settings.startupTimeout);
|
||||||
if (ready) {
|
if (ready) {
|
||||||
this.setState("running");
|
this.setState("running");
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface OpenCodeSettings {
|
|||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
opencodePath: string;
|
opencodePath: string;
|
||||||
projectDirectory: string;
|
projectDirectory: string;
|
||||||
|
startupTimeout: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: OpenCodeSettings = {
|
export const DEFAULT_SETTINGS: OpenCodeSettings = {
|
||||||
@@ -12,6 +13,7 @@ export const DEFAULT_SETTINGS: OpenCodeSettings = {
|
|||||||
autoStart: false,
|
autoStart: false,
|
||||||
opencodePath: "opencode",
|
opencodePath: "opencode",
|
||||||
projectDirectory: "",
|
projectDirectory: "",
|
||||||
|
startupTimeout: 15000,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OPENCODE_VIEW_TYPE = "opencode-view";
|
export const OPENCODE_VIEW_TYPE = "opencode-view";
|
||||||
|
|||||||
225
tests/ProcessManager.test.ts
Normal file
225
tests/ProcessManager.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user