diff --git a/genkit-tools/cli/src/commands/ui-start.ts b/genkit-tools/cli/src/commands/ui-start.ts index 08d48f745f..246d731835 100644 --- a/genkit-tools/cli/src/commands/ui-start.ts +++ b/genkit-tools/cli/src/commands/ui-start.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,6 +14,7 @@ * limitations under the License. */ +import { GenkitToolsError } from '@genkit-ai/tools-common/manager'; import { findProjectRoot, findServersDir, @@ -30,10 +31,14 @@ import fs from 'fs/promises'; import getPort, { makeRange } from 'get-port'; import open from 'open'; import path from 'path'; -import { SERVER_HARNESS_COMMAND } from './server-harness'; +import { detectCLIRuntime } from '../utils/runtime-detector'; +import { + buildServerHarnessSpawnConfig, + validateExecutablePath, +} from '../utils/spawn-config'; interface StartOptions { - port: string; + port?: string; open?: boolean; } @@ -42,7 +47,7 @@ export const uiStart = new Command('ui:start') .description( 'start the Developer UI which connects to runtimes in the same directory' ) - .option('-p, --port ', 'Port to serve on (defaults to 4000') + .option('-p, --port ', 'Port to serve on (defaults to 4000)') .option('-o, --open', 'Open the browser on UI start up') .action(async (options: StartOptions) => { let port: number; @@ -127,34 +132,76 @@ async function startAndWaitUntilHealthy( port: number, serversDir: string ): Promise { - return new Promise((resolve, reject) => { - const child = spawn( - process.execPath, - [SERVER_HARNESS_COMMAND, port.toString(), serversDir + '/devui.log'], - { - stdio: ['ignore', 'ignore', 'ignore'], - } - ); + // Detect runtime environment + const cliRuntime = detectCLIRuntime(); + logger.debug( + `Detected CLI runtime: ${cliRuntime.type} at ${cliRuntime.execPath}` + ); + if (cliRuntime.scriptPath) { + logger.debug(`Script path: ${cliRuntime.scriptPath}`); + } - // Only print out logs from the child process to debug output. - child.on('error', (error) => reject(error)); - child.on('exit', (code) => - reject(new Error(`UI process exited (code ${code}) unexpectedly`)) + // Build spawn configuration + const logPath = path.join(serversDir, 'devui.log'); + const spawnConfig = buildServerHarnessSpawnConfig(cliRuntime, port, logPath); + + // Validate executable path + const isExecutable = await validateExecutablePath(spawnConfig.command); + if (!isExecutable) { + throw new GenkitToolsError( + `Unable to execute command: ${spawnConfig.command}. ` + + `The file does not exist or is not executable.` ); + } + + logger.debug( + `Spawning: ${spawnConfig.command} ${spawnConfig.args.join(' ')}` + ); + const child = spawn( + spawnConfig.command, + spawnConfig.args, + spawnConfig.options + ); + + // Wait for the process to be ready + return new Promise((resolve, reject) => { + // Handle process events + child.on('error', (error) => { + logger.error(`Failed to start UI process: ${error.message}`); + reject( + new GenkitToolsError(`Failed to start UI process: ${error.message}`, { + cause: error, + }) + ); + }); + + child.on('exit', (code) => { + const msg = `UI process exited unexpectedly with code ${code}`; + logger.error(msg); + reject(new GenkitToolsError(msg)); + }); + + // Wait for the UI to become healthy waitUntilHealthy(`http://localhost:${port}`, 10000 /* 10 seconds */) .then((isHealthy) => { if (isHealthy) { child.unref(); resolve(child); } else { - reject( - new Error( - 'Timed out while waiting for UI to become healthy. ' + - 'To view full logs, set DEBUG environment variable.' - ) - ); + const msg = + 'Timed out while waiting for UI to become healthy. ' + + 'To view full logs, set DEBUG environment variable.'; + logger.error(msg); + reject(new GenkitToolsError(msg)); } }) - .catch((error) => reject(error)); + .catch((error) => { + logger.error(`Health check failed: ${error.message}`); + reject( + new GenkitToolsError(`Health check failed: ${error.message}`, { + cause: error, + }) + ); + }); }); } diff --git a/genkit-tools/cli/src/utils/runtime-detector.ts b/genkit-tools/cli/src/utils/runtime-detector.ts new file mode 100644 index 0000000000..f1bbbfafb4 --- /dev/null +++ b/genkit-tools/cli/src/utils/runtime-detector.ts @@ -0,0 +1,166 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { existsSync } from 'fs'; +import { basename, extname } from 'path'; + +const RUNTIME_NODE = 'node'; +const RUNTIME_BUN = 'bun'; +const RUNTIME_COMPILED = 'compiled-binary'; + +const NODE_PATTERNS = ['node', 'nodejs']; +const BUN_PATTERNS = ['bun']; + +const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']; + +/** + * CLI runtime types supported by the detector + */ +export type CLIRuntimeType = 'node' | 'bun' | 'compiled-binary'; + +/** + * Information about the CLI runtime environment + */ +export interface CLIRuntimeInfo { + /** Type of CLI runtime or execution mode */ + type: CLIRuntimeType; + /** Path to the executable (node, bun, or the compiled binary itself) */ + execPath: string; + /** Path to the script being executed (undefined for compiled binaries) */ + scriptPath?: string; + /** Whether this is a compiled binary (e.g., Bun-compiled) */ + isCompiledBinary: boolean; + /** Platform information */ + platform: NodeJS.Platform; +} + +/** + * Safely checks if a file exists without throwing errors + * @param path - File path to check + * @returns true if the file exists, false otherwise + */ +function safeExistsSync(path: string | undefined): boolean { + if (!path) return false; + try { + return existsSync(path); + } catch { + return false; + } +} + +/** + * Checks if the given path has a recognized script file extension + * @param path - File path to check + * @returns true if the path ends with a known script extension + * @internal Kept for potential future use, though not currently used in detection logic + */ +function isLikelyScriptFile(path: string | undefined): boolean { + if (!path) return false; + const ext = extname(path).toLowerCase(); + return SCRIPT_EXTENSIONS.includes(ext); +} + +/** + * Checks if executable name contains any of the given patterns + * @param execName - Name of the executable + * @param patterns - Array of patterns to match against + * @returns true if any pattern is found in the executable name + */ +function matchesPatterns(execName: string, patterns: string[]): boolean { + const lowerExecName = execName.toLowerCase(); + return patterns.some((pattern) => lowerExecName.includes(pattern)); +} + +/** + * Detects the current CLI runtime environment and execution context. + * This helps determine how to properly spawn child processes. + * + * @returns CLI runtime information including type, paths, and platform + * @throws Error if unable to determine CLI runtime executable path + */ +export function detectCLIRuntime(): CLIRuntimeInfo { + const platform = process.platform; + const execPath = process.execPath; + + if (!execPath || execPath.trim() === '') { + throw new Error('Unable to determine CLI runtime executable path'); + } + + const argv0 = process.argv[0]; + const argv1 = process.argv[1]; + + const execBasename = basename(execPath); + const argv0Basename = argv0 ? basename(argv0) : ''; + + const hasBunVersion = 'bun' in (process.versions || {}); + const hasNodeVersion = 'node' in (process.versions || {}); + + const execMatchesBun = matchesPatterns(execBasename, BUN_PATTERNS); + const execMatchesNode = matchesPatterns(execBasename, NODE_PATTERNS); + const argv0MatchesBun = matchesPatterns(argv0Basename, BUN_PATTERNS); + const argv0MatchesNode = matchesPatterns(argv0Basename, NODE_PATTERNS); + + const hasScriptArg = !!argv1; + const scriptExists = hasScriptArg && safeExistsSync(argv1); + + let type: CLIRuntimeType; + let scriptPath: string | undefined; + let isCompiledBinary: boolean; + + // Determine runtime type based on most reliable indicators + if (hasBunVersion || execMatchesBun || argv0MatchesBun) { + // Check if this is a Bun-compiled binary + // Bun compiled binaries have virtual paths like /$bunfs/root/... + if ( + argv1 && + (argv1.startsWith('/$bunfs/') || /^[A-Za-z]:[\\/]+~BUN[\\/]+/.test(argv1)) + ) { + // This is a Bun-compiled binary + type = RUNTIME_COMPILED; + scriptPath = undefined; + isCompiledBinary = true; + } else { + // Regular Bun runtime + type = RUNTIME_BUN; + scriptPath = argv1; + isCompiledBinary = false; + } + } else if (hasNodeVersion || execMatchesNode || argv0MatchesNode) { + // Definitely Node.js + type = RUNTIME_NODE; + scriptPath = argv1; + isCompiledBinary = false; + } else if (!hasScriptArg || !scriptExists) { + // No script argument or script doesn't exist - likely compiled binary + type = RUNTIME_COMPILED; + scriptPath = undefined; + isCompiledBinary = true; + } else { + // Have a script argument that exists but unknown runtime + // This handles cases like custom Node.js builds with unusual names + type = RUNTIME_NODE; + scriptPath = argv1; + isCompiledBinary = false; + } + + return { + type, + execPath, + scriptPath, + isCompiledBinary, + platform, + }; +} diff --git a/genkit-tools/cli/src/utils/spawn-config.ts b/genkit-tools/cli/src/utils/spawn-config.ts new file mode 100644 index 0000000000..d36ee32e7d --- /dev/null +++ b/genkit-tools/cli/src/utils/spawn-config.ts @@ -0,0 +1,134 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { type SpawnOptions } from 'child_process'; +import { access, constants } from 'fs/promises'; +import { SERVER_HARNESS_COMMAND } from '../commands/server-harness'; +import { type CLIRuntimeInfo } from './runtime-detector'; + +/** + * Configuration for spawning a child process + */ +export interface SpawnConfig { + /** Executable to run */ + command: string; + /** Arguments array */ + args: string[]; + /** Spawn options */ + options: SpawnOptions; +} + +/** + * Validates that a path exists and is executable + * @param path - Path to validate + * @returns true if the path exists and is executable + */ +export async function validateExecutablePath(path: string): Promise { + try { + // Remove surrounding quotes if present (handle quotation) + const normalizedPath = + path.startsWith('"') && path.endsWith('"') ? path.slice(1, -1) : path; + await access(normalizedPath, constants.F_OK | constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Validates that a port number is valid (integer between 0 and 65535) + * @param port - Port number to validate + * @returns true if the port is valid + */ +function isValidPort(port: number): boolean { + return Number.isInteger(port) && port >= 0 && port <= 65535; +} + +/** + * Builds spawn configuration for the server harness based on runtime info + * + * @param cliRuntime - CLI runtime information from detector + * @param port - Port number for the server (must be valid port 0-65535) + * @param logPath - Path to the log file + * @returns Spawn configuration for child_process.spawn + * @throws Error if port is invalid or runtime info is missing required fields + * + * @example + * ```typescript + * const cliRuntime = detectRuntime(); + * const config = buildServerHarnessSpawnConfig(cliRuntime, 4000, '/path/to/log'); + * const child = spawn(config.command, config.args, config.options); + * ``` + */ +export function buildServerHarnessSpawnConfig( + cliRuntime: CLIRuntimeInfo, + port: number, + logPath: string +): SpawnConfig { + // Validate inputs + if (!cliRuntime) { + throw new Error('CLI runtime info is required'); + } + if (!cliRuntime.execPath) { + throw new Error('CLI runtime execPath is required'); + } + if (!isValidPort(port)) { + throw new Error( + `Invalid port number: ${port}. Must be between 0 and 65535` + ); + } + if (!logPath) { + throw new Error('Log path is required'); + } + + let command = cliRuntime.execPath; + let args: string[]; + + if (cliRuntime.type === 'compiled-binary') { + // For compiled binaries, execute directly with arguments + args = [SERVER_HARNESS_COMMAND, port.toString(), logPath]; + } else { + // For interpreted runtimes (Node.js, Bun), include script path if available + args = cliRuntime.scriptPath + ? [ + cliRuntime.scriptPath, + SERVER_HARNESS_COMMAND, + port.toString(), + logPath, + ] + : [SERVER_HARNESS_COMMAND, port.toString(), logPath]; + } + + // Build spawn options with platform-specific settings + const options: SpawnOptions = { + stdio: ['ignore', 'ignore', 'ignore'] as const, + detached: false, + // Use shell on Windows for better compatibility with paths containing spaces + shell: cliRuntime.platform === 'win32', + }; + + // Handles spaces in the command and arguments on Windows + if (cliRuntime.platform === 'win32') { + command = `"${command}"`; + args = args.map((arg) => `"${arg}"`); + } + + return { + command, + args, + options, + }; +} diff --git a/genkit-tools/cli/tests/commands/ui-start_test.ts b/genkit-tools/cli/tests/commands/ui-start_test.ts new file mode 100644 index 0000000000..07cf28dbde --- /dev/null +++ b/genkit-tools/cli/tests/commands/ui-start_test.ts @@ -0,0 +1,621 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + findProjectRoot, + findServersDir, + isValidDevToolsInfo, + logger, + waitUntilHealthy, + type DevToolsInfo, +} from '@genkit-ai/tools-common/utils'; +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import axios from 'axios'; +import { spawn, type ChildProcess } from 'child_process'; +import * as clc from 'colorette'; +import * as fs from 'fs/promises'; +import open from 'open'; +import path from 'path'; +import { uiStart } from '../../src/commands/ui-start'; +import { detectCLIRuntime } from '../../src/utils/runtime-detector'; +import { + buildServerHarnessSpawnConfig, + validateExecutablePath, +} from '../../src/utils/spawn-config'; + +// Mock all external dependencies +jest.mock('@genkit-ai/tools-common/utils'); +jest.mock('axios'); +jest.mock('child_process'); +jest.mock('colorette'); +jest.mock('fs/promises'); +// Use real getPort - don't mock it +jest.mock('open'); +jest.mock('../../src/utils/runtime-detector'); +jest.mock('../../src/utils/spawn-config'); + +const mockedFindProjectRoot = findProjectRoot as jest.MockedFunction< + typeof findProjectRoot +>; +const mockedFindServersDir = findServersDir as jest.MockedFunction< + typeof findServersDir +>; +const mockedIsValidDevToolsInfo = isValidDevToolsInfo as jest.MockedFunction< + typeof isValidDevToolsInfo +>; +const mockedLogger = logger as jest.Mocked; +const mockedWaitUntilHealthy = waitUntilHealthy as jest.MockedFunction< + typeof waitUntilHealthy +>; +const mockedAxios = axios as jest.Mocked; +const mockedSpawn = spawn as jest.MockedFunction; +const mockedClc = clc as jest.Mocked; +const mockedFs = fs as jest.Mocked; +const mockedOpen = open as jest.MockedFunction; +const mockedDetectCLIRuntime = detectCLIRuntime as jest.MockedFunction< + typeof detectCLIRuntime +>; +const mockedBuildServerHarnessSpawnConfig = + buildServerHarnessSpawnConfig as jest.MockedFunction< + typeof buildServerHarnessSpawnConfig + >; +const mockedValidateExecutablePath = + validateExecutablePath as jest.MockedFunction; + +describe('ui:start', () => { + const createCommand = () => + uiStart.exitOverride().configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + const mockProjectRoot = '/mock/project/root'; + const mockServersDir = '/mock/project/root/.genkit/servers'; + const mockToolsJsonPath = path.join(mockServersDir, 'tools.json'); + const mockLogPath = path.join(mockServersDir, 'devui.log'); + + const mockCLIRuntime = { + type: 'node' as const, + execPath: '/usr/bin/node', + scriptPath: '/usr/lib/node_modules/genkit-cli/dist/bin/genkit.js', + isCompiledBinary: false, + platform: 'darwin' as const, + }; + + const mockSpawnConfig = { + command: '/usr/bin/node', + args: [ + '/usr/lib/node_modules/genkit-cli/dist/bin/genkit.js', + 'server-harness', + '4000', + mockLogPath, + ], + options: { + stdio: ['ignore', 'ignore', 'ignore'] as ['ignore', 'ignore', 'ignore'], + detached: false, + shell: false, + }, + }; + + const mockChildProcess = { + on: jest.fn().mockReturnThis(), + unref: jest.fn(), + stdin: null, + stdout: null, + stderr: null, + stdio: [null, null, null], + pid: 12345, + connected: false, + exitCode: null, + signalCode: null, + spawnargs: [], + spawnfile: '', + kill: jest.fn(), + send: jest.fn(), + disconnect: jest.fn(), + ref: jest.fn(), + addListener: jest.fn(), + emit: jest.fn(), + once: jest.fn(), + prependListener: jest.fn(), + prependOnceListener: jest.fn(), + removeListener: jest.fn(), + removeAllListeners: jest.fn(), + setMaxListeners: jest.fn(), + getMaxListeners: jest.fn(), + listeners: jest.fn(), + rawListeners: jest.fn(), + listenerCount: jest.fn(), + eventNames: jest.fn(), + off: jest.fn(), + } as unknown as ChildProcess; + + beforeEach(() => { + jest.clearAllMocks(); + mockedFindProjectRoot.mockResolvedValue(mockProjectRoot); + mockedFindServersDir.mockResolvedValue(mockServersDir); + mockedDetectCLIRuntime.mockReturnValue(mockCLIRuntime); + mockedBuildServerHarnessSpawnConfig.mockReturnValue(mockSpawnConfig); + mockedValidateExecutablePath.mockResolvedValue(true); + mockedSpawn.mockReturnValue(mockChildProcess as any); + mockedWaitUntilHealthy.mockResolvedValue(true); + mockedClc.green.mockImplementation((text) => `GREEN:${text}`); + }); + + describe('port validation', () => { + it('should accept valid port number', async () => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + + await createCommand().parseAsync(['node', 'ui:start', '--port', '8080']); + + expect(mockedLogger.error).not.toHaveBeenCalled(); + }); + + it('should reject invalid port number (NaN)', async () => { + await createCommand().parseAsync([ + 'node', + 'ui:start', + '--port', + 'invalid', + ]); + + expect(mockedLogger.error).toHaveBeenCalledWith( + '"invalid" is not a valid port number' + ); + }); + + it('should reject negative port number', async () => { + await createCommand().parseAsync(['node', 'ui:start', '--port', '-1']); + + expect(mockedLogger.error).toHaveBeenCalledWith( + '"-1" is not a valid port number' + ); + }); + + it('should accept zero port number', async () => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + mockedFs.mkdir.mockResolvedValue(undefined); + mockedFs.writeFile.mockResolvedValue(undefined); + + await createCommand().parseAsync(['node', 'ui:start', '--port', '0']); + + expect(mockedLogger.error).not.toHaveBeenCalled(); + expect(mockedBuildServerHarnessSpawnConfig).toHaveBeenCalledWith( + mockCLIRuntime, + 0, + mockLogPath + ); + }); + + it('should use default port range when no port specified', async () => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + mockedFs.mkdir.mockResolvedValue(undefined); + mockedFs.writeFile.mockResolvedValue(undefined); + + await createCommand().parseAsync(['node', 'ui:start']); + }); + }); + + describe('existing server detection', () => { + it('should detect and report existing healthy server', async () => { + const mockServerInfo: DevToolsInfo = { + url: 'http://localhost:4000', + timestamp: new Date().toISOString(), + }; + + mockedIsValidDevToolsInfo.mockReturnValue(true); + mockedFs.readFile.mockResolvedValue(JSON.stringify(mockServerInfo)); + mockedAxios.get.mockResolvedValue({ status: 200 }); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.info).toHaveBeenCalledWith( + expect.stringContaining( + 'Genkit Developer UI is already running at: http://localhost:4000' + ) + ); + expect(mockedLogger.info).toHaveBeenCalledWith( + expect.stringContaining('To stop the UI, run `genkit ui:stop`') + ); + }); + + it('should start new server when existing server is unhealthy', async () => { + const mockServerInfo: DevToolsInfo = { + url: 'http://localhost:4000', + timestamp: new Date().toISOString(), + }; + + mockedIsValidDevToolsInfo.mockReturnValue(true); + mockedFs.readFile.mockResolvedValue(JSON.stringify(mockServerInfo)); + mockedAxios.get.mockRejectedValue(new Error('Connection refused')); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.debug).toHaveBeenCalledWith( + 'Found UI server metadata but server is not healthy. Starting a new one...' + ); + expect(mockedLogger.info).toHaveBeenCalledWith('Starting...'); + }); + + it('should start new server when tools.json is invalid', async () => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockResolvedValue('invalid json'); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.info).toHaveBeenCalledWith('Starting...'); + }); + + it('should start new server when tools.json does not exist', async () => { + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.debug).toHaveBeenCalledWith( + 'No UI running. Starting a new one...' + ); + expect(mockedLogger.info).toHaveBeenCalledWith('Starting...'); + }); + }); + + describe('server startup', () => { + beforeEach(() => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + mockedFs.mkdir.mockResolvedValue(undefined); + mockedFs.writeFile.mockResolvedValue(undefined); + }); + + it('should successfully start server and write metadata', async () => { + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedDetectCLIRuntime).toHaveBeenCalled(); + + const spawnConfigCall = mockedBuildServerHarnessSpawnConfig.mock.calls[0]; + const actualPort = spawnConfigCall[1]; + + expect(mockedBuildServerHarnessSpawnConfig).toHaveBeenCalledWith( + mockCLIRuntime, + actualPort, + mockLogPath + ); + expect(mockedValidateExecutablePath).toHaveBeenCalledWith( + mockSpawnConfig.command + ); + expect(mockedSpawn).toHaveBeenCalledWith( + mockSpawnConfig.command, + mockSpawnConfig.args, + mockSpawnConfig.options + ); + expect(mockedWaitUntilHealthy).toHaveBeenCalledWith( + `http://localhost:${actualPort}`, + 10000 + ); + expect(mockedFs.mkdir).toHaveBeenCalledWith(mockServersDir, { + recursive: true, + }); + expect(mockedFs.writeFile).toHaveBeenCalledWith( + mockToolsJsonPath, + expect.stringContaining(`"url": "http://localhost:${actualPort}"`) + ); + expect(mockedLogger.info).toHaveBeenCalledWith( + expect.stringContaining( + `Genkit Developer UI started at: http://localhost:${actualPort}` + ) + ); + }); + + it('should open browser when --open flag is provided', async () => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + mockedFs.mkdir.mockResolvedValue(undefined); + mockedFs.writeFile.mockResolvedValue(undefined); + + await createCommand().parseAsync(['node', 'ui:start', '--open']); + + const spawnConfigCall = mockedBuildServerHarnessSpawnConfig.mock.calls[0]; + const actualPort = spawnConfigCall[1]; + + expect(mockedOpen).toHaveBeenCalledWith(`http://localhost:${actualPort}`); + }); + + it('should handle server startup failure', async () => { + const startupError = new Error('Failed to start server'); + mockedWaitUntilHealthy.mockRejectedValue(startupError); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to start Genkit Developer UI') + ); + }); + + it('should handle executable validation failure', async () => { + mockedValidateExecutablePath.mockResolvedValue(false); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to start Genkit Developer UI') + ); + }); + + it('should handle spawn process error', async () => { + // Make waitUntilHealthy reject to simulate a failure + mockedWaitUntilHealthy.mockRejectedValue( + new Error('Health check failed') + ); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to start Genkit Developer UI') + ); + }); + + it('should handle spawn process exit', async () => { + // Make waitUntilHealthy reject to simulate a failure + mockedWaitUntilHealthy.mockRejectedValue(new Error('Process exited')); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to start Genkit Developer UI') + ); + }); + + it('should handle health check timeout', async () => { + mockedWaitUntilHealthy.mockResolvedValue(false); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to start Genkit Developer UI') + ); + }); + }); + + describe('metadata file operations', () => { + beforeEach(() => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + }); + + it('should handle metadata write failure gracefully', async () => { + mockedFs.mkdir.mockRejectedValue(new Error('Permission denied')); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.error).toHaveBeenCalledWith( + 'Failed to write UI server metadata. UI server will continue to run.' + ); + // Should still report success + expect(mockedLogger.info).toHaveBeenCalledWith( + expect.stringContaining('Genkit Developer UI started at:') + ); + }); + + it('should write correct metadata format', async () => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + mockedFs.mkdir.mockResolvedValue(undefined); + mockedFs.writeFile.mockResolvedValue(undefined); + + await createCommand().parseAsync(['node', 'ui:start']); + + const spawnConfigCall = mockedBuildServerHarnessSpawnConfig.mock.calls[0]; + const actualPort = spawnConfigCall[1]; + + expect(mockedFs.writeFile).toHaveBeenCalledWith( + mockToolsJsonPath, + expect.stringMatching( + new RegExp( + `^\\{\\s*"url":\\s*"http://localhost:${actualPort}",\\s*"timestamp":\\s*"[^"]+"\\s*\\}$` + ) + ) + ); + }); + }); + + describe('runtime detection integration', () => { + beforeEach(() => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + }); + + it('should handle different CLI runtime types', async () => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + mockedFs.mkdir.mockResolvedValue(undefined); + mockedFs.writeFile.mockResolvedValue(undefined); + + const bunRuntime = { + type: 'bun' as const, + execPath: '/usr/local/bin/bun', + scriptPath: '/usr/lib/node_modules/genkit-cli/dist/bin/genkit.js', + isCompiledBinary: false, + platform: 'darwin' as const, + }; + + mockedDetectCLIRuntime.mockReturnValue(bunRuntime); + + await createCommand().parseAsync(['node', 'ui:start']); + + const spawnConfigCall = mockedBuildServerHarnessSpawnConfig.mock.calls[0]; + const actualPort = spawnConfigCall[1]; + + expect(mockedBuildServerHarnessSpawnConfig).toHaveBeenCalledWith( + bunRuntime, + actualPort, + mockLogPath + ); + }); + + it('should handle compiled binary runtime', async () => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + mockedFs.mkdir.mockResolvedValue(undefined); + mockedFs.writeFile.mockResolvedValue(undefined); + + const binaryRuntime = { + type: 'compiled-binary' as const, + execPath: '/usr/local/bin/genkit', + scriptPath: undefined, + isCompiledBinary: true, + platform: 'linux' as const, + }; + + mockedDetectCLIRuntime.mockReturnValue(binaryRuntime); + + await createCommand().parseAsync(['node', 'ui:start']); + + const spawnConfigCall = mockedBuildServerHarnessSpawnConfig.mock.calls[0]; + const actualPort = spawnConfigCall[1]; + + expect(mockedBuildServerHarnessSpawnConfig).toHaveBeenCalledWith( + binaryRuntime, + actualPort, + mockLogPath + ); + }); + }); + + describe('error handling', () => { + it('should handle findProjectRoot failure', async () => { + mockedFindProjectRoot.mockRejectedValue( + new Error('Project root not found') + ); + + await expect( + createCommand().parseAsync(['node', 'ui:start']) + ).rejects.toThrow(); + }); + + it('should handle findServersDir failure', async () => { + mockedFindServersDir.mockRejectedValue( + new Error('Servers dir not found') + ); + + await expect( + createCommand().parseAsync(['node', 'ui:start']) + ).rejects.toThrow(); + }); + + it('should handle runtime detection failure', async () => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + mockedDetectCLIRuntime.mockImplementation(() => { + throw new Error('Runtime detection failed'); + }); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to start Genkit Developer UI') + ); + }); + + it('should handle spawn config build failure', async () => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + mockedBuildServerHarnessSpawnConfig.mockImplementation(() => { + throw new Error('Invalid spawn config'); + }); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to start Genkit Developer UI') + ); + }); + }); + + describe('logging and debugging', () => { + beforeEach(() => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + }); + + it('should log debug information for CLI runtime', async () => { + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.debug).toHaveBeenCalledWith( + 'Detected CLI runtime: node at /usr/bin/node' + ); + expect(mockedLogger.debug).toHaveBeenCalledWith( + 'Script path: /usr/lib/node_modules/genkit-cli/dist/bin/genkit.js' + ); + }); + + it('should log spawn command for debugging', async () => { + await createCommand().parseAsync(['node', 'ui:start']); + + // The debug message should contain the spawn command and args + expect(mockedLogger.debug).toHaveBeenCalledWith( + expect.stringMatching( + /^Spawning: \/usr\/bin\/node \/usr\/lib\/node_modules\/genkit-cli\/dist\/bin\/genkit\.js server-harness \d+ \/mock\/project\/root\/\.genkit\/servers\/devui\.log$/ + ) + ); + }); + + it('should not log script path when undefined', async () => { + const runtimeWithoutScript = { ...mockCLIRuntime, scriptPath: undefined }; + mockedDetectCLIRuntime.mockReturnValue(runtimeWithoutScript); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.debug).not.toHaveBeenCalledWith( + expect.stringContaining('Script path:') + ); + }); + }); + + describe('health check integration', () => { + beforeEach(() => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + }); + + it('should check for existing actions after startup', async () => { + mockedIsValidDevToolsInfo.mockReturnValue(false); + mockedFs.readFile.mockRejectedValue(new Error('ENOENT')); + mockedFs.mkdir.mockResolvedValue(undefined); + mockedFs.writeFile.mockResolvedValue(undefined); + + await createCommand().parseAsync(['node', 'ui:start']); + + const spawnConfigCall = mockedBuildServerHarnessSpawnConfig.mock.calls[0]; + const actualPort = spawnConfigCall[1]; + + expect(mockedAxios.get).toHaveBeenCalledWith( + `http://localhost:${actualPort}/api/trpc/listActions` + ); + }); + + it('should show dev environment message when no actions found', async () => { + mockedAxios.get.mockRejectedValue(new Error('No actions')); + + await createCommand().parseAsync(['node', 'ui:start']); + + expect(mockedLogger.info).toHaveBeenCalledWith( + 'Set env variable `GENKIT_ENV` to `dev` and start your app code to interact with it in the UI.' + ); + }); + }); +}); diff --git a/genkit-tools/cli/tests/utils/runtime-detector_test.ts b/genkit-tools/cli/tests/utils/runtime-detector_test.ts new file mode 100644 index 0000000000..77f605811a --- /dev/null +++ b/genkit-tools/cli/tests/utils/runtime-detector_test.ts @@ -0,0 +1,504 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import * as fs from 'fs'; +import { detectCLIRuntime } from '../../src/utils/runtime-detector'; + +jest.mock('fs'); +const mockedFs = fs as jest.Mocked; + +describe('runtime-detector', () => { + const originalArgv = process.argv; + const originalExecPath = process.execPath; + const originalVersions = process.versions; + const originalPlatform = process.platform; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + process.argv = originalArgv; + process.execPath = originalExecPath; + Object.defineProperty(process, 'versions', { + value: originalVersions, + writable: true, + configurable: true, + }); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + configurable: true, + }); + }); + + describe('Node.js CLI runtime detection', () => { + it('should detect Node.js CLI runtime with npm global install', () => { + process.argv = [ + '/usr/bin/node', + '/usr/lib/node_modules/genkit-cli/dist/bin/genkit.js', + ]; + process.execPath = '/usr/bin/node'; + mockedFs.existsSync.mockReturnValue(true); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/bin/node'); + expect(result.scriptPath).toBe( + '/usr/lib/node_modules/genkit-cli/dist/bin/genkit.js' + ); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should detect Node.js CLI runtime with npm link', () => { + process.argv = [ + '/usr/local/bin/node', + '/Users/dev/project/node_modules/.bin/genkit', + ]; + process.execPath = '/usr/local/bin/node'; + mockedFs.existsSync.mockReturnValue(true); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/local/bin/node'); + expect(result.scriptPath).toBe( + '/Users/dev/project/node_modules/.bin/genkit' + ); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should detect Node.js CLI runtime with direct execution', () => { + process.argv = ['node', './dist/bin/genkit.js']; + process.execPath = '/usr/bin/node'; + mockedFs.existsSync.mockReturnValue(true); + Object.defineProperty(process, 'versions', { + value: { ...originalVersions, node: '18.0.0' }, + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/bin/node'); + expect(result.scriptPath).toBe('./dist/bin/genkit.js'); + expect(result.isCompiledBinary).toBe(false); + }); + }); + + describe('Bun CLI runtime detection', () => { + it('should detect Bun CLI runtime via process.versions', () => { + process.argv = [ + '/usr/local/bin/bun', + '/usr/lib/node_modules/genkit-cli/dist/bin/genkit.js', + ]; + process.execPath = '/usr/local/bin/bun'; + mockedFs.existsSync.mockReturnValue(true); + Object.defineProperty(process, 'versions', { + value: { ...originalVersions, bun: '1.0.0' }, + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('bun'); + expect(result.execPath).toBe('/usr/local/bin/bun'); + expect(result.scriptPath).toBe( + '/usr/lib/node_modules/genkit-cli/dist/bin/genkit.js' + ); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should detect Bun CLI runtime via execPath', () => { + process.argv = ['/opt/homebrew/bin/bun', './genkit.js']; + process.execPath = '/opt/homebrew/bin/bun'; + mockedFs.existsSync.mockReturnValue(true); + // No bun in process.versions + Object.defineProperty(process, 'versions', { + value: { ...originalVersions }, + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('bun'); + expect(result.execPath).toBe('/opt/homebrew/bin/bun'); + expect(result.scriptPath).toBe('./genkit.js'); + expect(result.isCompiledBinary).toBe(false); + }); + }); + + describe('Compiled binary detection', () => { + it('should detect compiled binary when argv[1] is undefined', () => { + process.argv = ['/usr/local/bin/genkit']; + process.execPath = '/usr/local/bin/genkit'; + mockedFs.existsSync.mockReturnValue(false); + Object.defineProperty(process, 'versions', { + value: {}, + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('compiled-binary'); + expect(result.execPath).toBe('/usr/local/bin/genkit'); + expect(result.scriptPath).toBeUndefined(); + expect(result.isCompiledBinary).toBe(true); + }); + + it('should detect compiled binary when argv[1] does not exist', () => { + process.argv = ['/usr/local/bin/genkit', 'nonexistent.js']; + process.execPath = '/usr/local/bin/genkit'; + mockedFs.existsSync.mockReturnValue(false); + Object.defineProperty(process, 'versions', { + value: {}, + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('compiled-binary'); + expect(result.execPath).toBe('/usr/local/bin/genkit'); + expect(result.scriptPath).toBeUndefined(); + expect(result.isCompiledBinary).toBe(true); + }); + + it('should detect Bun-compiled binary', () => { + process.argv = ['./genkit']; + process.execPath = './genkit'; + mockedFs.existsSync.mockReturnValue(false); + Object.defineProperty(process, 'versions', { + value: {}, + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('compiled-binary'); + expect(result.execPath).toBe('./genkit'); + expect(result.scriptPath).toBeUndefined(); + expect(result.isCompiledBinary).toBe(true); + }); + }); + + describe('Edge cases and fallback behavior', () => { + it('should throw error when execPath is missing', () => { + const originalExecPath = process.execPath; + + Object.defineProperty(process, 'execPath', { + value: '', + writable: true, + configurable: true, + }); + + expect(() => detectCLIRuntime()).toThrow( + 'Unable to determine CLI runtime executable path' + ); + + Object.defineProperty(process, 'execPath', { + value: originalExecPath, + writable: true, + configurable: true, + }); + }); + + it('should throw error when execPath is whitespace only', () => { + const originalExecPath = process.execPath; + + Object.defineProperty(process, 'execPath', { + value: ' ', + writable: true, + configurable: true, + }); + + expect(() => detectCLIRuntime()).toThrow( + 'Unable to determine CLI runtime executable path' + ); + + Object.defineProperty(process, 'execPath', { + value: originalExecPath, + writable: true, + configurable: true, + }); + }); + + it('should fall back to node when CLI runtime cannot be determined', () => { + process.argv = ['/some/unknown/runtime', '/some/script.js']; + process.execPath = '/some/unknown/runtime'; + mockedFs.existsSync.mockReturnValue(true); + Object.defineProperty(process, 'versions', { + value: {}, + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/some/unknown/runtime'); + expect(result.scriptPath).toBe('/some/script.js'); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should handle Windows paths correctly', () => { + process.argv = [ + 'C:\\Program Files\\nodejs\\node.exe', + 'C:\\Users\\dev\\genkit\\dist\\cli.js', + ]; + process.execPath = 'C:\\Program Files\\nodejs\\node.exe'; + mockedFs.existsSync.mockReturnValue(true); + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('C:\\Program Files\\nodejs\\node.exe'); + expect(result.scriptPath).toBe('C:\\Users\\dev\\genkit\\dist\\cli.js'); + expect(result.isCompiledBinary).toBe(false); + expect(result.platform).toBe('win32'); + }); + + it('should detect platform correctly', () => { + process.argv = ['/usr/bin/node', '/usr/bin/genkit']; + process.execPath = '/usr/bin/node'; + mockedFs.existsSync.mockReturnValue(true); + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + expect(result.platform).toBe('darwin'); + }); + }); + + describe('Additional edge cases', () => { + it('should handle empty argv array with node executable', () => { + process.argv = []; + process.execPath = '/usr/bin/node'; + mockedFs.existsSync.mockReturnValue(false); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/bin/node'); + expect(result.scriptPath).toBeUndefined(); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should handle empty argv array with compiled binary', () => { + process.argv = []; + process.execPath = '/usr/bin/genkit'; + mockedFs.existsSync.mockReturnValue(false); + Object.defineProperty(process, 'versions', { + value: {}, + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('compiled-binary'); + expect(result.execPath).toBe('/usr/bin/genkit'); + expect(result.scriptPath).toBeUndefined(); + expect(result.isCompiledBinary).toBe(true); + }); + + it('should handle argv with only one element', () => { + process.argv = ['/usr/bin/node']; + process.execPath = '/usr/bin/node'; + mockedFs.existsSync.mockReturnValue(false); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/bin/node'); + expect(result.scriptPath).toBeUndefined(); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should handle fs.existsSync throwing an error', () => { + process.argv = ['/usr/bin/node', '/path/to/script.js']; + process.execPath = '/usr/bin/node'; + mockedFs.existsSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/bin/node'); + expect(result.scriptPath).toBe('/path/to/script.js'); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should handle script files with unusual extensions', () => { + process.argv = ['/usr/bin/node', '/path/to/script.xyz']; + process.execPath = '/usr/bin/node'; + mockedFs.existsSync.mockReturnValue(true); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/bin/node'); + expect(result.scriptPath).toBe('/path/to/script.xyz'); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should handle executables with version numbers in name', () => { + process.argv = ['node18', '/script.js']; + process.execPath = '/usr/bin/node18'; + mockedFs.existsSync.mockReturnValue(true); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/bin/node18'); + expect(result.scriptPath).toBe('/script.js'); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should handle bun executables with version numbers', () => { + process.argv = ['/usr/local/bin/bun1.0', '/script.js']; + process.execPath = '/usr/local/bin/bun1.0'; + mockedFs.existsSync.mockReturnValue(true); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('bun'); + expect(result.execPath).toBe('/usr/local/bin/bun1.0'); + expect(result.scriptPath).toBe('/script.js'); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should handle null or undefined in process.versions', () => { + process.argv = ['/usr/bin/node', '/script.js']; + process.execPath = '/usr/bin/node'; + mockedFs.existsSync.mockReturnValue(true); + Object.defineProperty(process, 'versions', { + value: null, + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/bin/node'); + expect(result.scriptPath).toBe('/script.js'); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should handle very long file paths', () => { + const longPath = '/very/long/path/'.repeat(50) + 'script.js'; + process.argv = ['/usr/bin/node', longPath]; + process.execPath = '/usr/bin/node'; + mockedFs.existsSync.mockReturnValue(true); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/bin/node'); + expect(result.scriptPath).toBe(longPath); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should handle special characters in paths', () => { + process.argv = ['/usr/bin/node', '/path with spaces/script (1).js']; + process.execPath = '/usr/bin/node'; + mockedFs.existsSync.mockReturnValue(true); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/bin/node'); + expect(result.scriptPath).toBe('/path with spaces/script (1).js'); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should handle argv[0] being different from execPath', () => { + process.argv = ['node', '/script.js']; + process.execPath = '/usr/local/bin/node'; + mockedFs.existsSync.mockReturnValue(true); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/local/bin/node'); + expect(result.scriptPath).toBe('/script.js'); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should handle custom Node.js build with unusual script extension', () => { + // Custom Node.js build named "my-node" with script having .xyz extension + process.argv = ['/usr/bin/my-node', '/path/to/script.xyz']; + process.execPath = '/usr/bin/my-node'; + mockedFs.existsSync.mockReturnValue(true); + Object.defineProperty(process, 'versions', { + value: { node: '20.0.0' }, // Has node version info + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + // Should correctly detect as Node.js based on version info + expect(result.type).toBe('node'); + expect(result.execPath).toBe('/usr/bin/my-node'); + expect(result.scriptPath).toBe('/path/to/script.xyz'); + expect(result.isCompiledBinary).toBe(false); + }); + + it('should detect Bun-compiled binary with virtual filesystem path', () => { + process.argv = ['/usr/local/bin/genkit', '/$bunfs/root/genkit']; + process.execPath = '/usr/local/bin/genkit'; + mockedFs.existsSync.mockReturnValue(false); // Virtual path doesn't exist on real filesystem + Object.defineProperty(process, 'versions', { + value: { ...originalVersions, bun: '1.0.0' }, + writable: true, + configurable: true, + }); + + const result = detectCLIRuntime(); + + expect(result.type).toBe('compiled-binary'); + expect(result.execPath).toBe('/usr/local/bin/genkit'); + expect(result.scriptPath).toBeUndefined(); + expect(result.isCompiledBinary).toBe(true); + }); + }); +}); diff --git a/genkit-tools/cli/tests/utils/spawn-config_test.ts b/genkit-tools/cli/tests/utils/spawn-config_test.ts new file mode 100644 index 0000000000..52fb28ea53 --- /dev/null +++ b/genkit-tools/cli/tests/utils/spawn-config_test.ts @@ -0,0 +1,429 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import * as fs from 'fs/promises'; +import { SERVER_HARNESS_COMMAND } from '../../src/commands/server-harness'; +import { type CLIRuntimeInfo } from '../../src/utils/runtime-detector'; +import { + buildServerHarnessSpawnConfig, + validateExecutablePath, +} from '../../src/utils/spawn-config'; + +jest.mock('fs/promises'); +const mockedFs = fs as jest.Mocked; + +describe('spawn-config', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('buildServerHarnessSpawnConfig', () => { + const mockPort = 4000; + const mockLogPath = '/path/to/devui.log'; + + describe('Node.js CLI runtime', () => { + it('should build config for Node.js with script path', () => { + const cliRuntime: CLIRuntimeInfo = { + type: 'node', + execPath: '/usr/bin/node', + scriptPath: '/usr/lib/node_modules/genkit-cli/dist/bin/genkit.js', + isCompiledBinary: false, + platform: 'darwin', + }; + + const config = buildServerHarnessSpawnConfig( + cliRuntime, + mockPort, + mockLogPath + ); + + expect(config.command).toBe('/usr/bin/node'); + expect(config.args).toEqual([ + '/usr/lib/node_modules/genkit-cli/dist/bin/genkit.js', + SERVER_HARNESS_COMMAND, + '4000', + '/path/to/devui.log', + ]); + expect(config.options.stdio).toEqual(['ignore', 'ignore', 'ignore']); + expect(config.options.detached).toBe(false); + expect(config.options.shell).toBe(false); + }); + + it('should build config for Node.js on Windows', () => { + const cliRuntime: CLIRuntimeInfo = { + type: 'node', + execPath: 'C:\\Program Files\\nodejs\\node.exe', + scriptPath: + 'C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\genkit-cli\\dist\\bin\\genkit.js', + isCompiledBinary: false, + platform: 'win32', + }; + + const config = buildServerHarnessSpawnConfig( + cliRuntime, + mockPort, + mockLogPath + ); + + expect(config.command).toBe('"C:\\Program Files\\nodejs\\node.exe"'); + expect(config.args).toEqual([ + '"C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\genkit-cli\\dist\\bin\\genkit.js"', + '"' + SERVER_HARNESS_COMMAND + '"', + '"4000"', + '"/path/to/devui.log"', + ]); + expect(config.options.shell).toBe(true); // Shell enabled on Windows + }); + + it('should handle Node.js without script path', () => { + const cliRuntime: CLIRuntimeInfo = { + type: 'node', + execPath: '/usr/bin/node', + scriptPath: undefined, + isCompiledBinary: false, + platform: 'linux', + }; + + const config = buildServerHarnessSpawnConfig( + cliRuntime, + mockPort, + mockLogPath + ); + + expect(config.command).toBe('/usr/bin/node'); + expect(config.args).toEqual([ + SERVER_HARNESS_COMMAND, + '4000', + '/path/to/devui.log', + ]); + expect(config.options.shell).toBe(false); + }); + }); + + describe('Bun CLI runtime', () => { + it('should build config for Bun with script path', () => { + const cliRuntime: CLIRuntimeInfo = { + type: 'bun', + execPath: '/usr/local/bin/bun', + scriptPath: '/usr/lib/node_modules/genkit-cli/dist/bin/genkit.js', + isCompiledBinary: false, + platform: 'darwin', + }; + + const config = buildServerHarnessSpawnConfig( + cliRuntime, + mockPort, + mockLogPath + ); + + expect(config.command).toBe('/usr/local/bin/bun'); + expect(config.args).toEqual([ + '/usr/lib/node_modules/genkit-cli/dist/bin/genkit.js', + SERVER_HARNESS_COMMAND, + '4000', + '/path/to/devui.log', + ]); + expect(config.options.stdio).toEqual(['ignore', 'ignore', 'ignore']); + expect(config.options.detached).toBe(false); + expect(config.options.shell).toBe(false); + }); + + it('should handle Bun without script path', () => { + const cliRuntime: CLIRuntimeInfo = { + type: 'bun', + execPath: '/opt/homebrew/bin/bun', + scriptPath: undefined, + isCompiledBinary: false, + platform: 'darwin', + }; + + const config = buildServerHarnessSpawnConfig( + cliRuntime, + mockPort, + mockLogPath + ); + + expect(config.command).toBe('/opt/homebrew/bin/bun'); + expect(config.args).toEqual([ + SERVER_HARNESS_COMMAND, + '4000', + '/path/to/devui.log', + ]); + }); + + it('should handle Bun on Windows', () => { + const cliRuntime: CLIRuntimeInfo = { + type: 'bun', + execPath: 'C:\\Program Files\\Bun\\bun.exe', + scriptPath: 'C:\\projects\\genkit\\dist\\bin\\genkit.js', + isCompiledBinary: false, + platform: 'win32', + }; + + const config = buildServerHarnessSpawnConfig( + cliRuntime, + mockPort, + mockLogPath + ); + + expect(config.command).toBe('"C:\\Program Files\\Bun\\bun.exe"'); + expect(config.options.shell).toBe(true); + }); + }); + + describe('Compiled binary', () => { + it('should build config for compiled binary', () => { + const cliRuntime: CLIRuntimeInfo = { + type: 'compiled-binary', + execPath: '/usr/local/bin/genkit', + scriptPath: undefined, + isCompiledBinary: true, + platform: 'linux', + }; + + const config = buildServerHarnessSpawnConfig( + cliRuntime, + mockPort, + mockLogPath + ); + + expect(config.command).toBe('/usr/local/bin/genkit'); + expect(config.args).toEqual([ + SERVER_HARNESS_COMMAND, + '4000', + '/path/to/devui.log', + ]); + expect(config.options.stdio).toEqual(['ignore', 'ignore', 'ignore']); + expect(config.options.detached).toBe(false); + expect(config.options.shell).toBe(false); + }); + + it('should handle compiled binary on Windows', () => { + const cliRuntime: CLIRuntimeInfo = { + type: 'compiled-binary', + execPath: 'C:\\Tools\\genkit.exe', + scriptPath: undefined, + isCompiledBinary: true, + platform: 'win32', + }; + + const config = buildServerHarnessSpawnConfig( + cliRuntime, + mockPort, + mockLogPath + ); + + expect(config.command).toBe('"C:\\Tools\\genkit.exe"'); + expect(config.options.shell).toBe(true); + }); + + it('should handle relative path compiled binary', () => { + const cliRuntime: CLIRuntimeInfo = { + type: 'compiled-binary', + execPath: './genkit', + scriptPath: undefined, + isCompiledBinary: true, + platform: 'darwin', + }; + + const config = buildServerHarnessSpawnConfig( + cliRuntime, + mockPort, + mockLogPath + ); + + expect(config.command).toBe('./genkit'); + expect(config.args).toEqual([ + SERVER_HARNESS_COMMAND, + '4000', + '/path/to/devui.log', + ]); + }); + }); + + describe('Edge cases', () => { + it('should handle different port numbers', () => { + const cliRuntime: CLIRuntimeInfo = { + type: 'node', + execPath: '/usr/bin/node', + scriptPath: '/script.js', + isCompiledBinary: false, + platform: 'linux', + }; + + const config = buildServerHarnessSpawnConfig( + cliRuntime, + 8080, + mockLogPath + ); + + expect(config.args).toContain('8080'); + }); + + it('should handle paths with spaces', () => { + const cliRuntime: CLIRuntimeInfo = { + type: 'node', + execPath: '/usr/bin/node', + scriptPath: '/path with spaces/script.js', + isCompiledBinary: false, + platform: 'darwin', + }; + + const config = buildServerHarnessSpawnConfig( + cliRuntime, + mockPort, + '/log path/devui.log' + ); + + expect(config.args).toEqual([ + '/path with spaces/script.js', + SERVER_HARNESS_COMMAND, + '4000', + '/log path/devui.log', + ]); + }); + + it('should handle very long paths', () => { + const longPath = '/very/long/path/'.repeat(50) + 'script.js'; + const cliRuntime: CLIRuntimeInfo = { + type: 'node', + execPath: '/usr/bin/node', + scriptPath: longPath, + isCompiledBinary: false, + platform: 'linux', + }; + + const config = buildServerHarnessSpawnConfig( + cliRuntime, + mockPort, + mockLogPath + ); + + expect(config.args[0]).toBe(longPath); + }); + }); + + describe('Input validation', () => { + const validRuntime: CLIRuntimeInfo = { + type: 'node', + execPath: '/usr/bin/node', + scriptPath: '/script.js', + isCompiledBinary: false, + platform: 'linux', + }; + + it('should throw error for null runtime', () => { + expect(() => + buildServerHarnessSpawnConfig(null as any, mockPort, mockLogPath) + ).toThrow('CLI runtime info is required'); + }); + + it('should throw error for runtime without execPath', () => { + const invalidRuntime = { ...validRuntime, execPath: '' }; + expect(() => + buildServerHarnessSpawnConfig( + invalidRuntime as any, + mockPort, + mockLogPath + ) + ).toThrow('CLI runtime execPath is required'); + }); + + it('should throw error for invalid port numbers', () => { + expect(() => + buildServerHarnessSpawnConfig(validRuntime, -1, mockLogPath) + ).toThrow('Invalid port number: -1. Must be between 0 and 65535'); + + expect(() => + buildServerHarnessSpawnConfig(validRuntime, 65536, mockLogPath) + ).toThrow('Invalid port number: 65536. Must be between 0 and 65535'); + + expect(() => + buildServerHarnessSpawnConfig(validRuntime, 3.14, mockLogPath) + ).toThrow('Invalid port number: 3.14. Must be between 0 and 65535'); + + expect(() => + buildServerHarnessSpawnConfig(validRuntime, NaN, mockLogPath) + ).toThrow('Invalid port number: NaN. Must be between 0 and 65535'); + }); + + it('should throw error for empty log path', () => { + expect(() => + buildServerHarnessSpawnConfig(validRuntime, mockPort, '') + ).toThrow('Log path is required'); + + expect(() => + buildServerHarnessSpawnConfig(validRuntime, mockPort, null as any) + ).toThrow('Log path is required'); + }); + + it('should accept valid edge case ports', () => { + expect(() => + buildServerHarnessSpawnConfig(validRuntime, 0, mockLogPath) + ).not.toThrow(); + + expect(() => + buildServerHarnessSpawnConfig(validRuntime, 65535, mockLogPath) + ).not.toThrow(); + }); + }); + }); + + describe('validateExecutablePath', () => { + it('should return true for valid executable path', async () => { + mockedFs.access.mockResolvedValue(undefined); + + const result = await validateExecutablePath('/usr/bin/node'); + + expect(result).toBe(true); + expect(mockedFs.access).toHaveBeenCalledWith( + '/usr/bin/node', + fs.constants.F_OK | fs.constants.X_OK + ); + }); + + it('should return false for non-existent path', async () => { + mockedFs.access.mockRejectedValue(new Error('ENOENT')); + + const result = await validateExecutablePath('/nonexistent/path'); + + expect(result).toBe(false); + }); + + it('should return false for non-executable file', async () => { + mockedFs.access.mockRejectedValue(new Error('EACCES')); + + const result = await validateExecutablePath('/path/to/non-executable'); + + expect(result).toBe(false); + }); + + it('should handle Windows paths', async () => { + mockedFs.access.mockResolvedValue(undefined); + + const result = await validateExecutablePath( + 'C:\\Windows\\System32\\cmd.exe' + ); + + expect(result).toBe(true); + expect(mockedFs.access).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\cmd.exe', + fs.constants.F_OK | fs.constants.X_OK + ); + }); + }); +});