From df35a8c5233656b7319f57f04182730804ded309 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Sun, 31 May 2026 20:31:05 +0800 Subject: [PATCH] fix(test): isolate integration tests from production hub via temp hub globalSetup (#734) --- cli/src/runner/runner.integration.test.ts | 16 +-- cli/src/test/globalSetup.ts | 116 ++++++++++++++++++++++ cli/src/test/setup.ts | 29 ++++++ cli/src/utils/spawnHappyCLI.test.ts | 8 ++ cli/src/utils/spawnHappyCLI.ts | 10 ++ cli/vitest.config.ts | 12 +-- 6 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 cli/src/test/globalSetup.ts create mode 100644 cli/src/test/setup.ts diff --git a/cli/src/runner/runner.integration.test.ts b/cli/src/runner/runner.integration.test.ts index 94c3f48a..9d3f1547 100644 --- a/cli/src/runner/runner.integration.test.ts +++ b/cli/src/runner/runner.integration.test.ts @@ -16,7 +16,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { execSync, spawn } from 'child_process'; +import { spawn } from 'child_process'; import { existsSync, unlinkSync, readFileSync, writeFileSync, readdirSync } from 'fs'; import path, { join } from 'path'; import { configuration } from '@/configuration'; @@ -435,14 +435,9 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: expect(initialState!.startedWithCliVersion).toBe(originalVersion); const initialPid = initialState!.pid; - // Re-build the CLI - so it will import the new package.json in its configuartion.ts - // and think it is a new version - // We are not using yarn build here because it cleans out dist/ - // and we want to avoid that, - // otherwise runner will spawn a non existing happy js script. - // We need to remove index, but not the other files, otherwise some of our code might fail when called from within the runner. - execSync('yarn build', { stdio: 'ignore' }); - + // No rebuild needed: bun runs TypeScript directly, so the spawned runner + // process reads package.json fresh and picks up the modified version automatically. + console.log(`[TEST] Current runner running with version ${originalVersion}, PID: ${initialPid}`); console.log(`[TEST] Changed package.json version to ${testVersion}`); @@ -462,8 +457,7 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: writeFileSync(packagePath, packageJsonOriginalRawText); console.log(`[TEST] Restored package.json version to ${originalVersion}`); - // Lets rebuild it so we keep it as we found it - execSync('yarn build', { stdio: 'ignore' }); + // No rebuild needed with bun (TypeScript is run directly). } }); diff --git a/cli/src/test/globalSetup.ts b/cli/src/test/globalSetup.ts new file mode 100644 index 00000000..ed117b86 --- /dev/null +++ b/cli/src/test/globalSetup.ts @@ -0,0 +1,116 @@ +import { randomBytes } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import net from 'node:net' +import { spawn, execSync } from 'node:child_process' +import type { ChildProcess } from 'node:child_process' + +// Workers can't inherit process.env from globalSetup, so we write config to a file +// and let setupFile.ts read it in each worker. +export const TEST_CONFIG_FILE = join(tmpdir(), 'hapi-test-config.json') + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as net.AddressInfo + server.close(() => resolve(addr.port)) + }) + server.on('error', reject) + }) +} + +async function waitForHub(baseUrl: string, timeoutMs = 15_000): Promise { + const healthUrl = `${baseUrl}/health` + const start = Date.now() + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(healthUrl, { signal: AbortSignal.timeout(1000) }) + if (res.ok) return + } catch { + // not ready yet — connection refused or timeout + } + await new Promise(resolve => setTimeout(resolve, 200)) + } + throw new Error(`Hub did not become ready within ${timeoutMs}ms`) +} + +function findBunExec(): string { + const cmd = process.platform === 'win32' ? 'where bun' : 'command -v bun' + const p = execSync(cmd, { encoding: 'utf8' }) + .split(/\r?\n/) + .map(line => line.trim()) + .find(Boolean) + if (!p) throw new Error('[globalSetup] bun executable not found') + return p +} + +let hubProcess: ChildProcess | null = null +let tmpHome: string | null = null + +export async function setup() { + const port = await getFreePort() + tmpHome = mkdtempSync(join(tmpdir(), 'hapi-test-')) + const token = randomBytes(20).toString('base64url') + const bunExec = findBunExec() + + // Use a minimal env whitelist to prevent shell credentials (DB_PATH, + // TELEGRAM_BOT_TOKEN, ELEVENLABS_API_KEY, etc.) from leaking into the + // test hub and triggering real notifications or opening a production DB. + const hubEnv: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + HOME: process.env.HOME, + ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}), + ...(process.env.BUN_INSTALL ? { BUN_INSTALL: process.env.BUN_INSTALL } : {}), + HAPI_HOME: tmpHome, + DB_PATH: join(tmpHome, 'hapi.db'), + HAPI_LISTEN_PORT: String(port), + HAPI_LISTEN_HOST: '127.0.0.1', + HAPI_PUBLIC_URL: `http://127.0.0.1:${port}`, + CLI_API_TOKEN: token, + TELEGRAM_NOTIFICATION: 'false', + SERVERCHAN_NOTIFICATION: 'false', + } + + // Write config so setupFile.ts can inject env vars into each test worker + writeFileSync(TEST_CONFIG_FILE, JSON.stringify({ port, token, tmpHome, bunExec })) + + const hubEntry = join( + dirname(fileURLToPath(import.meta.url)), + '../../../hub/src/index.ts' + ) + + hubProcess = spawn(bunExec, ['run', hubEntry], { + env: hubEnv, + stdio: 'ignore', + }) + + hubProcess.on('error', (err) => { + throw new Error(`[globalSetup] Failed to spawn hub: ${err.message}`) + }) + + await waitForHub(`http://127.0.0.1:${port}`) +} + +async function stopHubProcess(): Promise { + if (!hubProcess || hubProcess.exitCode !== null) return + + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 5_000) + hubProcess!.once('exit', () => { + clearTimeout(timeout) + resolve() + }) + hubProcess!.kill() + }) +} + +export async function teardown() { + await stopHubProcess() + try { rmSync(TEST_CONFIG_FILE) } catch {} + if (tmpHome) { + rmSync(tmpHome, { recursive: true, force: true }) + } +} diff --git a/cli/src/test/setup.ts b/cli/src/test/setup.ts new file mode 100644 index 00000000..2f55a21f --- /dev/null +++ b/cli/src/test/setup.ts @@ -0,0 +1,29 @@ +import { readFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// This file runs at the top level of each vitest worker, before any test file +// is imported. It reads the hub config written by globalSetup.ts and injects +// the env vars so the CLI configuration singleton sees the temp hub. +const CONFIG_FILE = join(tmpdir(), 'hapi-test-config.json') + +if (!existsSync(CONFIG_FILE)) { + throw new Error( + `[test setup] Missing isolated hub config: ${CONFIG_FILE}\n` + + 'Run the full test suite via "pnpm test" so globalSetup can spin up a temp hub first.' + ) +} + +let config: { port: number; token: string; tmpHome: string; bunExec: string } +try { + config = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) +} catch (err) { + throw new Error(`[test setup] Failed to parse hub config at ${CONFIG_FILE}: ${err}`) +} + +process.env.HAPI_API_URL = `http://127.0.0.1:${config.port}` +process.env.CLI_API_TOKEN = config.token +process.env.HAPI_HOME = config.tmpHome +process.env.HAPI_BUN_EXEC = config.bunExec +// Keep heartbeat short so the version-mismatch test doesn't need to wait 60s +process.env.HAPI_RUNNER_HEARTBEAT_INTERVAL ??= '30000' diff --git a/cli/src/utils/spawnHappyCLI.test.ts b/cli/src/utils/spawnHappyCLI.test.ts index eae409d7..18cddb06 100644 --- a/cli/src/utils/spawnHappyCLI.test.ts +++ b/cli/src/utils/spawnHappyCLI.test.ts @@ -37,6 +37,7 @@ vi.mock('@/projectPath', () => ({ const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); const originalInvokedCwd = process.env.HAPI_INVOKED_CWD; const originalCliExecutable = process.env.HAPI_CLI_EXECUTABLE; +const originalBunExec = process.env.HAPI_BUN_EXEC; function setPlatform(value: string) { Object.defineProperty(process, 'platform', { @@ -78,6 +79,11 @@ describe('spawnHappyCLI windowsHide behavior', () => { } else { process.env.HAPI_CLI_EXECUTABLE = originalCliExecutable; } + if (originalBunExec === undefined) { + delete process.env.HAPI_BUN_EXEC; + } else { + process.env.HAPI_BUN_EXEC = originalBunExec; + } }); afterAll(() => { @@ -129,6 +135,8 @@ describe('spawnHappyCLI windowsHide behavior', () => { }); it('forces Bun child processes to run with the cli project root as cwd', async () => { + // Clear the test-isolation override so we test the pure runtime behaviour + delete process.env.HAPI_BUN_EXEC; const { getHappyCliCommand } = await import('./spawnHappyCLI'); const command = getHappyCliCommand(['mcp', '--url', 'http://127.0.0.1:1234/']); diff --git a/cli/src/utils/spawnHappyCLI.ts b/cli/src/utils/spawnHappyCLI.ts index 0f207fd7..ccf34b82 100644 --- a/cli/src/utils/spawnHappyCLI.ts +++ b/cli/src/utils/spawnHappyCLI.ts @@ -116,6 +116,16 @@ export function getHappyCliCommand(args: string[]): HappyCliCommand { }; } + // When vitest runs under Node.js, HAPI_BUN_EXEC can point to the bun binary so that + // spawned CLI child processes still run under bun (which is required for TypeScript entrypoints). + const bunExecOverride = process.env['HAPI_BUN_EXEC']?.trim(); + if (bunExecOverride && isCrossPlatformAbsolutePath(bunExecOverride) && existsSync(bunExecOverride)) { + return { + command: bunExecOverride, + args: ['--cwd', projectRoot, entrypoint, ...args] + }; + } + // Node.js fallback: preserve execArgv (for compatibility) return { command: process.execPath, diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts index 121f6bd3..70469ff5 100644 --- a/cli/vitest.config.ts +++ b/cli/vitest.config.ts @@ -1,17 +1,13 @@ import { defineConfig } from 'vitest/config' import { resolve } from 'node:path' -import dotenv from 'dotenv' - -const testEnv = dotenv.config({ - path: '.env.integration-test' -}).parsed - export default defineConfig({ test: { globals: false, environment: 'node', include: ['src/**/*.test.ts'], + globalSetup: './src/test/globalSetup.ts', + setupFiles: './src/test/setup.ts', coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], @@ -23,10 +19,6 @@ export default defineConfig({ '**/mockData/**', ], }, - env: { - ...process.env, - ...testEnv, - } }, resolve: { alias: {