fix(hapi): consolidate approved web and Codex recovery fixes (#578)

This commit is contained in:
xiaobaifly7
2026-05-06 20:02:46 +08:00
committed by GitHub
parent 8185f0287e
commit 6df84df756
14 changed files with 1091 additions and 53 deletions
+85 -4
View File
@@ -1,7 +1,17 @@
import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SpawnOptions } from 'child_process';
const spawnMock = vi.fn((..._args: any[]) => ({ pid: 12345 } as any));
const {
spawnMock,
existsSyncMock,
isBunCompiledMock,
projectPathMock
} = vi.hoisted(() => ({
spawnMock: vi.fn((..._args: any[]) => ({ pid: 12345 }) as any),
existsSyncMock: vi.fn((path: string) => !path.includes('missing-hapi.exe')),
isBunCompiledMock: vi.fn(() => false),
projectPathMock: vi.fn(() => process.cwd())
}));
vi.mock('child_process', async () => {
const actual = await vi.importActual<typeof import('child_process')>('child_process');
@@ -11,8 +21,22 @@ vi.mock('child_process', async () => {
};
});
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
return {
...actual,
existsSync: existsSyncMock
};
});
vi.mock('@/projectPath', () => ({
isBunCompiled: isBunCompiledMock,
projectPath: projectPathMock
}));
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
const originalInvokedCwd = process.env.HAPI_INVOKED_CWD;
const originalCliExecutable = process.env.HAPI_CLI_EXECUTABLE;
function setPlatform(value: string) {
Object.defineProperty(process, 'platform', {
@@ -40,11 +64,20 @@ describe('spawnHappyCLI windowsHide behavior', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
existsSyncMock.mockImplementation((path: string) => !path.includes('missing-hapi.exe'));
isBunCompiledMock.mockReturnValue(false);
projectPathMock.mockReturnValue(process.cwd());
if (originalInvokedCwd === undefined) {
delete process.env.HAPI_INVOKED_CWD;
} else {
process.env.HAPI_INVOKED_CWD = originalInvokedCwd;
}
if (originalCliExecutable === undefined) {
delete process.env.HAPI_CLI_EXECUTABLE;
} else {
process.env.HAPI_CLI_EXECUTABLE = originalCliExecutable;
}
});
afterAll(() => {
@@ -104,13 +137,61 @@ describe('spawnHappyCLI windowsHide behavior', () => {
expect(command.command).toBe(process.execPath);
if (isBunRuntime) {
expect(command.args[0]).toBe('--cwd');
expect(command.args[1].replace(/\\/g, '/')).toMatch(/\/hapi\/cli$/);
expect(command.args[2].replace(/\\/g, '/')).toMatch(/\/hapi\/cli\/src\/index\.ts$/);
expect(command.args[1].replace(/\\/g, '/')).toMatch(/\/cli$/);
expect(command.args[2].replace(/\\/g, '/')).toMatch(/\/cli\/src\/index\.ts$/);
} else {
expect(command.args.some((arg) => arg.replace(/\\/g, '/').endsWith('/hapi/cli/src/index.ts'))).toBe(true);
expect(command.args.some((arg) => arg.replace(/\\/g, '/').endsWith('/cli/src/index.ts'))).toBe(true);
}
});
it('uses an inherited compiled CLI executable override when it points to an existing binary', async () => {
isBunCompiledMock.mockReturnValue(true);
process.env.HAPI_CLI_EXECUTABLE = 'C:\\Users\\Administrator\\.hapi\\patched\\hapi.exe';
const { getHappyCliCommand, resolveHappyCliExecutable } = await import('./spawnHappyCLI');
const command = getHappyCliCommand(['mcp', '--url', 'http://127.0.0.1:1234/']);
expect(resolveHappyCliExecutable()).toBe(process.env.HAPI_CLI_EXECUTABLE);
expect(command.command).toBe(process.env.HAPI_CLI_EXECUTABLE);
});
it('falls back to a real argv0 executable before process.execPath in compiled mode', async () => {
isBunCompiledMock.mockReturnValue(true);
const previousArgv0 = process.argv[0];
process.argv[0] = 'C:\\Users\\Administrator\\.hapi\\patched\\resume-recovery-0.17.2\\hapi.exe';
const { resolveHappyCliExecutable } = await import('./spawnHappyCLI');
try {
expect(resolveHappyCliExecutable()).toBe(process.argv[0]);
} finally {
process.argv[0] = previousArgv0;
}
});
it('ignores an inherited compiled CLI executable override when the binary is missing', async () => {
isBunCompiledMock.mockReturnValue(true);
process.env.HAPI_CLI_EXECUTABLE = 'C:\\Users\\Administrator\\.hapi\\patched\\missing-hapi.exe';
const { getHappyCliCommand } = await import('./spawnHappyCLI');
const command = getHappyCliCommand(['mcp', '--url', 'http://127.0.0.1:1234/']);
expect(command.command).toBe(process.execPath);
});
it('passes the resolved compiled executable to child HAPI processes', async () => {
isBunCompiledMock.mockReturnValue(true);
process.env.HAPI_CLI_EXECUTABLE = 'C:\\Users\\Administrator\\.hapi\\patched\\hapi.exe';
const { spawnHappyCLI } = await import('./spawnHappyCLI');
spawnHappyCLI(['mcp', '--url', 'http://127.0.0.1:1234/'], {
stdio: 'ignore'
});
const [command, _args, options] = spawnMock.mock.calls[0] as unknown[] | undefined ?? [];
expect(command).toBe(process.env.HAPI_CLI_EXECUTABLE);
expect((options as SpawnOptions | undefined)?.env?.HAPI_CLI_EXECUTABLE).toBe(process.env.HAPI_CLI_EXECUTABLE);
});
it('passes invoked workspace cwd to child processes when cwd is provided', async () => {
const { spawnHappyCLI } = await import('./spawnHappyCLI');
const childCwd = 'C:\\workspace\\project';
+33 -4
View File
@@ -32,6 +32,8 @@ import { isBunCompiled, projectPath } from '@/projectPath';
import { logger } from '@/ui/logger';
import { existsSync } from 'node:fs';
const HAPI_CLI_EXECUTABLE_ENV = 'HAPI_CLI_EXECUTABLE';
/**
* Resolve the TypeScript entrypoint for development mode.
*/
@@ -71,11 +73,30 @@ function resolveInvokedCwd(cwd: SpawnOptions['cwd']): string {
return process.cwd();
}
export function resolveHappyCliExecutable(): string {
const override = process.env[HAPI_CLI_EXECUTABLE_ENV]?.trim();
if (override && isCrossPlatformAbsolutePath(override) && existsSync(override)) {
return override;
}
const argv0 = process.argv[0]?.trim();
if (argv0 && isCrossPlatformAbsolutePath(argv0) && existsSync(argv0)) {
return argv0;
}
const bunArgv0 = globalThis.Bun?.argv?.[0]?.trim();
if (bunArgv0 && isCrossPlatformAbsolutePath(bunArgv0) && existsSync(bunArgv0)) {
return bunArgv0;
}
return process.execPath;
}
export function getHappyCliCommand(args: string[]): HappyCliCommand {
// Compiled binary mode: just use the executable directly
if (isBunCompiled()) {
return {
command: process.execPath,
command: resolveHappyCliExecutable(),
args
};
}
@@ -118,10 +139,11 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child
const fullCommand = `hapi ${args.join(' ')}`;
logger.debug(`[SPAWN HAPI CLI] Spawning: ${fullCommand} in ${directory}`);
const compiledMode = isBunCompiled();
const { command: spawnCommand, args: spawnArgs } = getHappyCliCommand(args);
// Sanity check that the entrypoint path exists
if (!isBunCompiled()) {
if (!compiledMode) {
const entrypoint = spawnArgs.find((arg) => arg.endsWith('index.ts'));
if (entrypoint && !existsSync(entrypoint)) {
const errorMessage = `Entrypoint ${entrypoint} does not exist`;
@@ -133,8 +155,12 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child
// On Windows, detached processes allocate a new console window by default.
// windowsHide: true suppresses this to prevent cmd windows from accumulating.
const finalOptions: SpawnOptions = { ...options };
if (!isBunCompiled()) {
const finalEnv = { ...process.env, ...options.env };
const finalEnv = { ...process.env, ...options.env };
let shouldSetEnv = false;
if (compiledMode) {
finalEnv[HAPI_CLI_EXECUTABLE_ENV] = spawnCommand;
shouldSetEnv = true;
} else {
const invokedCwd = finalEnv.HAPI_INVOKED_CWD?.trim();
const hasExplicitCwd = 'cwd' in options && options.cwd !== undefined;
finalEnv.HAPI_INVOKED_CWD = hasExplicitCwd
@@ -142,6 +168,9 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child
: invokedCwd && isCrossPlatformAbsolutePath(invokedCwd)
? invokedCwd
: resolveInvokedCwd(options.cwd);
shouldSetEnv = true;
}
if (shouldSetEnv) {
finalOptions.env = finalEnv;
}
if (process.platform === 'win32' && options.detached) {