From 90bea63eeacca7bf55e7ed36e07f0d08df45b58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 2 Sep 2026 22:22:45 -0400 Subject: [PATCH] chore(node-sdk): audit test-support and test test-support/harness.mjs owns process, server, JSON, fixture, and plugin-runner helpers; provider helper files keep their APIs as thin wrappers. Guard fixtures write 0600 files and the CLI security test isolates XDG_CONFIG_HOME, so the suite passes on Linux. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq --- test-support/augment-test-helpers.mjs | 183 ++++---------------- test-support/claudecode-test-helpers.mjs | 172 +++++-------------- test-support/cline-test-helpers.mjs | 156 ++++------------- test-support/codex-test-helpers.mjs | 181 ++++--------------- test-support/copilot-test-helpers.mjs | 181 +++++-------------- test-support/cursor-test-helpers.mjs | 160 ++++------------- test-support/droid-test-helpers.mjs | 177 ++++++------------- test-support/gemini-test-helpers.mjs | 194 +++++---------------- test-support/grok-test-helpers.mjs | 178 +++++-------------- test-support/harness.mjs | 202 ++++++++++++++++++++++ test-support/kimi-test-helpers.mjs | 191 +++++---------------- test-support/kirocli-test-helpers.mjs | 180 +++++-------------- test-support/kiroide-test-helpers.mjs | 178 +++++-------------- test-support/letta.mjs | 187 +++++--------------- test-support/opencode-test-helpers.mjs | 168 ++++-------------- test-support/qwen.mjs | 210 +++++------------------ test/cli-security.test.mjs | 1 + test/guard-template.test.mjs | 2 + test/letta-runtime.test.mjs | 4 +- 19 files changed, 785 insertions(+), 2120 deletions(-) create mode 100644 test-support/harness.mjs diff --git a/test-support/augment-test-helpers.mjs b/test-support/augment-test-helpers.mjs index 123d657..faf7716 100644 --- a/test-support/augment-test-helpers.mjs +++ b/test-support/augment-test-helpers.mjs @@ -1,140 +1,60 @@ -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + runPlugin as basePlugin, + runShell, + writeOptionalJson, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/augment.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/augment-io.js')).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/augment-installation.js'), -).href; -export const managedInstallationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/managed-installation.js'), -).href; -export const cliPath = path.resolve('dist/cli.js'); +export { + VALID_PRIVATE_KEY, + cliPath, + managedInstallationModuleUrl, + readSettings, + registryModuleUrl, + runNode, + runProcess, + startApiServer, + writeJson, +} from './harness.mjs'; + +export const pluginModuleUrl = distUrl('plugins/augment.js'); +export const ioModuleUrl = distUrl('plugins/augment-io.js'); +export const installationModuleUrl = distUrl('plugins/augment-installation.js'); export const wrapperExtension = process.platform === 'win32' ? '.cmd' : '.sh'; export const guardWrapperName = `augment-guard${wrapperExtension}`; export const auditWrapperName = `augment-hook${wrapperExtension}`; - -export function runProcess(command, args, env, cwd, input = '') { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd, - env: { ...process.env, ...env }, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -export function runNode(args, env, cwd, input = '') { - return runProcess(process.execPath, args, env, cwd, input); -} +const PLUGIN = { exportName: 'augmentPlugin', moduleUrl: pluginModuleUrl }; export function runHandler(handler, input, fixture) { - return new Promise((resolve, reject) => { - const child = spawn(handler.command, { - cwd: fixture.projectDir, - env: { ...process.env, HOME: fixture.homeDir, USERPROFILE: fixture.homeDir }, - shell: true, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -export async function writeJson(filePath, value) { - await mkdir(path.dirname(filePath), { recursive: true }); - const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; - await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); + return runShell(handler.command, homeEnvironment(fixture), fixture.projectDir, input); } export function installConfig(fixture, overrides = {}) { - return { - agentName: 'augment', - orgId: 'org-1', - agentId: 'agent-1', - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('augment', fixture, overrides); } -export async function runPlugin(fixture, method, argument) { - const source = ` - import { augmentPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await augmentPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - return runNode( - ['--input-type=module', '--eval', source], - { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - }, - fixture.projectDir, - ); +export function runPlugin(fixture, method, argument) { + return basePlugin(PLUGIN, fixture, method, argument); } export async function createFixture({ baseUrl = 'http://127.0.0.1:9', settings } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-augment-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote %AUGGIE%"); - const projectDir = path.join(rootDir, 'project with spaces'); - const settingsPath = path.join(homeDir, '.augment', 'settings.json'); - const agentDir = path.join(homeDir, '.elydora', 'agent-1'); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(projectDir, { recursive: true }); - if (settings !== undefined) await writeJson(settingsPath, settings); - const fixture = { - agentDir, - auditWrapperPath: path.join(agentDir, auditWrapperName), + const base = await createFixtureRoot('elydora-augment-', "home with spaces and 'quote %AUGGIE%"); + const settingsPath = path.join(base.homeDir, '.augment', 'settings.json'); + await writeOptionalJson(settingsPath, settings); + return { + ...base, + auditWrapperPath: path.join(base.agentDir, auditWrapperName), baseUrl, - guardScriptPath, - guardWrapperPath: path.join(agentDir, guardWrapperName), - homeDir, - hookScriptPath, - projectDir, - rootDir, + guardWrapperPath: path.join(base.agentDir, guardWrapperName), settingsPath, install(overrides = {}) { return runPlugin(this, 'install', installConfig(this, overrides)); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; - return fixture; -} - -export async function readSettings(filePath) { - const raw = await readFile(filePath, 'utf-8'); - return { raw, settings: JSON.parse(raw) }; } export function generatedCommand(wrapperPath) { @@ -150,34 +70,3 @@ export function managedHandler(settings, event, wrapperPath) { } return undefined; } - -export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ headers: request.headers, method: request.method, url: request.url, raw }); - if (request.method === 'GET') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ agent: { status } })); - return; - } - response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); - response.end(operationStatus >= 200 && operationStatus < 300 - ? JSON.stringify({ operation: { accepted: true } }) - : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; -} diff --git a/test-support/claudecode-test-helpers.mjs b/test-support/claudecode-test-helpers.mjs index 4f99404..7b732f2 100644 --- a/test-support/claudecode-test-helpers.mjs +++ b/test-support/claudecode-test-helpers.mjs @@ -1,38 +1,32 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + optionalEnvironment, + runPlugin as basePlugin, + runProcess as baseProcess, + writeOptionalJson, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/claudecode.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/claudecode-io.js')).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/claudecode-installation.js'), -).href; -export const cliPath = path.resolve('dist/cli.js'); +export { + VALID_PRIVATE_KEY, + cliPath, + readSettings, + registryModuleUrl, + startApiServer, + writeJson, +} from './harness.mjs'; + +export const pluginModuleUrl = distUrl('plugins/claudecode.js'); +export const ioModuleUrl = distUrl('plugins/claudecode-io.js'); +export const installationModuleUrl = distUrl('plugins/claudecode-installation.js'); +const PLUGIN = { exportName: 'claudecodePlugin', moduleUrl: pluginModuleUrl }; export function runProcess(command, args, env, cwd, input = '', unset = []) { - return new Promise((resolve, reject) => { - const childEnv = { ...process.env, ...env }; - for (const key of unset) delete childEnv[key]; - const child = spawn(command, args, { - cwd, - env: childEnv, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); + return baseProcess(command, args, env, cwd, input, { unset }); } export function runNode(args, env, cwd, input = '', unset = []) { @@ -44,8 +38,7 @@ export function runClaudeHook(handler, input, fixture, environment = {}) { handler.command, handler.args, { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, + ...homeEnvironment(fixture), CLAUDE_CONFIG_DIR: fixture.claudeConfigOverride ?? '', ...environment, }, @@ -54,51 +47,16 @@ export function runClaudeHook(handler, input, fixture, environment = {}) { ); } -export async function writeJson(filePath, value) { - await mkdir(path.dirname(filePath), { recursive: true }); - const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; - await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); -} - export function installConfig(fixture, overrides = {}) { - return { - agentName: 'claudecode', - orgId: 'org-1', - agentId: 'agent-1', - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('claudecode', fixture, overrides); } -export async function runPlugin(fixture, method, argument, environment = {}) { - const source = ` - import { claudecodePlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await claudecodePlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - const env = { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - ...environment, - }; - const unset = []; - if (fixture.claudeConfigOverride === undefined) unset.push('CLAUDE_CONFIG_DIR'); - else env.CLAUDE_CONFIG_DIR = fixture.claudeConfigOverride; - return runNode( - ['--input-type=module', '--eval', source], - env, - fixture.projectDir, - '', - unset, - ); +export function runPlugin(fixture, method, argument, environment = {}) { + const configDir = optionalEnvironment('CLAUDE_CONFIG_DIR', fixture.claudeConfigOverride); + return basePlugin(PLUGIN, fixture, method, argument, { + env: { ...environment, ...configDir.env }, + unset: configDir.unset, + }); } export async function createFixture({ @@ -106,42 +64,25 @@ export async function createFixture({ settings, explicitClaudeConfig = true, } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-claude-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote %CLAUDE_HOOK_EVENT%"); - const projectDir = path.join(rootDir, 'project with spaces'); + const base = await createFixtureRoot( + 'elydora-claude-', + "home with spaces and 'quote %CLAUDE_HOOK_EVENT%", + ); const claudeConfigDir = explicitClaudeConfig - ? path.join(homeDir, 'custom claude') - : path.join(homeDir, '.claude'); + ? path.join(base.homeDir, 'custom claude') + : path.join(base.homeDir, '.claude'); const settingsPath = path.join(claudeConfigDir, 'settings.json'); - const agentDir = path.join(homeDir, '.elydora', 'agent-1'); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(projectDir, { recursive: true }); - if (settings !== undefined) await writeJson(settingsPath, settings); - const fixture = { - agentDir, + await writeOptionalJson(settingsPath, settings); + return { + ...base, baseUrl, claudeConfigDir, claudeConfigOverride: explicitClaudeConfig ? claudeConfigDir : undefined, - guardScriptPath, - homeDir, - hookScriptPath, - projectDir, - rootDir, settingsPath, install(overrides = {}) { return runPlugin(this, 'install', installConfig(this, overrides)); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; - return fixture; -} - -export async function readSettings(filePath) { - const raw = await readFile(filePath, 'utf-8'); - return { raw, settings: JSON.parse(raw) }; } export function managedHandler(settings, event) { @@ -173,34 +114,3 @@ export function assertManagedHandler(handler, scriptPath, statusMessage) { export function legacyHandler(scriptPath) { return { type: 'command', command: `node "${scriptPath}"` }; } - -export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ headers: request.headers, method: request.method, url: request.url, raw }); - if (request.method === 'GET') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ agent: { status } })); - return; - } - response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); - response.end(operationStatus >= 200 && operationStatus < 300 - ? JSON.stringify({ operation: { accepted: true } }) - : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; -} diff --git a/test-support/cline-test-helpers.mjs b/test-support/cline-test-helpers.mjs index 6da3177..52ff53d 100644 --- a/test-support/cline-test-helpers.mjs +++ b/test-support/cline-test-helpers.mjs @@ -1,87 +1,39 @@ -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; +import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + runPlugin as basePlugin, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32, 7).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/cline.js')).href; -export const contractModuleUrl = pathToFileURL( - path.resolve('dist/plugins/cline-contract.js'), -).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/cline-io.js')).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/cline-installation.js'), -).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const cliPath = path.resolve('dist/cli.js'); +export { + VALID_PRIVATE_KEY, + cliPath, + readJson, + registryModuleUrl, + runNode, + runProcess, + startApiServer, +} from './harness.mjs'; -export function runProcess(command, args, env, cwd, input = '') { - return new Promise((resolve, reject) => { - const childEnvironment = { ...process.env, ...env }; - for (const [name, value] of Object.entries(env)) { - if (value === undefined) delete childEnvironment[name]; - } - const child = spawn(command, args, { - cwd, - env: childEnvironment, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -export function runNode(args, env, cwd, input = '') { - return runProcess(process.execPath, args, env, cwd, input); -} +export const pluginModuleUrl = distUrl('plugins/cline.js'); +export const contractModuleUrl = distUrl('plugins/cline-contract.js'); +export const ioModuleUrl = distUrl('plugins/cline-io.js'); +export const installationModuleUrl = distUrl('plugins/cline-installation.js'); +const PLUGIN = { exportName: 'clinePlugin', moduleUrl: pluginModuleUrl }; export function environment(fixture, clineDir = fixture.clineDir) { - return { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - CLINE_DIR: clineDir === null ? undefined : clineDir, - }; + return { ...homeEnvironment(fixture), CLINE_DIR: clineDir === null ? undefined : clineDir }; } export function installConfig(fixture, overrides = {}) { - return { - agentName: 'cline', - orgId: 'org-1', - agentId: fixture.agentId, - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('cline', fixture, overrides); } -export async function runPlugin(fixture, method, argument, clineDir = fixture.clineDir) { - const source = ` - import { clinePlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await clinePlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - return runNode( - ['--input-type=module', '--eval', source], - { - ...environment(fixture, clineDir), - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - }, - fixture.projectDir, - ); +export function runPlugin(fixture, method, argument, clineDir = fixture.clineDir) { + return basePlugin(PLUGIN, fixture, method, argument, { env: environment(fixture, clineDir) }); } async function writeOptional(filePath, contents) { @@ -96,66 +48,26 @@ export async function createFixture({ existingAudit, existingGuard, } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-cline-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote %CLINE%"); - const projectDir = path.join(rootDir, 'project with spaces'); - const clineDir = path.join(rootDir, 'custom-cline-home'); + const base = await createFixtureRoot( + 'elydora-cline-', + "home with spaces and 'quote %CLINE%", + { agentId }, + ); + const clineDir = path.join(base.rootDir, 'custom-cline-home'); const hooksDir = path.join(clineDir, 'hooks'); const guardWrapperPath = path.join(hooksDir, 'PreToolUse.mjs'); const auditWrapperPath = path.join(hooksDir, 'PostToolUse.mjs'); - const agentDir = path.join(homeDir, '.elydora', agentId); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(projectDir, { recursive: true }); await writeOptional(guardWrapperPath, existingGuard); await writeOptional(auditWrapperPath, existingAudit); - const fixture = { - agentDir, - agentId, + return { + ...base, auditWrapperPath, baseUrl, clineDir, - guardScriptPath, guardWrapperPath, - homeDir, - hookScriptPath, hooksDir, - projectDir, - rootDir, install(overrides = {}, clineDirOverride = this.clineDir) { return runPlugin(this, 'install', installConfig(this, overrides), clineDirOverride); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, - }; - return fixture; -} - -export async function readJson(filePath) { - return JSON.parse(await readFile(filePath, 'utf-8')); -} - -export async function startApiServer() { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ method: request.method, url: request.url, raw }); - response.writeHead(201, { 'Content-Type': 'application/json' }); - response.end('{"operation":{"accepted":true}}'); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), }; } diff --git a/test-support/codex-test-helpers.mjs b/test-support/codex-test-helpers.mjs index b9e3107..d4ad290 100644 --- a/test-support/codex-test-helpers.mjs +++ b/test-support/codex-test-helpers.mjs @@ -1,139 +1,58 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + runNode as baseNode, + runPlugin as basePlugin, + runProcess, + runShell, + writeOptionalJson, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/codex.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/codex-io.js')).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/codex-installation.js'), -).href; +export { VALID_PRIVATE_KEY, registryModuleUrl, startApiServer, writeJson } from './harness.mjs'; + +export const pluginModuleUrl = distUrl('plugins/codex.js'); +export const ioModuleUrl = distUrl('plugins/codex-io.js'); +export const installationModuleUrl = distUrl('plugins/codex-installation.js'); +const PLUGIN = { exportName: 'codexPlugin', moduleUrl: pluginModuleUrl }; export function runNode(args, env, cwd, input = '') { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, args, { - cwd, - env: { ...process.env, CODEX_HOME: '', ...env }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); + return baseNode(args, { CODEX_HOME: '', ...env }, cwd, input); } export function runHook(handler, input, fixture, environment = {}) { const command = process.platform === 'win32' ? handler.commandWindows : handler.command; - return new Promise((resolve, reject) => { - const child = process.platform === 'win32' - ? spawn(command, { - shell: true, - cwd: fixture.projectDir, - env: { - ...process.env, - CODEX_HOME: '', - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ...environment, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - : spawn('/bin/sh', ['-c', command], { - cwd: fixture.projectDir, - env: { - ...process.env, - CODEX_HOME: '', - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ...environment, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -export async function writeJson(filePath, value) { - await mkdir(path.dirname(filePath), { recursive: true }); - const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; - await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); + const env = { CODEX_HOME: '', ...homeEnvironment(fixture), ...environment }; + return process.platform === 'win32' + ? runShell(command, env, fixture.projectDir, input) + : runProcess('/bin/sh', ['-c', command], env, fixture.projectDir, input); } export function installConfig(fixture, overrides = {}) { - return { - agentName: 'codex', - orgId: 'org-1', - agentId: 'agent-1', - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('codex', fixture, overrides); } -export async function runPlugin(fixture, method, argument, environment = {}) { - const source = ` - import { codexPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await codexPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - return runNode( - ['--input-type=module', '--eval', source], - { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - ...environment, - }, - fixture.projectDir, - ); +export function runPlugin(fixture, method, argument, environment = {}) { + return basePlugin(PLUGIN, fixture, method, argument, { env: { CODEX_HOME: '', ...environment } }); } export async function createFixture({ existingConfig, baseUrl = 'http://127.0.0.1:9' } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-codex-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote %ELYDORA_HOOK_PATH%"); - const projectDir = path.join(rootDir, 'project with spaces'); - const configPath = path.join(homeDir, '.codex', 'hooks.json'); - const agentDir = path.join(homeDir, '.elydora', 'agent-1'); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(projectDir, { recursive: true }); - if (existingConfig !== undefined) await writeJson(configPath, existingConfig); + const base = await createFixtureRoot( + 'elydora-codex-', + "home with spaces and 'quote %ELYDORA_HOOK_PATH%", + ); + const configPath = path.join(base.homeDir, '.codex', 'hooks.json'); + await writeOptionalJson(configPath, existingConfig); return { - agentDir, + ...base, baseUrl, configPath, - guardScriptPath, - homeDir, - hookScriptPath, - projectDir, - rootDir, install(overrides = {}) { return runPlugin(this, 'install', installConfig(this, overrides)); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; } @@ -170,39 +89,3 @@ export function legacyHandler(scriptPath, statusMessage) { statusMessage, }; } - -export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ - headers: request.headers, - method: request.method, - url: request.url, - raw, - }); - if (request.method === 'GET') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ agent: { status } })); - return; - } - response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); - response.end(operationStatus >= 200 && operationStatus < 300 - ? JSON.stringify({ operation: { accepted: true } }) - : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; -} diff --git a/test-support/copilot-test-helpers.mjs b/test-support/copilot-test-helpers.mjs index 35ff8db..15d5e48 100644 --- a/test-support/copilot-test-helpers.mjs +++ b/test-support/copilot-test-helpers.mjs @@ -1,87 +1,44 @@ -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + runPlugin as basePlugin, + runProcess, + writeOptionalJson, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32, 11).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/copilot.js')).href; -export const contractModuleUrl = pathToFileURL( - path.resolve('dist/plugins/copilot-contract.js'), -).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/copilot-installation.js'), -).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/copilot-io.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const cliPath = path.resolve('dist/cli.js'); +export { + VALID_PRIVATE_KEY, + cliPath, + readJson, + registryModuleUrl, + runNode, + runProcess, + startApiServer, + writeJson, +} from './harness.mjs'; -export function runProcess(command, args, env, cwd, input = '') { - return new Promise((resolve, reject) => { - const childEnvironment = { ...process.env, ...env }; - for (const [name, value] of Object.entries(env)) { - if (value === undefined) delete childEnvironment[name]; - } - const child = spawn(command, args, { - cwd, - env: childEnvironment, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -export function runNode(args, env, cwd, input = '') { - return runProcess(process.execPath, args, env, cwd, input); -} +export const pluginModuleUrl = distUrl('plugins/copilot.js'); +export const contractModuleUrl = distUrl('plugins/copilot-contract.js'); +export const installationModuleUrl = distUrl('plugins/copilot-installation.js'); +export const ioModuleUrl = distUrl('plugins/copilot-io.js'); +const PLUGIN = { exportName: 'copilotPlugin', moduleUrl: pluginModuleUrl }; export function environment(fixture, copilotHome = fixture.copilotHome) { return { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, + ...homeEnvironment(fixture), COPILOT_HOME: copilotHome === null ? undefined : copilotHome, }; } export function installConfig(fixture, overrides = {}) { - return { - agentName: 'copilot', - orgId: 'org-1', - agentId: fixture.agentId, - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('copilot', fixture, overrides); } -export async function runPlugin(fixture, method, argument, copilotHome = fixture.copilotHome) { - const source = ` - import { copilotPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await copilotPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - return runNode( - ['--input-type=module', '--eval', source], - { - ...environment(fixture, copilotHome), - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - }, - fixture.projectDir, - ); +export function runPlugin(fixture, method, argument, copilotHome = fixture.copilotHome) { + return basePlugin(PLUGIN, fixture, method, argument, { env: environment(fixture, copilotHome) }); } export function runHook(handler, fixture, input) { @@ -91,16 +48,6 @@ export function runHook(handler, fixture, input) { return runProcess(command[0], command[1], environment(fixture), fixture.projectDir, input); } -export async function writeJson(filePath, value) { - await mkdir(path.dirname(filePath), { recursive: true }); - const source = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; - await writeFile(filePath, source, { mode: 0o600 }); -} - -async function writeOptional(filePath, value) { - if (value !== undefined) await writeJson(filePath, value); -} - export async function createFixture({ agentId = 'agent-1', baseUrl = 'http://127.0.0.1:9', @@ -113,46 +60,36 @@ export async function createFixture({ repositorySettings, localSettings, } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-copilot-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote %COPILOT%"); - const projectDir = path.join(rootDir, 'project with spaces'); + const base = await createFixtureRoot( + 'elydora-copilot-', + "home with spaces and 'quote %COPILOT%", + { agentId }, + ); + const { projectDir, rootDir } = base; const copilotHome = path.join(rootDir, "custom Copilot 'home"); const hooksDir = path.join(copilotHome, 'hooks'); const configPath = path.join(hooksDir, 'elydora-audit.json'); const legacyPath = path.join(projectDir, '.github', 'hooks', 'hooks.json'); - const agentDir = path.join(homeDir, '.elydora', agentId); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(projectDir, { recursive: true }); await Promise.all([ - writeOptional(configPath, userConfig), - writeOptional(legacyPath, legacyConfig), - writeOptional(path.join(copilotHome, 'settings.json'), userSettings), - writeOptional(path.join(copilotHome, 'config.json'), legacyUserConfig), - writeOptional(path.join(projectDir, '.claude', 'settings.json'), claudeSettings), - writeOptional(path.join(projectDir, '.claude', 'settings.local.json'), claudeLocalSettings), - writeOptional(path.join(projectDir, '.github', 'copilot', 'settings.json'), repositorySettings), - writeOptional(path.join(projectDir, '.github', 'copilot', 'settings.local.json'), localSettings), + writeOptionalJson(configPath, userConfig), + writeOptionalJson(legacyPath, legacyConfig), + writeOptionalJson(path.join(copilotHome, 'settings.json'), userSettings), + writeOptionalJson(path.join(copilotHome, 'config.json'), legacyUserConfig), + writeOptionalJson(path.join(projectDir, '.claude', 'settings.json'), claudeSettings), + writeOptionalJson(path.join(projectDir, '.claude', 'settings.local.json'), claudeLocalSettings), + writeOptionalJson(path.join(projectDir, '.github', 'copilot', 'settings.json'), repositorySettings), + writeOptionalJson(path.join(projectDir, '.github', 'copilot', 'settings.local.json'), localSettings), ]); return { - agentDir, - agentId, + ...base, baseUrl, configPath, copilotHome, - guardScriptPath, - homeDir, - hookScriptPath, hooksDir, legacyPath, - projectDir, - rootDir, install(overrides = {}, copilotHomeOverride = this.copilotHome) { return runPlugin(this, 'install', installConfig(this, overrides), copilotHomeOverride); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; } @@ -193,35 +130,3 @@ export function legacyManagedConfig(fixture, extraHooks = {}) { }, }; } - -export async function readJson(filePath) { - return JSON.parse(await readFile(filePath, 'utf-8')); -} - -export async function startApiServer() { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ method: request.method, url: request.url, raw }); - response.writeHead(request.method === 'POST' ? 201 : 200, { - 'Content-Type': 'application/json', - }); - response.end(request.method === 'POST' - ? '{"operation":{"accepted":true}}' - : '{"agent":{"status":"active"}}'); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; -} diff --git a/test-support/cursor-test-helpers.mjs b/test-support/cursor-test-helpers.mjs index 7d968be..e050e2a 100644 --- a/test-support/cursor-test-helpers.mjs +++ b/test-support/cursor-test-helpers.mjs @@ -1,127 +1,56 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + runPlugin as basePlugin, + runProcess, + writeOptionalJson, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor-io.js')).href; -export const contractModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor-contract.js')).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/cursor-installation.js'), -).href; +export { + VALID_PRIVATE_KEY, + registryModuleUrl, + runNode, + startApiServer, + writeJson, +} from './harness.mjs'; -export function runNode(args, env, cwd, input = '') { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, args, { - cwd, - env: { ...process.env, ...env }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} +export const pluginModuleUrl = distUrl('plugins/cursor.js'); +export const ioModuleUrl = distUrl('plugins/cursor-io.js'); +export const contractModuleUrl = distUrl('plugins/cursor-contract.js'); +export const installationModuleUrl = distUrl('plugins/cursor-installation.js'); +const PLUGIN = { exportName: 'cursorPlugin', moduleUrl: pluginModuleUrl }; export function runHook(handler, input, fixture, environment = {}) { const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh'; const args = process.platform === 'win32' ? ['-NoProfile', '-NonInteractive', '-Command', handler.command] : ['-c', handler.command]; - return new Promise((resolve, reject) => { - const child = spawn(executable, args, { - env: { - ...process.env, - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ...environment, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -export async function writeJson(filePath, value) { - await mkdir(path.dirname(filePath), { recursive: true }); - const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; - await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); + return runProcess(executable, args, { ...homeEnvironment(fixture), ...environment }, undefined, input); } export function installConfig(fixture, overrides = {}) { - return { - agentName: 'cursor', - orgId: 'org-1', - agentId: 'agent-1', - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('cursor', fixture, overrides); } -export async function runPlugin(fixture, method, argument) { - const source = ` - import { cursorPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await cursorPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - return runNode( - ['--input-type=module', '--eval', source], - { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - }, - fixture.projectDir, - ); +export function runPlugin(fixture, method, argument) { + return basePlugin(PLUGIN, fixture, method, argument); } export async function createFixture({ existingConfig, baseUrl = 'http://127.0.0.1:9' } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-cursor-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote"); - const projectDir = path.join(rootDir, 'project with spaces'); - const configPath = path.join(homeDir, '.cursor', 'hooks.json'); - const agentDir = path.join(homeDir, '.elydora', 'agent-1'); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(projectDir, { recursive: true }); - if (existingConfig !== undefined) await writeJson(configPath, existingConfig); + const base = await createFixtureRoot('elydora-cursor-', "home with spaces and 'quote"); + const configPath = path.join(base.homeDir, '.cursor', 'hooks.json'); + await writeOptionalJson(configPath, existingConfig); return { - agentDir, + ...base, baseUrl, configPath, - guardScriptPath, - homeDir, - hookScriptPath, - projectDir, - rootDir, install(overrides = {}) { return runPlugin(this, 'install', installConfig(this, overrides)); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; } @@ -145,34 +74,3 @@ export function assertNativeHandler(handler) { export function legacyHandler(scriptPath) { return { command: `node "${scriptPath}"` }; } - -export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ method: request.method, url: request.url, raw }); - if (request.method === 'GET') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ agent: { status } })); - return; - } - response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); - response.end(operationStatus >= 200 && operationStatus < 300 - ? JSON.stringify({ operation: { accepted: true } }) - : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; -} diff --git a/test-support/droid-test-helpers.mjs b/test-support/droid-test-helpers.mjs index 3f5694b..78fcf87 100644 --- a/test-support/droid-test-helpers.mjs +++ b/test-support/droid-test-helpers.mjs @@ -1,104 +1,58 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; +import { mkdir, readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; import { parse } from 'jsonc-parser'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + runPlugin as basePlugin, + runProcess as baseProcess, + writeOptionalJson, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32, 13).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/droid.js')).href; -export const configModuleUrl = pathToFileURL(path.resolve('dist/plugins/droid-config.js')).href; -export const contractModuleUrl = pathToFileURL(path.resolve('dist/plugins/droid-contract.js')).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/droid-installation.js'), -).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/droid-io.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const cliPath = path.resolve('dist/cli.js'); +export { + VALID_PRIVATE_KEY, + cliPath, + homeEnvironment as environment, + registryModuleUrl, + runNode, + startApiServer, + writeJson as writeConfig, +} from './harness.mjs'; + +export const pluginModuleUrl = distUrl('plugins/droid.js'); +export const configModuleUrl = distUrl('plugins/droid-config.js'); +export const contractModuleUrl = distUrl('plugins/droid-contract.js'); +export const installationModuleUrl = distUrl('plugins/droid-installation.js'); +export const ioModuleUrl = distUrl('plugins/droid-io.js'); +const PLUGIN = { exportName: 'droidPlugin', moduleUrl: pluginModuleUrl }; export function runProcess(command, args, env, cwd, input = '', shell = false) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd, - env: { ...process.env, ...env }, - shell, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -export function runNode(args, env, cwd, input = '') { - return runProcess(process.execPath, args, env, cwd, input); -} - -export function environment(fixture) { - return { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir }; + return baseProcess(command, args, env, cwd, input, { shell }); } export function installConfig(fixture, overrides = {}) { - return { - agentName: 'droid', - orgId: 'org-1', - agentId: fixture.agentId, - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('droid', fixture, overrides); } -export async function runPlugin(fixture, method, argument) { - const source = ` - import { droidPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await droidPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - return runNode( - ['--input-type=module', '--eval', source], - { - ...environment(fixture), - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - }, - fixture.workspaceDir, - ); +export function runPlugin(fixture, method, argument) { + return basePlugin(PLUGIN, fixture, method, argument, { cwd: fixture.workspaceDir }); } export function runHook(command, fixture, input) { + const env = homeEnvironment(fixture); if (process.platform === 'win32') { return runProcess( 'powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], - environment(fixture), + env, fixture.workspaceDir, input, ); } - return runProcess('/bin/sh', ['-c', command], environment(fixture), fixture.workspaceDir, input); -} - -export async function writeConfig(filePath, value) { - await mkdir(path.dirname(filePath), { recursive: true }); - const source = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; - await writeFile(filePath, source, { mode: 0o600 }); -} - -async function writeOptional(filePath, value) { - if (value !== undefined) await writeConfig(filePath, value); + return runProcess('/bin/sh', ['-c', command], env, fixture.workspaceDir, input); } export async function createFixture({ @@ -111,51 +65,44 @@ export async function createFixture({ projectSettings, projectLocalSettings, } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-droid-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote %DROID%"); - const workspaceDir = path.join(rootDir, 'workspace with spaces'); - const factoryDir = path.join(homeDir, '.factory'); + const base = await createFixtureRoot( + 'elydora-droid-', + "home with spaces and 'quote %DROID%", + { agentId, projectName: 'workspace with spaces' }, + ); + const workspaceDir = base.projectDir; + const factoryDir = path.join(base.homeDir, '.factory'); const rootPath = path.join(factoryDir, 'hooks.json'); const legacyPath = path.join(factoryDir, 'hooks', 'hooks.json'); const settingsPath = path.join(factoryDir, 'settings.json'); const localSettingsPath = path.join(factoryDir, 'settings.local.json'); - const agentDir = path.join(homeDir, '.elydora', agentId); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); const projectFactoryDir = path.join(workspaceDir, '.factory'); const projectSettingsPath = path.join(projectFactoryDir, 'settings.json'); const projectLocalSettingsPath = path.join(projectFactoryDir, 'settings.local.json'); await mkdir(path.join(workspaceDir, '.git'), { recursive: true }); await Promise.all([ - writeOptional(rootPath, rootConfig), - writeOptional(legacyPath, legacyConfig), - writeOptional(settingsPath, settings), - writeOptional(localSettingsPath, localSettings), - writeOptional(projectSettingsPath, projectSettings), - writeOptional(projectLocalSettingsPath, projectLocalSettings), + writeOptionalJson(rootPath, rootConfig), + writeOptionalJson(legacyPath, legacyConfig), + writeOptionalJson(settingsPath, settings), + writeOptionalJson(localSettingsPath, localSettings), + writeOptionalJson(projectSettingsPath, projectSettings), + writeOptionalJson(projectLocalSettingsPath, projectLocalSettings), ]); + const { projectDir: _projectDir, ...fixture } = base; return { - agentDir, - agentId, + ...fixture, baseUrl, factoryDir, - guardScriptPath, - homeDir, - hookScriptPath, legacyPath, localSettingsPath, projectLocalSettingsPath, projectSettingsPath, - rootDir, rootPath, settingsPath, workspaceDir, install(overrides = {}) { return runPlugin(this, 'install', installConfig(this, overrides)); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; } @@ -208,31 +155,3 @@ export async function assertNoTransactionFiles(fixture) { const leaked = names.filter((name) => /\.(tmp|rollback)$/.test(name)); if (leaked.length > 0) throw new Error(`Leaked transaction files: ${leaked.join(', ')}`); } - -export async function startApiServer() { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ method: request.method, url: request.url, raw }); - response.writeHead(request.method === 'POST' ? 201 : 200, { - 'Content-Type': 'application/json', - }); - response.end(request.method === 'POST' - ? '{"operation":{"accepted":true}}' - : '{"agent":{"status":"active"}}'); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; -} diff --git a/test-support/gemini-test-helpers.mjs b/test-support/gemini-test-helpers.mjs index 5d590fd..e740d90 100644 --- a/test-support/gemini-test-helpers.mjs +++ b/test-support/gemini-test-helpers.mjs @@ -1,65 +1,49 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { parse } from 'jsonc-parser'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + optionalEnvironment, + runPlugin as basePlugin, + runProcess as baseProcess, + windowsPowerShellPath, + writeOptionalJson, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/gemini.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/gemini-io.js')).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/gemini-installation.js'), -).href; -export const cliPath = path.resolve('dist/cli.js'); +export { + VALID_PRIVATE_KEY, + cliPath, + parseJsonc as parseSettings, + readJsoncSettings as readSettings, + registryModuleUrl, + startApiServer, + writeJson as writeSettings, +} from './harness.mjs'; + +export const pluginModuleUrl = distUrl('plugins/gemini.js'); +export const ioModuleUrl = distUrl('plugins/gemini-io.js'); +export const installationModuleUrl = distUrl('plugins/gemini-installation.js'); +const PLUGIN = { exportName: 'geminiPlugin', moduleUrl: pluginModuleUrl }; export function runProcess(command, args, env, cwd, input = '', unset = []) { - return new Promise((resolve, reject) => { - const childEnv = { ...process.env, ...env }; - for (const key of unset) delete childEnv[key]; - const child = spawn(command, args, { - cwd, - env: childEnv, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); + return baseProcess(command, args, env, cwd, input, { unset }); } export function runNode(args, env, cwd, input = '', unset = []) { return runProcess(process.execPath, args, env, cwd, input, unset); } -function windowsPowerShell() { - return path.win32.join( - process.env.SystemRoot || 'C:\\Windows', - 'System32', - 'WindowsPowerShell', - 'v1.0', - 'powershell.exe', - ); -} - export function runGeminiHook(handler, input, fixture, environment = {}) { const env = { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, + ...homeEnvironment(fixture), GEMINI_CLI_HOME: fixture.geminiCliHomeOverride ?? '', ...environment, }; if (process.platform === 'win32') { return runProcess( - windowsPowerShell(), + windowsPowerShellPath(), [ '-NoLogo', '-NoProfile', @@ -75,66 +59,16 @@ export function runGeminiHook(handler, input, fixture, environment = {}) { return runProcess('/bin/bash', ['-c', handler.command], env, fixture.projectDir, input); } -export async function writeSettings(filePath, value) { - await mkdir(path.dirname(filePath), { recursive: true }); - const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; - await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); -} - -export function parseSettings(raw) { - const errors = []; - const value = parse(raw, errors, { - allowTrailingComma: false, - disallowComments: false, - }); - assert.deepEqual(errors, []); - return value; -} - -export async function readSettings(filePath) { - const raw = await readFile(filePath, 'utf-8'); - return { raw, settings: parseSettings(raw) }; -} - export function installConfig(fixture, overrides = {}) { - return { - agentName: 'gemini', - orgId: 'org-1', - agentId: 'agent-1', - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('gemini', fixture, overrides); } -export async function runPlugin(fixture, method, argument, environment = {}) { - const source = ` - import { geminiPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await geminiPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - const env = { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - ...environment, - }; - const unset = []; - if (fixture.geminiCliHomeOverride === undefined) unset.push('GEMINI_CLI_HOME'); - else env.GEMINI_CLI_HOME = fixture.geminiCliHomeOverride; - return runNode( - ['--input-type=module', '--eval', source], - env, - fixture.projectDir, - '', - unset, - ); +export function runPlugin(fixture, method, argument, environment = {}) { + const home = optionalEnvironment('GEMINI_CLI_HOME', fixture.geminiCliHomeOverride); + return basePlugin(PLUGIN, fixture, method, argument, { + env: { ...environment, ...home.env }, + unset: home.unset, + }); } export async function createFixture({ @@ -142,38 +76,23 @@ export async function createFixture({ settings, explicitGeminiHome = true, } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-gemini-')); - const homeDir = path.join( - rootDir, + const base = await createFixtureRoot( + 'elydora-gemini-', "home with spaces and 'quote $GEMINI_CWD %GEMINI_CWD%", ); - const projectDir = path.join(rootDir, 'project with spaces'); - const geminiCliHome = homeDir; + const geminiCliHome = base.homeDir; const settingsPath = path.join(geminiCliHome, '.gemini', 'settings.json'); - const agentDir = path.join(homeDir, '.elydora', 'agent-1'); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(projectDir, { recursive: true }); - if (settings !== undefined) await writeSettings(settingsPath, settings); - const fixture = { - agentDir, + await writeOptionalJson(settingsPath, settings); + return { + ...base, baseUrl, geminiCliHome, geminiCliHomeOverride: explicitGeminiHome ? geminiCliHome : undefined, - guardScriptPath, - homeDir, - hookScriptPath, - projectDir, - rootDir, settingsPath, install(overrides = {}, environment = {}) { return runPlugin(this, 'install', installConfig(this, overrides), environment); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; - return fixture; } export function managedHandler(settings, event, name) { @@ -202,34 +121,3 @@ export function assertManagedHandler(handler, name) { export function legacyHandler(scriptPath) { return { type: 'command', command: `node "${scriptPath}"` }; } - -export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ headers: request.headers, method: request.method, url: request.url, raw }); - if (request.method === 'GET') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ agent: { status } })); - return; - } - response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); - response.end(operationStatus >= 200 && operationStatus < 300 - ? JSON.stringify({ operation: { accepted: true } }) - : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; -} diff --git a/test-support/grok-test-helpers.mjs b/test-support/grok-test-helpers.mjs index 67cfbc5..e0622a1 100644 --- a/test-support/grok-test-helpers.mjs +++ b/test-support/grok-test-helpers.mjs @@ -1,39 +1,27 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + optionalEnvironment, + runPlugin as basePlugin, + runProcess as baseProcess, + runShell, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/grok.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const contractModuleUrl = pathToFileURL(path.resolve('dist/plugins/grok-contract.js')).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/grok-io.js')).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/grok-installation.js'), -).href; -export const cliPath = path.resolve('dist/cli.js'); +export { VALID_PRIVATE_KEY, cliPath, registryModuleUrl, startApiServer } from './harness.mjs'; + +export const pluginModuleUrl = distUrl('plugins/grok.js'); +export const contractModuleUrl = distUrl('plugins/grok-contract.js'); +export const ioModuleUrl = distUrl('plugins/grok-io.js'); +export const installationModuleUrl = distUrl('plugins/grok-installation.js'); +const PLUGIN = { exportName: 'grokPlugin', moduleUrl: pluginModuleUrl }; export function runProcess(command, args, env, cwd, input = '', unset = []) { - return new Promise((resolve, reject) => { - const childEnv = { ...process.env, ...env }; - for (const key of unset) delete childEnv[key]; - const child = spawn(command, args, { - cwd, - env: childEnv, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); + return baseProcess(command, args, env, cwd, input, { unset }); } export function runNode(args, env, cwd, input = '', unset = []) { @@ -41,68 +29,23 @@ export function runNode(args, env, cwd, input = '', unset = []) { } export function runGrokHook(command, input, fixture, environment = {}) { - return new Promise((resolve, reject) => { - const child = spawn(command, { - shell: true, - cwd: fixture.projectDir, - env: { - ...process.env, - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - GROK_HOME: fixture.grokHomeOverride ?? '', - ...environment, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); + return runShell(command, { + ...homeEnvironment(fixture), + GROK_HOME: fixture.grokHomeOverride ?? '', + ...environment, + }, fixture.projectDir, input); } export function installConfig(fixture, overrides = {}) { - return { - agentName: 'grok', - orgId: 'org-1', - agentId: 'agent-1', - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('grok', fixture, overrides); } -export async function runPlugin(fixture, method, argument, environment = {}) { - const source = ` - import { grokPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await grokPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - const env = { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - ...environment, - }; - const unset = []; - if (fixture.grokHomeOverride === undefined) unset.push('GROK_HOME'); - else env.GROK_HOME = fixture.grokHomeOverride; - return runNode( - ['--input-type=module', '--eval', source], - env, - fixture.projectDir, - '', - unset, - ); +export function runPlugin(fixture, method, argument, environment = {}) { + const home = optionalEnvironment('GROK_HOME', fixture.grokHomeOverride); + return basePlugin(PLUGIN, fixture, method, argument, { + env: { ...environment, ...home.env }, + unset: home.unset, + }); } async function writeOptional(filePath, contents) { @@ -116,37 +59,25 @@ export async function createFixture({ config, explicitGrokHome = true, } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-grok-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote %GROK_HOOK_EVENT%"); - const projectDir = path.join(rootDir, 'project with spaces'); + const base = await createFixtureRoot( + 'elydora-grok-', + "home with spaces and 'quote %GROK_HOOK_EVENT%", + ); const grokHome = explicitGrokHome - ? path.join(homeDir, 'custom grok') - : path.join(homeDir, '.grok'); + ? path.join(base.homeDir, 'custom grok') + : path.join(base.homeDir, '.grok'); const configPath = path.join(grokHome, 'hooks', 'elydora-audit.json'); - const agentDir = path.join(homeDir, '.elydora', 'agent-1'); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(projectDir, { recursive: true }); await writeOptional(configPath, config); - const fixture = { - agentDir, + return { + ...base, baseUrl, configPath, grokHome, grokHomeOverride: explicitGrokHome ? grokHome : undefined, - guardScriptPath, - homeDir, - hookScriptPath, - projectDir, - rootDir, install(overrides = {}) { return runPlugin(this, 'install', installConfig(this, overrides)); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; - return fixture; } export async function readGrokConfig(filePath) { @@ -184,34 +115,3 @@ export function legacyCommand(scriptPath) { const quote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; return `${quote(process.execPath)} ${quote(scriptPath)}`; } - -export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ headers: request.headers, method: request.method, url: request.url, raw }); - if (request.method === 'GET') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ agent: { status } })); - return; - } - response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); - response.end(operationStatus >= 200 && operationStatus < 300 - ? JSON.stringify({ operation: { accepted: true } }) - : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; -} diff --git a/test-support/harness.mjs b/test-support/harness.mjs new file mode 100644 index 0000000..c919279 --- /dev/null +++ b/test-support/harness.mjs @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parse } from 'jsonc-parser'; + +export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); +export const cliPath = path.resolve('dist/cli.js'); + +export function distUrl(relativePath) { + return pathToFileURL(path.resolve('dist', relativePath)).href; +} + +export const registryModuleUrl = distUrl('plugins/registry.js'); +export const managedInstallationModuleUrl = distUrl('plugins/managed-installation.js'); + +export function runProcess(command, args, env, cwd, input = '', { unset = [], shell = false } = {}) { + return new Promise((resolve, reject) => { + const childEnvironment = { ...process.env, ...env }; + for (const [name, value] of Object.entries(env)) { + if (value === undefined) delete childEnvironment[name]; + } + for (const name of unset) delete childEnvironment[name]; + const child = spawn(command, args, { + cwd, + env: childEnvironment, + shell, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +export function runNode(args, env, cwd, input = '', options = {}) { + return runProcess(process.execPath, args, env, cwd, input, options); +} + +export function runShell(command, env, cwd, input = '') { + return runProcess(command, [], env, cwd, input, { shell: true }); +} + +export function windowsPowerShellPath() { + return path.win32.join( + process.env.SystemRoot || 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); +} + +export function homeEnvironment(fixture) { + return { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir }; +} + +// Sets NAME when a value is configured, otherwise removes it from the child environment. +export function optionalEnvironment(name, value) { + return value === undefined ? { env: {}, unset: [name] } : { env: { [name]: value }, unset: [] }; +} + +export function installConfig(agentName, fixture, overrides = {}) { + return { + agentName, + orgId: 'org-1', + agentId: fixture.agentId, + privateKey: VALID_PRIVATE_KEY, + kid: 'kid-1', + token: 'token-1', + baseUrl: fixture.baseUrl, + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + ...overrides, + }; +} + +export function runPlugin( + plugin, + fixture, + method, + argument, + { env = {}, unset = [], cwd = fixture.projectDir } = {}, +) { + const source = ` + import { ${plugin.exportName} } from ${JSON.stringify(plugin.moduleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await ${plugin.exportName}[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + return runNode( + ['--input-type=module', '--eval', source], + { + ...homeEnvironment(fixture), + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + ...env, + }, + cwd, + '', + { unset }, + ); +} + +export async function createFixtureRoot( + prefix, + homeName, + { agentId = 'agent-1', projectName = 'project with spaces', createHome = false } = {}, +) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), prefix)); + const homeDir = path.join(rootDir, homeName); + const projectDir = path.join(rootDir, projectName); + const agentDir = path.join(homeDir, '.elydora', agentId); + if (createHome) await mkdir(homeDir, { recursive: true }); + await mkdir(projectDir, { recursive: true }); + return { + agentDir, + agentId, + guardScriptPath: path.join(agentDir, 'guard.js'), + homeDir, + hookScriptPath: path.join(agentDir, 'hook.js'), + projectDir, + rootDir, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; +} + +export async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; + await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); +} + +export async function writeOptionalJson(filePath, value) { + if (value !== undefined) await writeJson(filePath, value); +} + +export async function readJson(filePath) { + return JSON.parse(await readFile(filePath, 'utf-8')); +} + +export async function readSettings(filePath) { + const raw = await readFile(filePath, 'utf-8'); + return { raw, settings: JSON.parse(raw) }; +} + +export function parseJsonc(raw) { + const errors = []; + const value = parse(raw, errors, { allowTrailingComma: false, disallowComments: false }); + assert.deepEqual(errors, []); + return value; +} + +export async function readJsoncSettings(filePath) { + const raw = await readFile(filePath, 'utf-8'); + return { raw, settings: parseJsonc(raw) }; +} + +export async function startApiServer({ status: initialStatus = 'active', operationStatus: initialOperationStatus = 201 } = {}) { + const requests = []; + let status = initialStatus; + let operationStatus = initialOperationStatus; + const server = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8'); + requests.push({ headers: request.headers, method: request.method, url: request.url, raw }); + if (request.method === 'GET') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ agent: { status } })); + return; + } + response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); + response.end(operationStatus >= 200 && operationStatus < 300 + ? JSON.stringify({ operation: { accepted: true } }) + : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + setStatus(value) { status = value; }, + setOperationStatus(value) { operationStatus = value; }, + close: () => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }), + }; +} diff --git a/test-support/kimi-test-helpers.mjs b/test-support/kimi-test-helpers.mjs index 5b58d19..462657a 100644 --- a/test-support/kimi-test-helpers.mjs +++ b/test-support/kimi-test-helpers.mjs @@ -1,104 +1,48 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; import { parse as parseToml } from '@decimalturn/toml-patch'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + optionalEnvironment, + runNode as baseNode, + runPlugin as basePlugin, + runShell, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/kimi.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const contractModuleUrl = pathToFileURL(path.resolve('dist/plugins/kimi-contract.js')).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/kimi-io.js')).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/kimi-installation.js'), -).href; -export const cliPath = path.resolve('dist/cli.js'); +export { VALID_PRIVATE_KEY, cliPath, registryModuleUrl, startApiServer } from './harness.mjs'; + +export const pluginModuleUrl = distUrl('plugins/kimi.js'); +export const contractModuleUrl = distUrl('plugins/kimi-contract.js'); +export const ioModuleUrl = distUrl('plugins/kimi-io.js'); +export const installationModuleUrl = distUrl('plugins/kimi-installation.js'); +const PLUGIN = { exportName: 'kimiPlugin', moduleUrl: pluginModuleUrl }; export function runNode(args, env, cwd, input = '', unset = []) { - return new Promise((resolve, reject) => { - const childEnv = { ...process.env, ...env }; - for (const key of unset) delete childEnv[key]; - const child = spawn(process.execPath, args, { - cwd, - env: childEnv, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); + return baseNode(args, env, cwd, input, { unset }); } export function runKimiHook(command, input, fixture, environment = {}) { - return new Promise((resolve, reject) => { - const child = spawn(command, { - shell: true, - cwd: fixture.projectDir, - env: { - ...process.env, - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - KIMI_CODE_HOME: fixture.kimiHomeOverride ?? '', - ...environment, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); + return runShell(command, { + ...homeEnvironment(fixture), + KIMI_CODE_HOME: fixture.kimiHomeOverride ?? '', + ...environment, + }, fixture.projectDir, input); } export function installConfig(fixture, overrides = {}) { - return { - agentName: 'kimi', - orgId: 'org-1', - agentId: 'agent-1', - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('kimi', fixture, overrides); } -export async function runPlugin(fixture, method, argument, environment = {}) { - const source = ` - import { kimiPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await kimiPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - const env = { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - ...environment, - }; - const unset = []; - if (fixture.kimiHomeOverride === undefined) unset.push('KIMI_CODE_HOME'); - else env.KIMI_CODE_HOME = fixture.kimiHomeOverride; - return runNode( - ['--input-type=module', '--eval', source], - env, - fixture.projectDir, - '', - unset, - ); +export function runPlugin(fixture, method, argument, environment = {}) { + const home = optionalEnvironment('KIMI_CODE_HOME', fixture.kimiHomeOverride); + return basePlugin(PLUGIN, fixture, method, argument, { + env: { ...environment, ...home.env }, + unset: home.unset, + }); } async function writeOptional(filePath, contents) { @@ -115,50 +59,37 @@ export async function createFixture({ legacyDetected = true, explicitKimiHome = true, } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kimi-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote %ELYDORA_HOOK_PATH%"); - const projectDir = path.join(rootDir, 'project with spaces'); + const base = await createFixtureRoot( + 'elydora-kimi-', + "home with spaces and 'quote %ELYDORA_HOOK_PATH%", + ); const kimiHome = explicitKimiHome - ? path.join(homeDir, 'custom kimi-code') - : path.join(homeDir, '.kimi-code'); + ? path.join(base.homeDir, 'custom kimi-code') + : path.join(base.homeDir, '.kimi-code'); const stablePath = path.join(kimiHome, 'config.toml'); - const legacyHome = path.join(homeDir, '.kimi'); + const legacyHome = path.join(base.homeDir, '.kimi'); const legacyPath = path.join(legacyHome, 'config.toml'); - const agentDir = path.join(homeDir, '.elydora', 'agent-1'); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(projectDir, { recursive: true }); if (stableDetected && !explicitKimiHome) await mkdir(kimiHome, { recursive: true }); if (legacyDetected) await mkdir(legacyHome, { recursive: true }); await writeOptional(stablePath, stableConfig); await writeOptional(legacyPath, legacyConfig); - const fixture = { - agentDir, + return { + ...base, baseUrl, - guardScriptPath, - homeDir, - hookScriptPath, kimiHome, kimiHomeOverride: explicitKimiHome ? kimiHome : undefined, legacyHome, legacyPath, - projectDir, - rootDir, stablePath, install(overrides = {}) { return runPlugin(this, 'install', installConfig(this, overrides)); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; - return fixture; } -export function readKimiConfig(filePath) { - return import('node:fs/promises') - .then(({ readFile }) => readFile(filePath, 'utf-8')) - .then((raw) => ({ raw, config: parseToml(raw) })); +export async function readKimiConfig(filePath) { + const raw = await readFile(filePath, 'utf-8'); + return { raw, config: parseToml(raw) }; } export function managedHook(config, event) { @@ -182,39 +113,3 @@ export function legacyCommand(scriptPath) { const quote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`; return `${quote(process.execPath)} ${quote(scriptPath)}`; } - -export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ - headers: request.headers, - method: request.method, - url: request.url, - raw, - }); - if (request.method === 'GET') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ agent: { status } })); - return; - } - response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); - response.end(operationStatus >= 200 && operationStatus < 300 - ? JSON.stringify({ operation: { accepted: true } }) - : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; -} diff --git a/test-support/kirocli-test-helpers.mjs b/test-support/kirocli-test-helpers.mjs index aaba2fa..950a173 100644 --- a/test-support/kirocli-test-helpers.mjs +++ b/test-support/kirocli-test-helpers.mjs @@ -1,98 +1,41 @@ -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { + createFixtureRoot, + distUrl, + installConfig as baseInstallConfig, + runPlugin as basePlugin, + runProcess as baseProcess, + writeOptionalJson, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32, 17).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/kirocli.js')).href; -export const contractModuleUrl = pathToFileURL( - path.resolve('dist/plugins/kirocli-contract.js'), -).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/kirocli-installation.js'), -).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/kirocli-io.js')).href; -export const kiroV1ModuleUrl = pathToFileURL( - path.resolve('dist/plugins/kiroide-contract.js'), -).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const cliPath = path.resolve('dist/cli.js'); +export { + VALID_PRIVATE_KEY, + cliPath, + homeEnvironment as environment, + readJson, + registryModuleUrl, + runNode, + runShell, + startApiServer, +} from './harness.mjs'; + +export const pluginModuleUrl = distUrl('plugins/kirocli.js'); +export const contractModuleUrl = distUrl('plugins/kirocli-contract.js'); +export const installationModuleUrl = distUrl('plugins/kirocli-installation.js'); +export const ioModuleUrl = distUrl('plugins/kirocli-io.js'); +export const kiroV1ModuleUrl = distUrl('plugins/kiroide-contract.js'); +const PLUGIN = { exportName: 'kirocliPlugin', moduleUrl: pluginModuleUrl }; export function runProcess(command, args, env, cwd, input = '', shell = false) { - return new Promise((resolve, reject) => { - const childEnvironment = { ...process.env, ...env }; - for (const [name, value] of Object.entries(env)) { - if (value === undefined) delete childEnvironment[name]; - } - const child = spawn(command, args, { - cwd, - env: childEnvironment, - shell, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -export function runNode(args, env, cwd, input = '') { - return runProcess(process.execPath, args, env, cwd, input); -} - -export function runShell(command, env, cwd, input = '') { - return runProcess(command, [], env, cwd, input, true); -} - -export function environment(fixture) { - return { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir }; + return baseProcess(command, args, env, cwd, input, { shell }); } export function installConfig(fixture, overrides = {}) { - return { - agentName: 'kirocli', - orgId: 'org-1', - agentId: fixture.agentId, - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('kirocli', fixture, overrides); } -export async function runPlugin(fixture, method, argument) { - const source = ` - import { kirocliPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await kirocliPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - return runNode( - ['--input-type=module', '--eval', source], - { - ...environment(fixture), - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - }, - fixture.projectDir, - ); -} - -async function writeOptional(filePath, value) { - if (value === undefined) return; - await mkdir(path.dirname(filePath), { recursive: true }); - const source = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; - await writeFile(filePath, source, { mode: 0o600 }); +export function runPlugin(fixture, method, argument) { + return basePlugin(PLUGIN, fixture, method, argument); } export function managedV2Document(overrides = {}) { @@ -112,71 +55,26 @@ export async function createFixture({ existingV2, existingV3, } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kirocli-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote %KIRO%"); - const projectDir = path.join(rootDir, 'workspace with spaces'); - const v2Path = path.join(homeDir, '.kiro', 'agents', 'elydora-audit.json'); - const v3Path = path.join(homeDir, '.kiro', 'hooks', 'elydora-audit.json'); - const agentDir = path.join(homeDir, '.elydora', agentId); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(homeDir, { recursive: true }); - await mkdir(projectDir, { recursive: true }); - await writeOptional(v2Path, existingV2); - await writeOptional(v3Path, existingV3); + const base = await createFixtureRoot( + 'elydora-kirocli-', + "home with spaces and 'quote %KIRO%", + { agentId, projectName: 'workspace with spaces', createHome: true }, + ); + const v2Path = path.join(base.homeDir, '.kiro', 'agents', 'elydora-audit.json'); + const v3Path = path.join(base.homeDir, '.kiro', 'hooks', 'elydora-audit.json'); + await writeOptionalJson(v2Path, existingV2); + await writeOptionalJson(v3Path, existingV3); return { - agentDir, - agentId, + ...base, baseUrl, - guardScriptPath, - homeDir, - hookScriptPath, - projectDir, - rootDir, v2Path, v3Path, install(overrides = {}) { return runPlugin(this, 'install', installConfig(this, overrides)); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; } -export async function readJson(filePath) { - return JSON.parse(await readFile(filePath, 'utf-8')); -} - export function findV3Hook(document, name) { return document.hooks.find((hook) => hook.name === name); } - -export async function startApiServer() { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ method: request.method, url: request.url, raw }); - if (request.method === 'GET') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end('{"agent":{"status":"active"}}'); - return; - } - response.writeHead(201, { 'Content-Type': 'application/json' }); - response.end('{"operation":{"accepted":true}}'); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; -} diff --git a/test-support/kiroide-test-helpers.mjs b/test-support/kiroide-test-helpers.mjs index 8f30a1b..6dba859 100644 --- a/test-support/kiroide-test-helpers.mjs +++ b/test-support/kiroide-test-helpers.mjs @@ -1,95 +1,40 @@ -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { + createFixtureRoot, + distUrl, + installConfig as baseInstallConfig, + runPlugin as basePlugin, + runProcess as baseProcess, + writeOptionalJson, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32, 11).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/kiroide.js')).href; -export const contractModuleUrl = pathToFileURL( - path.resolve('dist/plugins/kiroide-contract.js'), -).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/kiroide-installation.js'), -).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/kiroide-io.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const cliPath = path.resolve('dist/cli.js'); +export { + VALID_PRIVATE_KEY, + cliPath, + homeEnvironment as environment, + readJson, + registryModuleUrl, + runNode, + runShell, + startApiServer, +} from './harness.mjs'; + +export const pluginModuleUrl = distUrl('plugins/kiroide.js'); +export const contractModuleUrl = distUrl('plugins/kiroide-contract.js'); +export const installationModuleUrl = distUrl('plugins/kiroide-installation.js'); +export const ioModuleUrl = distUrl('plugins/kiroide-io.js'); +const PLUGIN = { exportName: 'kiroidePlugin', moduleUrl: pluginModuleUrl }; export function runProcess(command, args, env, cwd, input = '', shell = false) { - return new Promise((resolve, reject) => { - const childEnvironment = { ...process.env, ...env }; - for (const [name, value] of Object.entries(env)) { - if (value === undefined) delete childEnvironment[name]; - } - const child = spawn(command, args, { - cwd, - env: childEnvironment, - shell, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -export function runNode(args, env, cwd, input = '') { - return runProcess(process.execPath, args, env, cwd, input); -} - -export function runShell(command, env, cwd, input = '') { - return runProcess(command, [], env, cwd, input, true); -} - -export function environment(fixture) { - return { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir }; + return baseProcess(command, args, env, cwd, input, { shell }); } export function installConfig(fixture, overrides = {}) { - return { - agentName: 'kiroide', - orgId: 'org-1', - agentId: fixture.agentId, - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('kiroide', fixture, overrides); } -export async function runPlugin(fixture, method, argument) { - const source = ` - import { kiroidePlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await kiroidePlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - return runNode( - ['--input-type=module', '--eval', source], - { - ...environment(fixture), - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - }, - fixture.projectDir, - ); -} - -async function writeOptional(filePath, value) { - if (value === undefined) return; - await mkdir(path.dirname(filePath), { recursive: true }); - const source = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; - await writeFile(filePath, source, { mode: 0o600 }); +export function runPlugin(fixture, method, argument) { + return basePlugin(PLUGIN, fixture, method, argument); } export async function createFixture({ @@ -98,73 +43,28 @@ export async function createFixture({ existingConfig, existingLegacy, } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-kiroide-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote %KIRO%"); - const projectDir = path.join(rootDir, 'workspace with spaces'); - const hooksDir = path.join(projectDir, '.kiro', 'hooks'); + const base = await createFixtureRoot( + 'elydora-kiroide-', + "home with spaces and 'quote %KIRO%", + { agentId, projectName: 'workspace with spaces' }, + ); + const hooksDir = path.join(base.projectDir, '.kiro', 'hooks'); const configPath = path.join(hooksDir, 'elydora-audit.json'); - const legacyConfigPath = path.join(homeDir, '.kiro', 'hooks', 'elydora-audit.kiro.hook'); - const agentDir = path.join(homeDir, '.elydora', agentId); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(projectDir, { recursive: true }); - await writeOptional(configPath, existingConfig); - await writeOptional(legacyConfigPath, existingLegacy); - const fixture = { - agentDir, - agentId, + const legacyConfigPath = path.join(base.homeDir, '.kiro', 'hooks', 'elydora-audit.kiro.hook'); + await writeOptionalJson(configPath, existingConfig); + await writeOptionalJson(legacyConfigPath, existingLegacy); + return { + ...base, baseUrl, configPath, - guardScriptPath, - homeDir, - hookScriptPath, hooksDir, legacyConfigPath, - projectDir, - rootDir, install(overrides = {}) { return runPlugin(this, 'install', installConfig(this, overrides)); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; - return fixture; -} - -export async function readJson(filePath) { - return JSON.parse(await readFile(filePath, 'utf-8')); } export function findHook(document, name) { return document.hooks.find((hook) => hook.name === name); } - -export async function startApiServer() { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ method: request.method, url: request.url, raw }); - if (request.method === 'GET') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end('{"agent":{"status":"active"}}'); - return; - } - response.writeHead(201, { 'Content-Type': 'application/json' }); - response.end('{"operation":{"accepted":true}}'); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; -} diff --git a/test-support/letta.mjs b/test-support/letta.mjs index bd3a4a2..aae45e8 100644 --- a/test-support/letta.mjs +++ b/test-support/letta.mjs @@ -1,92 +1,38 @@ -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + runPlugin as basePlugin, + runProcess, + writeOptionalJson, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32, 23).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/letta.js')).href; -export const configModuleUrl = pathToFileURL(path.resolve('dist/plugins/letta-config.js')).href; -export const contractModuleUrl = pathToFileURL(path.resolve('dist/plugins/letta-contract.js')).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/letta-installation.js'), -).href; -export const sourcesModuleUrl = pathToFileURL(path.resolve('dist/plugins/letta-sources.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const cliPath = path.resolve('dist/cli.js'); +export { + VALID_PRIVATE_KEY, + cliPath, + homeEnvironment as environment, + readJson as readSettings, + registryModuleUrl, + runNode, + runProcess, + startApiServer, +} from './harness.mjs'; -export function runProcess(command, args, env, cwd, input = '') { - return new Promise((resolve, reject) => { - const childEnvironment = { ...process.env, ...env }; - for (const [name, value] of Object.entries(env)) { - if (value === undefined) delete childEnvironment[name]; - } - const child = spawn(command, args, { - cwd, - env: childEnvironment, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -export function runNode(args, env, cwd, input = '') { - return runProcess(process.execPath, args, env, cwd, input); -} - -export function environment(fixture) { - return { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - }; -} +export const pluginModuleUrl = distUrl('plugins/letta.js'); +export const configModuleUrl = distUrl('plugins/letta-config.js'); +export const contractModuleUrl = distUrl('plugins/letta-contract.js'); +export const installationModuleUrl = distUrl('plugins/letta-installation.js'); +export const sourcesModuleUrl = distUrl('plugins/letta-sources.js'); +const PLUGIN = { exportName: 'lettaPlugin', moduleUrl: pluginModuleUrl }; export function installConfig(fixture, overrides = {}) { - return { - agentName: 'letta', - orgId: 'org-1', - agentId: fixture.agentId, - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('letta', fixture, overrides); } -export async function runPlugin(fixture, method, argument) { - const source = ` - import { lettaPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await lettaPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - return runNode( - ['--input-type=module', '--eval', source], - { - ...environment(fixture), - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - }, - fixture.projectDir, - ); -} - -async function writeOptional(filePath, value) { - if (value === undefined) return; - await mkdir(path.dirname(filePath), { recursive: true }); - const source = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; - await writeFile(filePath, source, { mode: 0o600 }); +export function runPlugin(fixture, method, argument) { + return basePlugin(PLUGIN, fixture, method, argument); } export async function createFixture({ @@ -96,45 +42,29 @@ export async function createFixture({ projectSettings, localSettings, } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-letta-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote %HOME%"); - const projectDir = path.join(rootDir, 'workspace with spaces'); - const globalPath = path.join(homeDir, '.letta', 'settings.json'); - const projectPath = path.join(projectDir, '.letta', 'settings.json'); - const localPath = path.join(projectDir, '.letta', 'settings.local.json'); - const agentDir = path.join(homeDir, '.elydora', agentId); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(homeDir, { recursive: true }); - await mkdir(projectDir, { recursive: true }); - await writeOptional(globalPath, globalSettings); - await writeOptional(projectPath, projectSettings); - await writeOptional(localPath, localSettings); + const base = await createFixtureRoot( + 'elydora-letta-', + "home with spaces and 'quote %HOME%", + { agentId, projectName: 'workspace with spaces', createHome: true }, + ); + const globalPath = path.join(base.homeDir, '.letta', 'settings.json'); + const projectPath = path.join(base.projectDir, '.letta', 'settings.json'); + const localPath = path.join(base.projectDir, '.letta', 'settings.local.json'); + await writeOptionalJson(globalPath, globalSettings); + await writeOptionalJson(projectPath, projectSettings); + await writeOptionalJson(localPath, localSettings); return { - agentDir, - agentId, + ...base, baseUrl, globalPath, - guardScriptPath, - homeDir, - hookScriptPath, localPath, - projectDir, projectPath, - rootDir, install(overrides = {}) { return runPlugin(this, 'install', installConfig(this, overrides)); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; } -export async function readSettings(filePath) { - return JSON.parse(await readFile(filePath, 'utf-8')); -} - export function managedHandler(settings, event) { return settings.hooks[event].find((group) => ( group.matcher === '*' @@ -148,40 +78,5 @@ export function runLettaHook(handler, input, fixture) { const args = process.platform === 'win32' ? ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', handler.command] : ['-c', handler.command]; - return runProcess(shell, args, environment(fixture), fixture.projectDir, input); -} - -export async function startApiServer(initialStatus = 'active') { - const requests = []; - let status = initialStatus; - let operationStatus = 201; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ method: request.method, url: request.url, raw }); - if (request.method === 'GET') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ agent: { status } })); - return; - } - response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); - response.end(operationStatus < 400 - ? '{"operation":{"accepted":true}}' - : '{"error":{"code":"AUDIT_UNAVAILABLE"}}'); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - setStatus(value) { status = value; }, - setOperationStatus(value) { operationStatus = value; }, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; + return runProcess(shell, args, homeEnvironment(fixture), fixture.projectDir, input); } diff --git a/test-support/opencode-test-helpers.mjs b/test-support/opencode-test-helpers.mjs index 6ae992c..c553430 100644 --- a/test-support/opencode-test-helpers.mjs +++ b/test-support/opencode-test-helpers.mjs @@ -1,87 +1,40 @@ -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; +import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + runPlugin as basePlugin, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32, 19).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode.js')).href; -export const contractModuleUrl = pathToFileURL( - path.resolve('dist/plugins/opencode-contract.js'), -).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/opencode-installation.js'), -).href; -export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/opencode-io.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const cliPath = path.resolve('dist/cli.js'); +export { + VALID_PRIVATE_KEY, + cliPath, + readJson, + registryModuleUrl, + runNode, + runProcess, + startApiServer, +} from './harness.mjs'; -export function runProcess(command, args, env, cwd, input = '') { - return new Promise((resolve, reject) => { - const childEnvironment = { ...process.env, ...env }; - for (const [name, value] of Object.entries(env)) { - if (value === undefined) delete childEnvironment[name]; - } - const child = spawn(command, args, { - cwd, - env: childEnvironment, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -export function runNode(args, env, cwd, input = '') { - return runProcess(process.execPath, args, env, cwd, input); -} +export const pluginModuleUrl = distUrl('plugins/opencode.js'); +export const contractModuleUrl = distUrl('plugins/opencode-contract.js'); +export const installationModuleUrl = distUrl('plugins/opencode-installation.js'); +export const ioModuleUrl = distUrl('plugins/opencode-io.js'); +const PLUGIN = { exportName: 'opencodePlugin', moduleUrl: pluginModuleUrl }; export function environment(fixture) { - return { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - XDG_CONFIG_HOME: fixture.configRoot, - }; + return { ...homeEnvironment(fixture), XDG_CONFIG_HOME: fixture.configRoot }; } export function installConfig(fixture, overrides = {}) { - return { - agentName: 'opencode', - orgId: 'org-1', - agentId: fixture.agentId, - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('opencode', fixture, overrides); } -export async function runPlugin(fixture, method, argument) { - const source = ` - import { opencodePlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await opencodePlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - return runNode( - ['--input-type=module', '--eval', source], - { - ...environment(fixture), - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - }, - fixture.projectDir, - ); +export function runPlugin(fixture, method, argument) { + return basePlugin(PLUGIN, fixture, method, argument, { env: environment(fixture) }); } async function writeOptional(filePath, value) { @@ -96,33 +49,24 @@ export async function createFixture({ existingPlugin, existingLegacy, } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-opencode-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote %OPEN%"); - const configRoot = path.join(rootDir, 'xdg config'); - const projectDir = path.join(rootDir, 'workspace with spaces'); + const base = await createFixtureRoot( + 'elydora-opencode-', + "home with spaces and 'quote %OPEN%", + { agentId, projectName: 'workspace with spaces', createHome: true }, + ); + const configRoot = path.join(base.rootDir, 'xdg config'); const pluginsDir = path.join(configRoot, 'opencode', 'plugins'); const pluginPath = path.join(pluginsDir, 'elydora-audit.js'); const legacyPluginPath = path.join(pluginsDir, 'elydora-audit.mjs'); - const agentDir = path.join(homeDir, '.elydora', agentId); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(homeDir, { recursive: true }); - await mkdir(projectDir, { recursive: true }); await writeOptional(pluginPath, existingPlugin); await writeOptional(legacyPluginPath, existingLegacy); return { - agentDir, - agentId, + ...base, baseUrl, configRoot, - guardScriptPath, - homeDir, - hookScriptPath, legacyPluginPath, pluginPath, pluginsDir, - projectDir, - rootDir, install(overrides = {}) { return runPlugin(this, 'install', installConfig(this, overrides)); }, @@ -130,51 +74,5 @@ export async function createFixture({ const module = await import(`${pathToFileURL(this.pluginPath).href}?test=${Date.now()}`); return module.ElydoraAuditPlugin({}); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, - }; -} - -export async function readJson(filePath) { - return JSON.parse(await readFile(filePath, 'utf-8')); -} - -export async function startApiServer(initialStatus = 'active') { - const requests = []; - let status = initialStatus; - let operationStatus = 201; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ method: request.method, url: request.url, raw }); - if (request.method === 'GET') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ agent: { status } })); - return; - } - response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); - response.end(operationStatus < 400 - ? '{"operation":{"accepted":true}}' - : '{"error":{"code":"AUDIT_UNAVAILABLE"}}'); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - setStatus(value) { - status = value; - }, - setOperationStatus(value) { - operationStatus = value; - }, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), }; } diff --git a/test-support/qwen.mjs b/test-support/qwen.mjs index 35c5b9f..4e3d2a0 100644 --- a/test-support/qwen.mjs +++ b/test-support/qwen.mjs @@ -1,28 +1,32 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { parse } from 'jsonc-parser'; +import { + createFixtureRoot, + distUrl, + homeEnvironment, + installConfig as baseInstallConfig, + runPlugin as basePlugin, + runProcess as baseProcess, + windowsPowerShellPath, + writeOptionalJson, +} from './harness.mjs'; -export const VALID_PRIVATE_KEY = Buffer.alloc(32).toString('base64url'); -export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/qwen.js')).href; -export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -export const sourcesModuleUrl = pathToFileURL( - path.resolve('dist/plugins/qwen-sources.js'), -).href; -export const installationModuleUrl = pathToFileURL( - path.resolve('dist/plugins/qwen-installation.js'), -).href; -export const configModuleUrl = pathToFileURL( - path.resolve('dist/plugins/qwen-config.js'), -).href; -export const contractModuleUrl = pathToFileURL( - path.resolve('dist/plugins/qwen-contract.js'), -).href; -export const cliPath = path.resolve('dist/cli.js'); +export { + VALID_PRIVATE_KEY, + cliPath, + parseJsonc as parseSettings, + readJsoncSettings as readSettings, + registryModuleUrl, + startApiServer, + writeJson as writeSettings, +} from './harness.mjs'; + +export const pluginModuleUrl = distUrl('plugins/qwen.js'); +export const sourcesModuleUrl = distUrl('plugins/qwen-sources.js'); +export const installationModuleUrl = distUrl('plugins/qwen-installation.js'); +export const configModuleUrl = distUrl('plugins/qwen-config.js'); +export const contractModuleUrl = distUrl('plugins/qwen-contract.js'); +const PLUGIN = { exportName: 'qwenPlugin', moduleUrl: pluginModuleUrl }; const QWEN_ENV_KEYS = [ 'QWEN_HOME', @@ -33,48 +37,18 @@ const QWEN_ENV_KEYS = [ ]; export function runProcess(command, args, env, cwd, input = '', unset = []) { - return new Promise((resolve, reject) => { - const childEnv = { ...process.env, ...env }; - for (const key of unset) delete childEnv[key]; - const child = spawn(command, args, { - cwd, - env: childEnv, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.once('error', reject); - child.once('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); + return baseProcess(command, args, env, cwd, input, { unset }); } export function runNode(args, env, cwd, input = '', unset = []) { return runProcess(process.execPath, args, env, cwd, input, unset); } -function windowsPowerShell() { - return path.win32.join( - process.env.SystemRoot || 'C:\\Windows', - 'System32', - 'WindowsPowerShell', - 'v1.0', - 'powershell.exe', - ); -} - export function runQwenHook(handler, input, fixture, environment = {}) { - const env = { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ...environment, - }; + const env = { ...homeEnvironment(fixture), ...environment }; if (process.platform === 'win32') { return runProcess( - windowsPowerShell(), + windowsPowerShellPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', handler.command], env, fixture.projectDir, @@ -84,109 +58,36 @@ export function runQwenHook(handler, input, fixture, environment = {}) { return runProcess('/bin/bash', ['-c', handler.command], env, fixture.projectDir, input); } -export async function writeSettings(filePath, value) { - await mkdir(path.dirname(filePath), { recursive: true }); - const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; - await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); -} - -export function parseSettings(raw) { - const errors = []; - const value = parse(raw, errors, { - allowTrailingComma: false, - disallowComments: false, - }); - assert.deepEqual(errors, []); - return value; -} - -export async function readSettings(filePath) { - const raw = await readFile(filePath, 'utf-8'); - return { raw, settings: parseSettings(raw) }; -} - export function installConfig(fixture, overrides = {}) { - return { - agentName: 'qwen', - orgId: 'org-1', - agentId: 'agent-1', - privateKey: VALID_PRIVATE_KEY, - kid: 'kid-1', - token: 'token-1', - baseUrl: fixture.baseUrl, - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - ...overrides, - }; + return baseInstallConfig('qwen', fixture, overrides); } -function qwenEnvironment(fixture, environment) { +export function runPlugin(fixture, method, argument, environment = {}) { const env = { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, QWEN_CODE_SYSTEM_DEFAULTS_PATH: path.join(fixture.rootDir, 'system-defaults.json'), QWEN_CODE_SYSTEM_SETTINGS_PATH: path.join(fixture.rootDir, 'system-settings.json'), ...environment, }; const unset = QWEN_ENV_KEYS.filter((key) => !Object.hasOwn(env, key)); - return { env, unset }; + return basePlugin(PLUGIN, fixture, method, argument, { env, unset }); } -export async function runPlugin(fixture, method, argument, environment = {}) { - const source = ` - import { qwenPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await qwenPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - const configured = qwenEnvironment(fixture, { - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - ...environment, - }); - return runNode( - ['--input-type=module', '--eval', source], - configured.env, - fixture.projectDir, - '', - configured.unset, - ); -} - -export async function createFixture({ - baseUrl = 'http://127.0.0.1:9', - settings, -} = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-qwen-')); - const homeDir = path.join( - rootDir, +export async function createFixture({ baseUrl = 'http://127.0.0.1:9', settings } = {}) { + const base = await createFixtureRoot( + 'elydora-qwen-', "home with spaces and 'quote $QWEN_PROJECT_DIR %QWEN_PROJECT_DIR%", + { createHome: true }, ); - const projectDir = path.join(rootDir, 'project with spaces'); - const settingsPath = path.join(homeDir, '.qwen', 'settings.json'); - const agentDir = path.join(homeDir, '.elydora', 'agent-1'); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(homeDir, { recursive: true }); - await mkdir(projectDir, { recursive: true }); - if (settings !== undefined) await writeSettings(settingsPath, settings); - const fixture = { - agentDir, + const settingsPath = path.join(base.homeDir, '.qwen', 'settings.json'); + await writeOptionalJson(settingsPath, settings); + return { + ...base, baseUrl, - guardScriptPath, - homeDir, - hookScriptPath, - projectDir, - rootDir, settingsPath, install(overrides = {}, environment = {}) { return runPlugin(this, 'install', installConfig(this, overrides), environment); }, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, }; - return fixture; } export function managedHandler(settings, event, name) { @@ -243,34 +144,3 @@ export function legacyGroup(scriptPath) { }], }; } - -export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { - const requests = []; - const server = http.createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString('utf-8'); - requests.push({ headers: request.headers, method: request.method, url: request.url, raw }); - if (request.method === 'GET') { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ agent: { status } })); - return; - } - response.writeHead(operationStatus, { 'Content-Type': 'application/json' }); - response.end(operationStatus >= 200 && operationStatus < 300 - ? JSON.stringify({ operation: { accepted: true } }) - : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); - }), - }; -} diff --git a/test/cli-security.test.mjs b/test/cli-security.test.mjs index fdb7e81..cdb9df5 100644 --- a/test/cli-security.test.mjs +++ b/test/cli-security.test.mjs @@ -18,6 +18,7 @@ async function runCli(args, homeDir) { ...process.env, HOME: homeDir, USERPROFILE: homeDir, + XDG_CONFIG_HOME: path.join(homeDir, '.config'), }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/test/guard-template.test.mjs b/test/guard-template.test.mjs index f43d356..eff184f 100644 --- a/test/guard-template.test.mjs +++ b/test/guard-template.test.mjs @@ -26,6 +26,7 @@ async function createFixture(status) { await writeFile( path.join(agentDir, 'config.json'), JSON.stringify({ agent_id: AGENT_ID, base_url: `http://127.0.0.1:${address.port}` }), + { mode: 0o600 }, ); const scriptPath = path.join(homeDir, 'guard.cjs'); @@ -91,6 +92,7 @@ test('cached frozen status keeps the blocking exit code', async () => { await writeFile( path.join(fixture.homeDir, '.elydora', AGENT_ID, 'status-cache.json'), JSON.stringify({ status: 'frozen', cached_at: Date.now() }), + { mode: 0o600 }, ); const result = await runGuard(fixture.scriptPath, fixture.homeDir); diff --git a/test/letta-runtime.test.mjs b/test/letta-runtime.test.mjs index 1c2fcb1..f828c12 100644 --- a/test/letta-runtime.test.mjs +++ b/test/letta-runtime.test.mjs @@ -60,7 +60,7 @@ async function installedHandlers(fixture) { } test('Letta guard accepts active agents with native exit semantics', async () => { - const api = await startApiServer('active'); + const api = await startApiServer({ status: 'active' }); const fixture = await createFixture({ baseUrl: api.baseUrl }); try { assert.equal((await fixture.install()).code, 0); @@ -82,7 +82,7 @@ test('Letta guard accepts active agents with native exit semantics', async () => test('Letta guard propagates frozen and revoked states through exit code 2', async (t) => { for (const status of ['frozen', 'revoked']) { await t.test(status, async () => { - const api = await startApiServer(status); + const api = await startApiServer({ status }); const fixture = await createFixture({ baseUrl: api.baseUrl }); try { assert.equal((await fixture.install()).code, 0);