open-design/apps/daemon/tests/runtimes/probe-ghost-cli.test.ts
shangxinyu1 2976c76fc3
test: expand Memory and Routines coverage (#1521)
* test: expand settings and packaged coverage

* test: extend memory settings coverage

* test: cover routine settings failure states

* test: cover routine operation failures

* test: fix daemon test typing on CI

* test: decouple packaged smoke from orbit bug

* test: avoid live memory LLM calls in route tests

* test: fix daemon fetch typing in CI

* fix: restore preview comment and inspect toggles

* test: align manual edit flow with current inspector UX

* test: align comment attachment flow with current preview comments UI

* fix: probe resolved Codex launch path during detection

* fix: remove duplicate board activation helper after rebase

* test: update ghost cli detection mock

* test: align FileViewer toolbar expectation

* ci: move full app tests to extended lane

* ci: run app tests by changed scope

* ci: cover shared app inputs in test scopes

* ci: avoid setup-node cache in windows packaged smoke

* test: align extended settings and manual edit flows
2026-05-14 14:48:40 +08:00

245 lines
11 KiB
TypeScript

/**
* Regression coverage for issue #658: Open Design kept advertising
* `Codex CLI` in Settings > Local CLI after the user had uninstalled
* the binary. The probe in `apps/daemon/src/runtimes/detection.ts`
* swallowed every `--version` failure and returned `available: true`
* anyway, so a leftover wrapper shim made Settings think the CLI was
* alive when its underlying interpreter was gone.
*
* The fix classifies the version probe's failure shape:
*
* - **OS-level rejections.** `ENOENT` / `EACCES` / `ENOTDIR` from
* `child_process.execFile` (string `err.code`) mean the binary is
* not invocable at all. Reported as `available: false`.
*
* - **Stale-wrapper shell exits.** Numeric `err.code` 126 / 127
* ("not executable" / "command not found") is the canonical POSIX
* shell signature for a wrapper that spawned but whose delegated
* target is gone. Reported as `available: false`.
*
* - **Everything else.** Generic non-zero exit (1, 2, ...) or a
* timeout keeps the legacy "available, version=null" behaviour
* so adapters whose `--version` flag is unsupported are not
* regressed.
*
* Detection always probes the same launch path chat/run resolution
* picks, so a stale configured override that shadows a working PATH
* binary is reported as unavailable rather than swapped for the PATH
* candidate; advertising a different path would break the invariant
* that Settings and the chat spawn path agree on what the agent runs
* (PR #1301 review, Siri-Ray).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const execAgentFileMock = vi.fn();
const resolveAgentLaunchMock = vi.fn();
vi.mock('../../src/runtimes/invocation.js', () => ({
execAgentFile: (...args: unknown[]) =>
(execAgentFileMock as unknown as (...args: unknown[]) => unknown)(...args),
}));
vi.mock('../../src/runtimes/launch.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/runtimes/launch.js')>();
return {
...actual,
resolveAgentLaunch: (
...args: Parameters<typeof actual.resolveAgentLaunch>
) =>
(
resolveAgentLaunchMock as unknown as (
...a: Parameters<typeof actual.resolveAgentLaunch>
) => ReturnType<typeof actual.resolveAgentLaunch>
)(...args),
};
});
function fakeCodexLaunch() {
return {
configuredOverridePath: null,
pathResolvedPath: '/fake/bin/codex',
selectedPath: '/fake/bin/codex',
launchPath: '/fake/bin/codex',
launchKind: 'selected' as const,
childPathPrepend: ['/fake/bin'],
diagnostic: null,
};
}
function spawnError(code: 'ENOENT' | 'EACCES' | 'ENOTDIR' | 'ETIMEDOUT'): NodeJS.ErrnoException {
const error = new Error(`spawn failed (${code})`) as NodeJS.ErrnoException;
error.code = code;
return error;
}
function exitCodeError(code: number): NodeJS.ErrnoException {
// execFile's promisified rejection on a non-zero exit sets `err.code`
// to the numeric exit code (Node's documented behaviour). 127 is the
// POSIX-shell "command not found" exit for shims whose target is
// gone; 126 is the "not executable" sibling.
const error = new Error(`process exited with code ${code}`) as NodeJS.ErrnoException;
(error as { code: unknown }).code = code;
return error;
}
describe('probe (issue #658) — ghost CLI after the binary is uninstalled', () => {
beforeEach(() => {
execAgentFileMock.mockReset();
resolveAgentLaunchMock.mockReset();
// Default: pretend every agent definition resolves to a fake bin so
// we exercise the spawn path uniformly.
resolveAgentLaunchMock.mockImplementation(fakeCodexLaunch);
});
for (const failingCode of ['ENOENT', 'EACCES', 'ENOTDIR'] as const) {
it(`marks the agent unavailable when the version probe rejects with ${failingCode}`, async () => {
execAgentFileMock.mockRejectedValue(spawnError(failingCode));
const { detectAgents } = await import('../../src/runtimes/detection.js');
const agents = await detectAgents();
const codex = agents.find((agent) => agent.id === 'codex');
expect(codex).toBeDefined();
expect(codex?.available).toBe(false);
});
}
for (const stalenessExit of [126, 127] as const) {
it(`marks the agent unavailable when a wrapper shim exits ${stalenessExit} (stale interpreter / target)`, async () => {
// Regression for lefarcen P2: many shims (npm bin wrappers, env
// node, `.cmd` files) spawn successfully and then fail at the
// delegated-target step with the POSIX-shell exit codes. The
// execFile rejection carries the numeric exit code on `err.code`
// rather than an ENOENT string, so the old guard missed these
// and still reported the agent as available.
execAgentFileMock.mockRejectedValue(exitCodeError(stalenessExit));
const { detectAgents } = await import('../../src/runtimes/detection.js');
const agents = await detectAgents();
const codex = agents.find((agent) => agent.id === 'codex');
expect(codex).toBeDefined();
expect(codex?.available).toBe(false);
});
}
it('keeps available=true when the binary spawns but --version returns non-zero (timeout, unsupported flag)', async () => {
// Non-spawn, non-126/127 failures must NOT regress to unavailable;
// adapters whose --version flag is missing legitimately exit
// non-zero and have always shown up as "available, version=null".
execAgentFileMock.mockRejectedValue(spawnError('ETIMEDOUT'));
const { detectAgents } = await import('../../src/runtimes/detection.js');
const agents = await detectAgents();
const codex = agents.find((agent) => agent.id === 'codex');
expect(codex).toBeDefined();
expect(codex?.available).toBe(true);
expect(codex?.version).toBeNull();
});
it('keeps available=true on a generic non-zero exit (e.g. exit 1 from an adapter with no --version flag)', async () => {
execAgentFileMock.mockRejectedValue(exitCodeError(1));
const { detectAgents } = await import('../../src/runtimes/detection.js');
const agents = await detectAgents();
const codex = agents.find((agent) => agent.id === 'codex');
expect(codex).toBeDefined();
expect(codex?.available).toBe(true);
expect(codex?.version).toBeNull();
});
it('returns the parsed version on a clean --version run', async () => {
execAgentFileMock.mockResolvedValue({ stdout: 'codex 1.2.3\n', stderr: '' });
const { detectAgents } = await import('../../src/runtimes/detection.js');
const agents = await detectAgents();
const codex = agents.find((agent) => agent.id === 'codex');
expect(codex).toBeDefined();
expect(codex?.available).toBe(true);
expect(codex?.version).toBe('codex 1.2.3');
});
it('reports unavailable for a stale configured override even when a different PATH binary exists', async () => {
// Regression for Siri-Ray's #1301 review: an earlier revision tried
// to fall back to a PATH candidate when the configured override
// failed to spawn, but that broke the invariant that detection and
// chat-run resolution agree on the executable. resolveAgentBin
// still resolves via resolveAgentLaunch (configured override
// wins when present and executable), so if detection adopted a
// different PATH binary, Settings would show "available at
// /usr/local/bin/codex" while every actual run would spawn the
// stale /stale/custom/codex and fail. The fix is to keep detection
// honest: probe whichever path resolveAgentLaunch picks, and
// report exactly that path's availability. The Settings repair
// flow (PR #1205) needs to derive its adopt-or-clear affordance
// from the resolution diagnostic — not from `available`.
const {
resolveAgentLaunch: realResolveAgentLaunch,
} = await vi.importActual<typeof import('../../src/runtimes/launch.js')>(
'../../src/runtimes/launch.js',
);
const {
inspectAgentExecutableResolution,
} = await vi.importActual<typeof import('../../src/runtimes/executables.js')>(
'../../src/runtimes/executables.js',
);
// Drive the resolver through its real path so a future refactor
// that diverges resolution from detection trips this assertion.
resolveAgentLaunchMock.mockImplementation(
(def, env) => realResolveAgentLaunch(def, env),
);
// Force a stale configured override + a working PATH candidate.
execAgentFileMock.mockImplementation((cmd: string) => {
if (cmd === '/stale/custom/codex') return Promise.reject(spawnError('ENOENT'));
return Promise.resolve({ stdout: 'codex 1.4.2\n', stderr: '' });
});
const configuredEnv = { codex: { CODEX_BIN: '/stale/custom/codex' } };
// The resolver tries the configured override first; we don't have
// a real PATH candidate on this CI host but we have a configured
// override that points at a non-existent file. The resolver's
// existsSync check will reject the stale override, so we need to
// verify the chain ends up at the same place detection probes.
const { detectAgents } = await import('../../src/runtimes/detection.js');
const agents = await detectAgents(configuredEnv);
const codex = agents.find((agent) => agent.id === 'codex');
expect(codex).toBeDefined();
// Detection must report unavailable rather than swap to a hypothetical
// PATH candidate, because resolveAgentLaunch (which chat-run
// resolution uses) will pick whatever the same call returns.
const resolvedForRun = realResolveAgentLaunch(
// re-run AGENT_DEFS's codex entry through the real resolver to
// get the executable resolveAgentBin would pick at chat time.
// The detection side already validated this path.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ id: 'codex', bin: 'codex' } as any,
configuredEnv.codex,
);
if (resolvedForRun.selectedPath && resolvedForRun.launchPath) {
// If the resolver found a working PATH binary, detection must
// have reported available=true with the SAME path.
expect(codex?.available).toBe(true);
expect(codex?.path).toBe(resolvedForRun.selectedPath);
} else {
// Otherwise detection must report unavailable rather than invent
// a different path.
expect(codex?.available).toBe(false);
expect(codex?.path).toBeUndefined();
}
// The diagnostic field for Settings' repair flow stays available
// via inspectAgentExecutableResolution, which is independent of
// the detection result.
const inspection = inspectAgentExecutableResolution(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ id: 'codex', bin: 'codex' } as any,
configuredEnv.codex,
);
expect(inspection.configuredOverridePath === null
|| inspection.configuredOverridePath === '/stale/custom/codex').toBe(true);
});
});