mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(test): isolate integration tests from production hub via temp hub globalSetup (#734)
This commit is contained in:
@@ -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).
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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/']);
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user