Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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 }> {
Expand Down Expand Up @@ -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')

Expand All @@ -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(() => {
Expand Down
9 changes: 9 additions & 0 deletions src/commands/add-issue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
17 changes: 17 additions & 0 deletions src/commands/finish.pr-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/commands/finish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
9 changes: 9 additions & 0 deletions src/commands/open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
9 changes: 9 additions & 0 deletions src/commands/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
9 changes: 9 additions & 0 deletions src/lib/ClaudeService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions src/utils/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
47 changes: 43 additions & 4 deletions src/utils/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`
Expand Down Expand Up @@ -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
*/
Expand Down