206 lines
5.9 KiB
TypeScript
206 lines
5.9 KiB
TypeScript
// @vitest-environment node
|
|
import { chmod, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
|
import { tmpdir } from 'os';
|
|
import path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
clearShellEnvCache,
|
|
getCachedShellEnv,
|
|
resolveInteractiveShellEnv,
|
|
resolveInteractiveShellEnvBestEffort,
|
|
} from '@main/utils/shellEnv';
|
|
|
|
const describePosix = process.platform === 'win32' ? describe.skip : describe;
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function waitForCachedEnv(timeoutMs = 2_000): Promise<NodeJS.ProcessEnv | null> {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
const cached = getCachedShellEnv();
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
await sleep(25);
|
|
}
|
|
return getCachedShellEnv();
|
|
}
|
|
|
|
async function createFakeShell(tempDir: string, name: string, source: string): Promise<string> {
|
|
const shellPath = path.join(tempDir, name);
|
|
await writeFile(shellPath, `#!/usr/bin/env node\n${source}\n`, 'utf8');
|
|
await chmod(shellPath, 0o755);
|
|
return shellPath;
|
|
}
|
|
|
|
function envWriterSource(envExpression: string): string {
|
|
return `
|
|
function writeEnv(env) {
|
|
process.stdout.write(Object.entries(env).map(([key, value]) => key + '=' + value).join('\\0') + '\\0');
|
|
}
|
|
${envExpression}
|
|
`;
|
|
}
|
|
|
|
describePosix('shellEnv real child-process integration', () => {
|
|
const originalShell = process.env.SHELL;
|
|
const originalInvocationFile = process.env.FAKE_SHELL_INVOCATIONS;
|
|
let tempDir = '';
|
|
|
|
beforeEach(async () => {
|
|
clearShellEnvCache();
|
|
tempDir = await mkdtemp(path.join(tmpdir(), 'agent-teams-shell-env-'));
|
|
delete process.env.FAKE_SHELL_INVOCATIONS;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
clearShellEnvCache();
|
|
if (originalShell === undefined) {
|
|
delete process.env.SHELL;
|
|
} else {
|
|
process.env.SHELL = originalShell;
|
|
}
|
|
if (originalInvocationFile === undefined) {
|
|
delete process.env.FAKE_SHELL_INVOCATIONS;
|
|
} else {
|
|
process.env.FAKE_SHELL_INVOCATIONS = originalInvocationFile;
|
|
}
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('returns a real shell env from an executable shell path before best-effort timeout', async () => {
|
|
const fakeShell = await createFakeShell(
|
|
tempDir,
|
|
'fast-shell.js',
|
|
envWriterSource(`
|
|
writeEnv({
|
|
PATH: '/fake-fast/bin:/usr/bin',
|
|
HOME: '/fake-home',
|
|
SHELL: process.argv[1],
|
|
});
|
|
`)
|
|
);
|
|
process.env.SHELL = fakeShell;
|
|
|
|
const env = await resolveInteractiveShellEnvBestEffort({
|
|
timeoutMs: 1_000,
|
|
fallbackEnv: { PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' },
|
|
});
|
|
|
|
expect(env).toMatchObject({
|
|
PATH: '/fake-fast/bin:/usr/bin',
|
|
HOME: '/fake-home',
|
|
SHELL: fakeShell,
|
|
});
|
|
expect(getCachedShellEnv()).toMatchObject({
|
|
PATH: '/fake-fast/bin:/usr/bin',
|
|
HOME: '/fake-home',
|
|
});
|
|
});
|
|
|
|
it('returns fallback quickly while a slow shell warms the cache in the background', async () => {
|
|
const fakeShell = await createFakeShell(
|
|
tempDir,
|
|
'slow-shell.js',
|
|
envWriterSource(`
|
|
setTimeout(() => {
|
|
writeEnv({
|
|
PATH: '/slow-real/bin:/usr/bin',
|
|
HOME: '/slow-home',
|
|
});
|
|
}, 200);
|
|
`)
|
|
);
|
|
process.env.SHELL = fakeShell;
|
|
|
|
const startedAt = Date.now();
|
|
const env = await resolveInteractiveShellEnvBestEffort({
|
|
timeoutMs: 25,
|
|
fallbackEnv: { PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' },
|
|
});
|
|
const elapsedMs = Date.now() - startedAt;
|
|
|
|
expect(elapsedMs).toBeLessThan(150);
|
|
expect(env).toMatchObject({ PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' });
|
|
expect(getCachedShellEnv()).toBeNull();
|
|
|
|
await expect(waitForCachedEnv()).resolves.toMatchObject({
|
|
PATH: '/slow-real/bin:/usr/bin',
|
|
HOME: '/slow-home',
|
|
});
|
|
});
|
|
|
|
it('falls back from a failed login shell process to a successful interactive shell process', async () => {
|
|
const fakeShell = await createFakeShell(
|
|
tempDir,
|
|
'login-fails-shell.js',
|
|
envWriterSource(`
|
|
if ((process.argv[2] || '').includes('l')) {
|
|
process.exit(42);
|
|
}
|
|
writeEnv({
|
|
PATH: '/interactive-real/bin:/usr/bin',
|
|
HOME: '/interactive-home',
|
|
});
|
|
`)
|
|
);
|
|
process.env.SHELL = fakeShell;
|
|
|
|
await expect(resolveInteractiveShellEnv()).resolves.toMatchObject({
|
|
PATH: '/interactive-real/bin:/usr/bin',
|
|
HOME: '/interactive-home',
|
|
});
|
|
expect(console.warn).toHaveBeenCalledWith(
|
|
'[Utils:shellEnv]',
|
|
'Failed to resolve login shell env: shell env command exited with code 42'
|
|
);
|
|
vi.mocked(console.warn).mockClear();
|
|
expect(getCachedShellEnv()).toMatchObject({
|
|
PATH: '/interactive-real/bin:/usr/bin',
|
|
HOME: '/interactive-home',
|
|
});
|
|
});
|
|
|
|
it('coalesces concurrent best-effort calls into one real shell process', async () => {
|
|
const invocationFile = path.join(tempDir, 'invocations.log');
|
|
process.env.FAKE_SHELL_INVOCATIONS = invocationFile;
|
|
const fakeShell = await createFakeShell(
|
|
tempDir,
|
|
'coalesced-shell.js',
|
|
envWriterSource(`
|
|
const fs = require('fs');
|
|
fs.appendFileSync(process.env.FAKE_SHELL_INVOCATIONS, 'spawned\\n');
|
|
setTimeout(() => {
|
|
writeEnv({
|
|
PATH: '/coalesced-real/bin:/usr/bin',
|
|
HOME: '/coalesced-home',
|
|
});
|
|
}, 200);
|
|
`)
|
|
);
|
|
process.env.SHELL = fakeShell;
|
|
|
|
const results = await Promise.all(
|
|
Array.from({ length: 10 }, async (_, index) =>
|
|
resolveInteractiveShellEnvBestEffort({
|
|
timeoutMs: 25,
|
|
fallbackEnv: { PATH: `FALLBACK_${index}`, HOME: `FALLBACK_HOME_${index}` },
|
|
})
|
|
)
|
|
);
|
|
|
|
expect(results).toHaveLength(10);
|
|
expect(results.every((env, index) => env.PATH === `FALLBACK_${index}`)).toBe(true);
|
|
await expect(waitForCachedEnv()).resolves.toMatchObject({
|
|
PATH: '/coalesced-real/bin:/usr/bin',
|
|
HOME: '/coalesced-home',
|
|
});
|
|
|
|
const invocations = await readFile(invocationFile, 'utf8');
|
|
expect(invocations.trim().split('\n')).toHaveLength(1);
|
|
});
|
|
});
|