Skip to main content
Glama
xcodebuild.test.ts13.4 kB
/** * Xcodebuild Executor Unit Tests * Tests using dependency-injected shell executor */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { buildXcode, parseXcodebuildOutput, cleanXcodeDerivedData, isXcodebuildAvailable, XcodeBuildOptions, } from '../../../../src/platforms/ios/xcodebuild.js'; import { ShellExecutor } from '../../../../src/utils/shell-executor.js'; import * as fs from 'fs/promises'; // Mock fs module for file system operations vi.mock('fs/promises', () => ({ readdir: vi.fn(), rm: vi.fn(), })); const mockedFs = vi.mocked(fs); // Create a mock shell executor function createMockShell(): ShellExecutor & { execute: ReturnType<typeof vi.fn>; executeOrThrow: ReturnType<typeof vi.fn>; commandExists: ReturnType<typeof vi.fn>; } { return { execute: vi.fn(), executeOrThrow: vi.fn(), commandExists: vi.fn(), }; } describe('Xcodebuild Executor', () => { let mockShell: ReturnType<typeof createMockShell>; beforeEach(() => { vi.clearAllMocks(); mockShell = createMockShell(); // Default: workspace exists mockedFs.readdir.mockResolvedValue(['App.xcworkspace'] as unknown as Awaited< ReturnType<typeof fs.readdir> >); }); describe('buildXcode', () => { const baseOptions: XcodeBuildOptions = { variant: 'debug', cwd: '/project/iosApp', }; it('should build debug variant successfully', async () => { mockShell.execute.mockResolvedValue({ stdout: '** BUILD SUCCEEDED **', stderr: '', exitCode: 0, }); mockedFs.readdir .mockResolvedValueOnce(['App.xcworkspace'] as unknown as Awaited< ReturnType<typeof fs.readdir> >) .mockResolvedValueOnce(['App.app'] as unknown as Awaited<ReturnType<typeof fs.readdir>>); const result = await buildXcode(baseOptions, mockShell); expect(result.success).toBe(true); expect(result.platform).toBe('ios'); expect(result.variant).toBe('debug'); expect(result.durationMs).toBeGreaterThanOrEqual(0); expect(mockShell.execute).toHaveBeenCalledWith( 'xcodebuild', expect.arrayContaining(['build', '-configuration', 'Debug']), expect.objectContaining({ cwd: '/project/iosApp' }) ); }); it('should build release variant successfully', async () => { mockShell.execute.mockResolvedValue({ stdout: '** BUILD SUCCEEDED **', stderr: '', exitCode: 0, }); const result = await buildXcode({ ...baseOptions, variant: 'release' }, mockShell); expect(result.success).toBe(true); expect(result.variant).toBe('release'); expect(mockShell.execute).toHaveBeenCalledWith( 'xcodebuild', expect.arrayContaining(['-configuration', 'Release']), expect.any(Object) ); }); it('should include clean action when clean option is true', async () => { mockShell.execute.mockResolvedValue({ stdout: '** BUILD SUCCEEDED **', stderr: '', exitCode: 0, }); await buildXcode({ ...baseOptions, clean: true }, mockShell); expect(mockShell.execute).toHaveBeenCalledWith( 'xcodebuild', expect.arrayContaining(['clean', 'build']), expect.any(Object) ); }); it('should use workspace when found', async () => { mockedFs.readdir.mockResolvedValue(['MyApp.xcworkspace'] as unknown as Awaited< ReturnType<typeof fs.readdir> >); mockShell.execute.mockResolvedValue({ stdout: '** BUILD SUCCEEDED **', stderr: '', exitCode: 0, }); await buildXcode(baseOptions, mockShell); expect(mockShell.execute).toHaveBeenCalledWith( 'xcodebuild', expect.arrayContaining(['-workspace']), expect.any(Object) ); }); it('should use project when no workspace found', async () => { mockedFs.readdir.mockResolvedValue(['MyApp.xcodeproj'] as unknown as Awaited< ReturnType<typeof fs.readdir> >); mockShell.execute.mockResolvedValue({ stdout: '** BUILD SUCCEEDED **', stderr: '', exitCode: 0, }); await buildXcode(baseOptions, mockShell); expect(mockShell.execute).toHaveBeenCalledWith( 'xcodebuild', expect.arrayContaining(['-project']), expect.any(Object) ); }); it('should pass extra arguments', async () => { mockShell.execute.mockResolvedValue({ stdout: '** BUILD SUCCEEDED **', stderr: '', exitCode: 0, }); await buildXcode( { ...baseOptions, extraArgs: ['-allowProvisioningUpdates', 'CODE_SIGN_IDENTITY=""'] }, mockShell ); expect(mockShell.execute).toHaveBeenCalledWith( 'xcodebuild', expect.arrayContaining(['-allowProvisioningUpdates', 'CODE_SIGN_IDENTITY=""']), expect.any(Object) ); }); it('should handle build failure with error parsing', async () => { const errorOutput = ` /project/iosApp/Sources/ContentView.swift:15:10: error: cannot find 'unknownVariable' in scope /project/iosApp/Sources/ContentView.swift:20:5: error: type 'String' has no member 'nonExistent' ** BUILD FAILED ** `; mockShell.execute.mockResolvedValue({ stdout: errorOutput, stderr: '', exitCode: 65, }); const result = await buildXcode(baseOptions, mockShell); expect(result.success).toBe(false); expect(result.errorSummary).toBeDefined(); expect(result.errorSummary!.errorCount).toBeGreaterThanOrEqual(2); }); it('should find .app artifact path on success', async () => { mockShell.execute.mockResolvedValue({ stdout: '** BUILD SUCCEEDED **', stderr: '', exitCode: 0, }); mockedFs.readdir .mockResolvedValueOnce(['App.xcworkspace'] as unknown as Awaited< ReturnType<typeof fs.readdir> >) .mockResolvedValueOnce(['MyApp.app'] as unknown as Awaited<ReturnType<typeof fs.readdir>>); const result = await buildXcode(baseOptions, mockShell); expect(result.success).toBe(true); expect(result.artifactPath).toContain('MyApp.app'); }); it('should handle missing products directory gracefully', async () => { mockShell.execute.mockResolvedValue({ stdout: '** BUILD SUCCEEDED **', stderr: '', exitCode: 0, }); mockedFs.readdir .mockResolvedValueOnce(['App.xcworkspace'] as unknown as Awaited< ReturnType<typeof fs.readdir> >) .mockRejectedValueOnce(new Error('ENOENT')); const result = await buildXcode(baseOptions, mockShell); expect(result.success).toBe(true); expect(result.artifactPath).toBeUndefined(); }); it('should respect custom timeout', async () => { mockShell.execute.mockResolvedValue({ stdout: '** BUILD SUCCEEDED **', stderr: '', exitCode: 0, }); await buildXcode({ ...baseOptions, timeoutMs: 600000 }, mockShell); expect(mockShell.execute).toHaveBeenCalledWith( expect.any(String), expect.any(Array), expect.objectContaining({ timeoutMs: 600000 }) ); }); it('should use custom scheme', async () => { mockShell.execute.mockResolvedValue({ stdout: '** BUILD SUCCEEDED **', stderr: '', exitCode: 0, }); await buildXcode({ ...baseOptions, scheme: 'MyCustomScheme' }, mockShell); expect(mockShell.execute).toHaveBeenCalledWith( 'xcodebuild', expect.arrayContaining(['-scheme', 'MyCustomScheme']), expect.any(Object) ); }); it('should use custom destination', async () => { mockShell.execute.mockResolvedValue({ stdout: '** BUILD SUCCEEDED **', stderr: '', exitCode: 0, }); await buildXcode( { ...baseOptions, destination: 'platform=iOS Simulator,name=iPhone 14' }, mockShell ); expect(mockShell.execute).toHaveBeenCalledWith( 'xcodebuild', expect.arrayContaining(['-destination', 'platform=iOS Simulator,name=iPhone 14']), expect.any(Object) ); }); }); describe('parseXcodebuildOutput', () => { it('should parse Swift compiler errors', () => { const output = ` Compiling Swift source files /project/Sources/ContentView.swift:15:10: error: cannot find 'foo' in scope /project/Sources/AppDelegate.swift:25:5: error: type 'String' has no member 'bar' ** BUILD FAILED ** `; const summary = parseXcodebuildOutput(output); // At least 2 Swift errors (may also include BUILD FAILED) expect(summary.errorCount).toBeGreaterThanOrEqual(2); // Check the Swift compiler errors (those with file paths) const swiftErrors = summary.topErrors.filter((e) => e.file); expect(swiftErrors).toHaveLength(2); expect(swiftErrors[0]).toMatchObject({ file: '/project/Sources/ContentView.swift', line: 15, column: 10, message: "cannot find 'foo' in scope", severity: 'error', }); }); it('should parse warnings', () => { const output = ` /project/Sources/Utils.swift:10:5: warning: variable 'unused' was never used /project/Sources/Utils.swift:15:3: warning: expression result unused ** BUILD SUCCEEDED ** `; const summary = parseXcodebuildOutput(output); expect(summary.warningCount).toBe(2); expect(summary.errorCount).toBe(0); }); it('should parse linker errors', () => { const output = ` ld: error: undefined symbol: _someFunction clang: error: linker command failed with exit code 1 ** BUILD FAILED ** `; const summary = parseXcodebuildOutput(output); expect(summary.errorCount).toBeGreaterThan(0); expect(summary.topErrors.some((e) => e.message.includes('undefined symbol'))).toBe(true); }); it('should handle empty output', () => { const summary = parseXcodebuildOutput(''); expect(summary.errorCount).toBe(0); expect(summary.warningCount).toBe(0); expect(summary.topErrors).toHaveLength(0); }); it('should limit topErrors to 5', () => { const errors = Array.from( { length: 10 }, (_, i) => `/project/File${i}.swift:${i + 1}:1: error: Error ${i}` ).join('\n'); const summary = parseXcodebuildOutput(errors); expect(summary.topErrors).toHaveLength(5); }); it('should include log tail', () => { const output = 'Line 1\nLine 2\nLine 3\n** BUILD FAILED **'; const summary = parseXcodebuildOutput(output); expect(summary.logTail).toBeDefined(); expect(summary.logTail).toContain('Line 1'); }); it('should categorize errors and generate suggestions', () => { const output = ` /project/Sources/Main.swift:10:5: error: use of undeclared type 'UnknownType' ** BUILD FAILED ** `; const summary = parseXcodebuildOutput(output); expect(summary.errorCategories.length).toBeGreaterThan(0); expect(summary.suggestions).toBeDefined(); }); it('should distinguish between errors and warnings in severity', () => { const output = ` /project/Sources/File.swift:10:5: error: some error /project/Sources/File.swift:15:5: warning: some warning `; const summary = parseXcodebuildOutput(output); expect(summary.errorCount).toBe(1); expect(summary.warningCount).toBe(1); }); }); describe('cleanXcodeDerivedData', () => { it('should remove DerivedData directory', async () => { mockedFs.rm.mockResolvedValue(undefined); await cleanXcodeDerivedData('/project'); expect(mockedFs.rm).toHaveBeenCalledWith( expect.stringContaining('DerivedData'), expect.objectContaining({ recursive: true, force: true }) ); }); it('should use current directory when cwd not specified', async () => { mockedFs.rm.mockResolvedValue(undefined); await cleanXcodeDerivedData(); expect(mockedFs.rm).toHaveBeenCalled(); }); it('should handle non-existent directory gracefully', async () => { mockedFs.rm.mockRejectedValue(new Error('ENOENT')); // Should not throw await expect(cleanXcodeDerivedData('/project')).resolves.not.toThrow(); }); }); describe('isXcodebuildAvailable', () => { it('should return true when xcodebuild is available', async () => { mockShell.execute.mockResolvedValue({ stdout: 'Xcode 15.0\nBuild version 15A240d', stderr: '', exitCode: 0, }); const result = await isXcodebuildAvailable(mockShell); expect(result).toBe(true); expect(mockShell.execute).toHaveBeenCalledWith('xcodebuild', ['-version'], { silent: true }); }); it('should return false when xcodebuild is not available', async () => { mockShell.execute.mockResolvedValue({ stdout: '', stderr: 'xcodebuild: command not found', exitCode: 127, }); const result = await isXcodebuildAvailable(mockShell); expect(result).toBe(false); }); it('should return false when execution throws', async () => { mockShell.execute.mockRejectedValue(new Error('Command failed')); const result = await isXcodebuildAvailable(mockShell); expect(result).toBe(false); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/abd3lraouf/specter-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server