diff --git a/src/tools/image.ts b/src/tools/image.ts index 8cb85b4..78d2ffe 100644 --- a/src/tools/image.ts +++ b/src/tools/image.ts @@ -56,7 +56,7 @@ export async function imageToolHandler( const args = buildSwiftCliArgs(input, effectivePath, swiftFormat, logger); - const swiftResponse = await executeSwiftCli(args, logger); + const swiftResponse = await executeSwiftCli(args, logger, { timeout: 30000 }); if (!swiftResponse.success) { logger.error( diff --git a/src/tools/list.ts b/src/tools/list.ts index dfd2d41..18849a9 100644 --- a/src/tools/list.ts +++ b/src/tools/list.ts @@ -115,7 +115,7 @@ export async function listToolHandler( const args = buildSwiftCliArgs(input); // Execute Swift CLI - const swiftResponse = await executeSwiftCli(args, logger); + const swiftResponse = await executeSwiftCli(args, logger, { timeout: 15000 }); if (!swiftResponse.success) { logger.error({ error: swiftResponse.error }, "Swift CLI returned error"); diff --git a/src/utils/peekaboo-cli.ts b/src/utils/peekaboo-cli.ts index ae23bb1..39e43cd 100644 --- a/src/utils/peekaboo-cli.ts +++ b/src/utils/peekaboo-cli.ts @@ -102,6 +102,7 @@ function mapExitCodeToErrorMessage( export async function executeSwiftCli( args: string[], logger: Logger, + options: { timeout?: number } = {}, ): Promise { let cliPath: string; try { @@ -121,13 +122,54 @@ export async function executeSwiftCli( // Always add --json-output flag const fullArgs = [...args, "--json-output"]; - logger.debug({ command: cliPath, args: fullArgs }, "Executing Swift CLI"); + // Default timeout of 30 seconds, configurable via options or environment variable + const defaultTimeout = parseInt(process.env.PEEKABOO_CLI_TIMEOUT || "30000", 10); + const timeoutMs = options.timeout || defaultTimeout; + + logger.debug({ command: cliPath, args: fullArgs, timeoutMs }, "Executing Swift CLI"); return new Promise((resolve) => { const process = spawn(cliPath, fullArgs); let stdout = ""; let stderr = ""; + let isResolved = false; + + // Set up timeout + const timeoutId = setTimeout(() => { + if (!isResolved) { + isResolved = true; + logger.error( + { timeoutMs, command: cliPath, args: fullArgs }, + "Swift CLI execution timed out" + ); + + // Kill the process + process.kill('SIGTERM'); + + // Give it a moment to terminate gracefully, then force kill + setTimeout(() => { + if (!process.killed) { + process.kill('SIGKILL'); + } + }, 1000); + + resolve({ + success: false, + error: { + message: `Swift CLI execution timed out after ${timeoutMs}ms. This may indicate a permission dialog is waiting for user input, or the process is stuck.`, + code: "SWIFT_CLI_TIMEOUT", + details: `Command: ${cliPath} ${fullArgs.join(' ')}`, + }, + }); + } + }, timeoutMs); + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; process.stdout.on("data", (data: Buffer | string) => { stdout += data.toString(); @@ -141,6 +183,13 @@ export async function executeSwiftCli( }); process.on("close", (exitCode: number | null) => { + cleanup(); + + if (isResolved) { + return; // Already resolved due to timeout + } + isResolved = true; + logger.debug( { exitCode, stdout: stdout.slice(0, 200) }, "Swift CLI completed", @@ -261,6 +310,13 @@ export async function executeSwiftCli( }); process.on("error", (error: Error) => { + cleanup(); + + if (isResolved) { + return; // Already resolved due to timeout + } + isResolved = true; + logger.error({ error }, "Failed to spawn Swift CLI process"); resolve({ success: false, @@ -283,14 +339,44 @@ export async function readImageAsBase64(imagePath: string): Promise { export async function execPeekaboo( args: string[], packageRootDir: string, - options: { expectSuccess?: boolean } = {}, + options: { expectSuccess?: boolean; timeout?: number } = {}, ): Promise<{ success: boolean; data?: string; error?: string }> { const cliPath = process.env.PEEKABOO_CLI_PATH || path.resolve(packageRootDir, "peekaboo"); + const timeoutMs = options.timeout || 15000; // Default 15 seconds for simple commands return new Promise((resolve) => { const process = spawn(cliPath, args); let stdout = ""; let stderr = ""; + let isResolved = false; + + // Set up timeout + const timeoutId = setTimeout(() => { + if (!isResolved) { + isResolved = true; + + // Kill the process + process.kill('SIGTERM'); + + // Give it a moment to terminate gracefully, then force kill + setTimeout(() => { + if (!process.killed) { + process.kill('SIGKILL'); + } + }, 1000); + + resolve({ + success: false, + error: `Command timed out after ${timeoutMs}ms: ${cliPath} ${args.join(' ')}` + }); + } + }, timeoutMs); + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; process.stdout.on("data", (data) => { stdout += data.toString(); @@ -301,6 +387,13 @@ export async function execPeekaboo( }); process.on("close", (code) => { + cleanup(); + + if (isResolved) { + return; // Already resolved due to timeout + } + isResolved = true; + const success = code === 0; if (options.expectSuccess !== false && !success) { resolve({ success: false, error: stderr || stdout }); @@ -310,6 +403,13 @@ export async function execPeekaboo( }); process.on("error", (err) => { + cleanup(); + + if (isResolved) { + return; // Already resolved due to timeout + } + isResolved = true; + resolve({ success: false, error: err.message }); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 8b43659..2879cf9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,9 +7,24 @@ export default defineConfig({ include: [ "**/tests/unit/**/*.test.ts", "**/tests/integration/**/*.test.ts", - "peekaboo-cli/tests/e2e/**/*.test.ts", + // Only include E2E tests if running on macOS and not in CI + ...(process.platform === "darwin" && !process.env.CI + ? ["peekaboo-cli/tests/e2e/**/*.test.ts"] + : [] + ), ], - exclude: ["**/node_modules/**", "**/dist/**"], + exclude: [ + "**/node_modules/**", + "**/dist/**", + // Exclude E2E tests in CI or non-macOS environments + ...(process.platform !== "darwin" || process.env.CI + ? ["peekaboo-cli/tests/e2e/**/*.test.ts"] + : [] + ), + ], + // Set reasonable timeouts to prevent hanging + testTimeout: 60000, // 60 seconds for individual tests + hookTimeout: 30000, // 30 seconds for setup/teardown hooks coverage: { provider: "v8", reporter: ["text", "lcov", "html"],