diff --git a/src/cli.test.ts b/src/cli.test.ts index 61bfaf1..c7dc386 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { spawn } from 'child_process' +import { spawn, execSync } from 'child_process' import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs' import { join } from 'path' +import { tmpdir } from 'os' // Helper function to run CLI command and capture output function runCLI(args: string[], cwd?: string): Promise<{ stdout: string; stderr: string; code: number | null }> { @@ -120,7 +121,8 @@ describe('CLI', () => { }) describe('Settings validation on CLI startup', () => { - const testDir = join(process.cwd(), '.test-cli-settings') + // Use temp directory to avoid git repository detection from project + const testDir = join(tmpdir(), 'iloom-cli-test-settings') const iloomDirectory = join(testDir, '.iloom') const settingsPath = join(iloomDirectory, 'settings.json') @@ -132,6 +134,10 @@ describe('Settings validation on CLI startup', () => { // Create test directory structure mkdirSync(testDir, { recursive: true }) mkdirSync(iloomDirectory, { recursive: true }) + + // Initialize git repository to avoid "not a git repository" errors + execSync('git init', { cwd: testDir, stdio: 'ignore' }) + execSync('git remote add origin https://github.com/test/repo.git', { cwd: testDir, stdio: 'ignore' }) }) afterEach(() => { diff --git a/src/commands/add-issue.test.ts b/src/commands/add-issue.test.ts index dc8547a..863012c 100644 --- a/src/commands/add-issue.test.ts +++ b/src/commands/add-issue.test.ts @@ -7,6 +7,15 @@ import type { SettingsManager } from '../lib/SettingsManager.js' // Mock dependencies vi.mock('../lib/IssueEnhancementService.js') +vi.mock('../lib/SettingsManager.js', () => { + return { + SettingsManager: class MockSettingsManager { + async loadSettings() { + return {} + } + }, + } +}) // Mock remote utilities vi.mock('../utils/remote.js', () => ({ diff --git a/src/commands/finish.pr-workflow.test.ts b/src/commands/finish.pr-workflow.test.ts index 52222cd..8729ecb 100644 --- a/src/commands/finish.pr-workflow.test.ts +++ b/src/commands/finish.pr-workflow.test.ts @@ -21,6 +21,23 @@ vi.mock('../../src/utils/git.js', async () => { } }) +// Mock remote utilities +vi.mock('../../src/utils/remote.js', () => ({ + hasMultipleRemotes: vi.fn().mockResolvedValue(false), + getConfiguredRepoFromSettings: vi.fn().mockResolvedValue('owner/repo'), +})) + +// Mock SettingsManager to prevent reading actual settings files +vi.mock('../../src/lib/SettingsManager.js', () => { + return { + SettingsManager: class MockSettingsManager { + async loadSettings() { + return {} + } + }, + } +}) + describe('FinishCommand - PR State Detection', () => { let finishCommand: FinishCommand let mockGitHubService: GitHubService diff --git a/src/commands/finish.test.ts b/src/commands/finish.test.ts index daa6987..4b3a4a4 100644 --- a/src/commands/finish.test.ts +++ b/src/commands/finish.test.ts @@ -33,6 +33,15 @@ vi.mock('../lib/DatabaseManager.js') vi.mock('../lib/providers/NeonProvider.js') vi.mock('../lib/EnvironmentManager.js') vi.mock('../utils/env.js') +vi.mock('../lib/SettingsManager.js', () => { + return { + SettingsManager: class MockSettingsManager { + async loadSettings() { + return {} + } + }, + } +}) // Mock package-manager utilities vi.mock('../utils/package-manager.js', () => ({ diff --git a/src/commands/open.test.ts b/src/commands/open.test.ts index cb02ff2..eaa9299 100644 --- a/src/commands/open.test.ts +++ b/src/commands/open.test.ts @@ -18,6 +18,15 @@ vi.mock('../lib/DevServerManager.js') vi.mock('../utils/IdentifierParser.js') vi.mock('fs-extra') vi.mock('execa') +vi.mock('../lib/SettingsManager.js', () => { + return { + SettingsManager: class MockSettingsManager { + async loadSettings() { + return {} + } + }, + } +}) // Mock browser utilities vi.mock('../utils/browser.js', () => ({ diff --git a/src/commands/run.test.ts b/src/commands/run.test.ts index 92ca397..d7565f8 100644 --- a/src/commands/run.test.ts +++ b/src/commands/run.test.ts @@ -18,6 +18,15 @@ vi.mock('../lib/DevServerManager.js') vi.mock('../utils/IdentifierParser.js') vi.mock('fs-extra') vi.mock('execa') +vi.mock('../lib/SettingsManager.js', () => { + return { + SettingsManager: class MockSettingsManager { + async loadSettings() { + return {} + } + }, + } +}) // Mock browser utilities vi.mock('../utils/browser.js', () => ({ diff --git a/src/lib/ClaudeService.test.ts b/src/lib/ClaudeService.test.ts index 77fca1f..007d5ed 100644 --- a/src/lib/ClaudeService.test.ts +++ b/src/lib/ClaudeService.test.ts @@ -13,6 +13,15 @@ vi.mock('../utils/logger.js', () => ({ error: vi.fn(), }, })) +vi.mock('./SettingsManager.js', () => { + return { + SettingsManager: class MockSettingsManager { + async loadSettings() { + return {} + } + }, + } +}) describe('ClaudeService', () => { let service: ClaudeService diff --git a/src/utils/terminal.test.ts b/src/utils/terminal.test.ts index 7c64a31..eff7f95 100644 --- a/src/utils/terminal.test.ts +++ b/src/utils/terminal.test.ts @@ -257,6 +257,100 @@ describe('openTerminalWindow', () => { // This prevents commands from appearing in shell history when HISTCONTROL=ignorespace expect(applescript).toMatch(/do script " [^"]+/) }) + + it('should use iTerm2 when available for single terminal', async () => { + vi.mocked(existsSync).mockReturnValue(true) // iTerm2 exists + vi.mocked(execa).mockResolvedValue({} as unknown) + + await openTerminalWindow({ + workspacePath: '/Users/test/workspace', + command: 'pnpm dev', + }) + + // Should call osascript once (iTerm2 script includes activation) + expect(execa).toHaveBeenCalledTimes(1) + const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string + + // Verify iTerm2 AppleScript structure + expect(applescript).toContain('tell application id "com.googlecode.iterm2"') + expect(applescript).toContain('create window with default profile') + expect(applescript).toContain('activate') + expect(applescript).not.toContain('tell application "Terminal"') + }) + + it('should set session name when title provided in iTerm2 mode', async () => { + vi.mocked(existsSync).mockReturnValue(true) // iTerm2 exists + vi.mocked(execa).mockResolvedValue({} as unknown) + + await openTerminalWindow({ + workspacePath: '/Users/test/workspace', + command: 'pnpm dev', + title: 'Dev Server - Issue #42', + }) + + const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string + // Verify session name is set with escaped title + expect(applescript).toContain('set name of s1 to "Dev Server - Issue #42"') + }) + + it('should apply background color in iTerm2 mode', async () => { + vi.mocked(existsSync).mockReturnValue(true) // iTerm2 exists + vi.mocked(execa).mockResolvedValue({} as unknown) + + await openTerminalWindow({ + workspacePath: '/Users/test/workspace', + command: 'pnpm dev', + backgroundColor: { r: 128, g: 77, b: 179 }, + }) + + const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string + // 8-bit RGB (0-255) converted to 16-bit RGB (0-65535): multiply by 257 + // 128 * 257 = 32896, 77 * 257 = 19789, 179 * 257 = 46003 + expect(applescript).toContain('set background color of s1 to {32896, 19789, 46003}') + }) + + it('should fall back to Terminal.app when iTerm2 not available', async () => { + vi.mocked(existsSync).mockReturnValue(false) // iTerm2 not available + vi.mocked(execa).mockResolvedValue({} as unknown) + + await openTerminalWindow({ + workspacePath: '/Users/test/workspace', + command: 'pnpm dev', + }) + + // Should call execa twice: once for terminal creation, once for activation + expect(execa).toHaveBeenCalledTimes(2) + const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string + + // Verify Terminal.app AppleScript is used, not iTerm2 + expect(applescript).toContain('tell application "Terminal"') + expect(applescript).not.toContain('tell application id "com.googlecode.iterm2"') + }) + + it('should handle all options in iTerm2 single-tab mode', async () => { + vi.mocked(existsSync).mockReturnValue(true) // iTerm2 exists + vi.mocked(execa).mockResolvedValue({} as unknown) + + await openTerminalWindow({ + workspacePath: '/Users/test/workspace', + command: 'pnpm dev', + title: 'Dev Server', + backgroundColor: { r: 128, g: 77, b: 179 }, + port: 3042, + includeEnvSetup: true, + includePortExport: true, + }) + + const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string + + // Verify all options are present in the iTerm2 script + expect(applescript).toContain('/Users/test/workspace') + expect(applescript).toContain('source .env') + expect(applescript).toContain('export PORT=3042') + expect(applescript).toContain('pnpm dev') + expect(applescript).toContain('set name of s1 to "Dev Server"') + expect(applescript).toContain('set background color of s1 to {32896, 19789, 46003}') + }) }) describe('openDualTerminalWindow', () => { diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 7204539..6a2e22f 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -51,14 +51,22 @@ export async function openTerminalWindow( ) } - // macOS implementation using AppleScript - const applescript = buildAppleScript(options) + // Detect if iTerm2 is available + const hasITerm2 = await detectITerm2() + + // Build appropriate AppleScript based on terminal availability + const applescript = hasITerm2 + ? buildITerm2SingleTabScript(options) + : buildAppleScript(options) try { await execa('osascript', ['-e', applescript]) - // Activate Terminal.app to bring windows to front - await execa('osascript', ['-e', 'tell application "Terminal" to activate']) + // Activate the appropriate terminal application (only needed for Terminal.app) + // iTerm2 script includes its own activation + if (!hasITerm2) { + await execa('osascript', ['-e', 'tell application "Terminal" to activate']) + } } catch (error) { throw new Error( `Failed to open terminal window: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -146,6 +154,37 @@ function escapeForAppleScript(command: string): string { ) } +/** + * Build iTerm2 AppleScript for single tab + */ +function buildITerm2SingleTabScript(options: TerminalWindowOptions): string { + const command = buildCommandSequence(options) + + let script = 'tell application id "com.googlecode.iterm2"\n' + script += ' create window with default profile\n' + script += ' set s1 to current session of current window\n\n' + + // Set background color + if (options.backgroundColor) { + const { r, g, b } = options.backgroundColor + script += ` set background color of s1 to {${Math.round(r * 257)}, ${Math.round(g * 257)}, ${Math.round(b * 257)}}\n` + } + + // Execute command + script += ` tell s1 to write text "${escapeForAppleScript(command)}"\n\n` + + // Set session name (tab title) + if (options.title) { + script += ` set name of s1 to "${escapeForAppleScript(options.title)}"\n\n` + } + + // Activate iTerm2 + script += ' activate\n' + script += 'end tell' + + return script +} + /** * Build command sequence for terminal */