From 000c379bb587f2de2cd53badae6b1178bf2faf51 Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 4 Aug 2026 10:43:21 -0400 Subject: [PATCH] fix(cdxgen): report why cdxgen failed When `socket cdxgen` failed on a CI runner it could exit 1 and print nothing at all: no error, no hint, no way to tell whether cdxgen was never downloaded, never started, or ran and died. The command armed `process.exitCode = 1` before starting cdxgen, then handled only the signal and numeric-exit-code cases. There was no `else`, so a child that reported neither left the armed 1 standing and printed nothing. Because cdxgen is spawned with `stdio: 'inherit'`, a rejected spawn also reached the top level with an empty stderr, leaving the shared formatter nothing to attach beyond a generic line. Every way out of the run now either exits with cdxgen's own code or prints where the CLI looked for cdxgen and what to try next. A new `util/dlx/cdxgen-diagnostics.mts` builds those messages so the command and the spawn helper share one wording. `spawnCdxgenDlx` also checks `SOCKET_CLI_CDXGEN_LOCAL_PATH` on disk before spawning, so a wrong path says so instead of surfacing as a bare ENOENT that never names the variable. An `InputError` message is passed through untouched rather than nested inside a second Where/Saw/Fix block, which previously produced an outer `Fix:` telling the user to set the very variable they had already set. The underlying error is logged via `debugNs('error', ...)` so the message's advice to re-run with `SOCKET_CLI_DEBUG=1` leads somewhere. The successful path is unchanged: cdxgen's output still streams through and its exit code is still forwarded exactly as before. Refs SURF-1045. --- .../commands/manifest/cmd-manifest-cdxgen.mts | 32 ++- .../cli/src/util/dlx/cdxgen-diagnostics.mts | 89 +++++++++ packages/cli/src/util/dlx/spawn-cdxgen.mts | 11 + ...manifest-cdxgen-failure-reporting.test.mts | 189 ++++++++++++++++++ .../unit/util/dlx/cdxgen-diagnostics.test.mts | 160 +++++++++++++++ .../test/unit/util/dlx/spawn-cdxgen.test.mts | 27 +++ 6 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/util/dlx/cdxgen-diagnostics.mts create mode 100644 packages/cli/test/unit/commands/manifest/cmd-manifest-cdxgen-failure-reporting.test.mts create mode 100644 packages/cli/test/unit/util/dlx/cdxgen-diagnostics.test.mts diff --git a/packages/cli/src/commands/manifest/cmd-manifest-cdxgen.mts b/packages/cli/src/commands/manifest/cmd-manifest-cdxgen.mts index a9e0fa9d43..20af789f79 100644 --- a/packages/cli/src/commands/manifest/cmd-manifest-cdxgen.mts +++ b/packages/cli/src/commands/manifest/cmd-manifest-cdxgen.mts @@ -6,10 +6,15 @@ import terminalLink from 'terminal-link' import yargsParse from 'yargs-parser' import { joinAnd } from '@socketsecurity/lib-stable/arrays/join' +import { debugNs } from '@socketsecurity/lib-stable/debug/output' import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' import { isPath } from '@socketsecurity/lib-stable/paths/normalize' import { pluralize } from '@socketsecurity/lib-stable/words/pluralize' +import { + describeCdxgenSource, + formatCdxgenFailureMessage, +} from '../../util/dlx/cdxgen-diagnostics.mts' import { detectNodejsCdxgenSources, isNodejsCdxgenType, @@ -336,15 +341,36 @@ export async function run( } } + // Assume failure until cdxgen reports otherwise, so an unexpected early exit + // cannot be mistaken for a successful scan. process.exitCode = 1 - const { spawnPromise } = await runCdxgen(yargv) + let result + try { + const { spawnPromise } = await runCdxgen(yargv) + result = await spawnPromise + } catch (e) { + // cdxgen runs with stdio: 'inherit', so a failure to start it produces no + // child output at all. Say where we looked and what to try next. + // The message tells the user to re-run with SOCKET_CLI_DEBUG=1, so put the + // underlying error on the 'error' category that flag turns on. Handling it + // here means it never reaches the top level, and nothing else on this path + // logs it, so without this the advice would lead nowhere. + debugNs('error', `cdxgen failed to run while ${describeCdxgenSource()}`, e) + logger.fail(formatCdxgenFailureMessage(e)) + return + } - // Wait for the spawn promise to resolve and handle the result. - const result = await spawnPromise if (result.signal) { process.kill(process.pid, result.signal) } else if (typeof result.code === 'number') { process.exit(result.code) + } else { + // Neither an exit code nor a signal. Without this branch the command + // returns here with the exit code armed above and prints nothing at all. + // There is no error to show, so the spawn result is what SOCKET_CLI_DEBUG=1 + // has to offer. + debugNs('error', 'cdxgen returned no exit code and no signal', result) + logger.fail(formatCdxgenFailureMessage()) } } diff --git a/packages/cli/src/util/dlx/cdxgen-diagnostics.mts b/packages/cli/src/util/dlx/cdxgen-diagnostics.mts new file mode 100644 index 0000000000..c8e7b9848a --- /dev/null +++ b/packages/cli/src/util/dlx/cdxgen-diagnostics.mts @@ -0,0 +1,89 @@ +/** + * Diagnostics for the `socket cdxgen` command. + * + * Cdxgen runs as a child process, so when it cannot start there is nothing on + * stdout or stderr to explain why. These helpers turn that into a message that + * names where the CLI looked for cdxgen and what to try next. + */ + +import { existsSync } from 'node:fs' + +import { errorMessage } from '@socketsecurity/lib-stable/errors/message' + +import { resolveCdxgen } from './resolve-binary.mjs' +import { InputError } from '../error/errors-types.mts' + +/** + * Describe where the CLI resolved cdxgen from, so an error can say which of + * the two sources actually failed. + */ +export function describeCdxgenSource(): string { + const resolution = resolveCdxgen() + if (resolution.type === 'local') { + return `the local override SOCKET_CLI_CDXGEN_LOCAL_PATH=${resolution.path}` + } + if (resolution.type === 'dlx') { + const { name, version } = resolution.details + return `${name}@${version}, downloaded and cached by Socket's dlx` + } + return "a binary downloaded and cached by Socket's dlx" +} + +/** + * Build the message shown when cdxgen fails to produce a result. + * + * Pass the underlying error when there is one. Pass nothing for the case where + * the child process ended without reporting either an exit code or a signal, + * which is the shape that used to exit 1 with no output at all. + */ +export function formatCdxgenFailureMessage( + cause?: unknown | undefined, +): string { + // An InputError is raised with a message already written for the exact thing + // that went wrong, so pass it through. Wrapping it would nest one + // Where/Saw/Fix block inside another, and the generic Fix below would tell + // the user to set SOCKET_CLI_CDXGEN_LOCAL_PATH when a bad value for that + // very variable is what they are being told about. + if (cause instanceof InputError) { + return cause.message + } + + const detail = cause + ? errorMessage(cause) + : 'the cdxgen process ended without reporting an exit code or a signal' + + return [ + 'socket cdxgen could not run cdxgen.', + ` Where: ${describeCdxgenSource()}`, + ` Saw: ${detail || 'no error message was reported'}`, + ' Fix: Re-run with SOCKET_CLI_DEBUG=1 to see the full error. If cdxgen', + ' cannot be downloaded on this machine, install it yourself and', + ' point SOCKET_CLI_CDXGEN_LOCAL_PATH at the binary.', + ].join('\n') +} + +/** + * Build the message shown when SOCKET_CLI_CDXGEN_LOCAL_PATH points at + * something that is not there. + * + * Without this check the override is silently ignored and the failure looks + * identical to a download failure, which makes the override appear to have no + * effect at all. + */ +export function formatMissingCdxgenLocalPathMessage(localPath: string): string { + return [ + 'SOCKET_CLI_CDXGEN_LOCAL_PATH points at a file that does not exist.', + ` Where: ${localPath}`, + ' Saw: nothing at that path', + ' Fix: Correct the path, or unset SOCKET_CLI_CDXGEN_LOCAL_PATH to let', + ' Socket download cdxgen itself. On a CI runner, check that the', + ' path exists in the same step that runs socket cdxgen.', + ].join('\n') +} + +/** + * True when a local cdxgen override is configured but missing from disk. + */ +export function isMissingCdxgenLocalPath(localPath: string): boolean { + return !existsSync(localPath) +} diff --git a/packages/cli/src/util/dlx/spawn-cdxgen.mts b/packages/cli/src/util/dlx/spawn-cdxgen.mts index 2aff87619e..5ca56a83a0 100644 --- a/packages/cli/src/util/dlx/spawn-cdxgen.mts +++ b/packages/cli/src/util/dlx/spawn-cdxgen.mts @@ -13,9 +13,14 @@ import { detectExecutableType } from '@socketsecurity/lib-stable/dlx/detect' import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import { + formatMissingCdxgenLocalPathMessage, + isMissingCdxgenLocalPath, +} from './cdxgen-diagnostics.mts' import { defineAutoDispatch, defineVfsSpawn } from './define-tool-spawn.mts' import { spawnDlx } from './spawn.mts' import { resolveCdxgen } from './resolve-binary.mjs' +import { InputError } from '../error/errors.mts' import { buildSystemToolEnv } from '../spawn/system-tool.mts' import { resolveNodeExecutable } from '../spawn/spawn-node.mts' @@ -37,6 +42,12 @@ export async function spawnCdxgenDlx( // Use local cdxgen if available. if (resolution.type === 'local') { + // Check the override before spawning. Otherwise a wrong path surfaces as a + // bare ENOENT that never mentions the environment variable, so the + // override looks like it was ignored. + if (isMissingCdxgenLocalPath(resolution.path)) { + throw new InputError(formatMissingCdxgenLocalPathMessage(resolution.path)) + } const detection = detectExecutableType(resolution.path) const { env: spawnEnv, ...dlxOptions } = { __proto__: null, diff --git a/packages/cli/test/unit/commands/manifest/cmd-manifest-cdxgen-failure-reporting.test.mts b/packages/cli/test/unit/commands/manifest/cmd-manifest-cdxgen-failure-reporting.test.mts new file mode 100644 index 0000000000..c4a49c58a9 --- /dev/null +++ b/packages/cli/test/unit/commands/manifest/cmd-manifest-cdxgen-failure-reporting.test.mts @@ -0,0 +1,189 @@ +/** + * Unit tests for the cdxgen command's failure reporting. + * + * Purpose: The command arms process.exitCode = 1 before spawning cdxgen, and + * cdxgen itself runs with stdio: 'inherit'. Together that means any path out + * of the spawn that neither exits deliberately nor prints leaves the user with + * exit code 1 and an empty log. These tests cover every one of those paths. + * + * Related Files: - src/commands/manifest/cmd-manifest-cdxgen.mts. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { cmdManifestCdxgen } from '../../../../src/commands/manifest/cmd-manifest-cdxgen.mts' +import { formatMissingCdxgenLocalPathMessage } from '../../../../src/util/dlx/cdxgen-diagnostics.mts' +import { InputError } from '../../../../src/util/error/errors-types.mts' + +const mockLogger = vi.hoisted(() => ({ + error: vi.fn(), + fail: vi.fn(), + info: vi.fn(), + log: vi.fn(), + success: vi.fn(), + warn: vi.fn(), +})) + +vi.mock(import('@socketsecurity/lib-stable/logger/default'), () => ({ + getDefaultLogger: () => mockLogger, +})) + +const mockDebugNs = vi.hoisted(() => vi.fn()) + +vi.mock( + import('@socketsecurity/lib-stable/debug/output'), + async importOriginal => ({ + ...(await importOriginal()), + debugNs: mockDebugNs, + }), +) + +const mockRunCdxgen = vi.hoisted(() => vi.fn()) +const mockDetectNodejsCdxgenSources = vi.hoisted(() => + vi.fn().mockResolvedValue({ hasLockfile: true, hasNodeModules: true }), +) +const mockIsNodejsCdxgenType = vi.hoisted(() => vi.fn().mockReturnValue(true)) + +vi.mock(import('../../../../src/commands/manifest/run-cdxgen.mts'), () => ({ + detectNodejsCdxgenSources: mockDetectNodejsCdxgenSources, + isNodejsCdxgenType: mockIsNodejsCdxgenType, + runCdxgen: mockRunCdxgen, +})) + +describe('cmd-manifest-cdxgen failure reporting', () => { + const importMeta = { url: 'file:///test/cmd-manifest-cdxgen.mts' } + const context = { parentName: 'socket manifest' } + + beforeEach(() => { + vi.clearAllMocks() + mockDetectNodejsCdxgenSources.mockResolvedValue({ + hasLockfile: true, + hasNodeModules: true, + }) + mockIsNodejsCdxgenType.mockReturnValue(true) + process.exitCode = undefined + }) + + describe('never fails silently', () => { + // The command arms process.exitCode = 1 before spawning cdxgen. Every + // way out of the spawn must therefore either exit deliberately or print + // something, otherwise the user gets exit code 1 and an empty log. + it('reports an actionable error when cdxgen cannot be started', async () => { + mockRunCdxgen.mockRejectedValue(new Error('spawn cdxgen ENOENT')) + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown) + + await cmdManifestCdxgen.run(['.'], importMeta, context) + + expect(mockLogger.fail).toHaveBeenCalledTimes(1) + const message = String(mockLogger.fail.mock.calls[0]?.[0] ?? '') + expect(message.trim()).not.toBe('') + expect(message).toContain('socket cdxgen could not run cdxgen.') + expect(message).toContain('spawn cdxgen ENOENT') + expect(process.exitCode).toBe(1) + mockExit.mockRestore() + }) + + it('reports an actionable error when the spawn itself rejects', async () => { + mockRunCdxgen.mockResolvedValue({ + spawnPromise: Promise.reject(new Error('cdxgen download failed')), + }) + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown) + + await cmdManifestCdxgen.run(['.'], importMeta, context) + + expect(mockLogger.fail).toHaveBeenCalledTimes(1) + const message = String(mockLogger.fail.mock.calls[0]?.[0] ?? '') + expect(message).toContain('cdxgen download failed') + expect(process.exitCode).toBe(1) + mockExit.mockRestore() + }) + + it('reports an actionable error when cdxgen ends with no exit code and no signal', async () => { + mockRunCdxgen.mockResolvedValue({ + spawnPromise: Promise.resolve({ code: undefined, signal: undefined }), + }) + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown) + + await cmdManifestCdxgen.run(['.'], importMeta, context) + + expect(mockExit).not.toHaveBeenCalled() + expect(mockLogger.fail).toHaveBeenCalledTimes(1) + const message = String(mockLogger.fail.mock.calls[0]?.[0] ?? '') + expect(message).toContain('without reporting an exit code or a signal') + expect(process.exitCode).toBe(1) + mockExit.mockRestore() + }) + + // The failure message tells the user to re-run with SOCKET_CLI_DEBUG=1. + // That flag turns on the 'error' debug category, so the underlying detail + // has to be emitted there or the advice sends them nowhere. + it('makes SOCKET_CLI_DEBUG=1 worth running after a failed spawn', async () => { + const cause = new Error('spawn cdxgen ENOENT') + mockRunCdxgen.mockRejectedValue(cause) + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown) + + await cmdManifestCdxgen.run(['.'], importMeta, context) + + const debugCall = mockDebugNs.mock.calls.find(call => call[0] === 'error') + expect(debugCall).toBeDefined() + expect(debugCall).toContain(cause) + mockExit.mockRestore() + }) + + it('makes SOCKET_CLI_DEBUG=1 worth running after a resultless exit', async () => { + const spawnResult = { code: undefined, signal: undefined } + mockRunCdxgen.mockResolvedValue({ + spawnPromise: Promise.resolve(spawnResult), + }) + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown) + + await cmdManifestCdxgen.run(['.'], importMeta, context) + + const debugCall = mockDebugNs.mock.calls.find(call => call[0] === 'error') + expect(debugCall).toBeDefined() + expect(debugCall).toContain(spawnResult) + mockExit.mockRestore() + }) + + it('prints the missing-override message as written, without wrapping it', async () => { + const explained = formatMissingCdxgenLocalPathMessage('/tmp/acme-cdxgen') + mockRunCdxgen.mockRejectedValue(new InputError(explained)) + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown) + + await cmdManifestCdxgen.run(['.'], importMeta, context) + + expect(mockLogger.fail).toHaveBeenCalledTimes(1) + const message = String(mockLogger.fail.mock.calls[0]?.[0] ?? '') + expect(message).toBe(explained) + expect(process.exitCode).toBe(1) + mockExit.mockRestore() + }) + + it('stays quiet on the success path', async () => { + mockRunCdxgen.mockResolvedValue({ + spawnPromise: Promise.resolve({ code: 0, signal: undefined }), + }) + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown) + + await cmdManifestCdxgen.run(['.'], importMeta, context) + + expect(mockLogger.fail).not.toHaveBeenCalled() + expect(mockExit).toHaveBeenCalledWith(0) + mockExit.mockRestore() + }) + }) +}) diff --git a/packages/cli/test/unit/util/dlx/cdxgen-diagnostics.test.mts b/packages/cli/test/unit/util/dlx/cdxgen-diagnostics.test.mts new file mode 100644 index 0000000000..bbf60e2208 --- /dev/null +++ b/packages/cli/test/unit/util/dlx/cdxgen-diagnostics.test.mts @@ -0,0 +1,160 @@ +/** + * Unit tests for the cdxgen failure diagnostics. + * + * Purpose: cdxgen runs with stdio: 'inherit', so when it cannot start there is + * no child output to explain the failure. These tests pin the property that + * matters to a user staring at a CI log: the message is non-empty, names where + * the CLI looked, and says what to do next. + * + * Related Files: - src/util/dlx/cdxgen-diagnostics.mts (implementation) + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + describeCdxgenSource, + formatCdxgenFailureMessage, + formatMissingCdxgenLocalPathMessage, + isMissingCdxgenLocalPath, +} from '../../../../src/util/dlx/cdxgen-diagnostics.mts' +import { InputError } from '../../../../src/util/error/errors-types.mts' + +describe('formatCdxgenFailureMessage', () => { + it('is never empty, even with no underlying error', () => { + const message = formatCdxgenFailureMessage() + + expect(message.trim()).not.toBe('') + expect(message.length).toBeGreaterThan(40) + }) + + it('explains the case where the process reported no exit code and no signal', () => { + const message = formatCdxgenFailureMessage() + + expect(message).toContain('without reporting an exit code or a signal') + }) + + it('quotes the underlying error when there is one', () => { + const message = formatCdxgenFailureMessage(new Error('spawn cdxgen ENOENT')) + + expect(message).toContain('spawn cdxgen ENOENT') + }) + + it('survives a thrown non-Error value', () => { + const message = formatCdxgenFailureMessage('just a string') + + expect(message.trim()).not.toBe('') + expect(message).toContain('socket cdxgen could not run cdxgen.') + }) + + it('names where cdxgen was looked for and how to get more detail', () => { + const message = formatCdxgenFailureMessage(new Error('boom')) + + expect(message).toContain('Where:') + expect(message).toContain('Fix:') + expect(message).toContain('SOCKET_CLI_DEBUG=1') + expect(message).toContain('SOCKET_CLI_CDXGEN_LOCAL_PATH') + }) + + it('passes an InputError through instead of wrapping it', () => { + const explained = formatMissingCdxgenLocalPathMessage('/tmp/acme-cdxgen') + + expect(formatCdxgenFailureMessage(new InputError(explained))).toBe( + explained, + ) + }) + + it('never nests one Where/Saw/Fix block inside another', () => { + const message = formatCdxgenFailureMessage( + new InputError(formatMissingCdxgenLocalPathMessage('/tmp/acme-cdxgen')), + ) + + expect(message.match(/^\s*Where:/gm)).toHaveLength(1) + expect(message.match(/^\s*Saw:/gm)).toHaveLength(1) + expect(message.match(/^\s*Fix:/gm)).toHaveLength(1) + }) + + it('does not tell the user to set the variable that is already wrong', () => { + const message = formatCdxgenFailureMessage( + new InputError(formatMissingCdxgenLocalPathMessage('/tmp/acme-cdxgen')), + ) + + expect(message).not.toContain( + 'point SOCKET_CLI_CDXGEN_LOCAL_PATH at the binary', + ) + expect(message).toContain('Correct the path, or unset') + }) + + it('still wraps a plain Error that explains nothing on its own', () => { + const message = formatCdxgenFailureMessage(new Error('spawn EACCES')) + + expect(message).toContain('socket cdxgen could not run cdxgen.') + expect(message).toContain('spawn EACCES') + }) +}) + +describe('describeCdxgenSource', () => { + const originalPath = process.env['SOCKET_CLI_CDXGEN_LOCAL_PATH'] + + afterEach(() => { + if (originalPath === undefined) { + delete process.env['SOCKET_CLI_CDXGEN_LOCAL_PATH'] + } else { + process.env['SOCKET_CLI_CDXGEN_LOCAL_PATH'] = originalPath + } + }) + + it('names the dlx package when no override is configured', () => { + delete process.env['SOCKET_CLI_CDXGEN_LOCAL_PATH'] + + expect(describeCdxgenSource()).toContain('@cyclonedx/cdxgen') + }) +}) + +describe('SOCKET_CLI_CDXGEN_LOCAL_PATH validation', () => { + it('reports a path that is not on disk as missing', () => { + expect(isMissingCdxgenLocalPath('/definitely/not/here/acme-cdxgen')).toBe( + true, + ) + }) + + it('does not report an existing file as missing', () => { + expect(isMissingCdxgenLocalPath(process.execPath)).toBe(false) + }) + + it('names the variable and the offending path in the message', () => { + const message = formatMissingCdxgenLocalPathMessage( + '/definitely/not/here/acme-cdxgen', + ) + + expect(message).toContain('SOCKET_CLI_CDXGEN_LOCAL_PATH') + expect(message).toContain('/definitely/not/here/acme-cdxgen') + expect(message).toContain('Fix:') + }) +}) + +describe('message shape', () => { + let messages: string[] = [] + + beforeEach(() => { + messages = [ + formatCdxgenFailureMessage(), + formatCdxgenFailureMessage(new Error('boom')), + formatMissingCdxgenLocalPathMessage('/tmp/acme-cdxgen'), + ] + }) + + it('emits no ANSI escape codes, so a CI log stays clean', () => { + for (let i = 0, { length } = messages; i < length; i += 1) { + // oxlint-disable-next-line no-control-regex -- matching escapes is the point. + expect(messages[i]!).not.toMatch(/\[[0-9;]*m/) + } + }) + + it('leads with what went wrong before any detail', () => { + for (let i = 0, { length } = messages; i < length; i += 1) { + const firstLine = messages[i]!.split('\n')[0]! + expect(firstLine).not.toMatch(/^\s/) + expect(firstLine.length).toBeGreaterThan(20) + } + }) +}) diff --git a/packages/cli/test/unit/util/dlx/spawn-cdxgen.test.mts b/packages/cli/test/unit/util/dlx/spawn-cdxgen.test.mts index c7f8df8f3c..451a83bc42 100644 --- a/packages/cli/test/unit/util/dlx/spawn-cdxgen.test.mts +++ b/packages/cli/test/unit/util/dlx/spawn-cdxgen.test.mts @@ -29,11 +29,38 @@ vi.mock(import('../../../../src/util/dlx/resolve-binary.mts'), () => ({ resolveCdxgen: mockResolveCdxgen, })) +// The local-override paths below are fixtures, not real files. Stub the +// on-disk check so these tests stay about spawning; the check itself is +// covered in cdxgen-diagnostics.test.mts. +const mockIsMissingCdxgenLocalPath = vi.hoisted(() => + vi.fn().mockReturnValue(false), +) + +vi.mock(import('../../../../src/util/dlx/cdxgen-diagnostics.mts'), () => ({ + isMissingCdxgenLocalPath: mockIsMissingCdxgenLocalPath, + formatMissingCdxgenLocalPathMessage: (p: string) => + `SOCKET_CLI_CDXGEN_LOCAL_PATH points at a file that does not exist: ${p}`, +})) + import { spawnCdxgenDlx } from '../../../../src/util/dlx/spawn-cdxgen.mts' describe('spawnCdxgenDlx', () => { beforeEach(() => { vi.clearAllMocks() + mockIsMissingCdxgenLocalPath.mockReturnValue(false) + }) + + it('refuses a SOCKET_CLI_CDXGEN_LOCAL_PATH that is not on disk', async () => { + mockResolveCdxgen.mockReturnValue({ + type: 'local', + path: '/local/missing-cdxgen', + }) + mockIsMissingCdxgenLocalPath.mockReturnValue(true) + + await expect( + spawnCdxgenDlx(['-r', '.'], undefined, undefined), + ).rejects.toThrow(/SOCKET_CLI_CDXGEN_LOCAL_PATH/) + expect(mockSpawn).not.toHaveBeenCalled() }) it('runs a local cdxgen binary when SOCKET_CLI_CDXGEN_LOCAL_PATH is set', async () => {