fix(test): isolate integration tests from production hub via temp hub globalSetup (#734)

This commit is contained in:
SSU-WEI HUANG
2026-05-31 20:31:05 +08:00
committed by GitHub
parent 994a820e43
commit df35a8c523
6 changed files with 170 additions and 21 deletions
+4 -10
View File
@@ -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,13 +435,8 @@ 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}`);
@@ -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).
}
});
+116
View File
@@ -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<number> {
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<void> {
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<void> {
if (!hubProcess || hubProcess.exitCode !== null) return
await new Promise<void>((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 })
}
}
+29
View File
@@ -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'
+8
View File
@@ -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/']);
+10
View File
@@ -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,
+2 -10
View File
@@ -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: {