250 lines
8.7 KiB
TypeScript
250 lines
8.7 KiB
TypeScript
// @vitest-environment node
|
|
/* eslint-disable security/detect-non-literal-fs-filename */
|
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const augmentConfiguredConnectionEnvMock = vi.hoisted(() =>
|
|
vi.fn((env: NodeJS.ProcessEnv) => Promise.resolve(env))
|
|
);
|
|
const applyConfiguredConnectionEnvMock = vi.hoisted(() =>
|
|
vi.fn((env: NodeJS.ProcessEnv) => Promise.resolve(env))
|
|
);
|
|
const getConfiguredConnectionIssuesMock = vi.hoisted(() => vi.fn(() => Promise.resolve({})));
|
|
const getConfiguredConnectionLaunchArgsMock = vi.hoisted(() => vi.fn(() => Promise.resolve([])));
|
|
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.hoisted(() =>
|
|
vi.fn(() => Promise.resolve(null))
|
|
);
|
|
|
|
vi.mock('../../../../src/main/services/infrastructure/ConfigManager', () => ({
|
|
configManager: {
|
|
getConfig: () => ({
|
|
runtime: {
|
|
providerBackends: {
|
|
codex: 'codex-native',
|
|
gemini: 'cli',
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../../src/main/services/runtime/ProviderConnectionService', () => ({
|
|
providerConnectionService: {
|
|
augmentConfiguredConnectionEnv: (
|
|
...args: Parameters<typeof augmentConfiguredConnectionEnvMock>
|
|
) => augmentConfiguredConnectionEnvMock(...args),
|
|
applyConfiguredConnectionEnv: (...args: Parameters<typeof applyConfiguredConnectionEnvMock>) =>
|
|
applyConfiguredConnectionEnvMock(...args),
|
|
getConfiguredConnectionIssues: (
|
|
...args: Parameters<typeof getConfiguredConnectionIssuesMock>
|
|
) => getConfiguredConnectionIssuesMock(...args),
|
|
getConfiguredConnectionLaunchArgs: (
|
|
...args: Parameters<typeof getConfiguredConnectionLaunchArgsMock>
|
|
) => getConfiguredConnectionLaunchArgsMock(...args),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@features/codex-runtime-installer/main', () => ({
|
|
resolveVerifiedAppManagedCodexRuntimeBinaryPath: () =>
|
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock(),
|
|
}));
|
|
|
|
import { resolveVerifiedOpenCodeRuntimeBinaryPath } from '../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerService';
|
|
import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeBridgeRuntimeEnv';
|
|
import { buildProviderAwareCliEnv } from '../../../../src/main/services/runtime/providerAwareCliEnv';
|
|
import { clearResolvedNodePathForTests } from '../../../../src/main/services/team/TeamMcpConfigBuilder';
|
|
import { execCli } from '../../../../src/main/utils/childProcess';
|
|
import { setAppDataBasePath } from '../../../../src/main/utils/pathDecoder';
|
|
import { clearShellEnvCache } from '../../../../src/main/utils/shellEnv';
|
|
|
|
const describePosix = process.platform === 'win32' ? describe.skip : describe;
|
|
|
|
describePosix('OpenCode packaged-runtime preflight integration', () => {
|
|
let tempDir: string | null = null;
|
|
let originalPath: string | undefined;
|
|
let originalShell: string | undefined;
|
|
let originalFakeOpenCodeBinDir: string | undefined;
|
|
let originalFakeNodePath: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-prod-preflight-'));
|
|
setAppDataBasePath(path.join(tempDir, 'app-data'));
|
|
clearShellEnvCache();
|
|
clearResolvedNodePathForTests();
|
|
|
|
originalPath = process.env.PATH;
|
|
originalShell = process.env.SHELL;
|
|
originalFakeOpenCodeBinDir = process.env.FAKE_OPENCODE_BIN_DIR;
|
|
originalFakeNodePath = process.env.FAKE_NODE_PATH;
|
|
process.env.PATH = '';
|
|
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
clearShellEnvCache();
|
|
setAppDataBasePath(null);
|
|
|
|
if (originalPath === undefined) {
|
|
delete process.env.PATH;
|
|
} else {
|
|
process.env.PATH = originalPath;
|
|
}
|
|
if (originalShell === undefined) {
|
|
delete process.env.SHELL;
|
|
} else {
|
|
process.env.SHELL = originalShell;
|
|
}
|
|
if (originalFakeOpenCodeBinDir === undefined) {
|
|
delete process.env.FAKE_OPENCODE_BIN_DIR;
|
|
} else {
|
|
process.env.FAKE_OPENCODE_BIN_DIR = originalFakeOpenCodeBinDir;
|
|
}
|
|
if (originalFakeNodePath === undefined) {
|
|
delete process.env.FAKE_NODE_PATH;
|
|
} else {
|
|
process.env.FAKE_NODE_PATH = originalFakeNodePath;
|
|
}
|
|
|
|
if (tempDir) {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
tempDir = null;
|
|
}
|
|
});
|
|
|
|
async function createFakeOpenCodeBinary(): Promise<{ binDir: string; binaryPath: string }> {
|
|
const binDir = path.join(tempDir!, 'homebrew', 'bin');
|
|
const binaryPath = path.join(binDir, 'opencode');
|
|
await mkdir(binDir, { recursive: true });
|
|
await writeFile(
|
|
binaryPath,
|
|
[
|
|
'#!/bin/sh',
|
|
'if [ "$1" = "--version" ]; then',
|
|
' echo "opencode 9.9.9"',
|
|
' exit 0',
|
|
'fi',
|
|
'echo "unexpected opencode args: $*" >&2',
|
|
'exit 2',
|
|
].join('\n'),
|
|
'utf8'
|
|
);
|
|
await chmod(binaryPath, 0o755);
|
|
return { binDir, binaryPath };
|
|
}
|
|
|
|
async function createFakeNodeBinary(binDir: string): Promise<string> {
|
|
const binaryPath = path.join(binDir, 'node');
|
|
process.env.FAKE_NODE_PATH = binaryPath;
|
|
await writeFile(
|
|
binaryPath,
|
|
[
|
|
'#!/bin/sh',
|
|
'if [ "$1" = "-e" ]; then',
|
|
' printf "{\\"execPath\\":\\"%s\\",\\"version\\":\\"%s\\"}" "$FAKE_NODE_PATH" "24.16.0"',
|
|
' exit 0',
|
|
'fi',
|
|
'echo "unexpected node args: $*" >&2',
|
|
'exit 2',
|
|
].join('\n'),
|
|
'utf8'
|
|
);
|
|
await chmod(binaryPath, 0o755);
|
|
return binaryPath;
|
|
}
|
|
|
|
async function createFakeInteractiveShell(binDir: string): Promise<string> {
|
|
const shellPath = path.join(tempDir!, 'fake-login-shell');
|
|
process.env.FAKE_OPENCODE_BIN_DIR = binDir;
|
|
await writeFile(
|
|
shellPath,
|
|
[
|
|
'#!/bin/sh',
|
|
'printf "%s\\0" "PATH=$FAKE_OPENCODE_BIN_DIR" "HOME=$HOME" "SHELL=$0"',
|
|
].join('\n'),
|
|
'utf8'
|
|
);
|
|
await chmod(shellPath, 0o755);
|
|
return shellPath;
|
|
}
|
|
|
|
it('keeps OpenCode launch preflight and bridge commands working when packaged Electron starts with an empty PATH', async () => {
|
|
const { binDir, binaryPath } = await createFakeOpenCodeBinary();
|
|
process.env.SHELL = await createFakeInteractiveShell(binDir);
|
|
|
|
const providerEnv = await buildProviderAwareCliEnv({
|
|
providerId: 'opencode',
|
|
connectionMode: 'augment',
|
|
shellEnv: {},
|
|
env: {
|
|
PATH: '',
|
|
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: 'node',
|
|
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: '/mock/mcp-server/index.js',
|
|
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: '["/mock/mcp-server/index.js"]',
|
|
},
|
|
});
|
|
|
|
expect(providerEnv.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
|
|
expect(providerEnv.env.OPENCODE_BIN_PATH).toBe(binaryPath);
|
|
expect(providerEnv.env.PATH?.split(path.delimiter)[0]).toBe(binDir);
|
|
expect(augmentConfiguredConnectionEnvMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
CLAUDE_CODE_ENTRY_PROVIDER: 'opencode',
|
|
CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: binaryPath,
|
|
OPENCODE_BIN_PATH: binaryPath,
|
|
}),
|
|
'opencode',
|
|
undefined
|
|
);
|
|
|
|
const bridgeEnv: NodeJS.ProcessEnv = { PATH: '' };
|
|
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
|
targetEnv: bridgeEnv,
|
|
bridgeEnv,
|
|
resolveVerifiedOpenCodeRuntimeBinaryPath,
|
|
});
|
|
|
|
const commandEnv = { ...bridgeEnv };
|
|
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
|
targetEnv: commandEnv,
|
|
bridgeEnv,
|
|
resolveVerifiedOpenCodeRuntimeBinaryPath,
|
|
});
|
|
|
|
expect(commandEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
|
|
expect(commandEnv.OPENCODE_BIN_PATH).toBe(binaryPath);
|
|
expect(commandEnv.PATH?.split(path.delimiter)[0]).toBe(binDir);
|
|
|
|
const version = await execCli('opencode', ['--version'], {
|
|
env: commandEnv,
|
|
timeout: 2_000,
|
|
windowsHide: true,
|
|
});
|
|
expect(version.stdout.trim()).toBe('opencode 9.9.9');
|
|
});
|
|
|
|
it('resolves the Agent Teams MCP command to shell Node when GUI PATH is empty', async () => {
|
|
const { binDir } = await createFakeOpenCodeBinary();
|
|
const nodePath = await createFakeNodeBinary(binDir);
|
|
process.env.SHELL = await createFakeInteractiveShell(binDir);
|
|
|
|
const providerEnv = await buildProviderAwareCliEnv({
|
|
providerId: 'opencode',
|
|
connectionMode: 'augment',
|
|
shellEnv: {},
|
|
env: {
|
|
PATH: '',
|
|
},
|
|
});
|
|
|
|
expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBe(nodePath);
|
|
expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).not.toBe('node');
|
|
expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY).toBeTruthy();
|
|
expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON).toContain(
|
|
providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY ?? ''
|
|
);
|
|
});
|
|
});
|