Skip to content

Commit 3110d28

Browse files
committed
merge(upstream): sync tiann/hapi main into fork main (df35a8c)
2 parents aaea2ac + df35a8c commit 3110d28

6 files changed

Lines changed: 170 additions & 21 deletions

File tree

cli/src/runner/runner.integration.test.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
19-
import { execSync, spawn } from 'child_process';
19+
import { spawn } from 'child_process';
2020
import { existsSync, unlinkSync, readFileSync, writeFileSync, readdirSync } from 'fs';
2121
import path, { join } from 'path';
2222
import { configuration } from '@/configuration';
@@ -435,14 +435,9 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout:
435435
expect(initialState!.startedWithCliVersion).toBe(originalVersion);
436436
const initialPid = initialState!.pid;
437437

438-
// Re-build the CLI - so it will import the new package.json in its configuartion.ts
439-
// and think it is a new version
440-
// We are not using yarn build here because it cleans out dist/
441-
// and we want to avoid that,
442-
// otherwise runner will spawn a non existing happy js script.
443-
// We need to remove index, but not the other files, otherwise some of our code might fail when called from within the runner.
444-
execSync('yarn build', { stdio: 'ignore' });
445-
438+
// No rebuild needed: bun runs TypeScript directly, so the spawned runner
439+
// process reads package.json fresh and picks up the modified version automatically.
440+
446441
console.log(`[TEST] Current runner running with version ${originalVersion}, PID: ${initialPid}`);
447442

448443
console.log(`[TEST] Changed package.json version to ${testVersion}`);
@@ -462,8 +457,7 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout:
462457
writeFileSync(packagePath, packageJsonOriginalRawText);
463458
console.log(`[TEST] Restored package.json version to ${originalVersion}`);
464459

465-
// Lets rebuild it so we keep it as we found it
466-
execSync('yarn build', { stdio: 'ignore' });
460+
// No rebuild needed with bun (TypeScript is run directly).
467461
}
468462
});
469463

cli/src/test/globalSetup.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { randomBytes } from 'node:crypto'
2+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import { join, dirname } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import net from 'node:net'
7+
import { spawn, execSync } from 'node:child_process'
8+
import type { ChildProcess } from 'node:child_process'
9+
10+
// Workers can't inherit process.env from globalSetup, so we write config to a file
11+
// and let setupFile.ts read it in each worker.
12+
export const TEST_CONFIG_FILE = join(tmpdir(), 'hapi-test-config.json')
13+
14+
async function getFreePort(): Promise<number> {
15+
return new Promise((resolve, reject) => {
16+
const server = net.createServer()
17+
server.listen(0, '127.0.0.1', () => {
18+
const addr = server.address() as net.AddressInfo
19+
server.close(() => resolve(addr.port))
20+
})
21+
server.on('error', reject)
22+
})
23+
}
24+
25+
async function waitForHub(baseUrl: string, timeoutMs = 15_000): Promise<void> {
26+
const healthUrl = `${baseUrl}/health`
27+
const start = Date.now()
28+
while (Date.now() - start < timeoutMs) {
29+
try {
30+
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(1000) })
31+
if (res.ok) return
32+
} catch {
33+
// not ready yet — connection refused or timeout
34+
}
35+
await new Promise(resolve => setTimeout(resolve, 200))
36+
}
37+
throw new Error(`Hub did not become ready within ${timeoutMs}ms`)
38+
}
39+
40+
function findBunExec(): string {
41+
const cmd = process.platform === 'win32' ? 'where bun' : 'command -v bun'
42+
const p = execSync(cmd, { encoding: 'utf8' })
43+
.split(/\r?\n/)
44+
.map(line => line.trim())
45+
.find(Boolean)
46+
if (!p) throw new Error('[globalSetup] bun executable not found')
47+
return p
48+
}
49+
50+
let hubProcess: ChildProcess | null = null
51+
let tmpHome: string | null = null
52+
53+
export async function setup() {
54+
const port = await getFreePort()
55+
tmpHome = mkdtempSync(join(tmpdir(), 'hapi-test-'))
56+
const token = randomBytes(20).toString('base64url')
57+
const bunExec = findBunExec()
58+
59+
// Use a minimal env whitelist to prevent shell credentials (DB_PATH,
60+
// TELEGRAM_BOT_TOKEN, ELEVENLABS_API_KEY, etc.) from leaking into the
61+
// test hub and triggering real notifications or opening a production DB.
62+
const hubEnv: NodeJS.ProcessEnv = {
63+
PATH: process.env.PATH,
64+
HOME: process.env.HOME,
65+
...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}),
66+
...(process.env.BUN_INSTALL ? { BUN_INSTALL: process.env.BUN_INSTALL } : {}),
67+
HAPI_HOME: tmpHome,
68+
DB_PATH: join(tmpHome, 'hapi.db'),
69+
HAPI_LISTEN_PORT: String(port),
70+
HAPI_LISTEN_HOST: '127.0.0.1',
71+
HAPI_PUBLIC_URL: `http://127.0.0.1:${port}`,
72+
CLI_API_TOKEN: token,
73+
TELEGRAM_NOTIFICATION: 'false',
74+
SERVERCHAN_NOTIFICATION: 'false',
75+
}
76+
77+
// Write config so setupFile.ts can inject env vars into each test worker
78+
writeFileSync(TEST_CONFIG_FILE, JSON.stringify({ port, token, tmpHome, bunExec }))
79+
80+
const hubEntry = join(
81+
dirname(fileURLToPath(import.meta.url)),
82+
'../../../hub/src/index.ts'
83+
)
84+
85+
hubProcess = spawn(bunExec, ['run', hubEntry], {
86+
env: hubEnv,
87+
stdio: 'ignore',
88+
})
89+
90+
hubProcess.on('error', (err) => {
91+
throw new Error(`[globalSetup] Failed to spawn hub: ${err.message}`)
92+
})
93+
94+
await waitForHub(`http://127.0.0.1:${port}`)
95+
}
96+
97+
async function stopHubProcess(): Promise<void> {
98+
if (!hubProcess || hubProcess.exitCode !== null) return
99+
100+
await new Promise<void>((resolve) => {
101+
const timeout = setTimeout(resolve, 5_000)
102+
hubProcess!.once('exit', () => {
103+
clearTimeout(timeout)
104+
resolve()
105+
})
106+
hubProcess!.kill()
107+
})
108+
}
109+
110+
export async function teardown() {
111+
await stopHubProcess()
112+
try { rmSync(TEST_CONFIG_FILE) } catch {}
113+
if (tmpHome) {
114+
rmSync(tmpHome, { recursive: true, force: true })
115+
}
116+
}

cli/src/test/setup.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { readFileSync, existsSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
5+
// This file runs at the top level of each vitest worker, before any test file
6+
// is imported. It reads the hub config written by globalSetup.ts and injects
7+
// the env vars so the CLI configuration singleton sees the temp hub.
8+
const CONFIG_FILE = join(tmpdir(), 'hapi-test-config.json')
9+
10+
if (!existsSync(CONFIG_FILE)) {
11+
throw new Error(
12+
`[test setup] Missing isolated hub config: ${CONFIG_FILE}\n` +
13+
'Run the full test suite via "pnpm test" so globalSetup can spin up a temp hub first.'
14+
)
15+
}
16+
17+
let config: { port: number; token: string; tmpHome: string; bunExec: string }
18+
try {
19+
config = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
20+
} catch (err) {
21+
throw new Error(`[test setup] Failed to parse hub config at ${CONFIG_FILE}: ${err}`)
22+
}
23+
24+
process.env.HAPI_API_URL = `http://127.0.0.1:${config.port}`
25+
process.env.CLI_API_TOKEN = config.token
26+
process.env.HAPI_HOME = config.tmpHome
27+
process.env.HAPI_BUN_EXEC = config.bunExec
28+
// Keep heartbeat short so the version-mismatch test doesn't need to wait 60s
29+
process.env.HAPI_RUNNER_HEARTBEAT_INTERVAL ??= '30000'

cli/src/utils/spawnHappyCLI.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ vi.mock('@/projectPath', () => ({
3737
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
3838
const originalInvokedCwd = process.env.HAPI_INVOKED_CWD;
3939
const originalCliExecutable = process.env.HAPI_CLI_EXECUTABLE;
40+
const originalBunExec = process.env.HAPI_BUN_EXEC;
4041

4142
function setPlatform(value: string) {
4243
Object.defineProperty(process, 'platform', {
@@ -78,6 +79,11 @@ describe('spawnHappyCLI windowsHide behavior', () => {
7879
} else {
7980
process.env.HAPI_CLI_EXECUTABLE = originalCliExecutable;
8081
}
82+
if (originalBunExec === undefined) {
83+
delete process.env.HAPI_BUN_EXEC;
84+
} else {
85+
process.env.HAPI_BUN_EXEC = originalBunExec;
86+
}
8187
});
8288

8389
afterAll(() => {
@@ -129,6 +135,8 @@ describe('spawnHappyCLI windowsHide behavior', () => {
129135
});
130136

131137
it('forces Bun child processes to run with the cli project root as cwd', async () => {
138+
// Clear the test-isolation override so we test the pure runtime behaviour
139+
delete process.env.HAPI_BUN_EXEC;
132140
const { getHappyCliCommand } = await import('./spawnHappyCLI');
133141

134142
const command = getHappyCliCommand(['mcp', '--url', 'http://127.0.0.1:1234/']);

cli/src/utils/spawnHappyCLI.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,16 @@ export function getHappyCliCommand(args: string[]): HappyCliCommand {
116116
};
117117
}
118118

119+
// When vitest runs under Node.js, HAPI_BUN_EXEC can point to the bun binary so that
120+
// spawned CLI child processes still run under bun (which is required for TypeScript entrypoints).
121+
const bunExecOverride = process.env['HAPI_BUN_EXEC']?.trim();
122+
if (bunExecOverride && isCrossPlatformAbsolutePath(bunExecOverride) && existsSync(bunExecOverride)) {
123+
return {
124+
command: bunExecOverride,
125+
args: ['--cwd', projectRoot, entrypoint, ...args]
126+
};
127+
}
128+
119129
// Node.js fallback: preserve execArgv (for compatibility)
120130
return {
121131
command: process.execPath,

cli/vitest.config.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import { defineConfig } from 'vitest/config'
22
import { resolve } from 'node:path'
33

4-
import dotenv from 'dotenv'
5-
6-
const testEnv = dotenv.config({
7-
path: '.env.integration-test'
8-
}).parsed
9-
104
export default defineConfig({
115
test: {
126
globals: false,
137
environment: 'node',
148
include: ['src/**/*.test.ts'],
9+
globalSetup: './src/test/globalSetup.ts',
10+
setupFiles: './src/test/setup.ts',
1511
coverage: {
1612
provider: 'v8',
1713
reporter: ['text', 'json', 'html'],
@@ -23,10 +19,6 @@ export default defineConfig({
2319
'**/mockData/**',
2420
],
2521
},
26-
env: {
27-
...process.env,
28-
...testEnv,
29-
}
3022
},
3123
resolve: {
3224
alias: {

0 commit comments

Comments
 (0)