Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions tools/dev/opencode-dev-test-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import fs from 'fs';
import path from 'path';

import { parse as parseJsonC, modify as modifyJsonC, applyEdits } from 'jsonc-parser';

/**
*
*/
export function readJsonc(file: string): { json: any; raw: string } {
const raw = fs.readFileSync(file, 'utf8');
const errors: any[] = [];
const json = parseJsonC(raw, errors, { allowTrailingComma: true });
if (errors.length) throw new Error('parse error');
return { json, raw };
}

/**
*
*/
export function writeJsonc(file: string, originalRaw: string | null, obj: any) {
const base = originalRaw || '';
const edits = modifyJsonC(base, ['plugin'], obj.plugin || [], {
formattingOptions: { insertSpaces: true, tabSize: 2 },
} as any);
const newText = applyEdits(base, edits);
fs.writeFileSync(file, newText, 'utf8');
}

/**
*
*/
export async function createSymlink(target: string, linkPath: string, forceFail = false) {
try {
// Simulate symlink failure when forceFail is true
if (forceFail) throw new Error('simulated symlink failure');
try {
await fs.promises.lstat(linkPath);
await fs.promises.rm(linkPath, { recursive: true });
} catch {}
await fs.promises.symlink(target, linkPath, 'junction');
} catch (err) {
// fallback to copy
await copyDir(target, linkPath);
}
}

/**
*
*/
export async function copyDir(src: string, dest: string) {
await fs.promises.mkdir(dest, { recursive: true });
const entries = await fs.promises.readdir(src, { withFileTypes: true });
for (const e of entries) {
const srcPath = path.join(src, e.name);
const destPath = path.join(dest, e.name);
if (e.isDirectory()) await copyDir(srcPath, destPath);
else await fs.promises.copyFile(srcPath, destPath);
}
}

/**
*
*/
export function getLatestMtime(dir: string): number {
let latest = 0;
try {
const stack = [dir];
while (stack.length) {
const cur = stack.pop() as string;
const entries = fs.readdirSync(cur, { withFileTypes: true });
for (const e of entries) {
const p = path.join(cur, e.name);
if (e.isDirectory()) stack.push(p);
else {
try {
const s = fs.statSync(p);
const m = s.mtimeMs;
if (m > latest) latest = m;
} catch {}
}
}
}
} catch {}
return latest;
}

// network helpers for tests (mirror production logic)
import net from 'net';

/**
*
*/
export async function isServerListening(disposeUrl: string, timeoutMs = 500): Promise<boolean> {
try {
const u = new URL(disposeUrl);
const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80;
const host = u.hostname;
return await new Promise((resolve) => {
const socket = net.connect({ host, port }, () => {
socket.destroy();
resolve(true);
});
socket.on('error', () => {
try {
socket.destroy();
} catch {}
resolve(false);
});
socket.setTimeout(timeoutMs, () => {
try {
socket.destroy();
} catch {}
resolve(false);
});
});
} catch (err) {
return false;
}
}

/**
*
*/
export async function tryDispose(url: string, timeoutMs = 2000, retries = 2): Promise<boolean> {
if (!url) return false;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
const res = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: { 'content-type': 'application/json' },
body: '{}',
});
clearTimeout(id);
if (res.ok) return true;
} catch (err) {
// swallow
}
await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
}
return false;
}
129 changes: 129 additions & 0 deletions tools/dev/opencode-dev.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import fs from 'fs';
import { tmpdir } from 'os';
import path from 'path';

import { describe, it, expect, afterEach, beforeEach } from 'bun:test';


import {
readJsonc as readJsoncUtil,
writeJsonc as writeJsoncUtil,
createSymlink as createSymlinkUtil,
getLatestMtime as getLatestMtimeUtil,
} from './opencode-dev-test-helpers';

const WORK = path.join(tmpdir(), 'opencode-dev-test');

beforeEach(() => {
try {
fs.rmSync(WORK, { recursive: true, force: true });
} catch {}
fs.mkdirSync(WORK, { recursive: true });
});

afterEach(() => {
try {
fs.rmSync(WORK, { recursive: true, force: true });
} catch {}
});

describe('JSONC edit preserves comments', () => {
it('reads and writes JSONC keeping comments', () => {
const file = path.join(WORK, 'opencode.jsonc');
const raw = `// top comment\n{\n // plugins section\n "plugin": [\n // existing entry\n "file:///old/index.js"\n ]\n}\n`;
fs.writeFileSync(file, raw, 'utf8');
const { json, raw: before } = readJsoncUtil(file);
expect(Array.isArray(json.plugin)).toBe(true);
// add a plugin
json.plugin.push('file:///new/index.js');
writeJsoncUtil(file, before, json);
const afterRaw = fs.readFileSync(file, 'utf8');
expect(afterRaw).toContain('// top comment');
expect(afterRaw).toContain('// plugins section');
expect(afterRaw).toContain('file:///new/index.js');
});
});

describe('symlink fallback copy behavior', () => {
it('falls back to copy when symlink fails', async () => {
const src = path.join(WORK, 'src');
const dest = path.join(WORK, 'dest');
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, 'index.js'), 'console.log(1)');
// Simulate symlink failure by making fs.symlink throw via helper
await expect(createSymlinkUtil(src, dest, true)).resolves.toBeUndefined();
expect(fs.existsSync(path.join(dest, 'index.js'))).toBe(true);
});
});

describe('getLatestMtime detects changes', () => {
it('returns increasing mtime after file change', async () => {
const dir = path.join(WORK, 'd');
fs.mkdirSync(dir, { recursive: true });
const f = path.join(dir, 'a.txt');
fs.writeFileSync(f, 'a');
const t1 = getLatestMtimeUtil(dir);
await new Promise((r) => setTimeout(r, 50));
fs.writeFileSync(f, 'b');
const t2 = getLatestMtimeUtil(dir);
expect(t2).toBeGreaterThanOrEqual(t1);
});
});

// Tests for opencode-dev network helpers
import net from 'net';
import http from 'http';

import { isServerListening as isServerListeningUtil, tryDispose as tryDisposeUtil } from './opencode-dev-test-helpers';

describe('network helpers', () => {
it('isServerListening returns true for a listening TCP port', async () => {
const server = net.createServer((s) => s.end());
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
const addr = server.address() as net.AddressInfo;
const url = `http://127.0.0.1:${addr.port}/instance/dispose`;
const ok = await isServerListeningUtil(url, 500);
expect(ok).toBe(true);
server.close();
});

it('isServerListening returns false for closed port', async () => {
// pick a high port that's likely free; bind and close to get the port, then test
const server = net.createServer();
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
const addr = server.address() as net.AddressInfo;
const port = addr.port;
server.close();
const url = `http://127.0.0.1:${port}/instance/dispose`;
const ok = await isServerListeningUtil(url, 200);
expect(ok).toBe(false);
});

it('tryDispose succeeds against POST endpoint and fails on timeout', async () => {
// start a small HTTP server that responds to POST
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/instance/dispose') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{}');
} else {
res.writeHead(404);
res.end();
}
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
const addr = server.address() as net.AddressInfo;
const url = `http://127.0.0.1:${addr.port}/instance/dispose`;
const ok = await tryDisposeUtil(url, 1000, 1);
expect(ok).toBe(true);
server.close();

// now test timeout/path failure - point to a port with no listener
const server2 = net.createServer();
await new Promise<void>((resolve) => server2.listen(0, '127.0.0.1', () => resolve()));
const port2 = (server2.address() as net.AddressInfo).port;
server2.close();
const badUrl = `http://127.0.0.1:${port2}/instance/dispose`;
const ok2 = await tryDisposeUtil(badUrl, 200, 0);
expect(ok2).toBe(false);
});
});
Loading
Loading