diff --git a/AGENTS.md b/AGENTS.md index c4f13e25..6698c689 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,15 @@ When reading issues: - When working on skill sources in `skills/`, use the `skill-creator` skill workflow. - After modifying any skill source, run `npx skill-check ` and address all errors/warnings before handoff. - +## Multi-process filesystem state +- XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces. +- Shared filesystem state under `~/Library/Developer/XcodeBuildMCP` must be multi-process safe. +- Use workspace-key scoped directories for workspace-owned state. +- Do not store runtime state under `~/.xcodebuildmcp`; `.xcodebuildmcp/config.yaml` is only project configuration. +- Use shared lock and atomic-write helpers for mutable shared files. +- Prefer one-record-per-file registries over shared aggregate files. +- Cleanup must verify ownership before deleting shared artifacts. + ## Style - Keep answers short and concise - No emojis in commits, issues, PR comments, or code diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d0da2e..3a0a48c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ - Fixed simulator OSLog helper cleanup so server and daemon startup reconcile same-workspace orphaned log streams without stopping helpers owned by live sessions in other workspaces ([#382](https://github.com/getsentry/XcodeBuildMCP/issues/382)). - Fixed Weather example test discovery and made CLI test progress visible while tests are running instead of leaving the last build phase displayed. +### Changed + +- Centralized workspace log retention and startup/shutdown filesystem cleanup so XcodeBuildMCP-owned logs are pruned consistently while preserving active daemon and simulator OSLog outputs. + ## [2.5.0-beta.1] ### Breaking diff --git a/CLAUDE.md b/CLAUDE.md index 3bc17849..d991ecca 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,15 @@ When reading issues: - When working on skill sources in `skills/`, use the `skill-creator` skill workflow. - After modifying any skill source, run `npx skill-check ` and address all errors/warnings before handoff. - +## Multi-process filesystem state +- XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces. +- Shared filesystem state under `~/Library/Developer/XcodeBuildMCP` must be multi-process safe. +- Use workspace-key scoped directories for workspace-owned state. +- Do not store runtime state under `~/.xcodebuildmcp`; `.xcodebuildmcp/config.yaml` is only project configuration. +- Use shared lock and atomic-write helpers for mutable shared files. +- Prefer one-record-per-file registries over shared aggregate files. +- Cleanup must verify ownership before deleting shared artifacts. + ## Style - Keep answers short and concise - No emojis in commits, issues, PR comments, or code diff --git a/src/cli.ts b/src/cli.ts index 2c9e66d3..8d662f7a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,13 +2,12 @@ import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts'; import { buildCliToolCatalog } from './cli/cli-tool-catalog.ts'; import { buildYargsApp } from './cli/yargs-app.ts'; -import { getSocketPath, getWorkspaceKey, resolveWorkspaceRoot } from './daemon/socket-path.ts'; +import { getSocketPath } from './daemon/socket-path.ts'; import { startMcpServer } from './server/start-mcp-server.ts'; import { listCliWorkflowIdsFromManifest } from './runtime/tool-catalog.ts'; import { flushAndCloseSentry, initSentry, recordBootstrapDurationMetric } from './utils/sentry.ts'; import { coerceLogLevel, setLogLevel, type LogLevel } from './utils/logger.ts'; import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts'; -import { configureRuntimeWorkspaceKey } from './utils/runtime-instance.ts'; function findTopLevelCommand(argv: string[]): string | undefined { const flagsWithValue = new Set(['--socket', '--log-level', '--style']); @@ -119,23 +118,13 @@ async function main(): Promise { }, }); - // Compute workspace context for daemon routing - const workspaceRoot = resolveWorkspaceRoot({ - cwd: result.runtime.cwd, - projectConfigPath: result.configPath, - }); + const { workspaceRoot, workspaceKey } = result; const defaultSocketPath = getSocketPath({ cwd: result.runtime.cwd, projectConfigPath: result.configPath, }); - const workspaceKey = getWorkspaceKey({ - cwd: result.runtime.cwd, - projectConfigPath: result.configPath, - }); - configureRuntimeWorkspaceKey(workspaceKey); - const cliExposedWorkflowIds = await listCliWorkflowIdsFromManifest({ excludeWorkflows: ['session-management', 'workflow-discovery'], }); diff --git a/src/cli/__tests__/daemon-control-race.test.ts b/src/cli/__tests__/daemon-control-race.test.ts new file mode 100644 index 00000000..324ec53c --- /dev/null +++ b/src/cli/__tests__/daemon-control-race.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DaemonRegistryEntry } from '../../daemon/daemon-registry.ts'; + +const originalEntry: DaemonRegistryEntry = { + workspaceKey: 'workspace-a', + workspaceRoot: '/workspaces/workspace-a', + socketPath: '/tmp/xcodebuildmcp-daemon.sock', + pid: 123_456, + startedAt: '2026-05-05T00:00:00.000Z', + enabledWorkflows: ['build'], + version: '1.0.0', + instanceId: 'daemon-instance-a', +}; + +const changedEntry: DaemonRegistryEntry = { + ...originalEntry, + instanceId: 'daemon-instance-b', +}; + +const registryMocks = vi.hoisted(() => ({ + cleanupWorkspaceDaemonFiles: vi.fn(), + findDaemonRegistryEntryBySocketPath: vi.fn(), + isPidAlive: vi.fn(), + readDaemonRegistryEntry: vi.fn(), + release: vi.fn(), +})); + +vi.mock('../../daemon/daemon-registry.ts', () => ({ + acquireDaemonRegistryMutationLock: vi.fn(() => ({ + workspaceKey: 'workspace-a', + release: registryMocks.release, + })), + cleanupWorkspaceDaemonFiles: registryMocks.cleanupWorkspaceDaemonFiles, + findDaemonRegistryEntryBySocketPath: registryMocks.findDaemonRegistryEntryBySocketPath, + readDaemonRegistryEntry: registryMocks.readDaemonRegistryEntry, +})); + +vi.mock('../../utils/process-liveness.ts', () => ({ + isPidAlive: registryMocks.isPidAlive, +})); + +import { forceStopDaemon } from '../daemon-control.ts'; + +describe('daemon control force-stop registry races', () => { + beforeEach(() => { + registryMocks.cleanupWorkspaceDaemonFiles.mockReset(); + registryMocks.findDaemonRegistryEntryBySocketPath.mockReset(); + registryMocks.isPidAlive.mockReset(); + registryMocks.readDaemonRegistryEntry.mockReset(); + registryMocks.release.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sends the initial signal before releasing the registry mutation lock', async () => { + registryMocks.findDaemonRegistryEntryBySocketPath.mockReturnValue(originalEntry); + registryMocks.readDaemonRegistryEntry.mockReturnValue(originalEntry); + registryMocks.isPidAlive.mockReturnValue(false); + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + expect(registryMocks.release).not.toHaveBeenCalled(); + return true; + }); + + await forceStopDaemon(originalEntry.socketPath); + + expect(kill).toHaveBeenCalledWith(originalEntry.pid, 'SIGTERM'); + expect(registryMocks.release).toHaveBeenCalledOnce(); + expect(registryMocks.cleanupWorkspaceDaemonFiles).toHaveBeenCalledWith( + originalEntry.workspaceKey, + { + pid: originalEntry.pid, + socketPath: originalEntry.socketPath, + instanceId: originalEntry.instanceId, + allowLiveOwner: true, + }, + ); + }); + + it('does not signal when daemon metadata changes before the initial signal', async () => { + registryMocks.findDaemonRegistryEntryBySocketPath.mockReturnValue(originalEntry); + registryMocks.readDaemonRegistryEntry.mockReturnValue(changedEntry); + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true); + + await expect(forceStopDaemon(originalEntry.socketPath)).rejects.toThrow( + 'daemon registry metadata changed', + ); + + expect(kill).not.toHaveBeenCalled(); + expect(registryMocks.cleanupWorkspaceDaemonFiles).not.toHaveBeenCalled(); + expect(registryMocks.release).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/cli/__tests__/daemon-control.test.ts b/src/cli/__tests__/daemon-control.test.ts new file mode 100644 index 00000000..16f6ad81 --- /dev/null +++ b/src/cli/__tests__/daemon-control.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { forceStopDaemon } from '../daemon-control.ts'; +import { + readDaemonRegistryEntry, + type DaemonRegistryEntry, + writeDaemonRegistryEntry, +} from '../../daemon/daemon-registry.ts'; +import { + daemonDirForWorkspaceKey, + setDaemonRunDirOverrideForTests, +} from '../../daemon/socket-path.ts'; +import { setXcodeBuildMCPAppDirOverrideForTests } from '../../utils/log-paths.ts'; + +const daemonPid = 123_456; + +function createMissingPidError(): NodeJS.ErrnoException { + const error = new Error('no such process') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + return error; +} + +function createEntry(overrides: Partial = {}): DaemonRegistryEntry { + const workspaceKey = overrides.workspaceKey ?? 'workspace-a'; + return { + workspaceKey, + workspaceRoot: `/workspaces/${workspaceKey}`, + socketPath: path.join(daemonDirForWorkspaceKey(workspaceKey), 'd.sock'), + pid: daemonPid, + startedAt: '2026-05-05T00:00:00.000Z', + enabledWorkflows: ['build'], + version: '1.0.0', + instanceId: 'daemon-instance-a', + ...overrides, + }; +} + +describe('daemon control', () => { + let appDir: string; + let daemonRunDir: string; + + beforeEach(() => { + appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-daemon-control-app-')); + daemonRunDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-daemon-control-run-')); + setXcodeBuildMCPAppDirOverrideForTests(appDir); + setDaemonRunDirOverrideForTests(daemonRunDir); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + setXcodeBuildMCPAppDirOverrideForTests(null); + setDaemonRunDirOverrideForTests(null); + rmSync(appDir, { recursive: true, force: true }); + rmSync(daemonRunDir, { recursive: true, force: true }); + }); + + it('does not unlink sockets without registry metadata', async () => { + const socketPath = path.join(daemonRunDir, 'missing-registry.sock'); + mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(socketPath, 'socket placeholder'); + + await expect(forceStopDaemon(socketPath)).rejects.toThrow('registry metadata is missing'); + + expect(existsSync(socketPath)).toBe(true); + }); + + it('unregisters daemon files only after SIGTERM stops the process', async () => { + const entry = createEntry(); + writeDaemonRegistryEntry(entry); + mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(entry.socketPath, 'socket placeholder'); + let alive = true; + const kill = vi.spyOn(process, 'kill').mockImplementation((( + _pid: number, + signal?: string | number, + ) => { + if (signal === 0) { + if (alive) { + return true; + } + throw createMissingPidError(); + } + if (signal === 'SIGTERM') { + alive = false; + } + return true; + }) as typeof process.kill); + + await forceStopDaemon(entry.socketPath); + + expect(kill).toHaveBeenCalledWith(entry.pid, 'SIGTERM'); + expect(readDaemonRegistryEntry(entry.workspaceKey)).toBeNull(); + expect(existsSync(entry.socketPath)).toBe(false); + }); + + it('cleans daemon files when the stopped PID is reused before cleanup', async () => { + const entry = createEntry(); + writeDaemonRegistryEntry(entry); + mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(entry.socketPath, 'socket placeholder'); + let zeroSignalChecksAfterTerm = 0; + const kill = vi.spyOn(process, 'kill').mockImplementation((( + _pid: number, + signal?: string | number, + ) => { + if (signal === 0) { + zeroSignalChecksAfterTerm += 1; + if (zeroSignalChecksAfterTerm === 1) { + throw createMissingPidError(); + } + return true; + } + return true; + }) as typeof process.kill); + + await forceStopDaemon(entry.socketPath); + + expect(kill).toHaveBeenCalledWith(entry.pid, 'SIGTERM'); + expect(readDaemonRegistryEntry(entry.workspaceKey)).toBeNull(); + expect(existsSync(entry.socketPath)).toBe(false); + }); + + it('uses SIGKILL when the process stays alive after SIGTERM', async () => { + vi.useFakeTimers(); + const entry = createEntry(); + writeDaemonRegistryEntry(entry); + mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(entry.socketPath, 'socket placeholder'); + let alive = true; + const kill = vi.spyOn(process, 'kill').mockImplementation((( + _pid: number, + signal?: string | number, + ) => { + if (signal === 0) { + if (alive) { + return true; + } + throw createMissingPidError(); + } + if (signal === 'SIGKILL') { + alive = false; + } + return true; + }) as typeof process.kill); + + const stopped = forceStopDaemon(entry.socketPath); + await vi.advanceTimersByTimeAsync(1500); + await stopped; + + expect(kill).toHaveBeenCalledWith(entry.pid, 'SIGTERM'); + expect(kill).toHaveBeenCalledWith(entry.pid, 'SIGKILL'); + expect(readDaemonRegistryEntry(entry.workspaceKey)).toBeNull(); + expect(existsSync(entry.socketPath)).toBe(false); + }); + + it('preserves registry metadata and socket when the process remains alive', async () => { + vi.useFakeTimers(); + const entry = createEntry(); + writeDaemonRegistryEntry(entry); + mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(entry.socketPath, 'socket placeholder'); + vi.spyOn(process, 'kill').mockImplementation((() => true) as typeof process.kill); + + const stopped = forceStopDaemon(entry.socketPath); + const stoppedExpectation = expect(stopped).rejects.toThrow( + `Daemon PID ${entry.pid} did not exit after SIGKILL`, + ); + await vi.advanceTimersByTimeAsync(3000); + + await stoppedExpectation; + expect(readDaemonRegistryEntry(entry.workspaceKey)).toEqual(entry); + expect(existsSync(entry.socketPath)).toBe(true); + }); +}); diff --git a/src/cli/daemon-control.ts b/src/cli/daemon-control.ts index dd367b48..d6ce593a 100644 --- a/src/cli/daemon-control.ts +++ b/src/cli/daemon-control.ts @@ -1,10 +1,16 @@ import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { dirname, resolve, basename } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { existsSync } from 'node:fs'; import { DaemonClient, DaemonVersionMismatchError } from './daemon-client.ts'; -import { readDaemonRegistryEntry } from '../daemon/daemon-registry.ts'; -import { removeStaleSocket } from '../daemon/socket-path.ts'; +import { + acquireDaemonRegistryMutationLock, + cleanupWorkspaceDaemonFiles, + findDaemonRegistryEntryBySocketPath, + readDaemonRegistryEntry, + type DaemonRegistryEntry, +} from '../daemon/daemon-registry.ts'; +import { isPidAlive } from '../utils/process-liveness.ts'; /** * Default timeout for daemon startup in milliseconds. @@ -16,6 +22,50 @@ export const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 5000; */ export const DEFAULT_POLL_INTERVAL_MS = 100; +const FORCE_STOP_SIGNAL_TIMEOUT_MS = 1500; +const FORCE_STOP_POLL_INTERVAL_MS = 50; + +async function waitForPidExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isPidAlive(pid)) { + return true; + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, FORCE_STOP_POLL_INTERVAL_MS)); + } + return !isPidAlive(pid); +} + +function validateCurrentRegistryEntry( + expectedEntry: DaemonRegistryEntry, + currentEntry: DaemonRegistryEntry | null, + socketPath: string, +): void { + const matchesExpectedEntry = + currentEntry !== null && + currentEntry.workspaceKey === expectedEntry.workspaceKey && + currentEntry.socketPath === expectedEntry.socketPath && + currentEntry.pid === expectedEntry.pid && + currentEntry.instanceId === expectedEntry.instanceId; + + if (!matchesExpectedEntry) { + throw new Error(`Cannot force-stop daemon at ${socketPath}: daemon registry metadata changed`); + } +} + +function signalDaemonPid(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + return false; + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to send ${signal} to daemon PID ${pid}: ${message}`); + } +} + /** * Get the path to the daemon executable. */ @@ -34,22 +84,41 @@ export function getDaemonExecutablePath(): string { /** * Force-stop a daemon that cannot be stopped gracefully (e.g. protocol version mismatch). - * Derives the workspace key from the socket path, reads the registry for the PID, - * sends SIGTERM, and removes the stale socket. + * Uses registry ownership metadata to stop the process before unregistering daemon files. */ export async function forceStopDaemon(socketPath: string): Promise { - const workspaceKey = basename(dirname(socketPath)); - const entry = readDaemonRegistryEntry(workspaceKey); - if (entry?.pid) { - try { - process.kill(entry.pid, 'SIGTERM'); - } catch { - // Process may already be gone. + const entry = findDaemonRegistryEntryBySocketPath(socketPath); + if (!entry) { + throw new Error( + `Cannot force-stop daemon at ${socketPath}: daemon registry metadata is missing`, + ); + } + + const lock = acquireDaemonRegistryMutationLock(entry.workspaceKey); + if (!lock) { + throw new Error(`Unable to acquire daemon registry lock for ${entry.workspaceKey}`); + } + + let termSent: boolean; + try { + validateCurrentRegistryEntry(entry, readDaemonRegistryEntry(entry.workspaceKey), socketPath); + termSent = signalDaemonPid(entry.pid, 'SIGTERM'); + } finally { + lock.release(); + } + if (termSent && !(await waitForPidExit(entry.pid, FORCE_STOP_SIGNAL_TIMEOUT_MS))) { + const killSent = signalDaemonPid(entry.pid, 'SIGKILL'); + if (killSent && !(await waitForPidExit(entry.pid, FORCE_STOP_SIGNAL_TIMEOUT_MS))) { + throw new Error(`Daemon PID ${entry.pid} did not exit after SIGKILL`); } - // Brief wait for the process to exit. - await new Promise((resolve) => setTimeout(resolve, 500)); } - removeStaleSocket(socketPath); + + cleanupWorkspaceDaemonFiles(entry.workspaceKey, { + pid: entry.pid, + socketPath, + instanceId: entry.instanceId, + allowLiveOwner: true, + }); } export interface StartDaemonBackgroundOptions { diff --git a/src/daemon.ts b/src/daemon.ts index 3ef425d6..e397e991 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { randomUUID } from 'node:crypto'; import net from 'node:net'; import { dirname } from 'node:path'; import { existsSync, mkdirSync, renameSync, statSync } from 'node:fs'; @@ -9,15 +10,13 @@ import { ensureSocketDir, removeStaleSocket, getSocketPath, - getWorkspaceKey, - resolveWorkspaceRoot, logPathForWorkspaceKey, } from './daemon/socket-path.ts'; import { startDaemonServer } from './daemon/daemon-server.ts'; import { + acquireDaemonRegistryMutationLock, writeDaemonRegistryEntry, - removeDaemonRegistryEntry, - cleanupWorkspaceDaemonFiles, + type DaemonRegistryMutationLock, } from './daemon/daemon-registry.ts'; import { log, normalizeLogLevel, setLogFile, setLogLevel } from './utils/logger.ts'; import { version } from './version.ts'; @@ -42,11 +41,11 @@ import { } from './utils/sentry.ts'; import { isXcodemakeBinaryAvailable, isXcodemakeEnabled } from './utils/xcodemake/index.ts'; import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts'; -import { configureRuntimeWorkspaceKey } from './utils/runtime-instance.ts'; import { - reconcileSimulatorLaunchOsLogOrphansForWorkspace, - terminateLiveSimulatorLaunchOsLogSessionsSync, -} from './utils/log-capture/index.ts'; + cleanupOwnedWorkspaceFilesystemArtifacts, + runWorkspaceFilesystemLifecycleSweep, + terminateOwnedWorkspaceFilesystemArtifactsSync, +} from './utils/workspace-filesystem-lifecycle.ts'; async function checkExistingDaemon(socketPath: string): Promise { return new Promise((resolve) => { @@ -124,16 +123,8 @@ async function main(): Promise { }, }); - const workspaceRoot = resolveWorkspaceRoot({ - cwd: result.runtime.cwd, - projectConfigPath: result.configPath, - }); - - const workspaceKey = getWorkspaceKey({ - cwd: result.runtime.cwd, - projectConfigPath: result.configPath, - }); - configureRuntimeWorkspaceKey(workspaceKey); + const { workspaceRoot, workspaceKey } = result; + const daemonInstanceId = randomUUID(); const logPath = resolveDaemonLogPath(workspaceKey); if (logPath) { @@ -159,20 +150,27 @@ async function main(): Promise { log('info', `[Daemon] Workspace: ${workspaceRoot}`); log('info', `[Daemon] Socket: ${socketPath}`); - try { - const reconciliation = await reconcileSimulatorLaunchOsLogOrphansForWorkspace(workspaceKey); - if (reconciliation.stoppedSessionCount > 0 || reconciliation.errorCount > 0) { + + const runStartupLifecycleSweep = async (): Promise => { + try { + const lifecycle = await runWorkspaceFilesystemLifecycleSweep({ + workspaceKey, + trigger: 'startup', + }); + if (lifecycle.stopped > 0 || lifecycle.deleted > 0 || lifecycle.errors.length > 0) { + log( + lifecycle.errors.length > 0 ? 'warn' : 'info', + `[Daemon] Filesystem lifecycle: ${JSON.stringify(lifecycle)}`, + ); + } + } catch (error) { log( - reconciliation.errorCount > 0 ? 'warn' : 'info', - `[Daemon] Simulator OSLog reconciliation: ${JSON.stringify(reconciliation)}`, + 'warn', + `[Daemon] Filesystem lifecycle failed: ${error instanceof Error ? error.message : String(error)}`, ); } - } catch (error) { - log( - 'warn', - `[Daemon] Simulator OSLog reconciliation failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } + }; + if (logPath) { log('info', `[Daemon] Logs: ${logPath}`); } @@ -187,246 +185,333 @@ async function main(): Promise { process.exit(1); } - removeStaleSocket(socketPath); - - const excludedWorkflows = ['session-management', 'workflow-discovery']; - - // Daemon runtime serves CLI routing and should not be filtered by enabledWorkflows. - // CLI exposure is controlled at CLI catalog/command registration time. - // Get all workflows from manifest (for reporting purposes and filtering). - const manifest = loadManifest(); - const allWorkflowIds = Array.from(manifest.workflows.keys()); - const daemonWorkflows = allWorkflowIds.filter( - (workflowId) => !excludedWorkflows.includes(workflowId), - ); - const xcodeIdeWorkflowEnabled = daemonWorkflows.includes('xcode-ide'); - const axeBinary = resolveAxeBinary(); - const axeAvailable = axeBinary !== null; - const axeSource: 'env' | 'bundled' | 'path' | 'unavailable' = axeBinary?.source ?? 'unavailable'; - const xcodemakeAvailable = isXcodemakeBinaryAvailable(); - const xcodemakeEnabled = isXcodemakeEnabled(); - const baseSentryRuntimeContext = { - mode: 'cli-daemon' as const, - enabledWorkflows: daemonWorkflows, - disableSessionDefaults: result.runtime.config.disableSessionDefaults, - disableXcodeAutoSync: result.runtime.config.disableXcodeAutoSync, - incrementalBuildsEnabled: result.runtime.config.incrementalBuildsEnabled, - debugEnabled: result.runtime.config.debug, - uiDebuggerGuardMode: result.runtime.config.uiDebuggerGuardMode, - xcodeIdeWorkflowEnabled, - axeAvailable, - axeSource, - xcodemakeAvailable, - xcodemakeEnabled, - }; - setSentryRuntimeContext(baseSentryRuntimeContext); - - const enrichSentryMetadata = async (): Promise => { - const commandExecutor = getDefaultCommandExecutor(); - const xcodeVersion = await getXcodeVersionMetadata(async (command) => { - const result = await commandExecutor(command, 'Get Xcode Version'); - return { success: result.success, output: result.output }; - }); - const xcodeAvailable = Boolean( - xcodeVersion.version ?? - xcodeVersion.buildVersion ?? - xcodeVersion.developerDir ?? - xcodeVersion.xcodebuildPath, - ); - const axeVersion = await getAxeVersionMetadata(async (command) => { - const result = await commandExecutor(command, 'Get AXe Version'); - return { success: result.success, output: result.output }; - }, axeBinary?.path); - - setSentryRuntimeContext({ - ...baseSentryRuntimeContext, - xcodeAvailable, - axeVersion, - xcodeDeveloperDir: xcodeVersion.developerDir, - xcodebuildPath: xcodeVersion.xcodebuildPath, - xcodeVersion: xcodeVersion.version, - xcodeBuildVersion: xcodeVersion.buildVersion, - }); + const startupRegistryLock = acquireDaemonRegistryMutationLock(workspaceKey); + if (!startupRegistryLock) { + log('error', '[Daemon] Unable to acquire daemon registry lock'); + console.error('Error: Unable to acquire daemon registry lock'); + await flushAndCloseSentry(1000); + process.exit(1); + } + let pendingStartupRegistryLock: DaemonRegistryMutationLock | null = startupRegistryLock; + const releaseStartupRegistryLock = (): void => { + pendingStartupRegistryLock?.release(); + pendingStartupRegistryLock = null; }; - const catalog = await buildDaemonToolCatalogFromManifest({ - excludeWorkflows: excludedWorkflows, - }); + const isRunningAfterLock = await checkExistingDaemon(socketPath); + if (isRunningAfterLock) { + releaseStartupRegistryLock(); + log('error', '[Daemon] Another daemon is already running for this workspace'); + console.error('Error: Daemon is already running for this workspace'); + await flushAndCloseSentry(1000); + process.exit(1); + } - log('info', `[Daemon] Loaded ${catalog.tools.length} tools`); + try { + removeStaleSocket(socketPath); - const startedAt = new Date().toISOString(); - const idleTimeoutMs = resolveDaemonIdleTimeoutMs(); - const configuredIdleTimeout = process.env[DAEMON_IDLE_TIMEOUT_ENV_KEY]?.trim(); - if (configuredIdleTimeout) { - const parsedIdleTimeout = Number(configuredIdleTimeout); - if (!Number.isFinite(parsedIdleTimeout) || parsedIdleTimeout < 0) { - log( - 'warn', - `[Daemon] Invalid ${DAEMON_IDLE_TIMEOUT_ENV_KEY}=${configuredIdleTimeout}; using default ${idleTimeoutMs}ms`, - ); - } - } + const excludedWorkflows = ['session-management', 'workflow-discovery']; - if (idleTimeoutMs === 0) { - log('info', '[Daemon] Idle shutdown disabled'); - } else { - log( - 'info', - `[Daemon] Idle shutdown enabled: timeout=${idleTimeoutMs}ms interval=${DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS}ms`, + // Daemon runtime serves CLI routing and should not be filtered by enabledWorkflows. + // CLI exposure is controlled at CLI catalog/command registration time. + // Get all workflows from manifest (for reporting purposes and filtering). + const manifest = loadManifest(); + const allWorkflowIds = Array.from(manifest.workflows.keys()); + const daemonWorkflows = allWorkflowIds.filter( + (workflowId) => !excludedWorkflows.includes(workflowId), ); - } - recordDaemonGaugeMetric('idle_timeout_ms', idleTimeoutMs); - - let isShuttingDown = false; - let inFlightRequests = 0; - let lastActivityAt = Date.now(); - let idleCheckTimer: NodeJS.Timeout | null = null; + const xcodeIdeWorkflowEnabled = daemonWorkflows.includes('xcode-ide'); + const axeBinary = resolveAxeBinary(); + const axeAvailable = axeBinary !== null; + const axeSource: 'env' | 'bundled' | 'path' | 'unavailable' = + axeBinary?.source ?? 'unavailable'; + const xcodemakeAvailable = isXcodemakeBinaryAvailable(); + const xcodemakeEnabled = isXcodemakeEnabled(); + const baseSentryRuntimeContext = { + mode: 'cli-daemon' as const, + enabledWorkflows: daemonWorkflows, + disableSessionDefaults: result.runtime.config.disableSessionDefaults, + disableXcodeAutoSync: result.runtime.config.disableXcodeAutoSync, + incrementalBuildsEnabled: result.runtime.config.incrementalBuildsEnabled, + debugEnabled: result.runtime.config.debug, + uiDebuggerGuardMode: result.runtime.config.uiDebuggerGuardMode, + xcodeIdeWorkflowEnabled, + axeAvailable, + axeSource, + xcodemakeAvailable, + xcodemakeEnabled, + }; + setSentryRuntimeContext(baseSentryRuntimeContext); + + const enrichSentryMetadata = async (): Promise => { + const commandExecutor = getDefaultCommandExecutor(); + const xcodeVersion = await getXcodeVersionMetadata(async (command) => { + const result = await commandExecutor(command, 'Get Xcode Version'); + return { success: result.success, output: result.output }; + }); + const xcodeAvailable = Boolean( + xcodeVersion.version ?? + xcodeVersion.buildVersion ?? + xcodeVersion.developerDir ?? + xcodeVersion.xcodebuildPath, + ); + const axeVersion = await getAxeVersionMetadata(async (command) => { + const result = await commandExecutor(command, 'Get AXe Version'); + return { success: result.success, output: result.output }; + }, axeBinary?.path); + + setSentryRuntimeContext({ + ...baseSentryRuntimeContext, + xcodeAvailable, + axeVersion, + xcodeDeveloperDir: xcodeVersion.developerDir, + xcodebuildPath: xcodeVersion.xcodebuildPath, + xcodeVersion: xcodeVersion.version, + xcodeBuildVersion: xcodeVersion.buildVersion, + }); + }; - const markActivity = (): void => { - lastActivityAt = Date.now(); - }; + const catalog = await buildDaemonToolCatalogFromManifest({ + excludeWorkflows: excludedWorkflows, + }); - // Unified shutdown handler - const shutdown = (exitCode = 0): void => { - if (isShuttingDown) { - return; + log('info', `[Daemon] Loaded ${catalog.tools.length} tools`); + + const startedAt = new Date().toISOString(); + const idleTimeoutMs = resolveDaemonIdleTimeoutMs(); + const configuredIdleTimeout = process.env[DAEMON_IDLE_TIMEOUT_ENV_KEY]?.trim(); + if (configuredIdleTimeout) { + const parsedIdleTimeout = Number(configuredIdleTimeout); + if (!Number.isFinite(parsedIdleTimeout) || parsedIdleTimeout < 0) { + log( + 'warn', + `[Daemon] Invalid ${DAEMON_IDLE_TIMEOUT_ENV_KEY}=${configuredIdleTimeout}; using default ${idleTimeoutMs}ms`, + ); + } } - isShuttingDown = true; - if (idleCheckTimer) { - clearInterval(idleCheckTimer); - idleCheckTimer = null; + if (idleTimeoutMs === 0) { + log('info', '[Daemon] Idle shutdown disabled'); + } else { + log( + 'info', + `[Daemon] Idle shutdown enabled: timeout=${idleTimeoutMs}ms interval=${DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS}ms`, + ); } + recordDaemonGaugeMetric('idle_timeout_ms', idleTimeoutMs); - recordDaemonLifecycleMetric('shutdown'); - log('info', '[Daemon] Shutting down...'); - - // Close the server - server.close(() => { - log('info', '[Daemon] Server closed'); - - // Remove registry entry and socket - removeDaemonRegistryEntry(workspaceKey); - removeStaleSocket(socketPath); - - log('info', '[Daemon] Cleanup complete'); - void flushAndCloseSentry(2000).finally(() => { - process.exit(exitCode); - }); - }); + let isShuttingDown = false; + let inFlightRequests = 0; + let lastActivityAt = Date.now(); + let idleCheckTimer: NodeJS.Timeout | null = null; - // Force exit if server doesn't close in time - setTimeout(() => { - log('warn', '[Daemon] Forced shutdown after timeout'); - cleanupWorkspaceDaemonFiles(workspaceKey); - void flushAndCloseSentry(1000).finally(() => { - process.exit(1); - }); - }, 5000); - }; + const markActivity = (): void => { + lastActivityAt = Date.now(); + }; - const emitRequestGauges = (): void => { - recordDaemonGaugeMetric('inflight_requests', inFlightRequests); - recordDaemonGaugeMetric('active_sessions', getDaemonActivitySnapshot().activeOperationCount); - }; - - const server = startDaemonServer({ - socketPath, - logPath: logPath ?? undefined, - startedAt, - enabledWorkflows: daemonWorkflows, - catalog, - workspaceRoot, - workspaceKey, - xcodeIdeWorkflowEnabled, - requestShutdown: shutdown, - onRequestStarted: () => { - inFlightRequests += 1; - markActivity(); - emitRequestGauges(); - }, - onRequestFinished: () => { - inFlightRequests = Math.max(0, inFlightRequests - 1); - markActivity(); - emitRequestGauges(); - }, - }); - emitRequestGauges(); - - if (idleTimeoutMs > 0) { - idleCheckTimer = setInterval(() => { + // Unified shutdown handler + const shutdown = (exitCode = 0): void => { if (isShuttingDown) { return; } + isShuttingDown = true; - emitRequestGauges(); - - const idleForMs = Date.now() - lastActivityAt; - if (idleForMs < idleTimeoutMs) { - return; - } - - if (inFlightRequests > 0) { - return; - } - - if (hasActiveRuntimeSessions(getDaemonActivitySnapshot())) { - return; + if (idleCheckTimer) { + clearInterval(idleCheckTimer); + idleCheckTimer = null; } - log( - 'info', - `[Daemon] Idle timeout reached (${idleForMs}ms >= ${idleTimeoutMs}ms); shutting down`, - ); - shutdown(); - }, DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS); - idleCheckTimer.unref?.(); - } + recordDaemonLifecycleMetric('shutdown'); + log('info', '[Daemon] Shutting down...'); + + const cleanupArtifacts = (): ReturnType => + cleanupOwnedWorkspaceFilesystemArtifacts({ + workspaceKey, + trigger: 'shutdown', + daemonCleanup: { + pid: process.pid, + socketPath, + instanceId: daemonInstanceId, + allowLiveOwner: true, + }, + }); + + let cleanupStarted = false; + let forcedShutdownTimer: NodeJS.Timeout | null = null; + const finishShutdown = (finalExitCode: number, flushTimeoutMs: number): void => { + if (cleanupStarted) { + return; + } + cleanupStarted = true; + if (forcedShutdownTimer) { + clearTimeout(forcedShutdownTimer); + forcedShutdownTimer = null; + } + void cleanupArtifacts() + .then( + (result) => { + if (result.errors.length > 0) { + log('error', `[Daemon] Cleanup failed: ${result.errors.join('; ')}`, { + sentry: true, + }); + return; + } + log('info', '[Daemon] Cleanup complete'); + }, + (error) => { + const message = error instanceof Error ? error.message : String(error); + log('error', `[Daemon] Cleanup failed: ${message}`, { sentry: true }); + }, + ) + .finally(() => { + void flushAndCloseSentry(flushTimeoutMs).finally(() => { + process.exit(finalExitCode); + }); + }); + }; + + forcedShutdownTimer = setTimeout(() => { + log('warn', '[Daemon] Forced shutdown after timeout'); + finishShutdown(1, 1000); + }, 5000); + forcedShutdownTimer.unref?.(); + + server.close(() => { + log('info', '[Daemon] Server closed'); + finishShutdown(exitCode, 2000); + }); + }; - server.listen(socketPath, () => { - log('info', `[Daemon] Listening on ${socketPath}`); + const emitRequestGauges = (): void => { + recordDaemonGaugeMetric('inflight_requests', inFlightRequests); + recordDaemonGaugeMetric('active_sessions', getDaemonActivitySnapshot().activeOperationCount); + }; - // Write registry entry after successful listen - writeDaemonRegistryEntry({ - workspaceKey, - workspaceRoot, + const server = startDaemonServer({ socketPath, logPath: logPath ?? undefined, - pid: process.pid, startedAt, enabledWorkflows: daemonWorkflows, - version: String(version), + catalog, + workspaceRoot, + workspaceKey, + instanceId: daemonInstanceId, + xcodeIdeWorkflowEnabled, + requestShutdown: shutdown, + onRequestStarted: () => { + inFlightRequests += 1; + markActivity(); + emitRequestGauges(); + }, + onRequestFinished: () => { + inFlightRequests = Math.max(0, inFlightRequests - 1); + markActivity(); + emitRequestGauges(); + }, }); + emitRequestGauges(); + + if (idleTimeoutMs > 0) { + idleCheckTimer = setInterval(() => { + if (isShuttingDown) { + return; + } + + emitRequestGauges(); + + const idleForMs = Date.now() - lastActivityAt; + if (idleForMs < idleTimeoutMs) { + return; + } + + if (inFlightRequests > 0) { + return; + } + + if (hasActiveRuntimeSessions(getDaemonActivitySnapshot())) { + return; + } + + log( + 'info', + `[Daemon] Idle timeout reached (${idleForMs}ms >= ${idleTimeoutMs}ms); shutting down`, + ); + shutdown(); + }, DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS); + idleCheckTimer.unref?.(); + } - writeLine(`Daemon started (PID: ${process.pid})`); - writeLine(`Workspace: ${workspaceRoot}`); - writeLine(`Socket: ${socketPath}`); - writeLine(`Tools: ${catalog.tools.length}`); - recordBootstrapDurationMetric('cli-daemon', Date.now() - daemonBootstrapStart); + const handleStartupServerError = (error: Error): void => { + releaseStartupRegistryLock(); + const message = error.message; + log('error', `[Daemon] Server startup error: ${message}`, { sentry: true }); + console.error('Daemon error:', message); + void flushAndCloseSentry(2000).finally(() => { + process.exit(1); + }); + }; + server.once('error', handleStartupServerError); + + server.listen(socketPath, () => { + server.off('error', handleStartupServerError); + log('info', `[Daemon] Listening on ${socketPath}`); + + // Write registry entry after successful listen + try { + writeDaemonRegistryEntry( + { + workspaceKey, + workspaceRoot, + socketPath, + logPath: logPath ?? undefined, + pid: process.pid, + startedAt, + enabledWorkflows: daemonWorkflows, + version: String(version), + instanceId: daemonInstanceId, + }, + { lock: startupRegistryLock }, + ); + } finally { + releaseStartupRegistryLock(); + } - setImmediate(() => { - void enrichSentryMetadata().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - log('warn', `[Daemon] Failed to enrich Sentry metadata: ${message}`); + writeLine(`Daemon started (PID: ${process.pid})`); + writeLine(`Workspace: ${workspaceRoot}`); + writeLine(`Socket: ${socketPath}`); + writeLine(`Tools: ${catalog.tools.length}`); + recordBootstrapDurationMetric('cli-daemon', Date.now() - daemonBootstrapStart); + + // Filesystem orphan reconciliation and log retention run fire-and-forget after listen so + // a slow sweep cannot delay request serving. Request handlers must not assume orphans + // have been cleaned at startup. + setImmediate(() => { + void enrichSentryMetadata().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + log('warn', `[Daemon] Failed to enrich Sentry metadata: ${message}`); + }); + void runStartupLifecycleSweep(); }); }); - }); - const handleCrash = (reason: unknown): void => { - recordDaemonLifecycleMetric('crash'); - const message = reason instanceof Error ? reason.message : String(reason); - log('error', `[Daemon] Crash: ${message}`, { sentry: true }); - shutdown(1); - }; + const handleCrash = (reason: unknown): void => { + recordDaemonLifecycleMetric('crash'); + const message = reason instanceof Error ? reason.message : String(reason); + log('error', `[Daemon] Crash: ${message}`, { sentry: true }); + shutdown(1); + }; - process.on('exit', () => { - terminateLiveSimulatorLaunchOsLogSessionsSync(); - }); - process.on('SIGTERM', () => shutdown(0)); - process.on('SIGINT', () => shutdown(0)); - process.on('uncaughtException', handleCrash); - process.on('unhandledRejection', handleCrash); + process.on('exit', () => { + terminateOwnedWorkspaceFilesystemArtifactsSync(); + }); + process.on('SIGTERM', () => shutdown(0)); + process.on('SIGINT', () => shutdown(0)); + process.on('uncaughtException', handleCrash); + process.on('unhandledRejection', handleCrash); + } catch (error) { + releaseStartupRegistryLock(); + throw error; + } } main().catch(async (err) => { diff --git a/src/daemon/__tests__/daemon-registry.test.ts b/src/daemon/__tests__/daemon-registry.test.ts new file mode 100644 index 00000000..a1340484 --- /dev/null +++ b/src/daemon/__tests__/daemon-registry.test.ts @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { + acquireDaemonRegistryMutationLock, + cleanupWorkspaceDaemonFiles, + findDaemonRegistryEntryBySocketPath, + listDaemonRegistryEntries, + readDaemonRegistryEntry, + type DaemonRegistryEntry, + writeDaemonRegistryEntry, +} from '../daemon-registry.ts'; +import { + daemonDirForWorkspaceKey, + logPathForWorkspaceKey, + registryPathForWorkspaceKey, + setDaemonRunDirOverrideForTests, +} from '../socket-path.ts'; +import { setXcodeBuildMCPAppDirOverrideForTests } from '../../utils/log-paths.ts'; + +const stalePid = 999_999_999; + +function createEntry(overrides: Partial = {}): DaemonRegistryEntry { + const workspaceKey = overrides.workspaceKey ?? 'workspace-a'; + return { + workspaceKey, + workspaceRoot: `/workspaces/${workspaceKey}`, + socketPath: path.join(daemonDirForWorkspaceKey(workspaceKey), 'd.sock'), + pid: stalePid, + startedAt: '2026-05-02T00:00:00.000Z', + enabledWorkflows: ['build'], + version: '1.0.0', + ...overrides, + }; +} + +describe('daemon registry', () => { + let appDir: string; + let daemonRunDir: string; + + beforeEach(() => { + appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-daemon-registry-app-')); + daemonRunDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-daemon-registry-run-')); + setXcodeBuildMCPAppDirOverrideForTests(appDir); + setDaemonRunDirOverrideForTests(daemonRunDir); + }); + + afterEach(() => { + setXcodeBuildMCPAppDirOverrideForTests(null); + setDaemonRunDirOverrideForTests(null); + rmSync(appDir, { recursive: true, force: true }); + rmSync(daemonRunDir, { recursive: true, force: true }); + }); + + it('writes daemon metadata under workspace state and places the socket in temp runtime storage', () => { + const entry = createEntry(); + + writeDaemonRegistryEntry(entry); + + const expectedRegistryPath = path.join( + appDir, + 'workspaces', + 'workspace-a', + 'state', + 'daemon', + 'daemon.json', + ); + expect(registryPathForWorkspaceKey('workspace-a')).toBe(expectedRegistryPath); + expect(readDaemonRegistryEntry('workspace-a')).toEqual(entry); + expect(existsSync(expectedRegistryPath)).toBe(true); + expect(entry.socketPath).toBe(path.join(daemonRunDir, 'xcodebuildmcp-0dcf2d98505d', 'd.sock')); + expect(logPathForWorkspaceKey('workspace-a')).toBe( + path.join(appDir, 'workspaces', 'workspace-a', 'logs', 'daemon.log'), + ); + + const raw = readFileSync(expectedRegistryPath, 'utf8'); + expect(JSON.parse(raw)).toEqual(entry); + }); + + it('returns null when workspace metadata is invalid', () => { + const registryPath = registryPathForWorkspaceKey('workspace-a'); + mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 }); + writeFileSync(registryPath, '{invalid json'); + + expect(readDaemonRegistryEntry('workspace-a')).toBeNull(); + }); + + it('rejects workspace metadata stored under the wrong workspace key', () => { + const mismatchedEntry = createEntry({ workspaceKey: 'workspace-b', version: 'wrong' }); + + const registryPath = registryPathForWorkspaceKey('workspace-a'); + mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 }); + writeFileSync(registryPath, `${JSON.stringify(mismatchedEntry, null, 2)}\n`, { mode: 0o600 }); + + expect(readDaemonRegistryEntry('workspace-a')).toBeNull(); + }); + + it('lists workspace metadata', () => { + const workspaceEntry = createEntry({ workspaceKey: 'workspace-a', version: 'workspace' }); + + writeDaemonRegistryEntry(workspaceEntry); + + expect(listDaemonRegistryEntries()).toEqual(expect.arrayContaining([workspaceEntry])); + }); + + it('finds registry metadata by custom socket path', () => { + const entry = createEntry({ socketPath: path.join(daemonRunDir, 'custom.sock') }); + + writeDaemonRegistryEntry(entry); + + expect(findDaemonRegistryEntryBySocketPath(entry.socketPath)).toEqual(entry); + }); + + it('does not clean up live mismatched metadata or sockets', () => { + const entry = createEntry({ pid: process.pid }); + writeDaemonRegistryEntry(entry); + mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(entry.socketPath, 'socket placeholder'); + + cleanupWorkspaceDaemonFiles(entry.workspaceKey, { + pid: process.pid + 1, + socketPath: entry.socketPath, + allowLiveOwner: true, + }); + + expect(readDaemonRegistryEntry(entry.workspaceKey)).toEqual(entry); + expect(existsSync(entry.socketPath)).toBe(true); + }); + + it('cleans up current-owned workspace metadata and socket with matching instance identity', () => { + const entry = createEntry({ pid: process.pid, instanceId: 'daemon-instance-a' }); + writeDaemonRegistryEntry(entry); + mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(entry.socketPath, 'socket placeholder'); + + cleanupWorkspaceDaemonFiles(entry.workspaceKey, { + pid: process.pid, + socketPath: entry.socketPath, + instanceId: 'daemon-instance-a', + allowLiveOwner: true, + }); + + expect(readDaemonRegistryEntry(entry.workspaceKey)).toBeNull(); + expect(existsSync(entry.socketPath)).toBe(false); + }); + + it('does not clean up live metadata when instance identity is missing', () => { + const entry = createEntry({ pid: process.pid, instanceId: 'daemon-instance-a' }); + writeDaemonRegistryEntry(entry); + mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(entry.socketPath, 'socket placeholder'); + + cleanupWorkspaceDaemonFiles(entry.workspaceKey, { + pid: process.pid, + socketPath: entry.socketPath, + allowLiveOwner: true, + }); + + expect(readDaemonRegistryEntry(entry.workspaceKey)).toEqual(entry); + expect(existsSync(entry.socketPath)).toBe(true); + }); + + it('does not live-clean legacy metadata without instance identity', () => { + const entry = createEntry({ pid: process.pid }); + writeDaemonRegistryEntry(entry); + mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(entry.socketPath, 'socket placeholder'); + + cleanupWorkspaceDaemonFiles(entry.workspaceKey, { + pid: process.pid, + socketPath: entry.socketPath, + allowLiveOwner: true, + }); + + expect(readDaemonRegistryEntry(entry.workspaceKey)).toEqual(entry); + expect(existsSync(entry.socketPath)).toBe(true); + }); + + it('throws and preserves files while another daemon registry mutation holds the workspace lock', () => { + const entry = createEntry(); + writeDaemonRegistryEntry(entry); + mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(entry.socketPath, 'socket placeholder'); + const lock = acquireDaemonRegistryMutationLock(entry.workspaceKey); + expect(lock).not.toBeNull(); + + try { + expect(() => + cleanupWorkspaceDaemonFiles(entry.workspaceKey, { + socketPath: entry.socketPath, + }), + ).toThrow(`Unable to acquire daemon registry lock for ${entry.workspaceKey}`); + + expect(readDaemonRegistryEntry(entry.workspaceKey)).toEqual(entry); + expect(existsSync(entry.socketPath)).toBe(true); + } finally { + lock?.release(); + } + }); + + it('recovers an expired ownerless daemon registry lock', () => { + const lockDir = path.join(appDir, 'workspaces', 'workspace-a', 'locks', 'daemon-registry.lock'); + mkdirSync(lockDir, { recursive: true }); + const staleDate = new Date(Date.now() - 60_000); + utimesSync(lockDir, staleDate, staleDate); + + const lock = acquireDaemonRegistryMutationLock('workspace-a'); + + expect(lock).not.toBeNull(); + lock?.release(); + }); + + it('recovers an expired malformed daemon registry lock', () => { + const lockDir = path.join(appDir, 'workspaces', 'workspace-a', 'locks', 'daemon-registry.lock'); + mkdirSync(lockDir, { recursive: true }); + writeFileSync(path.join(lockDir, 'owner.json'), '{not-json'); + const staleDate = new Date(Date.now() - 60_000); + utimesSync(lockDir, staleDate, staleDate); + + const lock = acquireDaemonRegistryMutationLock('workspace-a'); + + expect(lock).not.toBeNull(); + lock?.release(); + }); + + it('recovers an expired daemon registry lock owned by a dead pid', () => { + const lockDir = path.join(appDir, 'workspaces', 'workspace-a', 'locks', 'daemon-registry.lock'); + mkdirSync(lockDir, { recursive: true }); + const now = Date.now(); + writeFileSync( + path.join(lockDir, 'owner.json'), + `${JSON.stringify({ + token: 'stale-token', + pid: stalePid, + purpose: 'daemon-registry', + acquiredAtMs: now - 60_000, + expiresAtMs: now - 30_000, + })}\n`, + ); + + const lock = acquireDaemonRegistryMutationLock('workspace-a'); + + expect(lock).not.toBeNull(); + lock?.release(); + }); + + it('does not unlink replacement daemon metadata or socket during old-owner cleanup', () => { + const oldEntry = createEntry({ + pid: stalePid, + startedAt: '2026-05-02T00:00:00.000Z', + instanceId: 'old-instance', + }); + const replacementEntry = createEntry({ + pid: process.pid, + startedAt: '2026-05-02T00:01:00.000Z', + instanceId: 'replacement-instance', + }); + writeDaemonRegistryEntry(oldEntry); + writeDaemonRegistryEntry(replacementEntry); + mkdirSync(path.dirname(replacementEntry.socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(replacementEntry.socketPath, 'replacement socket placeholder'); + + cleanupWorkspaceDaemonFiles(oldEntry.workspaceKey, { + pid: oldEntry.pid, + socketPath: oldEntry.socketPath, + instanceId: oldEntry.instanceId, + allowLiveOwner: true, + }); + + expect(readDaemonRegistryEntry(replacementEntry.workspaceKey)).toEqual(replacementEntry); + expect(existsSync(replacementEntry.socketPath)).toBe(true); + }); + + it('cleans up the registry-owned socket when no socket path is provided', () => { + const entry = createEntry({ socketPath: path.join(daemonRunDir, 'custom.sock') }); + writeDaemonRegistryEntry(entry); + mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(entry.socketPath, 'socket placeholder'); + + cleanupWorkspaceDaemonFiles(entry.workspaceKey); + + expect(existsSync(registryPathForWorkspaceKey(entry.workspaceKey))).toBe(false); + expect(existsSync(entry.socketPath)).toBe(false); + }); + + it('cleans up stale matching workspace metadata and socket', () => { + const entry = createEntry(); + writeDaemonRegistryEntry(entry); + mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 }); + writeFileSync(entry.socketPath, 'socket placeholder'); + + cleanupWorkspaceDaemonFiles(entry.workspaceKey, { + socketPath: entry.socketPath, + }); + + expect(existsSync(registryPathForWorkspaceKey(entry.workspaceKey))).toBe(false); + expect(existsSync(entry.socketPath)).toBe(false); + }); +}); diff --git a/src/daemon/__tests__/socket-path.test.ts b/src/daemon/__tests__/socket-path.test.ts new file mode 100644 index 00000000..fcb09989 --- /dev/null +++ b/src/daemon/__tests__/socket-path.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + chmodSync, + existsSync, + mkdtempSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { ensureSocketDir } from '../socket-path.ts'; + +let tempDir: string; + +describe('ensureSocketDir', () => { + beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-socket-path-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('creates a private socket directory', () => { + const socketPath = path.join(tempDir, 'daemon', 'd.sock'); + + ensureSocketDir(socketPath); + + expect(existsSync(path.dirname(socketPath))).toBe(true); + expect(statSync(path.dirname(socketPath)).mode & 0o777).toBe(0o700); + }); + + it('tightens permissions on an existing socket directory owned by the current user', () => { + const dir = path.join(tempDir, 'daemon'); + const socketPath = path.join(dir, 'd.sock'); + ensureSocketDir(socketPath); + chmodSync(dir, 0o755); + + ensureSocketDir(socketPath); + + expect(statSync(dir).mode & 0o777).toBe(0o700); + }); + + it('rejects symlink socket directories', () => { + const targetDir = path.join(tempDir, 'target'); + const linkDir = path.join(tempDir, 'daemon'); + ensureSocketDir(path.join(targetDir, 'placeholder.sock')); + symlinkSync(targetDir, linkDir); + + expect(() => ensureSocketDir(path.join(linkDir, 'd.sock'))).toThrow(/cannot be a symlink/u); + }); + + it('rejects non-directory socket path parents', () => { + const filePath = path.join(tempDir, 'daemon'); + writeFileSync(filePath, 'not a directory'); + + expect(() => ensureSocketDir(path.join(filePath, 'd.sock'))).toThrow(/not a directory/u); + }); +}); diff --git a/src/daemon/__tests__/tool-invoke-streaming.test.ts b/src/daemon/__tests__/tool-invoke-streaming.test.ts index 8321daf1..70425156 100644 --- a/src/daemon/__tests__/tool-invoke-streaming.test.ts +++ b/src/daemon/__tests__/tool-invoke-streaming.test.ts @@ -137,6 +137,33 @@ describe('daemon tool.invoke streaming', () => { }); }); + it('includes daemon instance identity in status', async () => { + const socketPath = await createSocketPath(); + cleanupPaths.push(socketPath); + + const server = startDaemonServer({ + socketPath, + startedAt: '2026-05-05T00:00:00.000Z', + enabledWorkflows: ['simulator'], + catalog: createCatalog([]), + workspaceRoot: '/repo', + workspaceKey: 'repo-key', + instanceId: 'daemon-instance-a', + xcodeIdeWorkflowEnabled: false, + requestShutdown: () => {}, + }); + cleanupServers.push(server); + await listen(server, socketPath); + + const client = new DaemonClient({ socketPath, timeout: 1000 }); + + await expect(client.status()).resolves.toMatchObject({ + instanceId: 'daemon-instance-a', + workspaceRoot: '/repo', + workspaceKey: 'repo-key', + }); + }); + it('returns an error frame when the handler throws', async () => { const tool: ToolDefinition = { cliName: 'failing-tool', diff --git a/src/daemon/daemon-registry.ts b/src/daemon/daemon-registry.ts index 5a18d141..e3ae3a6e 100644 --- a/src/daemon/daemon-registry.ts +++ b/src/daemon/daemon-registry.ts @@ -1,17 +1,63 @@ +import { randomUUID } from 'node:crypto'; import { - existsSync, mkdirSync, readdirSync, readFileSync, + renameSync, + rmSync, unlinkSync, writeFileSync, } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { - daemonsDir, - daemonDirForWorkspaceKey, - registryPathForWorkspaceKey, -} from './socket-path.ts'; +import { dirname, join } from 'node:path'; +import { registryPathForWorkspaceKey } from './socket-path.ts'; +import { getWorkspacesDir, getWorkspaceFilesystemLayout } from '../utils/log-paths.ts'; +import { tryAcquireFsLockSync } from '../utils/fs-lock-sync.ts'; +import { isPidAlive } from '../utils/process-liveness.ts'; + +export interface DaemonRegistryMutationLock { + readonly workspaceKey: string; + release(): void; +} + +const DAEMON_REGISTRY_LOCK_LEASE_MS = 30_000; +const DAEMON_REGISTRY_LOCK_WAIT_MS = 1_000; +const DAEMON_REGISTRY_LOCK_POLL_MS = 10; +const DAEMON_REGISTRY_LOCK_PURPOSE = 'daemon-registry'; + +const SLEEP_SYNC_WAIT_TARGET = new Int32Array(new SharedArrayBuffer(4)); + +function sleepSync(ms: number): void { + Atomics.wait(SLEEP_SYNC_WAIT_TARGET, 0, 0, ms); +} + +/** + * Synchronous lock acquisition with bounded busy-wait. Blocks the event loop for up to + * DAEMON_REGISTRY_LOCK_WAIT_MS on contention. Only safe to call from startup or shutdown + * paths (writeDaemonRegistryEntry, cleanupWorkspaceDaemonFiles) + * — never from request handlers. + */ +export function acquireDaemonRegistryMutationLock( + workspaceKey: string, +): DaemonRegistryMutationLock | null { + const lockDir = join(getWorkspaceFilesystemLayout(workspaceKey).locks, 'daemon-registry.lock'); + const deadline = Date.now() + DAEMON_REGISTRY_LOCK_WAIT_MS; + do { + const lock = tryAcquireFsLockSync({ + lockDir, + purpose: DAEMON_REGISTRY_LOCK_PURPOSE, + leaseMs: DAEMON_REGISTRY_LOCK_LEASE_MS, + }); + if (lock) { + return { + workspaceKey, + release: () => lock.release(), + }; + } + sleepSync(DAEMON_REGISTRY_LOCK_POLL_MS); + } while (Date.now() < deadline); + + return null; +} /** * Metadata stored for each running daemon. @@ -25,113 +71,274 @@ export interface DaemonRegistryEntry { startedAt: string; enabledWorkflows: string[]; version: string; + instanceId?: string; } -/** - * Write a daemon registry entry. - * Creates the daemon directory if it doesn't exist. - */ -export function writeDaemonRegistryEntry(entry: DaemonRegistryEntry): void { - const registryPath = registryPathForWorkspaceKey(entry.workspaceKey); - const dir = dirname(registryPath); +export interface DaemonFileCleanupOptions { + socketPath?: string; + pid?: number; + instanceId?: string; + allowLiveOwner?: boolean; +} + +interface WriteDaemonRegistryEntryOptions { + lock?: DaemonRegistryMutationLock; +} - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); +function isDaemonRegistryEntry(value: unknown): value is DaemonRegistryEntry { + if (typeof value !== 'object' || value === null) { + return false; } - writeFileSync(registryPath, JSON.stringify(entry, null, 2), { - mode: 0o600, - }); + const entry = value as Partial; + return ( + typeof entry.workspaceKey === 'string' && + entry.workspaceKey.length > 0 && + typeof entry.workspaceRoot === 'string' && + typeof entry.socketPath === 'string' && + (entry.logPath === undefined || typeof entry.logPath === 'string') && + typeof entry.pid === 'number' && + Number.isInteger(entry.pid) && + entry.pid > 0 && + typeof entry.startedAt === 'string' && + Array.isArray(entry.enabledWorkflows) && + entry.enabledWorkflows.every((workflow) => typeof workflow === 'string') && + typeof entry.version === 'string' && + (entry.instanceId === undefined || + (typeof entry.instanceId === 'string' && entry.instanceId.length > 0)) + ); } -/** - * Remove a daemon registry entry. - */ -export function removeDaemonRegistryEntry(workspaceKey: string): void { - const registryPath = registryPathForWorkspaceKey(workspaceKey); +type RegistryReadResult = + | { status: 'missing' } + | { status: 'invalid' } + | { status: 'valid'; entry: DaemonRegistryEntry }; - if (existsSync(registryPath)) { - unlinkSync(registryPath); +function readRegistryEntryAtPath( + registryPath: string, + expectedWorkspaceKey?: string, +): RegistryReadResult { + let content: string; + try { + content = readFileSync(registryPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { status: 'missing' }; + } + return { status: 'invalid' }; + } + + try { + const parsed = JSON.parse(content) as unknown; + if (!isDaemonRegistryEntry(parsed)) { + return { status: 'invalid' }; + } + if (expectedWorkspaceKey !== undefined && parsed.workspaceKey !== expectedWorkspaceKey) { + return { status: 'invalid' }; + } + return { status: 'valid', entry: parsed }; + } catch { + return { status: 'invalid' }; } } -/** - * Read a daemon registry entry by workspace key. - * Returns null if the entry doesn't exist. - */ -export function readDaemonRegistryEntry(workspaceKey: string): DaemonRegistryEntry | null { - const registryPath = registryPathForWorkspaceKey(workspaceKey); +function readValidRegistryEntryAtPath( + registryPath: string, + expectedWorkspaceKey?: string, +): DaemonRegistryEntry | null { + const result = readRegistryEntryAtPath(registryPath, expectedWorkspaceKey); + return result.status === 'valid' ? result.entry : null; +} - if (!existsSync(registryPath)) { +function writeFileAtomicSync(filePath: string, content: string): void { + const dir = dirname(filePath); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + + const tempPath = join(dir, `.daemon.json.${process.pid}.${randomUUID()}.tmp`); + try { + writeFileSync(tempPath, content, { encoding: 'utf8', mode: 0o600 }); + renameSync(tempPath, filePath); + } catch (error) { + rmSync(tempPath, { force: true }); + throw error; + } +} + +function withDaemonRegistryMutationLock( + workspaceKey: string, + callback: () => T, + existingLock?: DaemonRegistryMutationLock, +): T | null { + if (existingLock) { + if (existingLock.workspaceKey !== workspaceKey) { + throw new Error( + `Daemon registry lock for ${existingLock.workspaceKey} cannot guard ${workspaceKey}`, + ); + } + return callback(); + } + + const lock = acquireDaemonRegistryMutationLock(workspaceKey); + if (!lock) { return null; } try { - const content = readFileSync(registryPath, 'utf8'); - return JSON.parse(content) as DaemonRegistryEntry; - } catch { - return null; + return callback(); + } finally { + lock.release(); } } -/** - * List all daemon registry entries. - * Enumerates the daemons directory and reads each daemon.json file. - */ -export function listDaemonRegistryEntries(): DaemonRegistryEntry[] { - const dir = daemonsDir(); - - if (!existsSync(dir)) { - return []; +function entryMatchesCleanupTarget( + entry: DaemonRegistryEntry, + workspaceKey: string, + options?: DaemonFileCleanupOptions, +): boolean { + if (entry.workspaceKey !== workspaceKey) { + return false; } + if (options?.socketPath && entry.socketPath !== options.socketPath) { + return false; + } + return true; +} - const entries: DaemonRegistryEntry[] = []; +function canRemoveRegistryEntry( + entry: DaemonRegistryEntry, + workspaceKey: string, + options?: DaemonFileCleanupOptions, +): boolean { + if (!entryMatchesCleanupTarget(entry, workspaceKey, options)) { + return false; + } - try { - const subdirs = readdirSync(dir, { withFileTypes: true }); + if (!isPidAlive(entry.pid)) { + return true; + } - for (const subdir of subdirs) { - if (!subdir.isDirectory()) continue; + if (options?.allowLiveOwner !== true) { + return false; + } + if (options.pid === undefined || entry.pid !== options.pid) { + return false; + } + return entry.instanceId !== undefined && options.instanceId === entry.instanceId; +} - const workspaceKey = subdir.name; - const registryPath = join(daemonDirForWorkspaceKey(workspaceKey), 'daemon.json'); +function removeRegistryAtPathIfOwned( + registryPath: string, + workspaceKey: string, + options?: DaemonFileCleanupOptions, +): DaemonRegistryEntry | null { + const entry = readValidRegistryEntryAtPath(registryPath, workspaceKey); + if (!entry || !canRemoveRegistryEntry(entry, workspaceKey, options)) { + return null; + } - if (!existsSync(registryPath)) continue; + try { + unlinkSync(registryPath); + return entry; + } catch { + return null; + } +} - try { - const content = readFileSync(registryPath, 'utf8'); - const entry = JSON.parse(content) as DaemonRegistryEntry; - entries.push(entry); - } catch { - // Skip malformed entries +function listWorkspaceRegistryEntries(): DaemonRegistryEntry[] { + const entries: DaemonRegistryEntry[] = []; + try { + const workspaceDirs = readdirSync(getWorkspacesDir(), { withFileTypes: true }); + for (const workspaceDir of workspaceDirs) { + if (!workspaceDir.isDirectory()) { + continue; + } + const registryPath = registryPathForWorkspaceKey(workspaceDir.name); + const result = readRegistryEntryAtPath(registryPath, workspaceDir.name); + if (result.status === 'valid') { + entries.push(result.entry); } } } catch { - // Directory read error, return empty + // ignore } - return entries; } /** - * Remove all registry files for a workspace key (socket + registry). + * Write a daemon registry entry. + * Creates the daemon metadata directory if it doesn't exist. */ -export function cleanupWorkspaceDaemonFiles(workspaceKey: string): void { - const daemonDir = daemonDirForWorkspaceKey(workspaceKey); +export function writeDaemonRegistryEntry( + entry: DaemonRegistryEntry, + options: WriteDaemonRegistryEntryOptions = {}, +): void { + const result = withDaemonRegistryMutationLock( + entry.workspaceKey, + () => { + const registryPath = registryPathForWorkspaceKey(entry.workspaceKey); + writeFileAtomicSync(registryPath, `${JSON.stringify(entry, null, 2)}\n`); + }, + options.lock, + ); + if (result === null) { + throw new Error(`Unable to acquire daemon registry lock for ${entry.workspaceKey}`); + } +} - if (!existsSync(daemonDir)) { - return; +/** + * Read a daemon registry entry by workspace key. + * Returns null if the entry doesn't exist. + */ +export function readDaemonRegistryEntry(workspaceKey: string): DaemonRegistryEntry | null { + const workspaceResult = readRegistryEntryAtPath( + registryPathForWorkspaceKey(workspaceKey), + workspaceKey, + ); + if (workspaceResult.status === 'valid') { + return workspaceResult.entry; } + return null; +} - // Remove daemon.json - const registryPath = join(daemonDir, 'daemon.json'); - if (existsSync(registryPath)) { - unlinkSync(registryPath); +/** + * List all daemon registry entries. + */ +export function listDaemonRegistryEntries(): DaemonRegistryEntry[] { + const entriesByWorkspaceKey = new Map(); + for (const entry of listWorkspaceRegistryEntries()) { + entriesByWorkspaceKey.set(entry.workspaceKey, entry); } - // Remove daemon.sock - const socketPath = join(daemonDir, 'daemon.sock'); - if (existsSync(socketPath)) { - unlinkSync(socketPath); + return Array.from(entriesByWorkspaceKey.values()); +} + +export function findDaemonRegistryEntryBySocketPath( + socketPath: string, +): DaemonRegistryEntry | null { + return listDaemonRegistryEntries().find((entry) => entry.socketPath === socketPath) ?? null; +} + +/** + * Remove daemon metadata and socket for a workspace when owned or provably stale. + */ +export function cleanupWorkspaceDaemonFiles( + workspaceKey: string, + options?: DaemonFileCleanupOptions, +): void { + const result = withDaemonRegistryMutationLock(workspaceKey, () => { + const registryPath = registryPathForWorkspaceKey(workspaceKey); + const removed = removeRegistryAtPathIfOwned(registryPath, workspaceKey, options); + if (!removed) { + return; + } + + try { + unlinkSync(removed.socketPath); + } catch { + // ignore + } + }); + if (result === null) { + throw new Error(`Unable to acquire daemon registry lock for ${workspaceKey}`); } } diff --git a/src/daemon/daemon-server.ts b/src/daemon/daemon-server.ts index ca633867..04712e51 100644 --- a/src/daemon/daemon-server.ts +++ b/src/daemon/daemon-server.ts @@ -34,6 +34,7 @@ export interface DaemonServerContext { catalog: ToolCatalog; workspaceRoot: string; workspaceKey: string; + instanceId?: string; xcodeIdeWorkflowEnabled: boolean; /** Callback to request graceful shutdown (used instead of direct process.exit) */ requestShutdown: () => void; @@ -113,6 +114,7 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { toolCount: ctx.catalog.tools.length, workspaceRoot: ctx.workspaceRoot, workspaceKey: ctx.workspaceKey, + instanceId: ctx.instanceId, }; return writeFrame(socket, { ...base, result }); } diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index a06305fd..a59197ee 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -80,8 +80,10 @@ export interface DaemonStatusResult { toolCount: number; /** Workspace root this daemon is serving */ workspaceRoot: string; - /** Short hash key identifying this workspace */ + /** Filesystem-safe name-plus-hash key identifying this workspace */ workspaceKey: string; + /** Opaque identity for this daemon process instance. */ + instanceId?: string; } export interface ToolListItem { diff --git a/src/daemon/socket-path.ts b/src/daemon/socket-path.ts index 05fad29f..46bb83e1 100644 --- a/src/daemon/socket-path.ts +++ b/src/daemon/socket-path.ts @@ -1,48 +1,46 @@ -import { createHash } from 'node:crypto'; -import { mkdirSync, existsSync, unlinkSync, realpathSync } from 'node:fs'; -import { homedir } from 'node:os'; +import { chmodSync, existsSync, lstatSync, mkdirSync, statSync, unlinkSync } from 'node:fs'; import { join, dirname } from 'node:path'; - -export function daemonBaseDir(): string { - return join(homedir(), '.xcodebuildmcp'); -} - -export function daemonsDir(): string { - return join(daemonBaseDir(), 'daemons'); +import { tmpdir } from 'node:os'; +import { + resolveWorkspaceRoot, + shortWorkspaceHash, + workspaceKeyForRoot, + resolveWorkspaceIdentity, +} from '../utils/workspace-identity.ts'; +import { getWorkspaceFilesystemLayout } from '../utils/log-paths.ts'; + +export { resolveWorkspaceRoot, workspaceKeyForRoot, resolveWorkspaceIdentity }; + +let daemonRunDirOverrideForTests: string | null = null; + +function compactWorkspaceKey(workspaceKey: string): string { + const hashSuffix = workspaceKey.match(/-([a-f0-9]{12})$/u)?.[1]; + return hashSuffix ?? shortWorkspaceHash(workspaceKey); } -export function resolveWorkspaceRoot(opts: { cwd: string; projectConfigPath?: string }): string { - if (opts.projectConfigPath) { - const configDir = dirname(opts.projectConfigPath); - return dirname(configDir); - } - try { - return realpathSync(opts.cwd); - } catch { - return opts.cwd; - } +export function daemonRunDir(): string { + return daemonRunDirOverrideForTests ?? tmpdir(); } -export function workspaceKeyForRoot(workspaceRoot: string): string { - const hash = createHash('sha256').update(workspaceRoot).digest('hex'); - return hash.slice(0, 12); +export function setDaemonRunDirOverrideForTests(dir: string | null): void { + daemonRunDirOverrideForTests = dir; } export function daemonDirForWorkspaceKey(key: string): string { - return join(daemonsDir(), key); + return join(daemonRunDir(), `xcodebuildmcp-${compactWorkspaceKey(key)}`); } export function socketPathForWorkspaceRoot(workspaceRoot: string): string { const key = workspaceKeyForRoot(workspaceRoot); - return join(daemonDirForWorkspaceKey(key), 'daemon.sock'); + return join(daemonDirForWorkspaceKey(key), 'd.sock'); } export function registryPathForWorkspaceKey(key: string): string { - return join(daemonDirForWorkspaceKey(key), 'daemon.json'); + return join(getWorkspaceFilesystemLayout(key).state, 'daemon', 'daemon.json'); } export function logPathForWorkspaceKey(key: string): string { - return join(daemonDirForWorkspaceKey(key), 'daemon.log'); + return join(getWorkspaceFilesystemLayout(key).logs, 'daemon.log'); } export interface GetSocketPathOptions { @@ -67,13 +65,25 @@ export function getSocketPath(opts?: GetSocketPathOptions): string { return socketPathForWorkspaceRoot(workspaceRoot); } -export function getWorkspaceKey(opts?: GetSocketPathOptions): string { - const cwd = opts?.cwd ?? process.cwd(); - const workspaceRoot = resolveWorkspaceRoot({ - cwd, - projectConfigPath: opts?.projectConfigPath, - }); - return workspaceKeyForRoot(workspaceRoot); +function validateSocketDir(dir: string): void { + const linkStat = lstatSync(dir); + if (linkStat.isSymbolicLink()) { + throw new Error(`Daemon socket directory cannot be a symlink: ${dir}`); + } + + const stat = statSync(dir); + if (!stat.isDirectory()) { + throw new Error(`Daemon socket path parent is not a directory: ${dir}`); + } + + const uid = process.getuid?.(); + if (uid !== undefined && stat.uid !== uid) { + throw new Error(`Daemon socket directory is not owned by the current user: ${dir}`); + } + + if ((stat.mode & 0o077) !== 0) { + chmodSync(dir, 0o700); + } } export function ensureSocketDir(socketPath: string): void { @@ -81,6 +91,7 @@ export function ensureSocketDir(socketPath: string): void { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o700 }); } + validateSocketDir(dir); } export function removeStaleSocket(socketPath: string): void { @@ -90,8 +101,8 @@ export function removeStaleSocket(socketPath: string): void { } /** - * Legacy: Get the default socket path for the daemon. - * @deprecated Use getSocketPath() with workspace context instead. + * Get the daemon socket path for the current workspace context. + * @deprecated Use getSocketPath() with explicit workspace context instead. */ export function defaultSocketPath(): string { return getSocketPath(); diff --git a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts index 19effdf4..19f4aca1 100644 --- a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { computeScopedDerivedDataPath } from '../../../../utils/derived-data-path.ts'; -import { DERIVED_DATA_DIR } from '../../../../utils/log-paths.ts'; import * as z from 'zod'; import { createMockExecutor, @@ -197,7 +196,7 @@ describe('build_run_sim tool', () => { isAvailable: true, }, '-derivedDataPath', - DERIVED_DATA_DIR, + computeScopedDerivedDataPath('/path/to/workspace'), ], }, }), @@ -266,7 +265,7 @@ describe('build_run_sim tool', () => { isAvailable: true, }, '-derivedDataPath', - DERIVED_DATA_DIR, + computeScopedDerivedDataPath('/path/to/workspace'), ], }, }), diff --git a/src/runtime/__tests__/bootstrap-runtime.test.ts b/src/runtime/__tests__/bootstrap-runtime.test.ts index cb013712..2a075a6f 100644 --- a/src/runtime/__tests__/bootstrap-runtime.test.ts +++ b/src/runtime/__tests__/bootstrap-runtime.test.ts @@ -14,6 +14,8 @@ import { bootstrapRuntime, type RuntimeKind } from '../bootstrap-runtime.ts'; import { __resetConfigStoreForTests } from '../../utils/config-store.ts'; import { sessionStore } from '../../utils/session-store.ts'; import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts'; +import { getRuntimeInstance, setRuntimeInstanceForTests } from '../../utils/runtime-instance.ts'; +import { workspaceKeyForRoot } from '../../utils/workspace-identity.ts'; const cwd = '/repo'; const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); @@ -83,6 +85,7 @@ describe('bootstrapRuntime', () => { sessionStore.clear(); scheduleSimulatorDefaultsRefreshMock.mockReset(); scheduleSimulatorDefaultsRefreshMock.mockReturnValue(false); + setRuntimeInstanceForTests(null); }); it('hydrates session defaults for mcp runtime', async () => { @@ -98,6 +101,9 @@ describe('bootstrapRuntime', () => { simulatorId: 'SIM-UUID', simulatorName: 'iPhone 17', }); + expect(result.workspaceRoot).toBe(cwd); + expect(result.workspaceKey).toBe(workspaceKeyForRoot(cwd)); + expect(getRuntimeInstance().workspaceKey).toBe(result.workspaceKey); expect(scheduleSimulatorDefaultsRefreshMock).toHaveBeenCalledWith( expect.objectContaining({ reason: 'startup-hydration', diff --git a/src/runtime/__tests__/tool-invoker.test.ts b/src/runtime/__tests__/tool-invoker.test.ts index f8d3f74a..a1883a29 100644 --- a/src/runtime/__tests__/tool-invoker.test.ts +++ b/src/runtime/__tests__/tool-invoker.test.ts @@ -8,7 +8,8 @@ import type { ToolDefinition } from '../types.ts'; import { createToolCatalog } from '../tool-catalog.ts'; import { DefaultToolInvoker } from '../tool-invoker.ts'; import { createRenderSession } from '../../rendering/render.ts'; -import { ensureDaemonRunning } from '../../cli/daemon-control.ts'; +import { DaemonVersionMismatchError } from '../../cli/daemon-client.ts'; +import { ensureDaemonRunning, forceStopDaemon } from '../../cli/daemon-control.ts'; const daemonClientMock = { isRunning: vi.fn<() => Promise>(), @@ -117,16 +118,17 @@ function invokeAndFinalize( workspaceRoot?: string; cliExposedWorkflowIds?: string[]; }, -) { +): Promise { const session = createRenderSession('text'); const promise = invoker.invoke(toolName, args, { ...opts, renderSession: session }); return promise.then(() => { const text = session.finalize(); - return { - content: text ? [{ type: 'text' as const, text }] : [], + const response: ToolResponse = { + content: text ? [{ type: 'text', text }] : [], isError: session.isError() || undefined, - nextSteps: undefined as ToolResponse['nextSteps'], - } as ToolResponse; + nextSteps: undefined, + }; + return response; }); } @@ -309,6 +311,40 @@ describe('DefaultToolInvoker CLI routing', () => { expect(response.content[0].text).toContain('daemon-result'); }); + it('renders restart failure when force-stopping a protocol-mismatched daemon fails', async () => { + daemonClientMock.invokeTool.mockRejectedValueOnce(new DaemonVersionMismatchError('old daemon')); + vi.mocked(forceStopDaemon).mockRejectedValueOnce(new Error('registry metadata changed')); + const directHandler = emitHandler('direct-result'); + const catalog = createToolCatalog([ + makeTool({ + cliName: 'start-sim-log-cap', + workflow: 'logging', + stateful: true, + handler: directHandler, + }), + ]); + const invoker = new DefaultToolInvoker(catalog); + + const response = await invokeAndFinalize( + invoker, + 'start-sim-log-cap', + { value: 'hello' }, + { + runtime: 'cli', + socketPath: '/tmp/xcodebuildmcp.sock', + workspaceRoot: '/repo', + }, + ); + + expect(response.isError).toBe(true); + expect(response.content[0].text).toContain( + 'Daemon restart failed after protocol mismatch: registry metadata changed', + ); + expect(forceStopDaemon).toHaveBeenCalledWith('/tmp/xcodebuildmcp.sock'); + expect(ensureDaemonRunning).not.toHaveBeenCalled(); + expect(directHandler).not.toHaveBeenCalled(); + }); + it('renders streamed daemon progress without relying on terminal event replay', async () => { daemonClientMock.invokeTool.mockImplementation(async (_name, _args, options) => { options?.onFragment?.({ @@ -801,20 +837,22 @@ describe('DefaultToolInvoker next steps post-processing', () => { it('suppresses failure next steps for structured xcodebuild failures emitted via handler context', async () => { const directHandler: ToolDefinition['handler'] = vi.fn(async (_params, ctx) => { - ctx.emit({ + const invocationFragment: DomainFragment = { kind: 'build-result', fragment: 'invocation', operation: 'BUILD', request: { scheme: 'MyApp' }, - } as DomainFragment); - ctx.emit({ + }; + const diagnosticFragment: DomainFragment = { kind: 'build-result', fragment: 'compiler-diagnostic', operation: 'BUILD', severity: 'error', message: 'Build failed', rawLine: 'Build failed', - } as DomainFragment); + }; + ctx.emit(invocationFragment); + ctx.emit(diagnosticFragment); ctx.structuredOutput = { schema: 'xcodebuildmcp.output.build-result', schemaVersion: '1', diff --git a/src/runtime/bootstrap-runtime.ts b/src/runtime/bootstrap-runtime.ts index 7b062df2..3f16c155 100644 --- a/src/runtime/bootstrap-runtime.ts +++ b/src/runtime/bootstrap-runtime.ts @@ -11,6 +11,8 @@ import { log } from '../utils/logger.ts'; import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; import { scheduleSimulatorDefaultsRefresh } from '../utils/simulator-defaults-refresh.ts'; import { expandHomePrefix } from '../utils/path.ts'; +import { resolveWorkspaceIdentity } from '../utils/workspace-identity.ts'; +import { configureRuntimeWorkspaceKey } from '../utils/runtime-instance.ts'; export type RuntimeKind = 'cli' | 'daemon' | 'mcp'; @@ -29,6 +31,8 @@ export interface BootstrappedRuntime { export interface BootstrapRuntimeResult { runtime: BootstrappedRuntime; + workspaceRoot: string; + workspaceKey: string; configFound: boolean; configPath?: string; notices: string[]; @@ -136,6 +140,11 @@ export async function bootstrapRuntime( } const config = getConfig(); + const workspaceIdentity = resolveWorkspaceIdentity({ + cwd, + projectConfigPath: configResult.path, + }); + configureRuntimeWorkspaceKey(workspaceIdentity.workspaceKey); if (opts.runtime === 'mcp') { const hydration = hydrateSessionDefaultsForMcp( @@ -152,6 +161,8 @@ export async function bootstrapRuntime( cwd, config, }, + workspaceRoot: workspaceIdentity.workspaceRoot, + workspaceKey: workspaceIdentity.workspaceKey, configFound: configResult.found, configPath: configResult.path, notices: configResult.notices, diff --git a/src/runtime/tool-invoker.ts b/src/runtime/tool-invoker.ts index 21478494..82da7dd5 100644 --- a/src/runtime/tool-invoker.ts +++ b/src/runtime/tool-invoker.ts @@ -367,8 +367,8 @@ export class DefaultToolInvoker implements ToolInvoker { } catch (error) { if (error instanceof DaemonVersionMismatchError) { log('info', `[infra/tool-invoker] ${context.label} daemon protocol mismatch, restarting`); - await forceStopDaemon(socketPath); try { + await forceStopDaemon(socketPath); await ensureDaemonRunning({ socketPath, workspaceRoot: opts.workspaceRoot, @@ -536,11 +536,11 @@ export class DefaultToolInvoker implements ToolInvoker { const ctx: ToolHandlerContext = opts.handlerContext ?? { liveProgressEnabled: false, streamingFragmentsEnabled: false, - emit: (fragment) => { + emit: (fragment): void => { session.emit(fragment); opts.onProgress?.(fragment); }, - attach: (image) => { + attach: (image): void => { session.attach(image); }, }; diff --git a/src/server/__tests__/mcp-shutdown.test.ts b/src/server/__tests__/mcp-shutdown.test.ts index c0553df3..f03e3408 100644 --- a/src/server/__tests__/mcp-shutdown.test.ts +++ b/src/server/__tests__/mcp-shutdown.test.ts @@ -1,23 +1,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { McpLifecycleSnapshot } from '../mcp-lifecycle.ts'; const mocks = vi.hoisted(() => ({ stopXcodeStateWatcher: vi.fn(async () => undefined), shutdownXcodeToolsBridge: vi.fn(async () => undefined), disposeAll: vi.fn(async () => undefined), - stopOwnedSimulatorLaunchOsLogSessions: vi.fn(async () => ({ - stoppedSessionCount: 0, - errorCount: 0, - errors: [], + cleanupOwnedWorkspaceFilesystemArtifacts: vi.fn(async () => ({ + workspaceKey: 'workspace-a', + trigger: 'shutdown', + logDir: '/tmp/logs', + scanned: 0, + deleted: 0, + stopped: 0, + skippedByCooldown: false, + skippedByLock: false, + errors: [] as string[], })), stopAllVideoCaptureSessions: vi.fn(async () => ({ stoppedSessionCount: 0, errorCount: 0, - errors: [], + errors: [] as string[], })), stopAllTrackedProcesses: vi.fn(async () => ({ stoppedProcessCount: 0, errorCount: 0, - errors: [], + errors: [] as string[], })), captureMcpShutdownSummary: vi.fn(), flushSentry: vi.fn(async () => 'flushed'), @@ -33,8 +40,8 @@ vi.mock('../../integrations/xcode-tools-bridge/index.ts', () => ({ vi.mock('../../utils/debugger/index.ts', () => ({ getDefaultDebuggerManager: () => ({ disposeAll: mocks.disposeAll }), })); -vi.mock('../../utils/log-capture/simulator-launch-oslog-sessions.ts', () => ({ - stopOwnedSimulatorLaunchOsLogSessions: mocks.stopOwnedSimulatorLaunchOsLogSessions, +vi.mock('../../utils/workspace-filesystem-lifecycle.ts', () => ({ + cleanupOwnedWorkspaceFilesystemArtifacts: mocks.cleanupOwnedWorkspaceFilesystemArtifacts, })); vi.mock('../../utils/video_capture.ts', () => ({ stopAllVideoCaptureSessions: mocks.stopAllVideoCaptureSessions, @@ -56,6 +63,32 @@ function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function createSnapshot(overrides: Partial = {}): McpLifecycleSnapshot { + return { + pid: 1, + ppid: 1, + orphaned: false, + phase: 'running', + shutdownReason: 'sigterm', + uptimeMs: 100, + rssBytes: 1, + heapUsedBytes: 1, + watcherRunning: false, + watchedPath: null, + activeOperationCount: 0, + activeOperationByCategory: {}, + debuggerSessionCount: 0, + simulatorLaunchOsLogSessionCount: 0, + ownedSimulatorLaunchOsLogSessionCount: 0, + videoCaptureSessionCount: 0, + swiftPackageProcessCount: 0, + matchingMcpProcessCount: 0, + matchingMcpPeerSummary: [], + anomalies: [], + ...overrides, + }; +} + describe('runMcpShutdown', () => { beforeEach(() => { vi.clearAllMocks(); @@ -64,28 +97,7 @@ describe('runMcpShutdown', () => { it('runs cleanup, captures summary, seals capture, and flushes', async () => { const result = await runMcpShutdown({ reason: 'sigterm', - snapshot: { - pid: 1, - ppid: 1, - orphaned: true, - phase: 'running', - shutdownReason: 'sigterm', - uptimeMs: 100, - rssBytes: 1, - heapUsedBytes: 1, - watcherRunning: false, - watchedPath: null, - activeOperationCount: 0, - activeOperationByCategory: {}, - debuggerSessionCount: 0, - simulatorLaunchOsLogSessionCount: 0, - ownedSimulatorLaunchOsLogSessionCount: 0, - videoCaptureSessionCount: 0, - swiftPackageProcessCount: 0, - matchingMcpProcessCount: 0, - matchingMcpPeerSummary: [], - anomalies: [], - }, + snapshot: createSnapshot({ orphaned: true }), server: { close: async () => undefined }, }); @@ -96,87 +108,177 @@ describe('runMcpShutdown', () => { expect(mocks.stopXcodeStateWatcher).toHaveBeenCalledTimes(1); expect(mocks.shutdownXcodeToolsBridge).toHaveBeenCalledTimes(1); expect(mocks.disposeAll).toHaveBeenCalledTimes(1); - expect(mocks.stopOwnedSimulatorLaunchOsLogSessions).toHaveBeenCalledTimes(1); + expect(mocks.cleanupOwnedWorkspaceFilesystemArtifacts).toHaveBeenCalledTimes(1); expect(mocks.stopAllVideoCaptureSessions).toHaveBeenCalledTimes(1); expect(mocks.stopAllTrackedProcesses).toHaveBeenCalledTimes(1); }); + it('records workspace filesystem cleanup diagnostics without failing the step', async () => { + mocks.cleanupOwnedWorkspaceFilesystemArtifacts.mockResolvedValueOnce({ + workspaceKey: 'workspace-a', + trigger: 'shutdown', + logDir: '/tmp/logs', + scanned: 0, + deleted: 0, + stopped: 0, + skippedByCooldown: false, + skippedByLock: false, + errors: ['could not delete stale oslog file'], + }); + + const result = await runMcpShutdown({ + reason: 'sigterm', + snapshot: createSnapshot(), + server: { close: async () => undefined }, + }); + + const filesystemStep = result.steps.find( + (step) => step.name === 'workspace-filesystem.cleanup-owned', + ); + expect(filesystemStep?.status).toBe('completed'); + expect(filesystemStep?.diagnosticCount).toBe(1); + expect(filesystemStep?.diagnostics).toEqual(['could not delete stale oslog file']); + }); + + it('records video capture cleanup diagnostics without failing the step', async () => { + mocks.stopAllVideoCaptureSessions.mockResolvedValueOnce({ + stoppedSessionCount: 0, + errorCount: 1, + errors: ['failed to stop recorder'], + }); + + const result = await runMcpShutdown({ + reason: 'sigterm', + snapshot: createSnapshot({ videoCaptureSessionCount: 1 }), + server: { close: async () => undefined }, + }); + + const videoStep = result.steps.find((step) => step.name === 'video-capture.stop-all'); + expect(videoStep?.status).toBe('completed'); + expect(videoStep?.diagnosticCount).toBe(1); + expect(videoStep?.diagnostics).toEqual(['failed to stop recorder']); + }); + + it('records video capture diagnostics when errorCount is zero but errors are reported', async () => { + mocks.stopAllVideoCaptureSessions.mockResolvedValueOnce({ + stoppedSessionCount: 0, + errorCount: 0, + errors: ['failed to stop recorder'], + }); + + const result = await runMcpShutdown({ + reason: 'sigterm', + snapshot: createSnapshot({ videoCaptureSessionCount: 1 }), + server: { close: async () => undefined }, + }); + + const videoStep = result.steps.find((step) => step.name === 'video-capture.stop-all'); + expect(videoStep?.status).toBe('completed'); + expect(videoStep?.diagnosticCount).toBe(1); + expect(videoStep?.diagnostics).toEqual(['failed to stop recorder']); + }); + + it('records swift tracked process cleanup diagnostics without failing the step', async () => { + mocks.stopAllTrackedProcesses.mockResolvedValueOnce({ + stoppedProcessCount: 0, + errorCount: 1, + errors: ['failed to terminate swift process'], + }); + + const result = await runMcpShutdown({ + reason: 'sigterm', + snapshot: createSnapshot({ swiftPackageProcessCount: 1 }), + server: { close: async () => undefined }, + }); + + const swiftStep = result.steps.find((step) => step.name === 'swift-processes.stop-all'); + expect(swiftStep?.status).toBe('completed'); + expect(swiftStep?.diagnosticCount).toBe(1); + expect(swiftStep?.diagnostics).toEqual(['failed to terminate swift process']); + }); + + it('records swift tracked process diagnostics when errorCount is zero but errors are reported', async () => { + mocks.stopAllTrackedProcesses.mockResolvedValueOnce({ + stoppedProcessCount: 0, + errorCount: 0, + errors: ['failed to terminate swift process'], + }); + + const result = await runMcpShutdown({ + reason: 'sigterm', + snapshot: createSnapshot({ swiftPackageProcessCount: 1 }), + server: { close: async () => undefined }, + }); + + const swiftStep = result.steps.find((step) => step.name === 'swift-processes.stop-all'); + expect(swiftStep?.status).toBe('completed'); + expect(swiftStep?.diagnosticCount).toBe(1); + expect(swiftStep?.diagnostics).toEqual(['failed to terminate swift process']); + }); + it('adds outer timeout headroom for one-item bulk cleanup', async () => { - mocks.stopOwnedSimulatorLaunchOsLogSessions.mockImplementationOnce(async () => { + mocks.cleanupOwnedWorkspaceFilesystemArtifacts.mockImplementationOnce(async () => { await wait(1050); - return { stoppedSessionCount: 1, errorCount: 0, errors: [] }; + return { + workspaceKey: 'workspace-a', + trigger: 'shutdown', + logDir: '/tmp/logs', + scanned: 0, + deleted: 0, + stopped: 1, + skippedByCooldown: false, + skippedByLock: false, + errors: [], + }; }); const result = await runMcpShutdown({ reason: 'sigterm', - snapshot: { - pid: 1, - ppid: 1, - orphaned: false, - phase: 'running', - shutdownReason: 'sigterm', - uptimeMs: 100, - rssBytes: 1, - heapUsedBytes: 1, - watcherRunning: false, - watchedPath: null, - activeOperationCount: 0, - activeOperationByCategory: {}, - debuggerSessionCount: 0, + snapshot: createSnapshot({ simulatorLaunchOsLogSessionCount: 1, ownedSimulatorLaunchOsLogSessionCount: 1, - videoCaptureSessionCount: 0, - swiftPackageProcessCount: 0, - matchingMcpProcessCount: 0, - matchingMcpPeerSummary: [], - anomalies: [], - }, + }), server: { close: async () => undefined }, }); - const simulatorLogsStep = result.steps.find( - (step) => step.name === 'simulator-launch-oslogs.stop-owned', + const filesystemStep = result.steps.find( + (step) => step.name === 'workspace-filesystem.cleanup-owned', ); - expect(simulatorLogsStep?.status).toBe('completed'); + expect(filesystemStep?.status).toBe('completed'); }); it('uses an expanded timeout budget for sequential multi-item bulk cleanup steps', async () => { - mocks.stopOwnedSimulatorLaunchOsLogSessions.mockImplementationOnce(async () => { + mocks.cleanupOwnedWorkspaceFilesystemArtifacts.mockImplementationOnce(async () => { await wait(1100); - return { stoppedSessionCount: 2, errorCount: 0, errors: [] }; + return { + workspaceKey: 'workspace-a', + trigger: 'shutdown', + logDir: '/tmp/logs', + scanned: 0, + deleted: 0, + stopped: 2, + skippedByCooldown: false, + skippedByLock: false, + errors: [], + }; }); const result = await runMcpShutdown({ reason: 'sigterm', - snapshot: { - pid: 1, - ppid: 1, - orphaned: false, - phase: 'running', - shutdownReason: 'sigterm', - uptimeMs: 100, - rssBytes: 1, - heapUsedBytes: 1, - watcherRunning: false, - watchedPath: null, - activeOperationCount: 0, - activeOperationByCategory: {}, - debuggerSessionCount: 0, + snapshot: createSnapshot({ simulatorLaunchOsLogSessionCount: 2, ownedSimulatorLaunchOsLogSessionCount: 2, - videoCaptureSessionCount: 0, - swiftPackageProcessCount: 0, - matchingMcpProcessCount: 0, - matchingMcpPeerSummary: [], - anomalies: [], - }, + }), server: { close: async () => undefined }, }); - const simulatorLogsStep = result.steps.find( - (step) => step.name === 'simulator-launch-oslogs.stop-owned', + const filesystemStep = result.steps.find( + (step) => step.name === 'workspace-filesystem.cleanup-owned', ); - expect(simulatorLogsStep?.status).toBe('completed'); + expect(filesystemStep?.status).toBe('completed'); + expect(mocks.cleanupOwnedWorkspaceFilesystemArtifacts).toHaveBeenCalledWith({ + timeoutMs: 1000, + }); }); it('uses a larger timeout budget for debugger dispose-all', async () => { @@ -186,28 +288,7 @@ describe('runMcpShutdown', () => { const result = await runMcpShutdown({ reason: 'sigterm', - snapshot: { - pid: 1, - ppid: 1, - orphaned: false, - phase: 'running', - shutdownReason: 'sigterm', - uptimeMs: 100, - rssBytes: 1, - heapUsedBytes: 1, - watcherRunning: false, - watchedPath: null, - activeOperationCount: 0, - activeOperationByCategory: {}, - debuggerSessionCount: 1, - simulatorLaunchOsLogSessionCount: 0, - ownedSimulatorLaunchOsLogSessionCount: 0, - videoCaptureSessionCount: 0, - swiftPackageProcessCount: 0, - matchingMcpProcessCount: 0, - matchingMcpPeerSummary: [], - anomalies: [], - }, + snapshot: createSnapshot({ debuggerSessionCount: 1 }), server: { close: async () => undefined }, }); diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 0f4081cf..89943935 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -7,7 +7,6 @@ import type { RuntimeConfigOverrides } from '../utils/config-store.ts'; import { getRegisteredWorkflows, registerWorkflowsFromManifest } from '../utils/tool-registry.ts'; import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts'; import { getXcodeToolsBridgeManager } from '../integrations/xcode-tools-bridge/index.ts'; -import { resolveWorkspaceRoot, workspaceKeyForRoot } from '../daemon/socket-path.ts'; import { detectXcodeRuntime } from '../utils/xcode-process.ts'; import { readXcodeIdeState } from '../utils/xcode-state-reader.ts'; import { sessionStore } from '../utils/session-store.ts'; @@ -15,8 +14,7 @@ import { startXcodeStateWatcher, lookupBundleId } from '../utils/xcode-state-wat import { getDefaultCommandExecutor } from '../utils/command.ts'; import type { PredicateContext } from '../visibility/predicate-types.ts'; import { createStartupProfiler, getStartupProfileNowMs } from './startup-profiler.ts'; -import { configureRuntimeWorkspaceKey } from '../utils/runtime-instance.ts'; -import { reconcileSimulatorLaunchOsLogOrphansForWorkspace } from '../utils/log-capture/index.ts'; +import { runWorkspaceFilesystemLifecycleSweep } from '../utils/workspace-filesystem-lifecycle.ts'; export interface BootstrapOptions { enabledWorkflows?: string[]; @@ -29,6 +27,27 @@ export interface BootstrapResult { runDeferredInitialization: (options?: { isShutdownRequested?: () => boolean }) => Promise; } +function runStartupFilesystemLifecycleSweep(workspaceKey: string): Promise { + return runWorkspaceFilesystemLifecycleSweep({ + workspaceKey, + trigger: 'startup', + }) + .then((lifecycle) => { + if (lifecycle.stopped > 0 || lifecycle.deleted > 0 || lifecycle.errors.length > 0) { + log( + lifecycle.errors.length > 0 ? 'warn' : 'info', + `[startup] Filesystem lifecycle: ${JSON.stringify(lifecycle)}`, + ); + } + }) + .catch((error) => { + log( + 'warn', + `[startup] Filesystem lifecycle failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }); +} + export async function bootstrapServer( server: McpServer, options: BootstrapOptions = {}, @@ -74,27 +93,7 @@ export async function bootstrapServer( } const enabledWorkflows = result.runtime.config.enabledWorkflows; - const workspaceRoot = resolveWorkspaceRoot({ - cwd: result.runtime.cwd, - projectConfigPath: result.configPath, - }); - const workspaceKey = workspaceKeyForRoot(workspaceRoot); - configureRuntimeWorkspaceKey(workspaceKey); - - try { - const reconciliation = await reconcileSimulatorLaunchOsLogOrphansForWorkspace(workspaceKey); - if (reconciliation.stoppedSessionCount > 0 || reconciliation.errorCount > 0) { - log( - reconciliation.errorCount > 0 ? 'warn' : 'info', - `[startup] Simulator OSLog reconciliation: ${JSON.stringify(reconciliation)}`, - ); - } - } catch (error) { - log( - 'warn', - `[startup] Simulator OSLog reconciliation failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } + const { workspaceRoot, workspaceKey } = result; log('info', `🚀 Initializing server...`); @@ -127,6 +126,8 @@ export async function bootstrapServer( const deferredProfiler = createStartupProfiler('bootstrap-deferred'); const isShutdownRequested = options.isShutdownRequested; + void runStartupFilesystemLifecycleSweep(workspaceKey); + if (!xcodeDetection.runningUnderXcode) { return; } diff --git a/src/server/mcp-lifecycle.ts b/src/server/mcp-lifecycle.ts index 3ac4ce09..d21aa1b6 100644 --- a/src/server/mcp-lifecycle.ts +++ b/src/server/mcp-lifecycle.ts @@ -1,10 +1,8 @@ import process from 'node:process'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { getDefaultDebuggerManager } from '../utils/debugger/index.ts'; -import { - listActiveSimulatorLaunchOsLogSessions, - terminateLiveSimulatorLaunchOsLogSessionsSync, -} from '../utils/log-capture/simulator-launch-oslog-sessions.ts'; +import { listActiveSimulatorLaunchOsLogSessions } from '../utils/log-capture/simulator-launch-oslog-sessions.ts'; +import { terminateOwnedWorkspaceFilesystemArtifactsSync } from '../utils/workspace-filesystem-lifecycle.ts'; import { activeProcesses } from '../mcp/tools/swift-package/active-processes.ts'; import { getDaemonActivitySnapshot } from '../daemon/activity-registry.ts'; import { listActiveVideoCaptureSessionIds } from '../utils/video_capture.ts'; @@ -364,7 +362,7 @@ export function createMcpLifecycleCoordinator( void coordinator.shutdown('unhandled-rejection', reason); }; const handleExit = (): void => { - terminateLiveSimulatorLaunchOsLogSessionsSync(); + terminateOwnedWorkspaceFilesystemArtifactsSync(); }; let handlersAttached = false; diff --git a/src/server/mcp-shutdown.ts b/src/server/mcp-shutdown.ts index 56002eaf..06036124 100644 --- a/src/server/mcp-shutdown.ts +++ b/src/server/mcp-shutdown.ts @@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { getDefaultDebuggerManager } from '../utils/debugger/index.ts'; import { stopXcodeStateWatcher } from '../utils/xcode-state-watcher.ts'; import { shutdownXcodeToolsBridge } from '../integrations/xcode-tools-bridge/index.ts'; -import { stopOwnedSimulatorLaunchOsLogSessions } from '../utils/log-capture/simulator-launch-oslog-sessions.ts'; +import { cleanupOwnedWorkspaceFilesystemArtifacts } from '../utils/workspace-filesystem-lifecycle.ts'; import { stopAllVideoCaptureSessions } from '../utils/video_capture.ts'; import { stopAllTrackedProcesses } from '../mcp/tools/swift-package/active-processes.ts'; import { @@ -30,6 +30,8 @@ export interface ShutdownStepResult { status: ShutdownStepStatus; durationMs: number; error?: string; + diagnosticCount?: number; + diagnostics?: string[]; } interface ShutdownStepOutcome { @@ -112,6 +114,28 @@ function buildExitCode(reason: McpShutdownReason): number { return FAILURE_REASONS.has(reason) ? 1 : 0; } +function getCleanupDiagnostics(value: unknown): { count: number; messages: string[] } | null { + if (value === null || typeof value !== 'object') { + return null; + } + + const result = value as { errorCount?: unknown; errors?: unknown }; + const messages = Array.isArray(result.errors) + ? result.errors.filter((error): error is string => typeof error === 'string') + : []; + const explicitCount = + typeof result.errorCount === 'number' && Number.isFinite(result.errorCount) + ? result.errorCount + : 0; + const count = Math.max(explicitCount, messages.length); + + return count > 0 ? { count, messages } : null; +} + +function workspaceFilesystemCleanupTimeoutForOwnedSessions(ownedSessionCount: number): number { + return Math.max(1, ownedSessionCount) * STEP_TIMEOUT_MS * 2 + STEP_TIMEOUT_HEADROOM_MS; +} + export async function closeServerWithTimeout( server: Pick | null | undefined, timeoutMs: number, @@ -150,6 +174,15 @@ export async function runMcpShutdown(input: { if (outcome.error) { step.error = outcome.error; } + if (outcome.status === 'completed') { + const diagnostics = getCleanupDiagnostics(outcome.value); + if (diagnostics) { + step.diagnosticCount = diagnostics.count; + if (diagnostics.messages.length > 0) { + step.diagnostics = diagnostics.messages; + } + } + } steps.push(step); }; @@ -174,6 +207,10 @@ export async function runMcpShutdown(input: { ); }; + const workspaceFilesystemCleanupTimeoutMs = workspaceFilesystemCleanupTimeoutForOwnedSessions( + input.snapshot.ownedSimulatorLaunchOsLogSessionCount, + ); + const cleanupSteps: Array<{ name: string; timeoutMs: number; @@ -191,19 +228,27 @@ export async function runMcpShutdown(input: { operation: () => getDefaultDebuggerManager().disposeAll(), }, { - name: 'simulator-launch-oslogs.stop-owned', - timeoutMs: bulkStepTimeoutMs(input.snapshot.ownedSimulatorLaunchOsLogSessionCount), - operation: () => stopOwnedSimulatorLaunchOsLogSessions(STEP_TIMEOUT_MS), + name: 'workspace-filesystem.cleanup-owned', + timeoutMs: workspaceFilesystemCleanupTimeoutMs, + operation: async (): Promise => { + return cleanupOwnedWorkspaceFilesystemArtifacts({ + timeoutMs: STEP_TIMEOUT_MS, + }); + }, }, { name: 'video-capture.stop-all', timeoutMs: bulkStepTimeoutMs(input.snapshot.videoCaptureSessionCount), - operation: () => stopAllVideoCaptureSessions(STEP_TIMEOUT_MS), + operation: async (): Promise => { + return stopAllVideoCaptureSessions(STEP_TIMEOUT_MS); + }, }, { name: 'swift-processes.stop-all', timeoutMs: bulkStepTimeoutMs(input.snapshot.swiftPackageProcessCount), - operation: () => stopAllTrackedProcesses(STEP_TIMEOUT_MS), + operation: async (): Promise => { + return stopAllTrackedProcesses(STEP_TIMEOUT_MS); + }, }, ]; @@ -213,9 +258,13 @@ export async function runMcpShutdown(input: { } const triggerError = input.error === undefined ? undefined : toErrorMessage(input.error); - const cleanupFailureCount = steps.filter( + const shutdownStepFailureCount = steps.filter( (step) => step.status === 'failed' || step.status === 'timed_out', ).length; + const cleanupDiagnosticCount = steps.reduce( + (total, step) => total + (step.diagnosticCount ?? 0), + 0, + ); captureMcpShutdownSummary({ reason: input.reason, @@ -223,7 +272,8 @@ export async function runMcpShutdown(input: { exitCode, transportDisconnected, triggerError, - cleanupFailureCount, + shutdownStepFailureCount, + cleanupDiagnosticCount, shutdownDurationMs: Date.now() - shutdownStartedAt, snapshot: input.snapshot as unknown as Record, steps: steps as unknown as Array>, diff --git a/src/snapshot-tests/__fixtures__/cli/coverage/get-file-coverage--success.txt b/src/snapshot-tests/__fixtures__/cli/coverage/get-file-coverage--success.txt index d880ab4c..18f185fc 100644 --- a/src/snapshot-tests/__fixtures__/cli/coverage/get-file-coverage--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/coverage/get-file-coverage--success.txt @@ -6,11 +6,10 @@ File: example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift -ℹ️ Coverage: 77.8% (147/189 lines) +ℹ️ Coverage: 83.1% (157/189 lines) -🔴 Not Covered (8 functions, 27 lines) +🔴 Not Covered (7 functions, 22 lines) L159 CalculatorService.deleteLastDigit() -- 0/16 lines - L178 CalculatorService.setError(_:) -- 0/5 lines L58 implicit closure #2 in CalculatorService.inputNumber(_:) -- 0/1 lines L98 implicit closure #3 in CalculatorService.calculate() -- 0/1 lines L99 implicit closure #4 in CalculatorService.calculate() -- 0/1 lines @@ -19,12 +18,12 @@ File: example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorApp L214 implicit closure #4 in CalculatorService.formatNumber(_:) -- 0/1 lines 🟡 Partial Coverage (4 functions) - L63 CalculatorService.inputDecimal() -- 71.4% (10/14 lines) L184 CalculatorService.updateExpressionDisplay() -- 80.0% (8/10 lines) - L93 CalculatorService.calculate() -- 84.2% (32/38 lines) L195 CalculatorService.formatNumber(_:) -- 85.7% (18/21 lines) + L93 CalculatorService.calculate() -- 89.5% (34/38 lines) + L63 CalculatorService.inputDecimal() -- 92.9% (13/14 lines) -🟢 Full Coverage (27 functions) -- all at 100% +🟢 Full Coverage (28 functions) -- all at 100% Next steps: 1. View overall coverage: xcodebuildmcp coverage get-coverage-report --xcresult-path "/TestResults.xcresult" diff --git a/src/snapshot-tests/__fixtures__/cli/device/build--error-compiler.txt b/src/snapshot-tests/__fixtures__/cli/device/build--error-compiler.txt index d43c9fac..d6863893 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/build--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/build--error-compiler.txt @@ -5,7 +5,7 @@ Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Compiler Errors (1): @@ -13,4 +13,4 @@ Compiler Errors (1): example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33:42 ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/device/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/cli/device/build--error-wrong-scheme.txt index c4540858..b8e4d871 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/build--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/build--error-wrong-scheme.txt @@ -5,11 +5,11 @@ Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): ✗ The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/device/build--success.txt b/src/snapshot-tests/__fixtures__/cli/device/build--success.txt index c0152748..d96de7f4 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/build--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/build--success.txt @@ -5,10 +5,10 @@ Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- ✅ Build succeeded. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_device__pid.log Next steps: 1. Get built device app path: xcodebuildmcp device get-app-path --scheme "CalculatorApp" diff --git a/src/snapshot-tests/__fixtures__/cli/device/build-and-run--error-compiler.txt b/src/snapshot-tests/__fixtures__/cli/device/build-and-run--error-compiler.txt index 2f77b9a2..228fef71 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/build-and-run--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/build-and-run--error-compiler.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Device: () - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Compiler Errors (1): @@ -14,4 +14,4 @@ Compiler Errors (1): example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33:42 ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/device/build-and-run--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/cli/device/build-and-run--error-wrong-scheme.txt index 30782a48..6e721682 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/build-and-run--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/build-and-run--error-wrong-scheme.txt @@ -6,11 +6,11 @@ Configuration: Debug Platform: iOS Device: () - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): ✗ The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/device/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/cli/device/build-and-run--success.txt index 8500d23e..3d194b3d 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/build-and-run--success.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Device: () - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- ℹ️ Resolving app path ✅ Resolving app path @@ -16,10 +16,10 @@ ✅ Build succeeded. (⏱️ ) ✅ Build & Run complete - ├ App Path: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + ├ App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app ├ Bundle ID: io.sentry.calculatorapp ├ Process ID: - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_device__pid.log Next steps: 1. Stop app on device: xcodebuildmcp device stop --device-id "" --process-id "" diff --git a/src/snapshot-tests/__fixtures__/cli/device/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/cli/device/get-app-path--success.txt index 726fd48d..9e6d4af7 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/get-app-path--success.txt @@ -7,9 +7,9 @@ Platform: iOS ✅ Success - └ App Path: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + └ App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app Next steps: -1. Get bundle ID: xcodebuildmcp device get-app-bundle-id --app-path "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" -2. Install app on device: xcodebuildmcp device install --device-id "DEVICE_UDID" --app-path "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" +1. Get bundle ID: xcodebuildmcp device get-app-bundle-id --app-path "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" +2. Install app on device: xcodebuildmcp device install --device-id "DEVICE_UDID" --app-path "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" 3. Launch app on device: xcodebuildmcp device launch --device-id "DEVICE_UDID" --bundle-id "BUNDLE_ID" diff --git a/src/snapshot-tests/__fixtures__/cli/device/install--success.txt b/src/snapshot-tests/__fixtures__/cli/device/install--success.txt index cde00749..dd596fc8 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/install--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/install--success.txt @@ -2,6 +2,6 @@ 📦 Install App Device: () - App: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + App: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app ✅ App installed successfully. diff --git a/src/snapshot-tests/__fixtures__/cli/device/test--error-compiler.txt b/src/snapshot-tests/__fixtures__/cli/device/test--error-compiler.txt index abfb256d..970c7994 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/test--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/test--error-compiler.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Device: () - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Selective Testing: CalculatorAppTests/CalculatorAppTests/testAddition @@ -19,4 +19,4 @@ Compiler Errors (1): example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33:42 ❌ Test failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/device/test--failure.txt b/src/snapshot-tests/__fixtures__/cli/device/test--failure.txt index 26b0696d..1d9ffb45 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/test--failure.txt @@ -6,16 +6,39 @@ Configuration: Debug Platform: iOS Device: () - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- -Discovered 52 test(s): +Discovered 57 test(s): CalculatorAppFeatureTests/CalculatorBasicTests/testClear CalculatorAppFeatureTests/CalculatorBasicTests/testInitialState CalculatorAppFeatureTests/CalculatorBasicTests/testIntentionalFailure CalculatorAppFeatureTests/CalculatorIntegrationTests/testChainCalculations CalculatorAppFeatureTests/CalculatorIntegrationTests/testComplexCalculation CalculatorAppFeatureTests/CalculatorIntegrationTests/testExpressionDisplay - (...and 46 more) + (...and 51 more) +Running tests (1 completed, 0 failures, 0 skipped) +Running tests (2 completed, 0 failures, 0 skipped) +Running tests (3 completed, 0 failures, 0 skipped) +Running tests (4 completed, 0 failures, 0 skipped) +Running tests (5 completed, 0 failures, 0 skipped) +Running tests (6 completed, 0 failures, 0 skipped) +Running tests (7 completed, 0 failures, 0 skipped) +Running tests (8 completed, 0 failures, 0 skipped) +Running tests (9 completed, 1 failure, 0 skipped) +Running tests (10 completed, 1 failure, 0 skipped) +Running tests (11 completed, 1 failure, 0 skipped) +Running tests (12 completed, 1 failure, 0 skipped) +Running tests (13 completed, 1 failure, 0 skipped) +Running tests (14 completed, 1 failure, 0 skipped) +Running tests (15 completed, 1 failure, 0 skipped) +Running tests (16 completed, 1 failure, 0 skipped) +Running tests (17 completed, 1 failure, 0 skipped) +Running tests (18 completed, 1 failure, 0 skipped) +Running tests (19 completed, 1 failure, 0 skipped) +Running tests (20 completed, 1 failure, 0 skipped) +Running tests (21 completed, 1 failure, 0 skipped) +Running tests (22 completed, 1 failure, 0 skipped) +Running tests (23 completed, 2 failures, 0 skipped) CalculatorAppTests ✗ testCalculatorServiceFailure: @@ -28,4 +51,4 @@ IntentionalFailureTests example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:286 ❌ tests failed, passed, skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/device/test--success.txt b/src/snapshot-tests/__fixtures__/cli/device/test--success.txt index 8b015d22..982b0cfa 100644 --- a/src/snapshot-tests/__fixtures__/cli/device/test--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/device/test--success.txt @@ -6,12 +6,13 @@ Configuration: Debug Platform: iOS Device: () - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Selective Testing: CalculatorAppTests/CalculatorAppTests/testAddition Discovered 1 test(s): CalculatorAppTests/CalculatorAppTests/testAddition +Running tests (1 completed, 0 failures, 0 skipped) ✅ 1 test passed, 0 skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/macos/build--error-compiler.txt b/src/snapshot-tests/__fixtures__/cli/macos/build--error-compiler.txt index b44a2ac3..3f3ffcb8 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/build--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/build--error-compiler.txt @@ -5,7 +5,7 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Compiler Errors (1): @@ -13,4 +13,4 @@ Compiler Errors (1): example_projects/macOS/MCPTest/MCPTestApp.swift:20:42 ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/macos/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/cli/macos/build--error-wrong-scheme.txt index 9f4f4df8..57a867a9 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/build--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/build--error-wrong-scheme.txt @@ -5,11 +5,11 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Errors (1): ✗ The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/macos/build--success.txt b/src/snapshot-tests/__fixtures__/cli/macos/build--success.txt index e1ae3bb7..c765274f 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/build--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/build--success.txt @@ -5,11 +5,11 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- ✅ Build succeeded. (⏱️ ) ├ Bundle ID: io.sentry.MCPTest.macOS - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_macos__pid.log Next steps: 1. Get built macOS app path: xcodebuildmcp macos get-app-path --scheme "MCPTest" diff --git a/src/snapshot-tests/__fixtures__/cli/macos/build-and-run--error-compiler.txt b/src/snapshot-tests/__fixtures__/cli/macos/build-and-run--error-compiler.txt index ffebe949..553b0c52 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/build-and-run--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/build-and-run--error-compiler.txt @@ -5,7 +5,7 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Compiler Errors (1): @@ -13,4 +13,4 @@ Compiler Errors (1): example_projects/macOS/MCPTest/MCPTestApp.swift:20:42 ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/macos/build-and-run--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/cli/macos/build-and-run--error-wrong-scheme.txt index 7f8dd625..66796aa6 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/build-and-run--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/build-and-run--error-wrong-scheme.txt @@ -5,11 +5,11 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Errors (1): ✗ The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/macos/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/cli/macos/build-and-run--success.txt index 5a7a9706..a4dae807 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/build-and-run--success.txt @@ -5,7 +5,7 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- ℹ️ Resolving app path ✅ Resolving app path @@ -14,10 +14,10 @@ ✅ Build succeeded. (⏱️ ) ✅ Build & Run complete - ├ App Path: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + ├ App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app ├ Bundle ID: io.sentry.MCPTest.macOS ├ Process ID: - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_macos__pid.log Next steps: 1. Interact with the launched app in the foreground diff --git a/src/snapshot-tests/__fixtures__/cli/macos/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/cli/macos/get-app-path--success.txt index 8f2802a7..1e81b903 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/get-app-path--success.txt @@ -7,8 +7,8 @@ Platform: macOS ✅ Success - └ App Path: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + └ App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app Next steps: -1. Get bundle ID: xcodebuildmcp macos get-macos-bundle-id --app-path "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" -2. Launch app: xcodebuildmcp macos launch --app-path "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" +1. Get bundle ID: xcodebuildmcp macos get-macos-bundle-id --app-path "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" +2. Launch app: xcodebuildmcp macos launch --app-path "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" diff --git a/src/snapshot-tests/__fixtures__/cli/macos/launch--success.txt b/src/snapshot-tests/__fixtures__/cli/macos/launch--success.txt index 1fdb6327..ac05ad09 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/launch--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/launch--success.txt @@ -1,7 +1,7 @@ 🚀 Launch macOS App - App: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + App: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app ✅ App launched successfully ├ Bundle ID: io.sentry.MCPTest.macOS diff --git a/src/snapshot-tests/__fixtures__/cli/macos/test--error-compiler.txt b/src/snapshot-tests/__fixtures__/cli/macos/test--error-compiler.txt index a78ec0cd..5c83e7d6 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/test--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/test--error-compiler.txt @@ -5,7 +5,7 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Selective Testing: MCPTestTests/MCPTestTests/appNameIsCorrect() MCPTestTests/MCPTestsXCTests/testAppNameIsCorrect @@ -20,4 +20,4 @@ Compiler Errors (1): example_projects/macOS/MCPTest/MCPTestApp.swift:20:42 ❌ Test failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/macos/test--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/cli/macos/test--error-wrong-scheme.txt index 6e87d6c2..103e729b 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/test--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/test--error-wrong-scheme.txt @@ -5,11 +5,11 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Errors (1): ✗ The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. ❌ Test failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/macos/test--failure.txt b/src/snapshot-tests/__fixtures__/cli/macos/test--failure.txt index e347f14d..28ba26e3 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/test--failure.txt @@ -5,13 +5,17 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Discovered 4 test(s): MCPTestTests/MCPTestTests/appNameIsCorrect MCPTestTests/MCPTestTests/deliberateFailure MCPTestTests/MCPTestsXCTests/testAppNameIsCorrect MCPTestTests/MCPTestsXCTests/testDeliberateFailure +Running tests (1 completed, 0 failures, 0 skipped) +Running tests (2 completed, 1 failure, 0 skipped) +Running tests (3 completed, 1 failure, 0 skipped) +Running tests (4 completed, 2 failures, 0 skipped) MCPTestsXCTests ✗ testDeliberateFailure(): @@ -24,4 +28,4 @@ MCPTestTests example_projects/macOS/MCPTestTests/MCPTestTests.swift:11 ❌ tests failed, passed, skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/macos/test--success.txt b/src/snapshot-tests/__fixtures__/cli/macos/test--success.txt index 877b5a9a..b30dae77 100644 --- a/src/snapshot-tests/__fixtures__/cli/macos/test--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/macos/test--success.txt @@ -5,7 +5,7 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Selective Testing: MCPTestTests/MCPTestTests/appNameIsCorrect() MCPTestTests/MCPTestsXCTests/testAppNameIsCorrect @@ -13,6 +13,8 @@ Discovered 2 test(s): MCPTestTests/MCPTestTests/appNameIsCorrect MCPTestTests/MCPTestsXCTests/testAppNameIsCorrect +Running tests (1 completed, 0 failures, 0 skipped) +Running tests (2 completed, 0 failures, 0 skipped) ✅ 2 tests passed, 0 skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/build--error-compiler.txt b/src/snapshot-tests/__fixtures__/cli/simulator/build--error-compiler.txt index a387be9e..57ebbb0c 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/build--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/build--error-compiler.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Compiler Errors (1): @@ -14,4 +14,4 @@ Compiler Errors (1): example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33:42 ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/cli/simulator/build--error-wrong-scheme.txt index 709a9457..a1380d8e 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/build--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/build--error-wrong-scheme.txt @@ -6,11 +6,11 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): ✗ The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/build--success.txt b/src/snapshot-tests/__fixtures__/cli/simulator/build--success.txt index 14ef1940..acacb428 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/build--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/build--success.txt @@ -6,10 +6,10 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- ✅ Build succeeded. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_sim__pid.log Next steps: 1. Get built app path in simulator derived data: xcodebuildmcp simulator get-app-path --simulator-name "iPhone 17" --scheme "CalculatorApp" --platform "iOS Simulator" diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/build-and-run--error-compiler.txt b/src/snapshot-tests/__fixtures__/cli/simulator/build-and-run--error-compiler.txt index d05a3de4..ababd633 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/build-and-run--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/build-and-run--error-compiler.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Compiler Errors (1): @@ -14,4 +14,4 @@ Compiler Errors (1): example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33:42 ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/build-and-run--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/cli/simulator/build-and-run--error-wrong-scheme.txt index 17ba47c7..79876668 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/build-and-run--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/build-and-run--error-wrong-scheme.txt @@ -6,11 +6,11 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): ✗ The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/cli/simulator/build-and-run--success.txt index b4e0d675..1b9b02e6 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/build-and-run--success.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- ℹ️ Resolving app path ✅ Resolving app path @@ -18,12 +18,12 @@ ✅ Build succeeded. (⏱️ ) ✅ Build & Run complete - ├ App Path: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + ├ App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app ├ Bundle ID: io.sentry.calculatorapp ├ Process ID: - ├ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_sim__pid.log - ├ Runtime Logs: /Library/Developer/XcodeBuildMCP/logs/io.sentry.calculatorapp__pid.log - └ OSLog: /Library/Developer/XcodeBuildMCP/logs/io.sentry.calculatorapp_oslog__pid.log + ├ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_sim__pid.log + ├ Runtime Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/io.sentry.calculatorapp__pid.log + └ OSLog: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/io.sentry.calculatorapp_oslog__pid.log Next steps: 1. Stop app in simulator: xcodebuildmcp simulator stop --simulator-id "" --bundle-id "io.sentry.calculatorapp" diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/cli/simulator/get-app-path--success.txt index 68d67877..0bd60788 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/get-app-path--success.txt @@ -8,10 +8,10 @@ Simulator: iPhone 17 ✅ Get app path successful (⏱️ ) - └ App Path: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + └ App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app Next steps: -1. Get bundle ID: xcodebuildmcp device get-app-bundle-id --app-path "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" +1. Get bundle ID: xcodebuildmcp device get-app-bundle-id --app-path "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" 2. Boot simulator: xcodebuildmcp simulator-management boot --simulator-id "SIMULATOR_UUID" -3. Install app: xcodebuildmcp simulator install --simulator-id "SIMULATOR_UUID" --app-path "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" +3. Install app: xcodebuildmcp simulator install --simulator-id "SIMULATOR_UUID" --app-path "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" 4. Launch app: xcodebuildmcp simulator launch-app --simulator-id "SIMULATOR_UUID" --bundle-id "BUNDLE_ID" diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/install--success.txt b/src/snapshot-tests/__fixtures__/cli/simulator/install--success.txt index cf62bfbb..8c2c554d 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/install--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/install--success.txt @@ -2,7 +2,7 @@ 📦 Install App Simulator: - App Path: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app ✅ App installed successfully diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/launch-app--success.txt b/src/snapshot-tests/__fixtures__/cli/simulator/launch-app--success.txt index cd4410ef..1efee943 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/launch-app--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/launch-app--success.txt @@ -6,8 +6,8 @@ ✅ App launched successfully ├ Process ID: - ├ Runtime Logs: /Library/Developer/XcodeBuildMCP/logs/io.sentry.calculatorapp__pid.log - └ OSLog: /Library/Developer/XcodeBuildMCP/logs/io.sentry.calculatorapp_oslog__pid.log + ├ Runtime Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/io.sentry.calculatorapp__pid.log + └ OSLog: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/io.sentry.calculatorapp_oslog__pid.log Next steps: 1. Open Simulator app to see it: xcodebuildmcp simulator-management open diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/test--error-compiler.txt b/src/snapshot-tests/__fixtures__/cli/simulator/test--error-compiler.txt index b899ab95..cd695614 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/test--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/test--error-compiler.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Selective Testing: CalculatorAppTests/CalculatorAppTests/testAddition @@ -19,4 +19,4 @@ Compiler Errors (1): example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33:42 ❌ Test failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/test--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/cli/simulator/test--error-wrong-scheme.txt index d4042880..50e49847 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/test--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/test--error-wrong-scheme.txt @@ -6,11 +6,11 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): ✗ The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. ❌ Test failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/test--failure.txt b/src/snapshot-tests/__fixtures__/cli/simulator/test--failure.txt index 4e335e68..7a0afe07 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/test--failure.txt @@ -6,16 +6,24 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- -Discovered 52 test(s): +Discovered 57 test(s): CalculatorAppFeatureTests/CalculatorBasicTests/testClear CalculatorAppFeatureTests/CalculatorBasicTests/testInitialState CalculatorAppFeatureTests/CalculatorBasicTests/testIntentionalFailure CalculatorAppFeatureTests/CalculatorIntegrationTests/testChainCalculations CalculatorAppFeatureTests/CalculatorIntegrationTests/testComplexCalculation CalculatorAppFeatureTests/CalculatorIntegrationTests/testExpressionDisplay - (...and 46 more) + (...and 51 more) +Running tests (; final: 57 completed, 3 failed, 0 skipped) + +(Unknown Suite) + ✗ This test should fail to verify error reporting: + - Expectation failed: (calculator.display → "0") == "999" +// This test is designed to fail to test error reporting +This should fail - display should be 0, not 999 + example_projects/iOS_Calculator/CalculatorAppPackage/Tests/CalculatorAppFeatureTests/CalculatorServiceTests.swift:37 CalculatorAppTests ✗ testCalculatorServiceFailure: @@ -28,4 +36,4 @@ IntentionalFailureTests example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:286 ❌ tests failed, passed, skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/simulator/test--success.txt b/src/snapshot-tests/__fixtures__/cli/simulator/test--success.txt index d1595548..1538ea01 100644 --- a/src/snapshot-tests/__fixtures__/cli/simulator/test--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/simulator/test--success.txt @@ -6,12 +6,13 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Selective Testing: CalculatorAppTests/CalculatorAppTests/testAddition Discovered 1 test(s): CalculatorAppTests/CalculatorAppTests/testAddition +Running tests (1 completed, 0 failures, 0 skipped) ✅ 1 test passed, 0 skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/swift-package/build--error-bad-path.txt b/src/snapshot-tests/__fixtures__/cli/swift-package/build--error-bad-path.txt index 75cdb71c..1e8b416e 100644 --- a/src/snapshot-tests/__fixtures__/cli/swift-package/build--error-bad-path.txt +++ b/src/snapshot-tests/__fixtures__/cli/swift-package/build--error-bad-path.txt @@ -8,4 +8,4 @@ Errors (1): ✗ chdir error: No such file or directory (2): /example_projects/NONEXISTENT ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_spm__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_spm__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/swift-package/build--success.txt b/src/snapshot-tests/__fixtures__/cli/swift-package/build--success.txt index 8d1af4d4..eb8eee4e 100644 --- a/src/snapshot-tests/__fixtures__/cli/swift-package/build--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/swift-package/build--success.txt @@ -4,4 +4,4 @@ Package: /example_projects/spm ✅ Build succeeded. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_spm__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_spm__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/swift-package/run--success.txt b/src/snapshot-tests/__fixtures__/cli/swift-package/run--success.txt index 8fee095e..5b6b35fd 100644 --- a/src/snapshot-tests/__fixtures__/cli/swift-package/run--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/swift-package/run--success.txt @@ -8,7 +8,7 @@ ✅ Build & Run complete ├ App Path: example_projects/spm/.build/arm64-apple-macosx/debug/spm ├ Process ID: - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_spm__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_spm__pid.log Output Hello, world! diff --git a/src/snapshot-tests/__fixtures__/cli/swift-package/test--error-bad-path.txt b/src/snapshot-tests/__fixtures__/cli/swift-package/test--error-bad-path.txt index e6b6b48b..639bf732 100644 --- a/src/snapshot-tests/__fixtures__/cli/swift-package/test--error-bad-path.txt +++ b/src/snapshot-tests/__fixtures__/cli/swift-package/test--error-bad-path.txt @@ -4,11 +4,11 @@ Scheme: NONEXISTENT Configuration: debug Platform: Swift Package - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData Errors (1): ✗ chdir error: No such file or directory (2): /example_projects/NONEXISTENT ❌ Test failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/swift_package_test__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/swift_package_test__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/swift-package/test--failure.txt b/src/snapshot-tests/__fixtures__/cli/swift-package/test--failure.txt index a36e1525..9b59b743 100644 --- a/src/snapshot-tests/__fixtures__/cli/swift-package/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/cli/swift-package/test--failure.txt @@ -4,7 +4,14 @@ Scheme: spm Configuration: debug Platform: Swift Package - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData +Running tests (1 completed, 1 failure, 0 skipped) +Running tests (2 completed, 1 failure, 0 skipped) +Running tests (3 completed, 1 failure, 0 skipped) +Running tests (4 completed, 1 failure, 0 skipped) +Running tests (5 completed, 1 failure, 0 skipped) +Running tests (6 completed, 1 failure, 0 skipped) +Running tests (7 completed, 2 failures, 0 skipped) CalculatorAppTests ✗ testCalculatorServiceFailure: @@ -18,4 +25,4 @@ Test failed SimpleTests.swift:57 ❌ tests failed, passed, skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/swift_package_test__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/swift_package_test__pid.log diff --git a/src/snapshot-tests/__fixtures__/cli/swift-package/test--success.txt b/src/snapshot-tests/__fixtures__/cli/swift-package/test--success.txt index a5145e7a..ef7acec1 100644 --- a/src/snapshot-tests/__fixtures__/cli/swift-package/test--success.txt +++ b/src/snapshot-tests/__fixtures__/cli/swift-package/test--success.txt @@ -4,7 +4,9 @@ Scheme: spm Configuration: debug Platform: Swift Package - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData +Running tests (0 completed, 0 failures, 0 skipped) +Running tests (1 completed, 0 failures, 0 skipped) ✅ 1 test passed, 0 skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/swift_package_test__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/swift_package_test__pid.log diff --git a/src/snapshot-tests/__fixtures__/json/coverage/get-file-coverage--success.json b/src/snapshot-tests/__fixtures__/json/coverage/get-file-coverage--success.json index ee4f8d37..7ce3bc17 100644 --- a/src/snapshot-tests/__fixtures__/json/coverage/get-file-coverage--success.json +++ b/src/snapshot-tests/__fixtures__/json/coverage/get-file-coverage--success.json @@ -6,8 +6,8 @@ "data": { "summary": { "status": "SUCCEEDED", - "coveragePct": 77.8, - "coveredLines": 147, + "coveragePct": 83.1, + "coveredLines": 157, "executableLines": 189 }, "coverageScope": "file", @@ -24,12 +24,6 @@ "coveredLines": 0, "executableLines": 16 }, - { - "line": 178, - "name": "CalculatorService.setError(_:)", - "coveredLines": 0, - "executableLines": 5 - }, { "line": 58, "name": "implicit closure #2 in CalculatorService.inputNumber(_:)", @@ -68,13 +62,6 @@ } ], "partialCoverage": [ - { - "line": 63, - "name": "CalculatorService.inputDecimal()", - "coveragePct": 71.4, - "coveredLines": 10, - "executableLines": 14 - }, { "line": 184, "name": "CalculatorService.updateExpressionDisplay()", @@ -82,24 +69,31 @@ "coveredLines": 8, "executableLines": 10 }, - { - "line": 93, - "name": "CalculatorService.calculate()", - "coveragePct": 84.2, - "coveredLines": 32, - "executableLines": 38 - }, { "line": 195, "name": "CalculatorService.formatNumber(_:)", "coveragePct": 85.7, "coveredLines": 18, "executableLines": 21 + }, + { + "line": 93, + "name": "CalculatorService.calculate()", + "coveragePct": 89.5, + "coveredLines": 34, + "executableLines": 38 + }, + { + "line": 63, + "name": "CalculatorService.inputDecimal()", + "coveragePct": 92.9, + "coveredLines": 13, + "executableLines": 14 } ], - "fullCoverageCount": 27, - "notCoveredFunctionCount": 8, - "notCoveredLineCount": 27, + "fullCoverageCount": 28, + "notCoveredFunctionCount": 7, + "notCoveredLineCount": 22, "partialCoverageFunctionCount": 4 } } diff --git a/src/snapshot-tests/__fixtures__/json/device/build--error-compiler.json b/src/snapshot-tests/__fixtures__/json/device/build--error-compiler.json index 0ffabd31..f85d8f74 100644 --- a/src/snapshot-tests/__fixtures__/json/device/build--error-compiler.json +++ b/src/snapshot-tests/__fixtures__/json/device/build--error-compiler.json @@ -7,7 +7,7 @@ "request": { "scheme": "CalculatorApp", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS", "target": "device" @@ -18,7 +18,7 @@ "target": "device" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_device__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_device__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/device/build--error-wrong-scheme.json b/src/snapshot-tests/__fixtures__/json/device/build--error-wrong-scheme.json index 771bf4cf..d20e3bd4 100644 --- a/src/snapshot-tests/__fixtures__/json/device/build--error-wrong-scheme.json +++ b/src/snapshot-tests/__fixtures__/json/device/build--error-wrong-scheme.json @@ -7,7 +7,7 @@ "request": { "scheme": "NONEXISTENT", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS", "target": "device" @@ -18,7 +18,7 @@ "target": "device" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_device__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_device__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/device/build--success.json b/src/snapshot-tests/__fixtures__/json/device/build--success.json index f19d92d6..72bd8294 100644 --- a/src/snapshot-tests/__fixtures__/json/device/build--success.json +++ b/src/snapshot-tests/__fixtures__/json/device/build--success.json @@ -7,7 +7,7 @@ "request": { "scheme": "CalculatorApp", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS", "target": "device" @@ -18,7 +18,7 @@ "target": "device" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_device__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_device__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/device/build-and-run--error-compiler.json b/src/snapshot-tests/__fixtures__/json/device/build-and-run--error-compiler.json index 8235c836..bbe99948 100644 --- a/src/snapshot-tests/__fixtures__/json/device/build-and-run--error-compiler.json +++ b/src/snapshot-tests/__fixtures__/json/device/build-and-run--error-compiler.json @@ -7,7 +7,7 @@ "request": { "scheme": "CalculatorApp", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS", "deviceId": "", @@ -20,7 +20,7 @@ }, "artifacts": { "deviceId": "", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_run_device__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_device__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/device/build-and-run--error-wrong-scheme.json b/src/snapshot-tests/__fixtures__/json/device/build-and-run--error-wrong-scheme.json index ba02dc6b..193ec49e 100644 --- a/src/snapshot-tests/__fixtures__/json/device/build-and-run--error-wrong-scheme.json +++ b/src/snapshot-tests/__fixtures__/json/device/build-and-run--error-wrong-scheme.json @@ -7,6 +7,7 @@ "request": { "scheme": "NONEXISTENT", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS", "deviceId": "", @@ -19,7 +20,7 @@ }, "artifacts": { "deviceId": "", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_run_device__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_device__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/device/build-and-run--success.json b/src/snapshot-tests/__fixtures__/json/device/build-and-run--success.json index 287ecd4e..c1d15aa2 100644 --- a/src/snapshot-tests/__fixtures__/json/device/build-and-run--success.json +++ b/src/snapshot-tests/__fixtures__/json/device/build-and-run--success.json @@ -7,6 +7,7 @@ "request": { "scheme": "CalculatorApp", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS", "deviceId": "", @@ -18,11 +19,11 @@ "target": "device" }, "artifacts": { - "appPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app", + "appPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app", "bundleId": "io.sentry.calculatorapp", "processId": 99999, "deviceId": "", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_run_device__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_device__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/device/get-app-path--success.json b/src/snapshot-tests/__fixtures__/json/device/get-app-path--success.json index a50b34c7..c39cf894 100644 --- a/src/snapshot-tests/__fixtures__/json/device/get-app-path--success.json +++ b/src/snapshot-tests/__fixtures__/json/device/get-app-path--success.json @@ -15,7 +15,7 @@ "target": "device" }, "artifacts": { - "appPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" + "appPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" } } } diff --git a/src/snapshot-tests/__fixtures__/json/device/install--success.json b/src/snapshot-tests/__fixtures__/json/device/install--success.json index 27f62c64..50412f4a 100644 --- a/src/snapshot-tests/__fixtures__/json/device/install--success.json +++ b/src/snapshot-tests/__fixtures__/json/device/install--success.json @@ -9,7 +9,7 @@ }, "artifacts": { "deviceId": "", - "appPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" + "appPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/device/test--error-compiler.json b/src/snapshot-tests/__fixtures__/json/device/test--error-compiler.json index 9e9bc288..2f9766e9 100644 --- a/src/snapshot-tests/__fixtures__/json/device/test--error-compiler.json +++ b/src/snapshot-tests/__fixtures__/json/device/test--error-compiler.json @@ -7,7 +7,7 @@ "request": { "scheme": "CalculatorApp", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS", "deviceId": "", @@ -24,7 +24,7 @@ }, "artifacts": { "deviceId": "", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/test_device__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log" }, "tests": { "selected": [ diff --git a/src/snapshot-tests/__fixtures__/json/device/test--failure.json b/src/snapshot-tests/__fixtures__/json/device/test--failure.json index 21ad4f33..6a549c92 100644 --- a/src/snapshot-tests/__fixtures__/json/device/test--failure.json +++ b/src/snapshot-tests/__fixtures__/json/device/test--failure.json @@ -6,6 +6,8 @@ "data": { "request": { "scheme": "CalculatorApp", + "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS", "deviceId": "", @@ -25,11 +27,11 @@ }, "artifacts": { "deviceId": "", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/test_device__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log" }, "tests": { "discovered": { - "total": 52, + "total": 57, "items": [ "CalculatorAppFeatureTests/CalculatorBasicTests/testClear", "CalculatorAppFeatureTests/CalculatorBasicTests/testInitialState", diff --git a/src/snapshot-tests/__fixtures__/json/device/test--success.json b/src/snapshot-tests/__fixtures__/json/device/test--success.json index 33411647..e98ad4f7 100644 --- a/src/snapshot-tests/__fixtures__/json/device/test--success.json +++ b/src/snapshot-tests/__fixtures__/json/device/test--success.json @@ -6,6 +6,8 @@ "data": { "request": { "scheme": "CalculatorApp", + "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS", "deviceId": "", @@ -27,7 +29,7 @@ }, "artifacts": { "deviceId": "", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/test_device__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log" }, "tests": { "selected": [ diff --git a/src/snapshot-tests/__fixtures__/json/macos/build--error-compiler.json b/src/snapshot-tests/__fixtures__/json/macos/build--error-compiler.json index c182f632..5036f773 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/build--error-compiler.json +++ b/src/snapshot-tests/__fixtures__/json/macos/build--error-compiler.json @@ -7,7 +7,7 @@ "request": { "scheme": "MCPTest", "projectPath": "example_projects/macOS/MCPTest.xcodeproj", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-", "configuration": "Debug", "platform": "macOS", "target": "macos" @@ -18,7 +18,7 @@ "target": "macos" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_macos__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/macos/build--error-wrong-scheme.json b/src/snapshot-tests/__fixtures__/json/macos/build--error-wrong-scheme.json index da3d342e..9e407d70 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/build--error-wrong-scheme.json +++ b/src/snapshot-tests/__fixtures__/json/macos/build--error-wrong-scheme.json @@ -7,7 +7,7 @@ "request": { "scheme": "NONEXISTENT", "projectPath": "example_projects/macOS/MCPTest.xcodeproj", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-", "configuration": "Debug", "platform": "macOS", "target": "macos" @@ -18,7 +18,7 @@ "target": "macos" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_macos__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/macos/build--success.json b/src/snapshot-tests/__fixtures__/json/macos/build--success.json index 4a8884ad..1d9f5091 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/build--success.json +++ b/src/snapshot-tests/__fixtures__/json/macos/build--success.json @@ -7,7 +7,7 @@ "request": { "scheme": "MCPTest", "projectPath": "example_projects/macOS/MCPTest.xcodeproj", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-", "configuration": "Debug", "platform": "macOS", "target": "macos" @@ -19,7 +19,7 @@ }, "artifacts": { "bundleId": "io.sentry.MCPTest.macOS", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_macos__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/macos/build-and-run--error-compiler.json b/src/snapshot-tests/__fixtures__/json/macos/build-and-run--error-compiler.json index 4d4e836f..8d2e6abb 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/build-and-run--error-compiler.json +++ b/src/snapshot-tests/__fixtures__/json/macos/build-and-run--error-compiler.json @@ -7,7 +7,7 @@ "request": { "scheme": "MCPTest", "projectPath": "example_projects/macOS/MCPTest.xcodeproj", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-", "configuration": "Debug", "platform": "macOS", "target": "macos" @@ -18,7 +18,7 @@ "target": "macos" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_run_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_macos__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/macos/build-and-run--error-wrong-scheme.json b/src/snapshot-tests/__fixtures__/json/macos/build-and-run--error-wrong-scheme.json index 0fbdf21c..5abc9d44 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/build-and-run--error-wrong-scheme.json +++ b/src/snapshot-tests/__fixtures__/json/macos/build-and-run--error-wrong-scheme.json @@ -7,7 +7,7 @@ "request": { "scheme": "NONEXISTENT", "projectPath": "example_projects/macOS/MCPTest.xcodeproj", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-", "configuration": "Debug", "platform": "macOS", "target": "macos" @@ -18,7 +18,7 @@ "target": "macos" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_run_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_macos__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/macos/build-and-run--success.json b/src/snapshot-tests/__fixtures__/json/macos/build-and-run--success.json index 13517b4d..0f47df87 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/build-and-run--success.json +++ b/src/snapshot-tests/__fixtures__/json/macos/build-and-run--success.json @@ -7,7 +7,7 @@ "request": { "scheme": "MCPTest", "projectPath": "example_projects/macOS/MCPTest.xcodeproj", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-", "configuration": "Debug", "platform": "macOS", "target": "macos" @@ -18,10 +18,10 @@ "target": "macos" }, "artifacts": { - "appPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app", + "appPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app", "bundleId": "io.sentry.MCPTest.macOS", "processId": 99999, - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_run_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_macos__pid.log" }, "output": { "stdout": [], diff --git a/src/snapshot-tests/__fixtures__/json/macos/get-app-path--success.json b/src/snapshot-tests/__fixtures__/json/macos/get-app-path--success.json index a57d11f1..0ae14e08 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/get-app-path--success.json +++ b/src/snapshot-tests/__fixtures__/json/macos/get-app-path--success.json @@ -15,7 +15,7 @@ "target": "macos" }, "artifacts": { - "appPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" + "appPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" } } } diff --git a/src/snapshot-tests/__fixtures__/json/macos/launch--success.json b/src/snapshot-tests/__fixtures__/json/macos/launch--success.json index 648eb3ae..baa19c2f 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/launch--success.json +++ b/src/snapshot-tests/__fixtures__/json/macos/launch--success.json @@ -8,7 +8,7 @@ "status": "SUCCEEDED" }, "artifacts": { - "appPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app", + "appPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app", "bundleId": "io.sentry.MCPTest.macOS", "processId": 99999 }, diff --git a/src/snapshot-tests/__fixtures__/json/macos/test--error-compiler.json b/src/snapshot-tests/__fixtures__/json/macos/test--error-compiler.json index a10b16e2..4f5d345f 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/test--error-compiler.json +++ b/src/snapshot-tests/__fixtures__/json/macos/test--error-compiler.json @@ -7,7 +7,7 @@ "request": { "scheme": "MCPTest", "projectPath": "example_projects/macOS/MCPTest.xcodeproj", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-", "configuration": "Debug", "platform": "macOS", "onlyTesting": [ @@ -22,7 +22,7 @@ "target": "macos" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/test_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log" }, "tests": { "selected": [ diff --git a/src/snapshot-tests/__fixtures__/json/macos/test--error-wrong-scheme.json b/src/snapshot-tests/__fixtures__/json/macos/test--error-wrong-scheme.json index 98807c78..97bc2596 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/test--error-wrong-scheme.json +++ b/src/snapshot-tests/__fixtures__/json/macos/test--error-wrong-scheme.json @@ -7,7 +7,7 @@ "request": { "scheme": "NONEXISTENT", "projectPath": "example_projects/macOS/MCPTest.xcodeproj", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-", "configuration": "Debug", "platform": "macOS", "onlyTesting": [], @@ -19,7 +19,7 @@ "target": "macos" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/test_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/macos/test--failure.json b/src/snapshot-tests/__fixtures__/json/macos/test--failure.json index 2e5fb62b..03e9540e 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/test--failure.json +++ b/src/snapshot-tests/__fixtures__/json/macos/test--failure.json @@ -7,7 +7,7 @@ "request": { "scheme": "MCPTest", "projectPath": "example_projects/macOS/MCPTest.xcodeproj", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-", "configuration": "Debug", "platform": "macOS", "onlyTesting": [], @@ -24,7 +24,7 @@ "target": "macos" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/test_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log" }, "tests": { "discovered": { diff --git a/src/snapshot-tests/__fixtures__/json/macos/test--success.json b/src/snapshot-tests/__fixtures__/json/macos/test--success.json index 6343d43b..470a91df 100644 --- a/src/snapshot-tests/__fixtures__/json/macos/test--success.json +++ b/src/snapshot-tests/__fixtures__/json/macos/test--success.json @@ -7,7 +7,7 @@ "request": { "scheme": "MCPTest", "projectPath": "example_projects/macOS/MCPTest.xcodeproj", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-", "configuration": "Debug", "platform": "macOS", "onlyTesting": [ @@ -27,7 +27,7 @@ "target": "macos" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/test_macos__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log" }, "tests": { "selected": [ diff --git a/src/snapshot-tests/__fixtures__/json/simulator/build--error-compiler.json b/src/snapshot-tests/__fixtures__/json/simulator/build--error-compiler.json index 44ab7ffc..1d0c340f 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/build--error-compiler.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/build--error-compiler.json @@ -7,7 +7,7 @@ "request": { "scheme": "CalculatorApp", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS Simulator", "simulatorName": "iPhone 17" @@ -18,7 +18,7 @@ "target": "simulator" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_sim__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_sim__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/simulator/build--error-missing-params.json b/src/snapshot-tests/__fixtures__/json/simulator/build--error-missing-params.json deleted file mode 100644 index b605548c..00000000 --- a/src/snapshot-tests/__fixtures__/json/simulator/build--error-missing-params.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "schema": "xcodebuildmcp.output.build-result", - "schemaVersion": "1", - "didError": true, - "error": "Input validation error: Invalid arguments for tool build_sim: expected string for scheme", - "data": null -} diff --git a/src/snapshot-tests/__fixtures__/json/simulator/build--error-wrong-scheme.json b/src/snapshot-tests/__fixtures__/json/simulator/build--error-wrong-scheme.json index b7e81524..93dda05b 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/build--error-wrong-scheme.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/build--error-wrong-scheme.json @@ -7,7 +7,7 @@ "request": { "scheme": "NONEXISTENT", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS Simulator", "simulatorName": "iPhone 17" @@ -18,7 +18,7 @@ "target": "simulator" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_sim__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_sim__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/simulator/build--success.json b/src/snapshot-tests/__fixtures__/json/simulator/build--success.json index 72626e91..17680c2f 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/build--success.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/build--success.json @@ -7,7 +7,7 @@ "request": { "scheme": "CalculatorApp", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS Simulator", "simulatorName": "iPhone 17" @@ -18,7 +18,7 @@ "target": "simulator" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_sim__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_sim__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/simulator/build-and-run--error-compiler.json b/src/snapshot-tests/__fixtures__/json/simulator/build-and-run--error-compiler.json index d9f65bc9..6625f753 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/build-and-run--error-compiler.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/build-and-run--error-compiler.json @@ -7,7 +7,7 @@ "request": { "scheme": "CalculatorApp", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS Simulator", "simulatorName": "iPhone 17" @@ -18,7 +18,7 @@ "target": "simulator" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_run_sim__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_sim__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/simulator/build-and-run--error-wrong-scheme.json b/src/snapshot-tests/__fixtures__/json/simulator/build-and-run--error-wrong-scheme.json index 04d99ee8..2fadffee 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/build-and-run--error-wrong-scheme.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/build-and-run--error-wrong-scheme.json @@ -7,7 +7,7 @@ "request": { "scheme": "NONEXISTENT", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS Simulator", "simulatorName": "iPhone 17" @@ -18,7 +18,7 @@ "target": "simulator" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_run_sim__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_sim__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/simulator/build-and-run--success.json b/src/snapshot-tests/__fixtures__/json/simulator/build-and-run--success.json index 9e54153c..121b45f6 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/build-and-run--success.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/build-and-run--success.json @@ -7,7 +7,7 @@ "request": { "scheme": "CalculatorApp", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS Simulator", "simulatorName": "iPhone 17" @@ -18,13 +18,13 @@ "target": "simulator" }, "artifacts": { - "appPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app", + "appPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app", "bundleId": "io.sentry.calculatorapp", "processId": 99999, "simulatorId": "", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_run_sim__pid.log", - "runtimeLogPath": "/Library/Developer/XcodeBuildMCP/logs/io.sentry.calculatorapp__pid.log", - "osLogPath": "/Library/Developer/XcodeBuildMCP/logs/io.sentry.calculatorapp_oslog__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_sim__pid.log", + "runtimeLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/io.sentry.calculatorapp__pid.log", + "osLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/io.sentry.calculatorapp_oslog__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/simulator/get-app-path--success.json b/src/snapshot-tests/__fixtures__/json/simulator/get-app-path--success.json index 6e0f8bd5..2b45efb7 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/get-app-path--success.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/get-app-path--success.json @@ -17,7 +17,7 @@ "durationMs": 1234 }, "artifacts": { - "appPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" + "appPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" } } } diff --git a/src/snapshot-tests/__fixtures__/json/simulator/install--success.json b/src/snapshot-tests/__fixtures__/json/simulator/install--success.json index 1bd0167e..f1350688 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/install--success.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/install--success.json @@ -9,7 +9,7 @@ }, "artifacts": { "simulatorId": "", - "appPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" + "appPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/simulator/launch-app--success.json b/src/snapshot-tests/__fixtures__/json/simulator/launch-app--success.json index a1fc54a6..71c3482b 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/launch-app--success.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/launch-app--success.json @@ -11,8 +11,8 @@ "simulatorId": "", "bundleId": "io.sentry.calculatorapp", "processId": 99999, - "runtimeLogPath": "/Library/Developer/XcodeBuildMCP/logs/io.sentry.calculatorapp__pid.log", - "osLogPath": "/Library/Developer/XcodeBuildMCP/logs/io.sentry.calculatorapp_oslog__pid.log" + "runtimeLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/io.sentry.calculatorapp__pid.log", + "osLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/io.sentry.calculatorapp_oslog__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/simulator/test--error-compiler.json b/src/snapshot-tests/__fixtures__/json/simulator/test--error-compiler.json index 5e8518d6..b424f3b9 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/test--error-compiler.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/test--error-compiler.json @@ -7,7 +7,7 @@ "request": { "scheme": "CalculatorApp", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS Simulator", "simulatorName": "iPhone 17", @@ -22,7 +22,7 @@ "target": "simulator" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/test_sim__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log" }, "tests": { "selected": [ diff --git a/src/snapshot-tests/__fixtures__/json/simulator/test--error-wrong-scheme.json b/src/snapshot-tests/__fixtures__/json/simulator/test--error-wrong-scheme.json index 92a9ce47..7720890a 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/test--error-wrong-scheme.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/test--error-wrong-scheme.json @@ -7,7 +7,7 @@ "request": { "scheme": "NONEXISTENT", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS Simulator", "simulatorName": "iPhone 17", @@ -20,7 +20,7 @@ "target": "simulator" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/test_sim__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/simulator/test--failure.json b/src/snapshot-tests/__fixtures__/json/simulator/test--failure.json index 6bbb945c..c088f083 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/test--failure.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/test--failure.json @@ -7,7 +7,7 @@ "request": { "scheme": "CalculatorApp", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS Simulator", "simulatorName": "iPhone 17", @@ -18,18 +18,18 @@ "status": "FAILED", "durationMs": 1234, "counts": { - "passed": 21, - "failed": 2, + "passed": 54, + "failed": 3, "skipped": 0 }, "target": "simulator" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/test_sim__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log" }, "tests": { "discovered": { - "total": 52, + "total": 57, "items": [ "CalculatorAppFeatureTests/CalculatorBasicTests/testClear", "CalculatorAppFeatureTests/CalculatorBasicTests/testInitialState", @@ -44,6 +44,12 @@ "warnings": [], "errors": [], "testFailures": [ + { + "suite": "(Unknown Suite)", + "test": "This test should fail to verify error reporting", + "message": "Expectation failed: (calculator.display → \"0\") == \"999\"\n// This test is designed to fail to test error reporting\nThis should fail - display should be 0, not 999", + "location": "CalculatorServiceTests.swift:37" + }, { "suite": "CalculatorAppTests", "test": "testCalculatorServiceFailure", @@ -59,6 +65,176 @@ ] }, "testCases": [ + { + "test": "Adding decimal numbers", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Adding multiple digit numbers", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Adding single digit numbers", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Addition operation", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Calculate without setting operation", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Calculator handles invalid input gracefully", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Calculator initializes with correct default values", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Calculator state after multiple clears", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Chain calculations", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Clear function resets calculator to initial state", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Clear input through handler", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Complex calculation sequence", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Decimal input through handler", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Decimal operations precision", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Decimal point at start creates 0.", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Division by zero returns zero", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Division operation", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Expression display updates correctly", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Large number error handling", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Multiple decimal points should be ignored", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Multiple equals presses", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Multiplication operation", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Number input through handler", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Operation input through handler", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Percentage calculation", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Repetitive equals press repeats last operation", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Setting operation without previous number", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Simple addition calculation", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Subtraction operation", + "status": "passed", + "durationMs": 0 + }, + { + "test": "This test should fail to verify error reporting", + "status": "failed", + "durationMs": 0 + }, + { + "test": "Toggle sign on negative number", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Toggle sign on positive number", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Toggle sign on zero has no effect", + "status": "passed", + "durationMs": 0 + }, + { + "test": "Very small decimal numbers", + "status": "passed", + "durationMs": 0 + }, { "suite": "CalculatorAppTests", "test": "testAddition", diff --git a/src/snapshot-tests/__fixtures__/json/simulator/test--success.json b/src/snapshot-tests/__fixtures__/json/simulator/test--success.json index d0d00832..08db9978 100644 --- a/src/snapshot-tests/__fixtures__/json/simulator/test--success.json +++ b/src/snapshot-tests/__fixtures__/json/simulator/test--success.json @@ -7,7 +7,7 @@ "request": { "scheme": "CalculatorApp", "workspacePath": "example_projects/iOS_Calculator/CalculatorApp.xcworkspace", - "derivedDataPath": "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-", + "derivedDataPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-", "configuration": "Debug", "platform": "iOS Simulator", "simulatorName": "iPhone 17", @@ -27,7 +27,7 @@ "target": "simulator" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/test_sim__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log" }, "tests": { "selected": [ diff --git a/src/snapshot-tests/__fixtures__/json/swift-package/build--error-bad-path.json b/src/snapshot-tests/__fixtures__/json/swift-package/build--error-bad-path.json index e32304b0..4741b697 100644 --- a/src/snapshot-tests/__fixtures__/json/swift-package/build--error-bad-path.json +++ b/src/snapshot-tests/__fixtures__/json/swift-package/build--error-bad-path.json @@ -15,7 +15,7 @@ }, "artifacts": { "packagePath": "/example_projects/NONEXISTENT", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_spm__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_spm__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/swift-package/build--success.json b/src/snapshot-tests/__fixtures__/json/swift-package/build--success.json index f96a4e69..eaf49ad7 100644 --- a/src/snapshot-tests/__fixtures__/json/swift-package/build--success.json +++ b/src/snapshot-tests/__fixtures__/json/swift-package/build--success.json @@ -15,7 +15,7 @@ }, "artifacts": { "packagePath": "/example_projects/spm", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_spm__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_spm__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/swift-package/run--error-bad-executable.json b/src/snapshot-tests/__fixtures__/json/swift-package/run--error-bad-executable.json index 0284cb49..89108e05 100644 --- a/src/snapshot-tests/__fixtures__/json/swift-package/run--error-bad-executable.json +++ b/src/snapshot-tests/__fixtures__/json/swift-package/run--error-bad-executable.json @@ -16,7 +16,7 @@ }, "artifacts": { "packagePath": "/example_projects/spm", - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_run_spm__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_spm__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/swift-package/run--success.json b/src/snapshot-tests/__fixtures__/json/swift-package/run--success.json index f3ab9661..16dbe956 100644 --- a/src/snapshot-tests/__fixtures__/json/swift-package/run--success.json +++ b/src/snapshot-tests/__fixtures__/json/swift-package/run--success.json @@ -18,7 +18,7 @@ "packagePath": "/example_projects/spm", "executablePath": "/example_projects/spm/.build/arm64-apple-macosx/debug/spm", "processId": 99999, - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/build_run_spm__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_spm__pid.log" }, "output": { "stdout": [ diff --git a/src/snapshot-tests/__fixtures__/json/swift-package/test--error-bad-path.json b/src/snapshot-tests/__fixtures__/json/swift-package/test--error-bad-path.json index 76cf3c20..33817b29 100644 --- a/src/snapshot-tests/__fixtures__/json/swift-package/test--error-bad-path.json +++ b/src/snapshot-tests/__fixtures__/json/swift-package/test--error-bad-path.json @@ -16,7 +16,7 @@ "target": "swift-package" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/swift_package_test__pid.log", + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/swift_package_test__pid.log", "packagePath": "/example_projects/NONEXISTENT" }, "diagnostics": { diff --git a/src/snapshot-tests/__fixtures__/json/swift-package/test--failure.json b/src/snapshot-tests/__fixtures__/json/swift-package/test--failure.json index b453e41e..db70bd5d 100644 --- a/src/snapshot-tests/__fixtures__/json/swift-package/test--failure.json +++ b/src/snapshot-tests/__fixtures__/json/swift-package/test--failure.json @@ -21,7 +21,7 @@ "target": "swift-package" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/swift_package_test__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/swift_package_test__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/json/swift-package/test--success.json b/src/snapshot-tests/__fixtures__/json/swift-package/test--success.json index b68e5495..7351eae6 100644 --- a/src/snapshot-tests/__fixtures__/json/swift-package/test--success.json +++ b/src/snapshot-tests/__fixtures__/json/swift-package/test--success.json @@ -21,7 +21,7 @@ "target": "swift-package" }, "artifacts": { - "buildLogPath": "/Library/Developer/XcodeBuildMCP/logs/swift_package_test__pid.log" + "buildLogPath": "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/swift_package_test__pid.log" }, "diagnostics": { "warnings": [], diff --git a/src/snapshot-tests/__fixtures__/mcp/coverage/get-file-coverage--success.txt b/src/snapshot-tests/__fixtures__/mcp/coverage/get-file-coverage--success.txt index 064fd9eb..a44ae894 100644 --- a/src/snapshot-tests/__fixtures__/mcp/coverage/get-file-coverage--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/coverage/get-file-coverage--success.txt @@ -6,11 +6,10 @@ File: example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift -ℹ️ Coverage: 77.8% (147/189 lines) +ℹ️ Coverage: 83.1% (157/189 lines) -🔴 Not Covered (8 functions, 27 lines) +🔴 Not Covered (7 functions, 22 lines) L159 CalculatorService.deleteLastDigit() -- 0/16 lines - L178 CalculatorService.setError(_:) -- 0/5 lines L58 implicit closure #2 in CalculatorService.inputNumber(_:) -- 0/1 lines L98 implicit closure #3 in CalculatorService.calculate() -- 0/1 lines L99 implicit closure #4 in CalculatorService.calculate() -- 0/1 lines @@ -19,12 +18,12 @@ File: example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorApp L214 implicit closure #4 in CalculatorService.formatNumber(_:) -- 0/1 lines 🟡 Partial Coverage (4 functions) - L63 CalculatorService.inputDecimal() -- 71.4% (10/14 lines) L184 CalculatorService.updateExpressionDisplay() -- 80.0% (8/10 lines) - L93 CalculatorService.calculate() -- 84.2% (32/38 lines) L195 CalculatorService.formatNumber(_:) -- 85.7% (18/21 lines) + L93 CalculatorService.calculate() -- 89.5% (34/38 lines) + L63 CalculatorService.inputDecimal() -- 92.9% (13/14 lines) -🟢 Full Coverage (27 functions) -- all at 100% +🟢 Full Coverage (28 functions) -- all at 100% Next steps: 1. View overall coverage: get_coverage_report({ xcresultPath: "/TestResults.xcresult" }) diff --git a/src/snapshot-tests/__fixtures__/mcp/device/build--error-compiler.txt b/src/snapshot-tests/__fixtures__/mcp/device/build--error-compiler.txt index 20bc32b7..7a6b95ea 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/build--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/build--error-compiler.txt @@ -5,7 +5,7 @@ Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): @@ -13,4 +13,4 @@ Errors (1): /example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33 ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/device/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/mcp/device/build--error-wrong-scheme.txt index c4540858..b8e4d871 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/build--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/build--error-wrong-scheme.txt @@ -5,11 +5,11 @@ Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): ✗ The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/device/build--success.txt b/src/snapshot-tests/__fixtures__/mcp/device/build--success.txt index 821c750a..5b415b7b 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/build--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/build--success.txt @@ -5,10 +5,10 @@ Workspace: example_projects/iOS_Calculator/CalculatorApp.xcworkspace Configuration: Debug Platform: iOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- ✅ Build succeeded. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_device__pid.log Next steps: 1. Get built device app path: get_device_app_path({ scheme: "CalculatorApp" }) diff --git a/src/snapshot-tests/__fixtures__/mcp/device/build-and-run--error-compiler.txt b/src/snapshot-tests/__fixtures__/mcp/device/build-and-run--error-compiler.txt index e273fb5a..02ca9c5e 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/build-and-run--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/build-and-run--error-compiler.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Device: () - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): @@ -14,4 +14,4 @@ Errors (1): /example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33 ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/device/build-and-run--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/mcp/device/build-and-run--error-wrong-scheme.txt index 30782a48..6e721682 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/build-and-run--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/build-and-run--error-wrong-scheme.txt @@ -6,11 +6,11 @@ Configuration: Debug Platform: iOS Device: () - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): ✗ The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/device/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/mcp/device/build-and-run--success.txt index 78847f92..2c3864bc 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/build-and-run--success.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Device: () - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- ℹ️ Resolving app path ✅ Resolving app path @@ -16,10 +16,10 @@ ✅ Build succeeded. (⏱️ ) ✅ Build & Run complete - ├ App Path: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + ├ App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app ├ Bundle ID: io.sentry.calculatorapp ├ Process ID: - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_device__pid.log Next steps: 1. Stop app on device: stop_app_device({ deviceId: "", processId: }) diff --git a/src/snapshot-tests/__fixtures__/mcp/device/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/mcp/device/get-app-path--success.txt index 9512d14e..039e3873 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/get-app-path--success.txt @@ -7,9 +7,9 @@ Platform: iOS ✅ Success - └ App Path: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + └ App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app Next steps: -1. Get bundle ID: get_app_bundle_id({ appPath: "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" }) -2. Install app on device: install_app_device({ deviceId: "DEVICE_UDID", appPath: "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" }) +1. Get bundle ID: get_app_bundle_id({ appPath: "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" }) +2. Install app on device: install_app_device({ deviceId: "DEVICE_UDID", appPath: "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app" }) 3. Launch app on device: launch_app_device({ deviceId: "DEVICE_UDID", bundleId: "BUNDLE_ID" }) diff --git a/src/snapshot-tests/__fixtures__/mcp/device/install--success.txt b/src/snapshot-tests/__fixtures__/mcp/device/install--success.txt index cde00749..dd596fc8 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/install--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/install--success.txt @@ -2,6 +2,6 @@ 📦 Install App Device: () - App: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app + App: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphoneos/CalculatorApp.app ✅ App installed successfully. diff --git a/src/snapshot-tests/__fixtures__/mcp/device/test--error-compiler.txt b/src/snapshot-tests/__fixtures__/mcp/device/test--error-compiler.txt index 0b3acf22..cd0a8c7c 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/test--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/test--error-compiler.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Device: () - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Selective Testing: CalculatorAppTests/CalculatorAppTests/testAddition @@ -19,4 +19,4 @@ Errors (1): /example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33 ❌ Test failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/device/test--failure.txt b/src/snapshot-tests/__fixtures__/mcp/device/test--failure.txt index 279b40fd..983acb4c 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/test--failure.txt @@ -6,16 +6,16 @@ Configuration: Debug Platform: iOS Device: () - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- -Discovered 52 test(s): +Discovered 57 test(s): CalculatorAppFeatureTests/CalculatorBasicTests/testClear CalculatorAppFeatureTests/CalculatorBasicTests/testInitialState CalculatorAppFeatureTests/CalculatorBasicTests/testIntentionalFailure CalculatorAppFeatureTests/CalculatorIntegrationTests/testChainCalculations CalculatorAppFeatureTests/CalculatorIntegrationTests/testComplexCalculation CalculatorAppFeatureTests/CalculatorIntegrationTests/testExpressionDisplay - (...and 46 more) + (...and 51 more) Test Failures (2): @@ -26,4 +26,4 @@ Test Failures (2): /example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:286 ❌ tests failed, passed, skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/device/test--success.txt b/src/snapshot-tests/__fixtures__/mcp/device/test--success.txt index 8b015d22..eefb3f2d 100644 --- a/src/snapshot-tests/__fixtures__/mcp/device/test--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/device/test--success.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Device: () - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Selective Testing: CalculatorAppTests/CalculatorAppTests/testAddition @@ -14,4 +14,4 @@ Discovered 1 test(s): CalculatorAppTests/CalculatorAppTests/testAddition ✅ 1 test passed, 0 skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_device__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_device__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/build--error-compiler.txt b/src/snapshot-tests/__fixtures__/mcp/macos/build--error-compiler.txt index 66803c8d..52ffe01a 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/build--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/build--error-compiler.txt @@ -5,7 +5,7 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Errors (1): @@ -13,4 +13,4 @@ Errors (1): /example_projects/macOS/MCPTest/MCPTestApp.swift:20 ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/mcp/macos/build--error-wrong-scheme.txt index 9f4f4df8..57a867a9 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/build--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/build--error-wrong-scheme.txt @@ -5,11 +5,11 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Errors (1): ✗ The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/build--success.txt b/src/snapshot-tests/__fixtures__/mcp/macos/build--success.txt index fb2e36e9..179a8145 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/build--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/build--success.txt @@ -5,11 +5,11 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- ✅ Build succeeded. (⏱️ ) ├ Bundle ID: io.sentry.MCPTest.macOS - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_macos__pid.log Next steps: 1. Get built macOS app path: get_mac_app_path({ scheme: "MCPTest" }) diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/build-and-run--error-compiler.txt b/src/snapshot-tests/__fixtures__/mcp/macos/build-and-run--error-compiler.txt index b3491412..14f54f4c 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/build-and-run--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/build-and-run--error-compiler.txt @@ -5,7 +5,7 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Errors (1): @@ -13,4 +13,4 @@ Errors (1): /example_projects/macOS/MCPTest/MCPTestApp.swift:20 ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/build-and-run--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/mcp/macos/build-and-run--error-wrong-scheme.txt index 7f8dd625..66796aa6 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/build-and-run--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/build-and-run--error-wrong-scheme.txt @@ -5,11 +5,11 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Errors (1): ✗ The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/mcp/macos/build-and-run--success.txt index 5a7a9706..a4dae807 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/build-and-run--success.txt @@ -5,7 +5,7 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- ℹ️ Resolving app path ✅ Resolving app path @@ -14,10 +14,10 @@ ✅ Build succeeded. (⏱️ ) ✅ Build & Run complete - ├ App Path: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + ├ App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app ├ Bundle ID: io.sentry.MCPTest.macOS ├ Process ID: - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_macos__pid.log Next steps: 1. Interact with the launched app in the foreground diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/mcp/macos/get-app-path--success.txt index ca9aae0c..6bd49335 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/get-app-path--success.txt @@ -7,8 +7,8 @@ Platform: macOS ✅ Success - └ App Path: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + └ App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app Next steps: -1. Get bundle ID: get_mac_bundle_id({ appPath: "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" }) -2. Launch app: launch_mac_app({ appPath: "/Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" }) +1. Get bundle ID: get_mac_bundle_id({ appPath: "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" }) +2. Launch app: launch_mac_app({ appPath: "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app" }) diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/launch--success.txt b/src/snapshot-tests/__fixtures__/mcp/macos/launch--success.txt index 1fdb6327..ac05ad09 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/launch--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/launch--success.txt @@ -1,7 +1,7 @@ 🚀 Launch macOS App - App: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app + App: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest-/Build/Products/Debug/MCPTest.app ✅ App launched successfully ├ Bundle ID: io.sentry.MCPTest.macOS diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/test--error-compiler.txt b/src/snapshot-tests/__fixtures__/mcp/macos/test--error-compiler.txt index 60ac06f9..35e6c263 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/test--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/test--error-compiler.txt @@ -5,7 +5,7 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Selective Testing: MCPTestTests/MCPTestTests/appNameIsCorrect() MCPTestTests/MCPTestsXCTests/testAppNameIsCorrect @@ -20,4 +20,4 @@ Errors (1): /example_projects/macOS/MCPTest/MCPTestApp.swift:20 ❌ Test failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/test--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/mcp/macos/test--error-wrong-scheme.txt index 6e87d6c2..103e729b 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/test--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/test--error-wrong-scheme.txt @@ -5,11 +5,11 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Errors (1): ✗ The project named "MCPTest" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the project. ❌ Test failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/test--failure.txt b/src/snapshot-tests/__fixtures__/mcp/macos/test--failure.txt index f18e9f94..b3b83e14 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/test--failure.txt @@ -5,7 +5,7 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Discovered 4 test(s): MCPTestTests/MCPTestTests/appNameIsCorrect @@ -22,4 +22,4 @@ Test Failures (2): MCPTestTests.swift:11 ❌ tests failed, passed, skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/macos/test--success.txt b/src/snapshot-tests/__fixtures__/mcp/macos/test--success.txt index 877b5a9a..e519c8a1 100644 --- a/src/snapshot-tests/__fixtures__/mcp/macos/test--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/macos/test--success.txt @@ -5,7 +5,7 @@ Project: example_projects/macOS/MCPTest.xcodeproj Configuration: Debug Platform: macOS - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/MCPTest- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/MCPTest- Selective Testing: MCPTestTests/MCPTestTests/appNameIsCorrect() MCPTestTests/MCPTestsXCTests/testAppNameIsCorrect @@ -15,4 +15,4 @@ Discovered 2 test(s): MCPTestTests/MCPTestsXCTests/testAppNameIsCorrect ✅ 2 tests passed, 0 skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_macos__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_macos__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/build--error-compiler.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/build--error-compiler.txt index 230c8f5f..de3c6c2a 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/build--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/build--error-compiler.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): @@ -14,4 +14,4 @@ Errors (1): /example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33 ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/build--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/build--error-wrong-scheme.txt index 709a9457..a1380d8e 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/build--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/build--error-wrong-scheme.txt @@ -6,11 +6,11 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): ✗ The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/build--success.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/build--success.txt index 16bbaa0b..ab64c799 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/build--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/build--success.txt @@ -6,10 +6,10 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- ✅ Build succeeded. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_sim__pid.log Next steps: 1. Get built app path in simulator derived data: get_sim_app_path({ simulatorName: "iPhone 17", scheme: "CalculatorApp", platform: "iOS Simulator" }) diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/build-and-run--error-compiler.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/build-and-run--error-compiler.txt index ddc35f16..147e596e 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/build-and-run--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/build-and-run--error-compiler.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): @@ -14,4 +14,4 @@ Errors (1): /example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33 ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/build-and-run--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/build-and-run--error-wrong-scheme.txt index 17ba47c7..79876668 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/build-and-run--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/build-and-run--error-wrong-scheme.txt @@ -6,11 +6,11 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): ✗ The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/build-and-run--success.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/build-and-run--success.txt index c69b56a4..d88d37c4 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/build-and-run--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/build-and-run--success.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- ℹ️ Resolving app path ✅ Resolving app path @@ -18,12 +18,12 @@ ✅ Build succeeded. (⏱️ ) ✅ Build & Run complete - ├ App Path: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + ├ App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app ├ Bundle ID: io.sentry.calculatorapp ├ Process ID: - ├ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_sim__pid.log - ├ Runtime Logs: /Library/Developer/XcodeBuildMCP/logs/io.sentry.calculatorapp__pid.log - └ OSLog: /Library/Developer/XcodeBuildMCP/logs/io.sentry.calculatorapp_oslog__pid.log + ├ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_sim__pid.log + ├ Runtime Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/io.sentry.calculatorapp__pid.log + └ OSLog: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/io.sentry.calculatorapp_oslog__pid.log Next steps: 1. Stop app in simulator: stop_app_sim({ simulatorId: "", bundleId: "io.sentry.calculatorapp" }) diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/get-app-path--success.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/get-app-path--success.txt index 9d80b39c..1478122f 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/get-app-path--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/get-app-path--success.txt @@ -8,10 +8,10 @@ Simulator: iPhone 17 ✅ Get app path successful (⏱️ ) - └ App Path: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + └ App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app Next steps: -1. Get bundle ID: get_app_bundle_id({ appPath: "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" }) +1. Get bundle ID: get_app_bundle_id({ appPath: "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" }) 2. Boot simulator: boot_sim({ simulatorId: "SIMULATOR_UUID" }) -3. Install app: install_app_sim({ simulatorId: "SIMULATOR_UUID", appPath: "/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" }) +3. Install app: install_app_sim({ simulatorId: "SIMULATOR_UUID", appPath: "/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app" }) 4. Launch app: launch_app_sim({ simulatorId: "SIMULATOR_UUID", bundleId: "BUNDLE_ID" }) diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/install--success.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/install--success.txt index 02528861..52806565 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/install--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/install--success.txt @@ -2,7 +2,7 @@ 📦 Install App Simulator: - App Path: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app + App Path: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp-/Build/Products/Debug-iphonesimulator/CalculatorApp.app ✅ App installed successfully diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/launch-app--success.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/launch-app--success.txt index 4642de2b..d87bbccb 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/launch-app--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/launch-app--success.txt @@ -6,8 +6,8 @@ ✅ App launched successfully ├ Process ID: - ├ Runtime Logs: /Library/Developer/XcodeBuildMCP/logs/io.sentry.calculatorapp__pid.log - └ OSLog: /Library/Developer/XcodeBuildMCP/logs/io.sentry.calculatorapp_oslog__pid.log + ├ Runtime Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/io.sentry.calculatorapp__pid.log + └ OSLog: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/io.sentry.calculatorapp_oslog__pid.log Next steps: 1. Open Simulator app to see it: open_sim() diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/test--error-compiler.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/test--error-compiler.txt index ccae37ae..5eaa7aa3 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/test--error-compiler.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/test--error-compiler.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Selective Testing: CalculatorAppTests/CalculatorAppTests/testAddition @@ -19,4 +19,4 @@ Errors (1): /example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift:33 ❌ Test failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/test--error-wrong-scheme.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/test--error-wrong-scheme.txt index d4042880..50e49847 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/test--error-wrong-scheme.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/test--error-wrong-scheme.txt @@ -6,11 +6,11 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Errors (1): ✗ The workspace named "CalculatorApp" does not contain a scheme named "NONEXISTENT". The "-list" option can be used to find the names of the schemes in the workspace. ❌ Test failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/test--failure.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/test--failure.txt index 67c14084..38bd5688 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/test--failure.txt @@ -6,18 +6,23 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- -Discovered 52 test(s): +Discovered 57 test(s): CalculatorAppFeatureTests/CalculatorBasicTests/testClear CalculatorAppFeatureTests/CalculatorBasicTests/testInitialState CalculatorAppFeatureTests/CalculatorBasicTests/testIntentionalFailure CalculatorAppFeatureTests/CalculatorIntegrationTests/testChainCalculations CalculatorAppFeatureTests/CalculatorIntegrationTests/testComplexCalculation CalculatorAppFeatureTests/CalculatorIntegrationTests/testExpressionDisplay - (...and 46 more) + (...and 51 more) -Test Failures (2): +Test Failures (3): + + ✗ (Unknown Suite) / This test should fail to verify error reporting: Expectation failed: (calculator.display → "0") == "999" + // This test is designed to fail to test error reporting + This should fail - display should be 0, not 999 + CalculatorServiceTests.swift:37 ✗ CalculatorAppTests / testCalculatorServiceFailure: XCTAssertEqual failed: ("0") is not equal to ("999") - This test should fail - display should be 0, not 999 /example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:52 @@ -26,4 +31,4 @@ Test Failures (2): /example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift:286 ❌ tests failed, passed, skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/simulator/test--success.txt b/src/snapshot-tests/__fixtures__/mcp/simulator/test--success.txt index d1595548..a1ca41f9 100644 --- a/src/snapshot-tests/__fixtures__/mcp/simulator/test--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/simulator/test--success.txt @@ -6,7 +6,7 @@ Configuration: Debug Platform: iOS Simulator Simulator: iPhone 17 - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp- + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData/CalculatorApp- Selective Testing: CalculatorAppTests/CalculatorAppTests/testAddition @@ -14,4 +14,4 @@ Discovered 1 test(s): CalculatorAppTests/CalculatorAppTests/testAddition ✅ 1 test passed, 0 skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/test_sim__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/test_sim__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/swift-package/build--error-bad-path.txt b/src/snapshot-tests/__fixtures__/mcp/swift-package/build--error-bad-path.txt index 75cdb71c..1e8b416e 100644 --- a/src/snapshot-tests/__fixtures__/mcp/swift-package/build--error-bad-path.txt +++ b/src/snapshot-tests/__fixtures__/mcp/swift-package/build--error-bad-path.txt @@ -8,4 +8,4 @@ Errors (1): ✗ chdir error: No such file or directory (2): /example_projects/NONEXISTENT ❌ Build failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_spm__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_spm__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/swift-package/build--success.txt b/src/snapshot-tests/__fixtures__/mcp/swift-package/build--success.txt index 8d1af4d4..eb8eee4e 100644 --- a/src/snapshot-tests/__fixtures__/mcp/swift-package/build--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/swift-package/build--success.txt @@ -4,4 +4,4 @@ Package: /example_projects/spm ✅ Build succeeded. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_spm__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_spm__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/swift-package/run--success.txt b/src/snapshot-tests/__fixtures__/mcp/swift-package/run--success.txt index 8fee095e..5b6b35fd 100644 --- a/src/snapshot-tests/__fixtures__/mcp/swift-package/run--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/swift-package/run--success.txt @@ -8,7 +8,7 @@ ✅ Build & Run complete ├ App Path: example_projects/spm/.build/arm64-apple-macosx/debug/spm ├ Process ID: - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/build_run_spm__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/build_run_spm__pid.log Output Hello, world! diff --git a/src/snapshot-tests/__fixtures__/mcp/swift-package/test--error-bad-path.txt b/src/snapshot-tests/__fixtures__/mcp/swift-package/test--error-bad-path.txt index e6b6b48b..639bf732 100644 --- a/src/snapshot-tests/__fixtures__/mcp/swift-package/test--error-bad-path.txt +++ b/src/snapshot-tests/__fixtures__/mcp/swift-package/test--error-bad-path.txt @@ -4,11 +4,11 @@ Scheme: NONEXISTENT Configuration: debug Platform: Swift Package - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData Errors (1): ✗ chdir error: No such file or directory (2): /example_projects/NONEXISTENT ❌ Test failed. (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/swift_package_test__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/swift_package_test__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/swift-package/test--failure.txt b/src/snapshot-tests/__fixtures__/mcp/swift-package/test--failure.txt index 4a0dc410..ece0337a 100644 --- a/src/snapshot-tests/__fixtures__/mcp/swift-package/test--failure.txt +++ b/src/snapshot-tests/__fixtures__/mcp/swift-package/test--failure.txt @@ -4,7 +4,7 @@ Scheme: spm Configuration: debug Platform: Swift Package - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData Test Failures (2): @@ -16,4 +16,4 @@ Test Failures (2): SimpleTests.swift:57 ❌ tests failed, passed, skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/swift_package_test__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/swift_package_test__pid.log diff --git a/src/snapshot-tests/__fixtures__/mcp/swift-package/test--success.txt b/src/snapshot-tests/__fixtures__/mcp/swift-package/test--success.txt index a5145e7a..0ad98e1a 100644 --- a/src/snapshot-tests/__fixtures__/mcp/swift-package/test--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/swift-package/test--success.txt @@ -4,7 +4,7 @@ Scheme: spm Configuration: debug Platform: Swift Package - Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData + Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData ✅ 1 test passed, 0 skipped (⏱️ ) - └ Build Logs: /Library/Developer/XcodeBuildMCP/logs/swift_package_test__pid.log + └ Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/logs/swift_package_test__pid.log diff --git a/src/snapshot-tests/__tests__/json-normalize.test.ts b/src/snapshot-tests/__tests__/json-normalize.test.ts index ba98c3e9..f49968ca 100644 --- a/src/snapshot-tests/__tests__/json-normalize.test.ts +++ b/src/snapshot-tests/__tests__/json-normalize.test.ts @@ -3,6 +3,62 @@ import type { StructuredOutputEnvelope } from '../../types/structured-output.ts' import { normalizeStructuredEnvelope } from '../json-normalize.ts'; describe('normalizeStructuredEnvelope', () => { + it('keeps suite-less simulator test cases while normalizing volatile durations', () => { + const envelope: StructuredOutputEnvelope = { + schema: 'xcodebuildmcp.output.test-result', + schemaVersion: '1', + didError: true, + error: 'Tests failed', + data: { + summary: { target: 'simulator' }, + testCases: [ + { test: 'Volatile Swift Testing pass', status: 'passed', durationMs: 12 }, + { test: 'Swift Testing failure', status: 'failed', durationMs: 34 }, + { suite: 'XCTestSuite', test: 'testStablePass', status: 'passed', durationMs: 56 }, + ], + }, + }; + + expect(normalizeStructuredEnvelope(envelope)).toEqual({ + schema: 'xcodebuildmcp.output.test-result', + schemaVersion: '1', + didError: true, + error: 'Tests failed', + data: { + summary: { target: 'simulator' }, + testCases: [ + { test: 'Swift Testing failure', status: 'failed', durationMs: 0 }, + { test: 'Volatile Swift Testing pass', status: 'passed', durationMs: 0 }, + { suite: 'XCTestSuite', test: 'testStablePass', status: 'passed', durationMs: 0 }, + ], + }, + }); + }); + + it('keeps suite-less passed test cases for non-simulator results', () => { + const envelope: StructuredOutputEnvelope = { + schema: 'xcodebuildmcp.output.test-result', + schemaVersion: '1', + didError: false, + error: null, + data: { + summary: { target: 'swift-package' }, + testCases: [{ test: 'Package Swift Testing pass', status: 'passed', durationMs: 12 }], + }, + }; + + expect(normalizeStructuredEnvelope(envelope)).toEqual({ + schema: 'xcodebuildmcp.output.test-result', + schemaVersion: '1', + didError: false, + error: null, + data: { + summary: { target: 'swift-package' }, + testCases: [{ test: 'Package Swift Testing pass', status: 'passed', durationMs: 0 }], + }, + }); + }); + it('normalizes volatile build settings PATH entry values without dropping the entry', () => { const envelope: StructuredOutputEnvelope = { schema: 'xcodebuildmcp.output.build-settings', diff --git a/src/snapshot-tests/__tests__/normalize.test.ts b/src/snapshot-tests/__tests__/normalize.test.ts new file mode 100644 index 00000000..f46bbdeb --- /dev/null +++ b/src/snapshot-tests/__tests__/normalize.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeSnapshotOutput } from '../normalize.ts'; + +function progressBlock(total: number, failed: number): string { + return Array.from({ length: total + 1 }, (_, completed) => { + const failures = completed === total ? failed : 0; + const label = failures === 1 ? 'failure' : 'failures'; + return `Running tests (${completed} completed, ${failures} ${label}, 0 skipped)`; + }).join('\n'); +} + +describe('normalizeSnapshotOutput', () => { + it('collapses long simulator failure progress streams while preserving final counts', () => { + const normalized = normalizeSnapshotOutput(`${progressBlock(42, 3)}\n`); + + expect(normalized).toBe( + 'Running tests (; final: 42 completed, 3 failed, 0 skipped)\n', + ); + }); + + it('does not collapse short progress streams', () => { + const block = `${progressBlock(4, 1)}\n`; + + expect(normalizeSnapshotOutput(block)).toBe(block); + }); + + it('does not collapse long successful progress streams', () => { + const block = `${progressBlock(40, 0)}\n`; + + expect(normalizeSnapshotOutput(block)).toBe(block); + }); + + it('collapses long simulator failure progress streams that start after the initial zero update', () => { + const normalized = normalizeSnapshotOutput( + `${progressBlock(42, 3).split('\n').slice(1).join('\n')}\n`, + ); + + expect(normalized).toBe( + 'Running tests (; final: 42 completed, 3 failed, 0 skipped)\n', + ); + }); + + it('does not collapse progress streams with non-monotonic counts', () => { + const block = [ + progressBlock(20, 0), + 'Running tests (19 completed, 0 failures, 0 skipped)', + progressBlock(40, 2).split('\n').slice(21).join('\n'), + ].join('\n'); + + expect(normalizeSnapshotOutput(`${block}\n`)).toBe(`${block}\n`); + }); +}); diff --git a/src/snapshot-tests/normalize.ts b/src/snapshot-tests/normalize.ts index 9a8ab534..20eeec4e 100644 --- a/src/snapshot-tests/normalize.ts +++ b/src/snapshot-tests/normalize.ts @@ -9,7 +9,9 @@ const APPLE_DEVICE_UDID_REGEX = /[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}/g; const UUID_REGEX = /[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/g; const DURATION_REGEX = /\d+\.\d+s\b/g; const PID_NUMBER_REGEX = /(pid:\s*)\d+/gi; -const PID_FILENAME_SUFFIX_REGEX = /_pid\d+\.log/g; +const PID_FILENAME_SUFFIX_REGEX = /_pid\d+(?:_[0-9a-f]{8})?\.log/g; +const HELPER_PID_FILENAME_SUFFIX_REGEX = + /_(?:helperpid\d+_ownerpid\d+|ownerpid\d+)_[0-9a-f]{8}\.log/g; const PID_JSON_REGEX = /"pid"\s*:\s*\d+/g; const PROCESS_ID_REGEX = /Process ID: \d+/g; const PROCESS_INLINE_PID_REGEX = /process \d+/g; @@ -42,11 +44,62 @@ const ACQUIRED_USAGE_ASSERTION_TIME_REGEX = /(^\s*)\d{2}:\d{2}:\d{2}( {2}Acquired usage assertion\.)$/gm; const BUILD_SETTINGS_PATH_REGEX = /^( {6}PATH = ).+$/gm; const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm; +const SIMULATOR_FAILURE_TEST_PROGRESS_BLOCK_REGEX = + /(?:^Running tests \(\d+ completed, \d+ failures?, \d+ skipped\)\n){30,}/gm; +const TEST_PROGRESS_LINE_REGEX = + /^Running tests \((\d+) completed, (\d+) failures?, (\d+) skipped\)$/u; + +type TestProgress = { completed: number; failed: number; skipped: number }; function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +function parseTestProgressLine(line: string): TestProgress | null { + const match = line.match(TEST_PROGRESS_LINE_REGEX); + if (!match) { + return null; + } + + return { + completed: Number(match[1]), + failed: Number(match[2]), + skipped: Number(match[3]), + }; +} + +function isMonotonicProgress(progress: TestProgress[]): boolean { + return progress.every((current, index) => { + const previous = progress[index - 1]; + return ( + previous === undefined || + (current.completed >= previous.completed && + current.failed >= previous.failed && + current.skipped >= previous.skipped) + ); + }); +} + +function normalizeSimulatorFailureTestProgressBlock(match: string): string { + const progress = match.trimEnd().split('\n').map(parseTestProgressLine); + const parsedProgress = progress.filter((line): line is TestProgress => line !== null); + if (parsedProgress.length !== progress.length) { + return match; + } + const first = parsedProgress[0]; + const final = parsedProgress.at(-1); + if (!first || !final) { + return match; + } + + const hasCleanStart = first.completed <= 1 && first.failed === 0 && first.skipped === 0; + if (!hasCleanStart || final.failed === 0 || !isMonotonicProgress(parsedProgress)) { + return match; + } + + return `Running tests (; final: ${final.completed} completed, ${final.failed} failed, ${final.skipped} skipped)\n`; +} + export function normalizeSnapshotOutput(text: string): string { let normalized = text; @@ -75,6 +128,14 @@ export function normalizeSnapshotOutput(text: string): string { new RegExp(escapeRegex(tmpDir) + '/[A-Za-z0-9._-]+(?=/|[^A-Za-z0-9._/-]|$)', 'g'), '', ); + normalized = normalized.replace( + /(\/Library\/Developer\/XcodeBuildMCP\/workspaces\/[^/]+)-[0-9a-f]{12}(?=\/logs\/)/g, + '$1-', + ); + normalized = normalized.replace( + /(\/Library\/Developer\/XcodeBuildMCP\/workspaces\/[^/]+)-[0-9a-f]{12}\/DerivedData(?=$|[^A-Za-z0-9])/g, + '$1-/DerivedData', + ); normalized = normalized.replace( /(Build Logs: )(?:|\/Library\/Developer\/XcodeBuildMCP)\/logs\//g, '$1/Library/Developer/XcodeBuildMCP/logs/', @@ -89,6 +150,7 @@ export function normalizeSnapshotOutput(text: string): string { normalized = normalized.replace(DEVICE_TRANSPORT_TYPE_REGEX, ''); normalized = normalized.replace(DURATION_REGEX, ''); normalized = normalized.replace(PID_NUMBER_REGEX, '$1'); + normalized = normalized.replace(HELPER_PID_FILENAME_SUFFIX_REGEX, '_pid.log'); normalized = normalized.replace(PID_FILENAME_SUFFIX_REGEX, '_pid.log'); normalized = normalized.replace(PID_JSON_REGEX, '"pid" : '); normalized = normalized.replace(PROCESS_ID_REGEX, 'Process ID: '); @@ -127,6 +189,11 @@ export function normalizeSnapshotOutput(text: string): string { normalized = normalized.replace(COVERAGE_CALL_COUNT_REGEX, 'called x)'); + normalized = normalized.replace( + SIMULATOR_FAILURE_TEST_PROGRESS_BLOCK_REGEX, + normalizeSimulatorFailureTestProgressBlock, + ); + // Normalize final test summary line (counts vary across environments) normalized = normalized.replace( /\d+ (tests? failed), \d+ (passed)(?:, \d+ (skipped))?/g, diff --git a/src/test-utils/vitest-executor-safety.setup.ts b/src/test-utils/vitest-executor-safety.setup.ts index 5e282364..f9a1a7ac 100644 --- a/src/test-utils/vitest-executor-safety.setup.ts +++ b/src/test-utils/vitest-executor-safety.setup.ts @@ -9,6 +9,10 @@ */ import { beforeEach, afterEach } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; import { __setTestCommandExecutorOverride, __setTestFileSystemExecutorOverride, @@ -21,14 +25,35 @@ import { createNoopFileSystemExecutor, createNoopInteractiveSpawner, } from './mock-executors.ts'; +import { setXcodebuildLogDirOverrideForTests } from '../utils/xcodebuild-log-capture.ts'; +import { resetWorkspaceFilesystemLifecycleStateForTests } from '../utils/workspace-filesystem-lifecycle.ts'; +import { setXcodeBuildMCPAppDirOverrideForTests } from '../utils/log-paths.ts'; + +let xcodebuildLogDir: string | null = null; +let appDir: string | null = null; beforeEach(() => { __setTestCommandExecutorOverride(createNoopExecutor()); __setTestFileSystemExecutorOverride(createNoopFileSystemExecutor()); __setTestInteractiveSpawnerOverride(createNoopInteractiveSpawner()); + appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-test-app-dir-')); + setXcodeBuildMCPAppDirOverrideForTests(appDir); + xcodebuildLogDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-test-logs-')); + setXcodebuildLogDirOverrideForTests(xcodebuildLogDir); }); -afterEach(() => { +afterEach(async () => { __clearTestExecutorOverrides(); __clearTestInteractiveSpawnerOverride(); + setXcodebuildLogDirOverrideForTests(null); + setXcodeBuildMCPAppDirOverrideForTests(null); + resetWorkspaceFilesystemLifecycleStateForTests(); + if (xcodebuildLogDir) { + await rm(xcodebuildLogDir, { recursive: true, force: true }); + xcodebuildLogDir = null; + } + if (appDir) { + await rm(appDir, { recursive: true, force: true }); + appDir = null; + } }); diff --git a/src/utils/__tests__/build-preflight.test.ts b/src/utils/__tests__/build-preflight.test.ts index 56f0ef52..15291207 100644 --- a/src/utils/__tests__/build-preflight.test.ts +++ b/src/utils/__tests__/build-preflight.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from 'vitest'; import { displayPath, formatToolPreflight } from '../build-preflight.ts'; import { computeScopedDerivedDataPath } from '../derived-data-path.ts'; -import { DERIVED_DATA_DIR } from '../log-paths.ts'; +import { getWorkspaceFilesystemLayout } from '../log-paths.ts'; +import { workspaceKeyForRoot } from '../workspace-identity.ts'; -const DISPLAY_DERIVED_DATA = displayPath(DERIVED_DATA_DIR); +const displayDefaultDerivedData = (): string => + displayPath(getWorkspaceFilesystemLayout(workspaceKeyForRoot(process.cwd())).derivedData); const displayScopedDerivedData = (anchorPath: string): string => displayPath(computeScopedDerivedDataPath(anchorPath)); @@ -195,7 +197,7 @@ describe('formatToolPreflight', () => { ' Scheme: MyApp', ' Configuration: Debug', ' Platform: macOS', - ` Derived Data: ${DISPLAY_DERIVED_DATA}`, + ` Derived Data: ${displayDefaultDerivedData()}`, '', ].join('\n'), ); diff --git a/src/utils/__tests__/derived-data-path.test.ts b/src/utils/__tests__/derived-data-path.test.ts index de2457c5..728a0580 100644 --- a/src/utils/__tests__/derived-data-path.test.ts +++ b/src/utils/__tests__/derived-data-path.test.ts @@ -5,18 +5,23 @@ import { computeScopedDerivedDataPath, resolveEffectiveDerivedDataPath, } from '../derived-data-path.ts'; -import { DERIVED_DATA_DIR } from '../log-paths.ts'; +import { getWorkspaceFilesystemLayout } from '../log-paths.ts'; +import { workspaceKeyForRoot } from '../workspace-identity.ts'; describe('resolveEffectiveDerivedDataPath', () => { - it('returns the global DerivedData root when no explicit path or anchor is present', () => { - expect(resolveEffectiveDerivedDataPath()).toBe(DERIVED_DATA_DIR); + it('returns the workspace DerivedData root when no explicit path or anchor is present', () => { + const cwd = '/Users/dev/repo'; + const expectedRoot = getWorkspaceFilesystemLayout(workspaceKeyForRoot(cwd)).derivedData; + + expect(resolveEffectiveDerivedDataPath({ cwd })).toBe(expectedRoot); expect( resolveEffectiveDerivedDataPath({ derivedDataPath: ' ', workspacePath: '\t', projectPath: '', + cwd, }), - ).toBe(DERIVED_DATA_DIR); + ).toBe(expectedRoot); }); it('uses an explicit absolute derivedDataPath', () => { diff --git a/src/utils/__tests__/log-paths.test.ts b/src/utils/__tests__/log-paths.test.ts new file mode 100644 index 00000000..6b1288c8 --- /dev/null +++ b/src/utils/__tests__/log-paths.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as path from 'node:path'; +import { + getWorkspaceFilesystemLayout, + getWorkspacesDir, + setXcodeBuildMCPAppDirOverrideForTests, +} from '../log-paths.ts'; + +describe('log paths', () => { + afterEach(() => { + setXcodeBuildMCPAppDirOverrideForTests(null); + }); + + it('builds the workspace-first filesystem layout', () => { + const appDir = path.join('/tmp', 'xcodebuildmcp-app'); + setXcodeBuildMCPAppDirOverrideForTests(appDir); + + const layout = getWorkspaceFilesystemLayout('workspace-a'); + + expect(getWorkspacesDir()).toBe(path.join(appDir, 'workspaces')); + expect(layout).toMatchObject({ + workspaceKey: 'workspace-a', + root: path.join(appDir, 'workspaces', 'workspace-a'), + logs: path.join(appDir, 'workspaces', 'workspace-a', 'logs'), + state: path.join(appDir, 'workspaces', 'workspace-a', 'state'), + locks: path.join(appDir, 'workspaces', 'workspace-a', 'locks'), + derivedData: path.join(appDir, 'workspaces', 'workspace-a', 'DerivedData'), + logRetention: { + lockDir: path.join(appDir, 'workspaces', 'workspace-a', 'locks', 'log-retention.lock'), + markerPath: path.join( + appDir, + 'workspaces', + 'workspace-a', + 'state', + 'log-retention', + 'last-cleanup', + ), + }, + filesystemLifecycle: { + lockDir: path.join( + appDir, + 'workspaces', + 'workspace-a', + 'locks', + 'filesystem-lifecycle.lock', + ), + markerPath: path.join( + appDir, + 'workspaces', + 'workspace-a', + 'state', + 'filesystem-lifecycle', + 'last-cleanup', + ), + }, + simulatorLaunchOsLogRegistryDir: path.join( + appDir, + 'workspaces', + 'workspace-a', + 'state', + 'simulator-launch-oslog', + ), + }); + }); +}); diff --git a/src/utils/__tests__/log-retention.test.ts b/src/utils/__tests__/log-retention.test.ts new file mode 100644 index 00000000..f046fbd7 --- /dev/null +++ b/src/utils/__tests__/log-retention.test.ts @@ -0,0 +1,412 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync, utimesSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { + resetWorkspaceFilesystemLifecycleStateForTests, + runWorkspaceFilesystemLifecycleSweep, + type WorkspaceFilesystemLifecycleOptions, +} from '../workspace-filesystem-lifecycle.ts'; +import { + setSimulatorLaunchOsLogRecordActiveOverrideForTests, + setSimulatorLaunchOsLogRegistryDirForTests, + writeSimulatorLaunchOsLogRegistryRecord, +} from '../log-capture/simulator-launch-oslog-registry.ts'; + +interface PruneOptions { + logDir?: string; + markerPath?: string; + lockDir?: string; + now?: number; + maxAgeMs?: number; + maxFiles?: number; + cooldownMs?: number; + force?: boolean; + activeGraceMs?: number; + protectedLogPaths?: string[]; +} + +async function pruneLogDirectory(options: PruneOptions = {}): Promise<{ + logDir: string; + scanned: number; + deleted: number; + skippedByCooldown: boolean; + skippedByLock: boolean; +}> { + const sweepOptions: WorkspaceFilesystemLifecycleOptions = { + trigger: 'manual', + logDir: options.logDir, + markerPath: options.markerPath, + lockDir: options.lockDir, + now: options.now, + maxAgeMs: options.maxAgeMs, + maxFiles: options.maxFiles, + cooldownMs: options.cooldownMs, + force: options.force, + minVisibleMs: options.activeGraceMs, + protectedLogPaths: options.protectedLogPaths, + lockPurpose: 'log-retention', + }; + const result = await runWorkspaceFilesystemLifecycleSweep(sweepOptions); + return { + logDir: result.logDir, + scanned: result.scanned, + deleted: result.deleted, + skippedByCooldown: result.skippedByCooldown, + skippedByLock: result.skippedByLock, + }; +} + +let logDir: string; + +const MANAGED_LOG_TIMESTAMP = '2026-05-02T12-00-00-000Z'; + +function managedXcodebuildLogName(toolName: string, pid = 1234, suffix = 'abcdef12'): string { + return `${toolName}_${MANAGED_LOG_TIMESTAMP}_pid${pid}_${suffix}.log`; +} + +function managedSimulatorLogName( + bundleId: string, + ownerPid: number, + suffix = 'abcdef12', + helperPid?: number, +): string { + const helperPart = helperPid === undefined ? '' : `helperpid${helperPid}_`; + return `${bundleId}_${MANAGED_LOG_TIMESTAMP}_${helperPart}ownerpid${ownerPid}_${suffix}.log`; +} + +function writeLogIn(targetLogDir: string, name: string, mtimeMs: number): string { + const filePath = path.join(targetLogDir, name); + writeFileSync(filePath, name); + const date = new Date(mtimeMs); + utimesSync(filePath, date, date); + return filePath; +} + +function writeLog(name: string, mtimeMs: number): string { + return writeLogIn(logDir, name, mtimeMs); +} + +describe('log retention', () => { + beforeEach(() => { + logDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-log-retention-')); + resetWorkspaceFilesystemLifecycleStateForTests(); + }); + + afterEach(async () => { + resetWorkspaceFilesystemLifecycleStateForTests(); + setSimulatorLaunchOsLogRecordActiveOverrideForTests(null); + setSimulatorLaunchOsLogRegistryDirForTests(null); + await rm(logDir, { recursive: true, force: true }); + }); + + it('deletes logs older than the retention window and keeps recent logs', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const oldLog = writeLog( + managedXcodebuildLogName('old', 1234, 'abcdef12'), + now - 4 * 24 * 60 * 60 * 1000, + ); + const recentLog = writeLog( + managedXcodebuildLogName('recent', 1234, 'abcdef13'), + now - 2 * 24 * 60 * 60 * 1000, + ); + + const result = await pruneLogDirectory({ logDir, now, force: true }); + + expect(result).toMatchObject({ scanned: 2, deleted: 1, skippedByCooldown: false }); + expect(existsSync(oldLog)).toBe(false); + expect(existsSync(recentLog)).toBe(true); + }); + + it('enforces a max file cap after age pruning', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const oldest = writeLog(managedXcodebuildLogName('oldest', 1234, 'abcdef12'), now - 30_000); + const middle = writeLog(managedXcodebuildLogName('middle', 1234, 'abcdef13'), now - 20_000); + const newest = writeLog(managedXcodebuildLogName('newest', 1234, 'abcdef14'), now - 10_000); + + const result = await pruneLogDirectory({ + logDir, + now, + maxAgeMs: 24 * 60 * 60 * 1000, + maxFiles: 2, + activeGraceMs: 0, + force: true, + }); + + expect(result).toMatchObject({ scanned: 3, deleted: 1, skippedByCooldown: false }); + expect(existsSync(oldest)).toBe(false); + expect(existsSync(middle)).toBe(true); + expect(existsSync(newest)).toBe(true); + }); + + it('uses the cooldown marker to skip repeated sweeps', async () => { + const now = Date.UTC(2026, 4, 2, 12); + writeLog(managedXcodebuildLogName('old'), now - 4 * 24 * 60 * 60 * 1000); + + await pruneLogDirectory({ logDir, now, force: true }); + const result = await pruneLogDirectory({ logDir, now: now + 1000 }); + + expect(result).toEqual({ + logDir, + scanned: 0, + deleted: 0, + skippedByCooldown: true, + skippedByLock: false, + }); + }); + + it('skips a recent ownerless retention lock', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const lockDir = path.join(logDir, 'recent-ownerless.lock'); + mkdirSync(lockDir); + const recentDate = new Date(now - 30 * 1000); + utimesSync(lockDir, recentDate, recentDate); + const oldLog = writeLog(managedXcodebuildLogName('old'), now - 4 * 24 * 60 * 60 * 1000); + + const result = await pruneLogDirectory({ logDir, lockDir, now, force: true }); + + expect(result).toMatchObject({ scanned: 0, deleted: 0, skippedByLock: true }); + expect(existsSync(oldLog)).toBe(true); + }); + + it('recovers an expired ownerless retention lock', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const lockDir = path.join(logDir, 'expired-ownerless.lock'); + mkdirSync(lockDir); + const staleDate = new Date(now - 20 * 60 * 1000); + utimesSync(lockDir, staleDate, staleDate); + const oldLog = writeLog(managedXcodebuildLogName('old'), now - 4 * 24 * 60 * 60 * 1000); + + const result = await pruneLogDirectory({ logDir, lockDir, now, force: true }); + + expect(result).toMatchObject({ scanned: 1, deleted: 1, skippedByLock: false }); + expect(existsSync(oldLog)).toBe(false); + }); + + it('recovers an expired retention lock owned by a crashed process', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const lockDir = path.join(logDir, 'crashed-owner.lock'); + mkdirSync(lockDir); + writeFileSync( + path.join(lockDir, 'owner.json'), + `${JSON.stringify({ + token: 'stale-token', + pid: 999_999_999, + purpose: 'log-retention', + acquiredAtMs: now - 20 * 60 * 1000, + expiresAtMs: now - 10 * 60 * 1000, + })}\n`, + ); + const oldLog = writeLog(managedXcodebuildLogName('old'), now - 4 * 24 * 60 * 60 * 1000); + + const result = await pruneLogDirectory({ logDir, lockDir, now, force: true }); + + expect(result).toMatchObject({ scanned: 1, deleted: 1, skippedByLock: false }); + expect(existsSync(oldLog)).toBe(false); + }); + + it('skips when another process owns the retention lock', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const lockDir = path.join(logDir, 'held.lock'); + mkdirSync(lockDir); + writeLog(managedXcodebuildLogName('old'), now - 4 * 24 * 60 * 60 * 1000); + + const result = await pruneLogDirectory({ logDir, lockDir, now, force: true }); + + expect(result).toEqual({ + logDir, + scanned: 0, + deleted: 0, + skippedByCooldown: false, + skippedByLock: true, + }); + }); + + it('does not treat creator pid names as active after the grace window', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const oldLog = writeLog( + managedXcodebuildLogName('build', process.pid), + now - 4 * 24 * 60 * 60 * 1000, + ); + + const result = await pruneLogDirectory({ logDir, now, force: true }); + + expect(result).toMatchObject({ scanned: 1, deleted: 1, skippedByLock: false }); + expect(existsSync(oldLog)).toBe(false); + }); + + it('does not delete logs protected by live helper pid names', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const activeLog = writeLog( + managedSimulatorLogName('app', 1234, 'abcdef12', process.pid), + now - 4 * 24 * 60 * 60 * 1000, + ); + + const result = await pruneLogDirectory({ logDir, now, force: true }); + + expect(result).toMatchObject({ scanned: 1, deleted: 0, skippedByLock: false }); + expect(existsSync(activeLog)).toBe(true); + }); + + it('keeps protected logs out of max-file cap pruning', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const protectedLog = writeLog( + managedSimulatorLogName('protected', 1234, 'abcdef12', process.pid), + now - 30_000, + ); + const oldDeletable = writeLog( + managedXcodebuildLogName('old-deletable', 1234, 'abcdef13'), + now - 20_000, + ); + const newDeletable = writeLog( + managedXcodebuildLogName('new-deletable', 1234, 'abcdef14'), + now - 10_000, + ); + + const result = await pruneLogDirectory({ + logDir, + now, + maxAgeMs: 24 * 60 * 60 * 1000, + maxFiles: 1, + activeGraceMs: 0, + force: true, + }); + + expect(result).toMatchObject({ scanned: 3, deleted: 1 }); + expect(existsSync(protectedLog)).toBe(true); + expect(existsSync(oldDeletable)).toBe(false); + expect(existsSync(newDeletable)).toBe(true); + }); + + it('allows only one same-directory concurrent sweep to acquire the retention lock', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const lockDir = path.join(logDir, 'same-dir.lock'); + const markerPath = path.join(logDir, 'same-dir.marker'); + writeLog(managedXcodebuildLogName('old-a', 1234, 'abcdef12'), now - 4 * 24 * 60 * 60 * 1000); + writeLog(managedXcodebuildLogName('old-b', 1234, 'abcdef13'), now - 4 * 24 * 60 * 60 * 1000); + + const results = await Promise.all([ + pruneLogDirectory({ logDir, lockDir, markerPath, now, force: true }), + pruneLogDirectory({ logDir, lockDir, markerPath, now, force: true }), + ]); + + expect(results.filter((result) => result.skippedByLock)).toHaveLength(1); + expect(results.filter((result) => !result.skippedByLock)).toHaveLength(1); + expect(results.reduce((count, result) => count + result.deleted, 0)).toBe(2); + }); + + it('allows concurrent sweeps for different log directories', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const otherLogDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-log-retention-other-')); + try { + const oldLog = writeLog( + managedXcodebuildLogName('old-a', 1234, 'abcdef12'), + now - 4 * 24 * 60 * 60 * 1000, + ); + const otherOldLog = writeLogIn( + otherLogDir, + managedXcodebuildLogName('old-b', 1234, 'abcdef13'), + now - 4 * 24 * 60 * 60 * 1000, + ); + + const [left, right] = await Promise.all([ + pruneLogDirectory({ + logDir, + lockDir: path.join(logDir, 'lock'), + markerPath: path.join(logDir, 'marker'), + now, + force: true, + }), + pruneLogDirectory({ + logDir: otherLogDir, + lockDir: path.join(otherLogDir, 'lock'), + markerPath: path.join(otherLogDir, 'marker'), + now, + force: true, + }), + ]); + + expect(left).toMatchObject({ deleted: 1, skippedByLock: false }); + expect(right).toMatchObject({ deleted: 1, skippedByLock: false }); + expect(existsSync(oldLog)).toBe(false); + expect(existsSync(otherOldLog)).toBe(false); + } finally { + await rm(otherLogDir, { recursive: true, force: true }); + } + }); + + it('protects OSLog registry paths without mutating registry records', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const registryDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-oslog-protect-')); + const protectedLog = writeLog( + managedSimulatorLogName('io.sentry.app_oslog', process.pid, 'abcdef12', process.pid), + now - 4 * 24 * 60 * 60 * 1000, + ); + const malformedRecord = path.join(registryDir, 'malformed.json'); + const staleRecord = path.join(registryDir, 'stale.json'); + setSimulatorLaunchOsLogRegistryDirForTests(registryDir); + setSimulatorLaunchOsLogRecordActiveOverrideForTests(async (record) => { + return record.sessionId === 'protected'; + }); + + try { + writeFileSync(malformedRecord, '{not-json'); + await writeSimulatorLaunchOsLogRegistryRecord({ + sessionId: 'protected', + owner: { instanceId: 'instance-1', pid: process.pid, workspaceKey: 'workspace-a' }, + simulatorUuid: 'sim-1', + bundleId: 'io.sentry.app', + helperPid: process.pid, + logFilePath: protectedLog, + startedAtMs: now, + expectedCommandParts: ['node'], + }); + writeFileSync( + staleRecord, + `${JSON.stringify({ + sessionId: 'stale', + owner: { instanceId: 'instance-2', pid: process.pid, workspaceKey: 'workspace-a' }, + simulatorUuid: 'sim-1', + bundleId: 'io.sentry.app', + helperPid: 999999, + logFilePath: path.join( + logDir, + managedSimulatorLogName('io.sentry.app_oslog', process.pid, 'abcdef13', 999999), + ), + startedAtMs: now, + expectedCommandParts: ['not-active'], + })}\n`, + ); + + const result = await pruneLogDirectory({ logDir, now, force: true }); + + expect(result).toMatchObject({ scanned: 1, deleted: 0, skippedByLock: false }); + expect(existsSync(protectedLog)).toBe(true); + expect(existsSync(malformedRecord)).toBe(true); + expect(existsSync(staleRecord)).toBe(true); + } finally { + await rm(registryDir, { recursive: true, force: true }); + setSimulatorLaunchOsLogRegistryDirForTests(null); + setSimulatorLaunchOsLogRecordActiveOverrideForTests(null); + } + }); + + it('ignores non-log files and unknown log files', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const note = path.join(logDir, 'note.txt'); + const unknownLog = writeLog('unknown.log', now - 4 * 24 * 60 * 60 * 1000); + const managedLog = writeLog( + managedXcodebuildLogName('old', 1234, 'abcdef12'), + now - 4 * 24 * 60 * 60 * 1000, + ); + writeFileSync(note, 'keep'); + + const result = await pruneLogDirectory({ logDir, now, force: true }); + + expect(result).toMatchObject({ scanned: 1, deleted: 1 }); + expect(existsSync(note)).toBe(true); + expect(existsSync(unknownLog)).toBe(true); + expect(existsSync(managedLog)).toBe(false); + }); +}); diff --git a/src/utils/__tests__/process-liveness.test.ts b/src/utils/__tests__/process-liveness.test.ts new file mode 100644 index 00000000..dfa01e0a --- /dev/null +++ b/src/utils/__tests__/process-liveness.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { isPidAlive } from '../process-liveness.ts'; + +describe('isPidAlive', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('rejects invalid pid values without signaling', () => { + const kill = vi.spyOn(process, 'kill'); + + expect(isPidAlive(0)).toBe(false); + expect(isPidAlive(-1)).toBe(false); + expect(isPidAlive(1.5)).toBe(false); + expect(kill).not.toHaveBeenCalled(); + }); + + it('returns false when signal zero reports the pid is missing', () => { + const kill = vi.spyOn(process, 'kill'); + kill.mockImplementation((() => { + const error = new Error('no such process') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + }) as typeof process.kill); + + expect(isPidAlive(123)).toBe(false); + }); + + it('returns true when signal zero reports permission denied', () => { + const kill = vi.spyOn(process, 'kill'); + kill.mockImplementation((() => { + const error = new Error('permission denied') as NodeJS.ErrnoException; + error.code = 'EPERM'; + throw error; + }) as typeof process.kill); + + expect(isPidAlive(123)).toBe(true); + }); + + it('returns true when signal zero succeeds', () => { + const kill = vi.spyOn(process, 'kill'); + kill.mockReturnValue(true); + + expect(isPidAlive(123)).toBe(true); + }); +}); diff --git a/src/utils/__tests__/simulator-launch-oslog-registry.test.ts b/src/utils/__tests__/simulator-launch-oslog-registry.test.ts index 4450a160..0faa2908 100644 --- a/src/utils/__tests__/simulator-launch-oslog-registry.test.ts +++ b/src/utils/__tests__/simulator-launch-oslog-registry.test.ts @@ -1,20 +1,28 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + listSimulatorLaunchOsLogProtectedPaths, listSimulatorLaunchOsLogRegistryRecords, + removeSimulatorLaunchOsLogRegistryRecord, setSimulatorLaunchOsLogRecordActiveOverrideForTests, setSimulatorLaunchOsLogRegistryDirForTests, writeSimulatorLaunchOsLogRegistryRecord, + type SimulatorLaunchOsLogRegistryRecord, } from '../log-capture/simulator-launch-oslog-registry.ts'; +import { + getWorkspaceFilesystemLayout, + setXcodeBuildMCPAppDirOverrideForTests, +} from '../log-paths.ts'; let registryDir: string; +let appDir: string; function createRecord( - overrides?: Partial[0]>, -) { + overrides: Partial = {}, +): SimulatorLaunchOsLogRegistryRecord { return { sessionId: 'session-1', owner: { instanceId: 'instance-1', pid: 1234, workspaceKey: 'workspace-a' }, @@ -31,7 +39,9 @@ function createRecord( describe.sequential('simulator launch OSLog registry', () => { beforeEach(() => { registryDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-oslog-registry-')); + appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-oslog-app-')); setSimulatorLaunchOsLogRegistryDirForTests(registryDir); + setXcodeBuildMCPAppDirOverrideForTests(appDir); setSimulatorLaunchOsLogRecordActiveOverrideForTests(async (record) => { return record.helperPid === process.pid && record.expectedCommandParts.includes('node'); }); @@ -40,7 +50,9 @@ describe.sequential('simulator launch OSLog registry', () => { afterEach(async () => { setSimulatorLaunchOsLogRecordActiveOverrideForTests(null); setSimulatorLaunchOsLogRegistryDirForTests(null); + setXcodeBuildMCPAppDirOverrideForTests(null); await rm(registryDir, { recursive: true, force: true }); + await rm(appDir, { recursive: true, force: true }); }); it('writes and lists valid records', async () => { @@ -55,17 +67,97 @@ describe.sequential('simulator launch OSLog registry', () => { ]); }); + it('writes new records under workspace state when no test registry override is set', async () => { + setSimulatorLaunchOsLogRegistryDirForTests(null); + + await writeSimulatorLaunchOsLogRegistryRecord(createRecord()); + + const workspaceRecordPath = path.join( + getWorkspaceFilesystemLayout('workspace-a').simulatorLaunchOsLogRegistryDir, + 'session-1.json', + ); + expect(existsSync(workspaceRecordPath)).toBe(true); + }); + + it('lists current workspace records while excluding other workspace state', async () => { + setSimulatorLaunchOsLogRegistryDirForTests(null); + await writeSimulatorLaunchOsLogRegistryRecord(createRecord({ sessionId: 'workspace-current' })); + await writeSimulatorLaunchOsLogRegistryRecord( + createRecord({ + sessionId: 'workspace-other', + owner: { instanceId: 'instance-2', pid: 1235, workspaceKey: 'workspace-b' }, + }), + ); + + await expect( + listSimulatorLaunchOsLogRegistryRecords({ workspaceKey: 'workspace-a' }), + ).resolves.toEqual([expect.objectContaining({ sessionId: 'workspace-current' })]); + }); + + it('removes only the requested workspace record', async () => { + setSimulatorLaunchOsLogRegistryDirForTests(null); + await writeSimulatorLaunchOsLogRegistryRecord(createRecord({ sessionId: 'same-session' })); + await writeSimulatorLaunchOsLogRegistryRecord( + createRecord({ + sessionId: 'same-session', + owner: { instanceId: 'instance-2', pid: 1235, workspaceKey: 'workspace-b' }, + }), + ); + + await removeSimulatorLaunchOsLogRegistryRecord({ + sessionId: 'same-session', + workspaceKey: 'workspace-a', + }); + + expect( + existsSync( + path.join( + getWorkspaceFilesystemLayout('workspace-a').simulatorLaunchOsLogRegistryDir, + 'same-session.json', + ), + ), + ).toBe(false); + expect( + existsSync( + path.join( + getWorkspaceFilesystemLayout('workspace-b').simulatorLaunchOsLogRegistryDir, + 'same-session.json', + ), + ), + ).toBe(true); + }); + it('prunes malformed registry files', async () => { writeFileSync(path.join(registryDir, 'broken.json'), '{not-json'); await expect(listSimulatorLaunchOsLogRegistryRecords()).resolves.toEqual([]); }); + it('lists protected log paths without mutating registry files', async () => { + const brokenPath = path.join(registryDir, 'broken.json'); + const staleRecordPath = path.join(registryDir, 'stale.json'); + writeFileSync(brokenPath, '{not-json'); + writeFileSync( + staleRecordPath, + `${JSON.stringify(createRecord({ sessionId: 'stale', helperPid: 999999 }))}\n`, + ); + await writeSimulatorLaunchOsLogRegistryRecord( + createRecord({ sessionId: 'active', logFilePath: '/tmp/active.log' }), + ); + + await expect(listSimulatorLaunchOsLogProtectedPaths()).resolves.toEqual( + new Set(['/tmp/active.log']), + ); + expect(existsSync(brokenPath)).toBe(true); + expect(existsSync(staleRecordPath)).toBe(true); + }); + it('prunes records without owner workspace keys', async () => { + const missingWorkspaceRecord = createRecord(); writeFileSync( path.join(registryDir, 'missing-workspace.json'), `${JSON.stringify({ - ...createRecord(), + ...missingWorkspaceRecord, owner: { instanceId: 'instance-1', pid: 1234 }, })}\n`, ); diff --git a/src/utils/__tests__/simulator-launch-oslog-sessions.test.ts b/src/utils/__tests__/simulator-launch-oslog-sessions.test.ts index c1a991d8..572f0ac8 100644 --- a/src/utils/__tests__/simulator-launch-oslog-sessions.test.ts +++ b/src/utils/__tests__/simulator-launch-oslog-sessions.test.ts @@ -394,7 +394,7 @@ describe('simulator launch OSLog sessions', () => { await expect(listActiveSimulatorLaunchOsLogSessions()).resolves.toEqual([]); }); - it('does not reconcile sessions from a different workspace', async () => { + it('does not scan or reconcile sessions from a different workspace', async () => { const otherWorkspaceChild = createMockChild({ pid: 1001 }); setRuntimeInstanceForTests({ instanceId: 'dead-instance', @@ -417,11 +417,11 @@ describe('simulator launch OSLog sessions', () => { const result = await reconcileSimulatorLaunchOsLogOrphansForWorkspace('workspace-a', 1); expect(result).toEqual({ - scannedSessionCount: 1, + scannedSessionCount: 0, eligibleOrphanCount: 0, stoppedSessionCount: 0, skippedLiveOwnerCount: 0, - skippedDifferentWorkspaceCount: 1, + skippedDifferentWorkspaceCount: 0, errorCount: 0, errors: [], }); diff --git a/src/utils/__tests__/simulator-steps-pid.test.ts b/src/utils/__tests__/simulator-steps-pid.test.ts index 3c570a09..8a240426 100644 --- a/src/utils/__tests__/simulator-steps-pid.test.ts +++ b/src/utils/__tests__/simulator-steps-pid.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import type { ChildProcess, SpawnOptions } from 'node:child_process'; import { EventEmitter } from 'node:events'; +import * as fs from 'node:fs'; import { mkdtempSync, writeFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -9,6 +10,7 @@ import { launchSimulatorAppWithLogging, setSimulatorLogDirOverrideForTests, } from '../simulator-steps.ts'; +import { getWorkspaceFilesystemLayout } from '../log-paths.ts'; import type { CommandExecutor } from '../CommandExecutor.ts'; import { setRuntimeInstanceForTests } from '../runtime-instance.ts'; import { @@ -110,6 +112,26 @@ describe.sequential('launchSimulatorAppWithLogging PID resolution', () => { expect(result.processId).toBe(42567); }); + it('writes logs under the current workspace log directory when no test override is set', async () => { + setSimulatorLogDirOverrideForTests(null); + const spawner = createMockSpawner(); + const executor = createMockExecutor(42567); + + const result = await launchSimulatorAppWithLogging( + 'test-sim-uuid', + 'com.example.app', + executor, + undefined, + { spawner }, + ); + + expect(result.success).toBe(true); + expect(result.logFilePath).toContain(getWorkspaceFilesystemLayout('workspace-a').logs); + expect(result.logFilePath).toContain('_helperpid90000_ownerpid'); + expect(result.osLogPath).toContain(getWorkspaceFilesystemLayout('workspace-a').logs); + expect(result.osLogPath).toContain('_helperpid90001_ownerpid'); + }); + it('returns undefined processId when executor returns no PID', async () => { const spawner = createMockSpawner(); const executor = createMockExecutor(); @@ -165,6 +187,55 @@ describe.sequential('launchSimulatorAppWithLogging PID resolution', () => { expect(result.success).toBe(false); }); + it('kills and unrefs the launch helper if helper log rename fails', async () => { + const children: ChildProcess[] = []; + const spawner = (_command: string, _args: string[], _options: SpawnOptions): ChildProcess => { + const child = createMockChild(null); + children.push(child); + fs.chmodSync(logDir, 0o500); + return child; + }; + + const result = await launchSimulatorAppWithLogging( + 'test-sim-uuid', + 'com.example.app', + createMockExecutor(42567), + undefined, + { spawner }, + ); + fs.chmodSync(logDir, 0o700); + + expect(result.success).toBe(false); + expect(children[0].kill).toHaveBeenCalledWith('SIGTERM'); + expect(children[0].unref).toHaveBeenCalledOnce(); + }); + + it('kills and unrefs the OSLog helper if helper log rename fails', async () => { + const children: ChildProcess[] = []; + const spawner = (_command: string, _args: string[], _options: SpawnOptions): ChildProcess => { + const child = createMockChild(null); + children.push(child); + if (children.length === 2) { + fs.chmodSync(logDir, 0o500); + } + return child; + }; + + const result = await launchSimulatorAppWithLogging( + 'test-sim-uuid', + 'com.example.app', + createMockExecutor(42567), + undefined, + { spawner }, + ); + fs.chmodSync(logDir, 0o700); + + expect(result.success).toBe(true); + expect(result.osLogPath).toBeUndefined(); + expect(children[1].kill).toHaveBeenCalledWith('SIGTERM'); + expect(children[1].unref).toHaveBeenCalledOnce(); + }); + it('registers a tracked OSLog session after launch', async () => { const spawner = createMockSpawner(); const executor = createMockExecutor(42567); @@ -232,6 +303,7 @@ describe.sequential('launchSimulatorAppWithLogging PID resolution', () => { expect(result.success).toBe(true); expect(result.osLogPath).toBeUndefined(); expect(children[1].kill).toHaveBeenCalledWith('SIGTERM'); + expect(children[1].unref).toHaveBeenCalledOnce(); await expect(listActiveSimulatorLaunchOsLogSessions()).resolves.toEqual([]); }); }); diff --git a/src/utils/__tests__/snapshot-normalize.test.ts b/src/utils/__tests__/snapshot-normalize.test.ts index 8dd17e76..33b1acb3 100644 --- a/src/utils/__tests__/snapshot-normalize.test.ts +++ b/src/utils/__tests__/snapshot-normalize.test.ts @@ -3,9 +3,12 @@ import { normalizeSnapshotOutput } from '../../snapshot-tests/normalize.ts'; describe('normalizeSnapshotOutput tilde handling', () => { it('normalizes ~/ paths to /', () => { - const input = 'Derived Data: ~/Library/Developer/XcodeBuildMCP/DerivedData\n'; + const input = + 'Workspace Logs: ~/Library/Developer/XcodeBuildMCP/workspaces/Weather-abc123def456/logs\n'; const result = normalizeSnapshotOutput(input); - expect(result).toContain('/Library/Developer/XcodeBuildMCP/DerivedData'); + expect(result).toContain( + '/Library/Developer/XcodeBuildMCP/workspaces/Weather-abc123def456/logs', + ); expect(result).not.toContain('~/'); }); @@ -40,15 +43,45 @@ describe('normalizeSnapshotOutput tilde handling', () => { ); }); - it('normalizes scoped XcodeBuildMCP DerivedData hashes', () => { + it('normalizes workspace-scoped log paths without flattening the workspace layout', () => { + const input = [ + 'Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/Weather-abc123def456/logs/build_sim_2026-05-02T12-00-00-000Z_pid1234_abcd1234.log', + 'Runtime Logs: /Library/Developer/XcodeBuildMCP/workspaces/Weather-abc123def456/logs/io.app_2026-05-02T12-00-00-000Z_helperpid1234_ownerpid5678_abcd1234.log', + '', + ].join('\n'); + + const result = normalizeSnapshotOutput(input); + + expect(result).toContain( + 'Build Logs: /Library/Developer/XcodeBuildMCP/workspaces/Weather-/logs/build_sim__pid.log', + ); + expect(result).toContain( + 'Runtime Logs: /Library/Developer/XcodeBuildMCP/workspaces/Weather-/logs/io.app__pid.log', + ); + }); + + it('normalizes workspace-scoped XcodeBuildMCP DerivedData hashes', () => { const input = - 'Derived Data: /Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-22d700c6d603\n'; + 'Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/Weather-abc123def456/DerivedData/CalculatorApp-22d700c6d603\n'; const result = normalizeSnapshotOutput(input); expect(result).toContain( - '/Library/Developer/XcodeBuildMCP/DerivedData/CalculatorApp-', + '/Library/Developer/XcodeBuildMCP/workspaces/Weather-/DerivedData/CalculatorApp-', ); + expect(result).not.toContain('Weather-abc123def456'); expect(result).not.toContain('22d700c6d603'); }); + + it('normalizes workspace-scoped DerivedData root with no trailing path', () => { + const input = + 'Derived Data: /Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-c5da0cbe19a7/DerivedData\n'; + + const result = normalizeSnapshotOutput(input); + + expect(result).toContain( + '/Library/Developer/XcodeBuildMCP/workspaces/XcodeBuildMCP-/DerivedData\n', + ); + expect(result).not.toContain('c5da0cbe19a7'); + }); }); diff --git a/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts b/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts new file mode 100644 index 00000000..0570fa3d --- /dev/null +++ b/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts @@ -0,0 +1,227 @@ +import { EventEmitter } from 'node:events'; +import type { ChildProcess } from 'node:child_process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync, utimesSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { + resetWorkspaceFilesystemLifecycleStateForTests, + runWorkspaceFilesystemLifecycleSweep, + scheduleWorkspaceFilesystemLifecycleSweep, +} from '../workspace-filesystem-lifecycle.ts'; +import { + getWorkspaceFilesystemLayout, + setXcodeBuildMCPAppDirOverrideForTests, +} from '../log-paths.ts'; +import { writeDaemonRegistryEntry } from '../../daemon/daemon-registry.ts'; +import { setRuntimeInstanceForTests } from '../runtime-instance.ts'; +import { + clearAllSimulatorLaunchOsLogSessionsForTests, + registerSimulatorLaunchOsLogSession, +} from '../log-capture/simulator-launch-oslog-sessions.ts'; +import { setSimulatorLaunchOsLogRecordActiveOverrideForTests } from '../log-capture/simulator-launch-oslog-registry.ts'; + +let appDir: string; + +function writeFileWithMtime(filePath: string, content: string, mtimeMs: number): void { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, content); + const mtime = new Date(mtimeMs); + utimesSync(filePath, mtime, mtime); +} + +function managedXcodebuildLogName(name = 'build_sim'): string { + return `${name}_2026-05-02T12-00-00-000Z_pid123_abcdef12.log`; +} + +function createTrackedChild(pid: number, onKill: () => void): ChildProcess { + const child = new EventEmitter() as ChildProcess; + Object.defineProperty(child, 'pid', { value: pid, configurable: true }); + Object.defineProperty(child, 'exitCode', { value: null, writable: true, configurable: true }); + child.kill = vi.fn(() => { + onKill(); + return true; + }) as ChildProcess['kill']; + return child; +} + +describe('workspace filesystem lifecycle', () => { + beforeEach(() => { + appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-filesystem-lifecycle-')); + setXcodeBuildMCPAppDirOverrideForTests(appDir); + setRuntimeInstanceForTests({ + instanceId: 'filesystem-lifecycle-test', + pid: process.pid, + workspaceKey: 'workspace-a', + }); + resetWorkspaceFilesystemLifecycleStateForTests(); + }); + + afterEach(async () => { + resetWorkspaceFilesystemLifecycleStateForTests(); + setSimulatorLaunchOsLogRecordActiveOverrideForTests(null); + await clearAllSimulatorLaunchOsLogSessionsForTests(); + setRuntimeInstanceForTests(null); + setXcodeBuildMCPAppDirOverrideForTests(null); + await rm(appDir, { recursive: true, force: true }); + }); + + it('prunes only known workspace log files and never scans DerivedData', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const oldLog = path.join(layout.logs, managedXcodebuildLogName()); + const derivedDataLog = path.join(layout.derivedData, 'Build', 'old.log'); + writeFileWithMtime(oldLog, 'old', now - 4 * 24 * 60 * 60 * 1000); + writeFileWithMtime(derivedDataLog, 'xcode-owned', now - 4 * 24 * 60 * 60 * 1000); + + const result = await runWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'manual', + now, + force: true, + minVisibleMs: 0, + }); + + expect(result).toMatchObject({ scanned: 1, deleted: 1, skippedByLock: false }); + expect(existsSync(oldLog)).toBe(false); + expect(existsSync(derivedDataLog)).toBe(true); + }); + + it('protects active daemon logs through the existing daemon registry', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const daemonLog = path.join(layout.logs, 'daemon.log'); + const oldLog = path.join(layout.logs, managedXcodebuildLogName()); + writeFileWithMtime(daemonLog, 'active daemon', now - 4 * 24 * 60 * 60 * 1000); + writeFileWithMtime(oldLog, 'old', now - 4 * 24 * 60 * 60 * 1000); + writeDaemonRegistryEntry({ + workspaceKey: 'workspace-a', + workspaceRoot: '/tmp/workspace-a', + socketPath: '/tmp/xcodebuildmcp.sock', + logPath: daemonLog, + pid: process.pid, + startedAt: new Date(now).toISOString(), + enabledWorkflows: [], + version: 'test', + }); + + const result = await runWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'manual', + now, + force: true, + minVisibleMs: 0, + }); + + expect(result).toMatchObject({ scanned: 2, deleted: 1 }); + expect(existsSync(daemonLog)).toBe(true); + expect(existsSync(oldLog)).toBe(false); + }); + + it('runs startup OSLog reconciliation even when log retention is cooling down', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + let helperActive = true; + const child = createTrackedChild(901, () => { + helperActive = false; + }); + const sessionId = await registerSimulatorLaunchOsLogSession({ + process: child, + simulatorUuid: 'sim-1', + bundleId: 'io.sentry.app', + logFilePath: path.join(layout.logs, 'oslog.log'), + }); + writeFileSync( + path.join(layout.simulatorLaunchOsLogRegistryDir, `${sessionId}.json`), + `${JSON.stringify({ + sessionId, + owner: { instanceId: 'dead-owner', pid: 999999999, workspaceKey: 'workspace-a' }, + simulatorUuid: 'sim-1', + bundleId: 'io.sentry.app', + helperPid: 901, + logFilePath: path.join(layout.logs, 'oslog.log'), + startedAtMs: now, + expectedCommandParts: ['node'], + })}\n`, + ); + setSimulatorLaunchOsLogRecordActiveOverrideForTests(async () => helperActive); + writeFileWithMtime(layout.filesystemLifecycle.markerPath, String(now), now); + + const result = await runWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'startup', + now: now + 1000, + timeoutMs: 1, + }); + + expect(result).toMatchObject({ stopped: 1, skippedByCooldown: true, scanned: 0 }); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('preserves unknown log files while pruning known generated logs', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const unknownLog = path.join(layout.logs, 'unknown.log'); + const knownLog = path.join(layout.logs, managedXcodebuildLogName()); + writeFileWithMtime(unknownLog, 'unknown', now - 4 * 24 * 60 * 60 * 1000); + writeFileWithMtime(knownLog, 'known', now - 4 * 24 * 60 * 60 * 1000); + + const result = await runWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'manual', + now, + force: true, + minVisibleMs: 0, + }); + + expect(result).toMatchObject({ scanned: 1, deleted: 1 }); + expect(existsSync(unknownLog)).toBe(true); + expect(existsSync(knownLog)).toBe(false); + }); + + it('cooldowns repeat schedule calls for the same workspace', () => { + vi.useFakeTimers(); + try { + scheduleWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'artifact-created', + }); + const firstCount = vi.getTimerCount(); + + scheduleWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'artifact-created', + }); + scheduleWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'artifact-created', + }); + + expect(firstCount).toBe(1); + expect(vi.getTimerCount()).toBe(1); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it('uses the lifecycle lock to skip a held same-workspace sweep', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('workspace-a'); + const oldLog = path.join(layout.logs, managedXcodebuildLogName()); + writeFileWithMtime(oldLog, 'old', now - 4 * 24 * 60 * 60 * 1000); + mkdirSync(layout.filesystemLifecycle.lockDir, { recursive: true }); + + const result = await runWorkspaceFilesystemLifecycleSweep({ + workspaceKey: 'workspace-a', + trigger: 'manual', + now, + force: true, + minVisibleMs: 0, + }); + + expect(result).toMatchObject({ skippedByLock: true, scanned: 0, deleted: 0 }); + expect(existsSync(oldLog)).toBe(true); + }); +}); diff --git a/src/utils/__tests__/workspace-identity.test.ts b/src/utils/__tests__/workspace-identity.test.ts new file mode 100644 index 00000000..2b72ddc8 --- /dev/null +++ b/src/utils/__tests__/workspace-identity.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import * as path from 'node:path'; +import { + resolveWorkspaceIdentity, + resolveWorkspaceRoot, + workspaceKeyForRoot, +} from '../workspace-identity.ts'; + +describe('workspace identity', () => { + it('uses the project root when a project config path is available', () => { + const workspaceRoot = path.join('/repo', 'app'); + const projectConfigPath = path.join(workspaceRoot, '.xcodebuildmcp', 'config.yaml'); + + expect(resolveWorkspaceRoot({ cwd: '/elsewhere', projectConfigPath })).toBe(workspaceRoot); + expect(resolveWorkspaceIdentity({ cwd: '/elsewhere', projectConfigPath })).toEqual({ + workspaceRoot, + workspaceKey: workspaceKeyForRoot(workspaceRoot), + }); + }); + + it('uses cwd when no project config path is available', () => { + const workspaceRoot = '/definitely-not-a-real-workspace-root'; + + expect(resolveWorkspaceIdentity({ cwd: workspaceRoot })).toEqual({ + workspaceRoot, + workspaceKey: workspaceKeyForRoot(workspaceRoot), + }); + }); + + it('prefixes the workspace key with a filesystem-safe workspace name', () => { + const key = workspaceKeyForRoot('/Users/dev/My Weather App!'); + + expect(key).toMatch(/^My-Weather-App-[a-f0-9]{12}$/); + }); + + it('falls back to a generic name when the root has no usable basename', () => { + const key = workspaceKeyForRoot('/'); + + expect(key).toMatch(/^workspace-[a-f0-9]{12}$/); + }); +}); diff --git a/src/utils/__tests__/xcodebuild-event-parser.test.ts b/src/utils/__tests__/xcodebuild-event-parser.test.ts index f9cfa5e4..abfbcfe4 100644 --- a/src/utils/__tests__/xcodebuild-event-parser.test.ts +++ b/src/utils/__tests__/xcodebuild-event-parser.test.ts @@ -662,7 +662,7 @@ describe('xcodebuild-event-parser', () => { ]); }); - it('counts additional failures reported only by a Swift Testing summary', () => { + it('defers Swift Testing failure progress until the run summary', () => { const events = collectEvents('TEST', [ { source: 'stdout', @@ -675,10 +675,7 @@ describe('xcodebuild-event-parser', () => { ]); const progress = events.filter((event) => event.fragment === 'test-progress'); - expect(progress).toEqual([ - expect.objectContaining({ completed: 1, failed: 1, skipped: 0 }), - expect.objectContaining({ completed: 2, failed: 2, skipped: 0 }), - ]); + expect(progress).toEqual([expect.objectContaining({ completed: 2, failed: 2, skipped: 0 })]); }); it('keeps parameterized Swift Testing result counts aligned with the run summary', () => { diff --git a/src/utils/__tests__/xcodebuild-line-parsers.test.ts b/src/utils/__tests__/xcodebuild-line-parsers.test.ts index 2b76b96a..63b39db9 100644 --- a/src/utils/__tests__/xcodebuild-line-parsers.test.ts +++ b/src/utils/__tests__/xcodebuild-line-parsers.test.ts @@ -94,4 +94,10 @@ describe('parseRawTestName', () => { testName: 'test', }); }); + + it('keeps display names ending in a period as test names', () => { + expect(parseRawTestName('Decimal point at start creates 0.')).toEqual({ + testName: 'Decimal point at start creates 0.', + }); + }); }); diff --git a/src/utils/__tests__/xcodebuild-log-capture.test.ts b/src/utils/__tests__/xcodebuild-log-capture.test.ts new file mode 100644 index 00000000..9c965921 --- /dev/null +++ b/src/utils/__tests__/xcodebuild-log-capture.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdtempSync } from 'node:fs'; +import { readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { + createLogCapture, + createParserDebugCapture, + setXcodebuildLogDirOverrideForTests, +} from '../xcodebuild-log-capture.ts'; +import { resetWorkspaceFilesystemLifecycleStateForTests } from '../workspace-filesystem-lifecycle.ts'; +import { getWorkspaceFilesystemLayout } from '../log-paths.ts'; +import { setRuntimeInstanceForTests } from '../runtime-instance.ts'; + +let logDir: string; + +describe('xcodebuild log capture', () => { + beforeEach(() => { + logDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-log-capture-')); + setXcodebuildLogDirOverrideForTests(logDir); + setRuntimeInstanceForTests({ + instanceId: 'capture-test', + pid: process.pid, + workspaceKey: 'workspace-a', + }); + resetWorkspaceFilesystemLifecycleStateForTests(); + }); + + afterEach(async () => { + setXcodebuildLogDirOverrideForTests(null); + setRuntimeInstanceForTests(null); + resetWorkspaceFilesystemLifecycleStateForTests(); + await rm(logDir, { recursive: true, force: true }); + }); + + it('does not create a file before the first write', () => { + const capture = createLogCapture('build_sim'); + + expect(capture.path).toContain(logDir); + expect(existsSync(capture.path)).toBe(false); + + capture.close(); + + expect(existsSync(capture.path)).toBe(false); + }); + + it('uses the current workspace log directory when no test override is set', () => { + setXcodebuildLogDirOverrideForTests(null); + + const capture = createLogCapture('build_sim'); + + expect(capture.path).toContain(getWorkspaceFilesystemLayout('workspace-a').logs); + capture.close(); + }); + + it('creates the file on first non-empty write', async () => { + const capture = createLogCapture('build_sim'); + + capture.write('CompileSwift normal arm64 /tmp/App.swift\n'); + capture.close(); + + await expect(readFile(capture.path, 'utf-8')).resolves.toBe( + 'CompileSwift normal arm64 /tmp/App.swift\n', + ); + }); + + it('ignores empty writes', () => { + const capture = createLogCapture('build_sim'); + + capture.write(''); + capture.close(); + + expect(existsSync(capture.path)).toBe(false); + }); + + it('writes parser debug logs to the resolved log directory', async () => { + const capture = createParserDebugCapture('build_sim'); + capture.addUnrecognizedLine('unexpected output'); + + const debugPath = capture.flush(); + + expect(debugPath).not.toBeNull(); + expect(debugPath).toContain(logDir); + await expect(readFile(debugPath as string, 'utf-8')).resolves.toContain('unexpected output'); + }); +}); diff --git a/src/utils/__tests__/xcresult-test-failures.test.ts b/src/utils/__tests__/xcresult-test-failures.test.ts new file mode 100644 index 00000000..ef62ad6b --- /dev/null +++ b/src/utils/__tests__/xcresult-test-failures.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { parseXcresultFailureMessage } from '../xcresult-test-failures.ts'; + +describe('parseXcresultFailureMessage', () => { + it('preserves locations from multi-line Swift Testing failure messages', () => { + const parsed = parseXcresultFailureMessage( + 'CalculatorServiceTests.swift:37: Expectation failed: (calculator.display → "0") == "999": // This test is designed to fail to test error reporting\n' + + 'This should fail - display should be 0, not 999', + ); + + expect(parsed).toEqual({ + location: 'CalculatorServiceTests.swift:37', + message: + 'Expectation failed: (calculator.display → "0") == "999"\n' + + '// This test is designed to fail to test error reporting\n' + + 'This should fail - display should be 0, not 999', + }); + }); + + it('strips xcresult failure prefixes without inventing a zero-line location', () => { + expect(parseXcresultFailureMessage('AppTests.swift:0: failed - setup failed')).toEqual({ + message: 'setup failed', + }); + }); +}); diff --git a/src/utils/derived-data-path.ts b/src/utils/derived-data-path.ts index 77828c60..8bad179e 100644 --- a/src/utils/derived-data-path.ts +++ b/src/utils/derived-data-path.ts @@ -1,7 +1,8 @@ -import * as crypto from 'node:crypto'; import * as path from 'node:path'; -import { DERIVED_DATA_DIR } from './log-paths.ts'; +import { getWorkspaceFilesystemLayout } from './log-paths.ts'; import { resolvePathFromCwd } from './path.ts'; +import { getRuntimeInstanceIfConfigured } from './runtime-instance.ts'; +import { shortWorkspaceHash, workspaceKeyForRoot } from './workspace-identity.ts'; export type DerivedDataPathInput = { derivedDataPath?: string | null; @@ -14,11 +15,19 @@ function getNonEmptyPath(pathValue?: string | null): string | undefined { return pathValue && pathValue.trim().length > 0 ? pathValue : undefined; } +function resolveWorkspaceDerivedDataRoot(cwd: string): string { + const workspaceKey = getRuntimeInstanceIfConfigured()?.workspaceKey ?? workspaceKeyForRoot(cwd); + return getWorkspaceFilesystemLayout(workspaceKey).derivedData; +} + export function computeScopedDerivedDataPath(anchorPath: string, cwd?: string): string { - const resolved = resolvePathFromCwd(anchorPath, cwd); - const hash = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 12); + const resolvedCwd = cwd ?? process.cwd(); + const resolved = resolvePathFromCwd(anchorPath, resolvedCwd); const name = path.basename(resolved, path.extname(resolved)); - return path.join(DERIVED_DATA_DIR, `${name}-${hash}`); + return path.join( + resolveWorkspaceDerivedDataRoot(resolvedCwd), + `${name}-${shortWorkspaceHash(resolved)}`, + ); } export function resolveEffectiveDerivedDataPath(input: DerivedDataPathInput = {}): string { @@ -38,5 +47,5 @@ export function resolveEffectiveDerivedDataPath(input: DerivedDataPathInput = {} return computeScopedDerivedDataPath(projectPath, cwd); } - return DERIVED_DATA_DIR; + return resolveWorkspaceDerivedDataRoot(cwd); } diff --git a/src/utils/fs-lock-shared.ts b/src/utils/fs-lock-shared.ts new file mode 100644 index 00000000..e62bfb67 --- /dev/null +++ b/src/utils/fs-lock-shared.ts @@ -0,0 +1,43 @@ +export interface FsLockOwner { + token: string; + pid: number; + purpose: string; + acquiredAtMs: number; + expiresAtMs: number; +} + +export const FS_LOCK_OWNER_FILE = 'owner.json'; + +export function isFsLockOwner(value: unknown): value is FsLockOwner { + if (typeof value !== 'object' || value === null) { + return false; + } + const owner = value as Partial; + return ( + typeof owner.token === 'string' && + owner.token.length > 0 && + typeof owner.pid === 'number' && + Number.isInteger(owner.pid) && + owner.pid > 0 && + typeof owner.purpose === 'string' && + owner.purpose.length > 0 && + typeof owner.acquiredAtMs === 'number' && + Number.isFinite(owner.acquiredAtMs) && + typeof owner.expiresAtMs === 'number' && + Number.isFinite(owner.expiresAtMs) + ); +} + +export function fsLockOwnersEqual(left: FsLockOwner | null, right: FsLockOwner): boolean { + return ( + left?.token === right.token && + left.pid === right.pid && + left.purpose === right.purpose && + left.acquiredAtMs === right.acquiredAtMs && + left.expiresAtMs === right.expiresAtMs + ); +} + +export function guardDirForLockDir(lockDir: string): string { + return `${lockDir}.guard`; +} diff --git a/src/utils/fs-lock-sync.ts b/src/utils/fs-lock-sync.ts new file mode 100644 index 00000000..b10b18e5 --- /dev/null +++ b/src/utils/fs-lock-sync.ts @@ -0,0 +1,199 @@ +import { randomUUID } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; +import { + FS_LOCK_OWNER_FILE, + fsLockOwnersEqual, + guardDirForLockDir, + isFsLockOwner, + type FsLockOwner, +} from './fs-lock-shared.ts'; +import { isPidAlive } from './process-liveness.ts'; + +export interface AcquiredFsLockSync { + readonly owner: FsLockOwner; + release(): void; +} + +export interface TryAcquireFsLockSyncOptions { + lockDir: string; + purpose: string; + leaseMs: number; + now?: number; + pid?: number; +} + +function readLockOwnerSync(lockDir: string): FsLockOwner | null { + try { + const raw = readFileSync(join(lockDir, FS_LOCK_OWNER_FILE), 'utf8'); + const parsed = JSON.parse(raw) as unknown; + return isFsLockOwner(parsed) ? parsed : null; + } catch { + return null; + } +} + +function isDirectoryOlderThan(dir: string, now: number, ageMs: number): boolean { + try { + return now - statSync(dir).mtimeMs > ageMs; + } catch { + return false; + } +} + +function shouldRecoverLockDir( + lockDir: string, + purpose: string, + now: number, + leaseMs: number, +): { recover: false } | { recover: true; owner: FsLockOwner | null } { + const staleOwner = readLockOwnerSync(lockDir); + if (!staleOwner) { + return isDirectoryOlderThan(lockDir, now, leaseMs) + ? { recover: true, owner: null } + : { recover: false }; + } + if ( + staleOwner.purpose !== purpose || + staleOwner.expiresAtMs > now || + isPidAlive(staleOwner.pid) + ) { + return { recover: false }; + } + return { recover: true, owner: staleOwner }; +} + +function tryRecoverExpiredLockDir( + lockDir: string, + purpose: string, + now: number, + leaseMs: number, +): boolean { + const recovery = shouldRecoverLockDir(lockDir, purpose, now, leaseMs); + if (!recovery.recover) { + return false; + } + + const quarantineDir = join( + dirname(lockDir), + `.${basename(lockDir)}.stale.${process.pid}.${randomUUID()}`, + ); + try { + renameSync(lockDir, quarantineDir); + } catch { + return false; + } + + if (recovery.owner) { + const quarantinedOwner = readLockOwnerSync(quarantineDir); + if (!fsLockOwnersEqual(quarantinedOwner, recovery.owner)) { + try { + renameSync(quarantineDir, lockDir); + } catch { + // Leave quarantined dir intact rather than deleting a lock we could not validate. + } + return false; + } + } + + rmSync(quarantineDir, { recursive: true, force: true }); + return true; +} + +function createLock(lockDir: string, owner: FsLockOwner): AcquiredFsLockSync { + mkdirSync(lockDir, { mode: 0o700 }); + try { + writeFileSync(join(lockDir, FS_LOCK_OWNER_FILE), `${JSON.stringify(owner)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + } catch (error) { + rmSync(lockDir, { recursive: true, force: true }); + throw error; + } + + return { + owner, + release(): void { + const currentOwner = readLockOwnerSync(lockDir); + if (currentOwner?.token !== owner.token) { + return; + } + rmSync(lockDir, { recursive: true, force: true }); + }, + }; +} + +function tryAcquireGuard( + lockDir: string, + purpose: string, + leaseMs: number, + now: number, +): AcquiredFsLockSync | null { + const guardDir = guardDirForLockDir(lockDir); + const guardOwner: FsLockOwner = { + token: randomUUID(), + pid: process.pid, + purpose: `${purpose}:guard`, + acquiredAtMs: now, + expiresAtMs: now + leaseMs, + }; + + try { + return createLock(guardDir, guardOwner); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + return null; + } + if (!tryRecoverExpiredLockDir(guardDir, guardOwner.purpose, now, leaseMs)) { + return null; + } + try { + return createLock(guardDir, guardOwner); + } catch { + return null; + } + } +} + +export function tryAcquireFsLockSync( + options: TryAcquireFsLockSyncOptions, +): AcquiredFsLockSync | null { + const now = options.now ?? Date.now(); + const owner: FsLockOwner = { + token: randomUUID(), + pid: options.pid ?? process.pid, + purpose: options.purpose, + acquiredAtMs: now, + expiresAtMs: now + options.leaseMs, + }; + + try { + mkdirSync(dirname(options.lockDir), { recursive: true, mode: 0o700 }); + const guard = tryAcquireGuard(options.lockDir, options.purpose, options.leaseMs, now); + if (!guard) { + return null; + } + + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + return createLock(options.lockDir, owner); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + return null; + } + if (!tryRecoverExpiredLockDir(options.lockDir, options.purpose, now, options.leaseMs)) { + return null; + } + } + } + } finally { + guard.release(); + } + } catch { + return null; + } + + return null; +} diff --git a/src/utils/fs-lock.ts b/src/utils/fs-lock.ts new file mode 100644 index 00000000..8d0660f3 --- /dev/null +++ b/src/utils/fs-lock.ts @@ -0,0 +1,233 @@ +import { randomUUID } from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { isPidAlive } from './process-liveness.ts'; +import { + FS_LOCK_OWNER_FILE, + fsLockOwnersEqual, + guardDirForLockDir, + isFsLockOwner, + type FsLockOwner, +} from './fs-lock-shared.ts'; + +export { FS_LOCK_OWNER_FILE, type FsLockOwner } from './fs-lock-shared.ts'; + +export interface AcquiredFsLock { + readonly owner: FsLockOwner; + release(): Promise; +} + +export interface TryAcquireFsLockOptions { + lockDir: string; + purpose: string; + leaseMs: number; + now?: number; + pid?: number; +} + +async function readLockOwner(lockDir: string): Promise { + try { + const raw = await fs.readFile(path.join(lockDir, FS_LOCK_OWNER_FILE), 'utf8'); + const parsed = JSON.parse(raw) as unknown; + return isFsLockOwner(parsed) ? parsed : null; + } catch { + return null; + } +} + +async function removeLockDir(lockDir: string): Promise { + try { + await fs.rm(lockDir, { recursive: true, force: true }); + } catch { + // ignore + } +} + +async function isDirectoryOlderThan(dir: string, now: number, ageMs: number): Promise { + try { + const stat = await fs.stat(dir); + return now - stat.mtimeMs > ageMs; + } catch { + return false; + } +} + +async function quarantineLockDir(lockDir: string): Promise { + const quarantineDir = path.join( + path.dirname(lockDir), + `.${path.basename(lockDir)}.stale.${process.pid}.${randomUUID()}`, + ); + + try { + await fs.rename(lockDir, quarantineDir); + return quarantineDir; + } catch { + return null; + } +} + +async function restoreQuarantinedLockDir(quarantineDir: string, lockDir: string): Promise { + try { + await fs.rename(quarantineDir, lockDir); + } catch { + // Another contender may already have acquired the lock. Leave the quarantined + // directory intact rather than deleting a lock we could not validate. + } +} + +async function shouldRecoverLockDir( + lockDir: string, + purpose: string, + now: number, + leaseMs: number, +): Promise<{ recover: false } | { recover: true; owner: FsLockOwner | null }> { + const staleOwner = await readLockOwner(lockDir); + if (!staleOwner) { + return (await isDirectoryOlderThan(lockDir, now, leaseMs)) + ? { recover: true, owner: null } + : { recover: false }; + } + if ( + staleOwner.purpose !== purpose || + staleOwner.expiresAtMs > now || + isPidAlive(staleOwner.pid) + ) { + return { recover: false }; + } + return { recover: true, owner: staleOwner }; +} + +async function tryRecoverExpiredLockDir( + lockDir: string, + purpose: string, + now: number, + leaseMs: number, +): Promise { + const recovery = await shouldRecoverLockDir(lockDir, purpose, now, leaseMs); + if (!recovery.recover) { + return false; + } + + const quarantineDir = await quarantineLockDir(lockDir); + if (!quarantineDir) { + return false; + } + + if (recovery.owner) { + const quarantinedOwner = await readLockOwner(quarantineDir); + if (!fsLockOwnersEqual(quarantinedOwner, recovery.owner)) { + await restoreQuarantinedLockDir(quarantineDir, lockDir); + return false; + } + } + + await removeLockDir(quarantineDir); + return true; +} + +async function createLock(lockDir: string, owner: FsLockOwner): Promise { + await fs.mkdir(lockDir, { mode: 0o700 }); + try { + await fs.writeFile(path.join(lockDir, FS_LOCK_OWNER_FILE), `${JSON.stringify(owner)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + } catch (error) { + await removeLockDir(lockDir); + throw error; + } + + return { + owner, + async release(): Promise { + const currentOwner = await readLockOwner(lockDir); + if (currentOwner?.token !== owner.token) { + return; + } + await fs.rm(lockDir, { recursive: true, force: true }); + }, + }; +} + +async function tryAcquireGuard( + lockDir: string, + purpose: string, + leaseMs: number, + now: number, +): Promise { + const guardDir = guardDirForLockDir(lockDir); + const guardOwner: FsLockOwner = { + token: randomUUID(), + pid: process.pid, + purpose: `${purpose}:guard`, + acquiredAtMs: now, + expiresAtMs: now + leaseMs, + }; + + try { + return await createLock(guardDir, guardOwner); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + return null; + } + const recovered = await tryRecoverExpiredLockDir(guardDir, guardOwner.purpose, now, leaseMs); + if (!recovered) { + return null; + } + try { + return await createLock(guardDir, guardOwner); + } catch { + return null; + } + } +} + +export async function tryAcquireFsLock( + options: TryAcquireFsLockOptions, +): Promise { + const now = options.now ?? Date.now(); + const owner: FsLockOwner = { + token: randomUUID(), + pid: options.pid ?? process.pid, + purpose: options.purpose, + acquiredAtMs: now, + expiresAtMs: now + options.leaseMs, + }; + + try { + await fs.mkdir(path.dirname(options.lockDir), { recursive: true, mode: 0o700 }); + const guard = await tryAcquireGuard(options.lockDir, options.purpose, options.leaseMs, now); + if (!guard) { + return null; + } + + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + return await createLock(options.lockDir, owner); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EEXIST') { + return null; + } + + const recovered = await tryRecoverExpiredLockDir( + options.lockDir, + options.purpose, + now, + options.leaseMs, + ); + if (!recovered) { + return null; + } + } + } + } finally { + await guard.release(); + } + } catch { + return null; + } + + return null; +} diff --git a/src/utils/log-capture/simulator-launch-oslog-registry.ts b/src/utils/log-capture/simulator-launch-oslog-registry.ts index 58279d18..2a900ff5 100644 --- a/src/utils/log-capture/simulator-launch-oslog-registry.ts +++ b/src/utils/log-capture/simulator-launch-oslog-registry.ts @@ -1,9 +1,11 @@ import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; import * as path from 'node:path'; import { promisify } from 'node:util'; -import { SIMULATOR_LAUNCH_OSLOG_REGISTRY_DIR } from '../log-paths.ts'; +import { getWorkspaceFilesystemLayout, getWorkspacesDir } from '../log-paths.ts'; import type { RuntimeInstance } from '../runtime-instance.ts'; +import { getRuntimeInstanceIfConfigured } from '../runtime-instance.ts'; const execFileAsync = promisify(execFile); const PROCESS_SAMPLE_CHUNK_SIZE = 100; @@ -19,21 +21,43 @@ export interface SimulatorLaunchOsLogRegistryRecord { expectedCommandParts: string[]; } +interface RegistryEntry { + filePath: string; + record: SimulatorLaunchOsLogRegistryRecord; +} + +interface InvalidRegistryEntry { + filePath: string; +} + +interface RegistryDirectoryReadResult { + entries: RegistryEntry[]; + invalidEntries: InvalidRegistryEntry[]; +} + +interface ListRegistryRecordsOptions { + workspaceKey?: string; + includeAllWorkspaces?: boolean; +} + let registryDirOverride: string | null = null; let recordActiveOverrideForTests: | ((record: SimulatorLaunchOsLogRegistryRecord) => Promise) | null = null; -function getRegistryDir(): string { - return registryDirOverride ?? SIMULATOR_LAUNCH_OSLOG_REGISTRY_DIR; +function getWorkspaceRegistryDir(workspaceKey: string): string { + return ( + registryDirOverride ?? + getWorkspaceFilesystemLayout(workspaceKey).simulatorLaunchOsLogRegistryDir + ); } -function getRegistryPath(sessionId: string): string { - return path.join(getRegistryDir(), `${sessionId}.json`); +function getWorkspaceRegistryPath(sessionId: string, workspaceKey: string): string { + return path.join(getWorkspaceRegistryDir(workspaceKey), `${sessionId}.json`); } -async function ensureRegistryDir(): Promise { - await fs.mkdir(getRegistryDir(), { recursive: true, mode: 0o700 }); +async function ensureRegistryDir(dir: string): Promise { + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); } function isRecord(value: unknown): value is SimulatorLaunchOsLogRegistryRecord { @@ -80,6 +104,102 @@ async function removeRegistryPaths(paths: string[]): Promise { ); } +async function readRegistryRecordFile( + filePath: string, +): Promise { + try { + const content = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(content) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +async function readRegistryDirectory(dir: string): Promise { + let candidatePaths: string[]; + try { + const dirEntries = await fs.readdir(dir, { withFileTypes: true }); + candidatePaths = dirEntries + .filter((dirEntry) => dirEntry.isFile() && dirEntry.name.endsWith('.json')) + .map((dirEntry) => path.join(dir, dirEntry.name)); + } catch { + return { entries: [], invalidEntries: [] }; + } + + const records = await Promise.all(candidatePaths.map(readRegistryRecordFile)); + + const entries: RegistryEntry[] = []; + const invalidEntries: InvalidRegistryEntry[] = []; + for (const [index, record] of records.entries()) { + const filePath = candidatePaths[index]; + if (record) { + entries.push({ filePath, record }); + } else { + invalidEntries.push({ filePath }); + } + } + + return { entries, invalidEntries }; +} + +async function listWorkspaceRegistryDirs(): Promise { + if (registryDirOverride) { + return [registryDirOverride]; + } + + const workspacesRoot = getWorkspacesDir(); + try { + const workspaceEntries = await fs.readdir(workspacesRoot, { withFileTypes: true }); + return workspaceEntries + .filter((entry) => entry.isDirectory()) + .map((entry) => getWorkspaceRegistryDir(entry.name)); + } catch { + return []; + } +} + +async function resolveRegistryDirsForRead(options: ListRegistryRecordsOptions): Promise { + if (registryDirOverride) { + return [registryDirOverride]; + } + + const dirs: string[] = []; + + if (options.includeAllWorkspaces) { + dirs.push(...(await listWorkspaceRegistryDirs())); + } else { + const workspaceKey = options.workspaceKey ?? getRuntimeInstanceIfConfigured()?.workspaceKey; + if (workspaceKey) { + dirs.push(getWorkspaceRegistryDir(workspaceKey)); + } + } + + const seen = new Set(); + return dirs.filter((dir) => { + if (seen.has(dir)) { + return false; + } + seen.add(dir); + return true; + }); +} + +async function readSimulatorLaunchOsLogRegistryEntries( + options: ListRegistryRecordsOptions = {}, +): Promise { + const entries: RegistryEntry[] = []; + const invalidEntries: InvalidRegistryEntry[] = []; + + for (const dir of await resolveRegistryDirsForRead(options)) { + const result = await readRegistryDirectory(dir); + entries.push(...result.entries); + invalidEntries.push(...result.invalidEntries); + } + + return { entries, invalidEntries }; +} + async function sampleProcessCommands(pids: number[]): Promise | null> { if (pids.length === 0) { return new Map(); @@ -141,18 +261,28 @@ function commandMatchesRecord( export async function writeSimulatorLaunchOsLogRegistryRecord( record: SimulatorLaunchOsLogRegistryRecord, ): Promise { - await ensureRegistryDir(); - const destinationPath = getRegistryPath(record.sessionId); - const tempPath = `${destinationPath}.${process.pid}.${Date.now()}.tmp`; - await fs.writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`, { - encoding: 'utf8', - mode: 0o600, - }); - await fs.rename(tempPath, destinationPath); + const registryDir = getWorkspaceRegistryDir(record.owner.workspaceKey); + await ensureRegistryDir(registryDir); + const destinationPath = getWorkspaceRegistryPath(record.sessionId, record.owner.workspaceKey); + const tempPath = `${destinationPath}.${process.pid}.${randomUUID()}.tmp`; + try { + await fs.writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); + await fs.rename(tempPath, destinationPath); + } catch (error) { + await fs.unlink(tempPath).catch(() => undefined); + throw error; + } } -export async function removeSimulatorLaunchOsLogRegistryRecord(sessionId: string): Promise { - await removeRegistryPaths([getRegistryPath(sessionId)]); +export async function removeSimulatorLaunchOsLogRegistryRecord(params: { + sessionId: string; + workspaceKey: string; +}): Promise { + await removeRegistryPaths([getWorkspaceRegistryPath(params.sessionId, params.workspaceKey)]); } async function isRecordActive(record: SimulatorLaunchOsLogRegistryRecord): Promise { @@ -168,13 +298,13 @@ async function isRecordActive(record: SimulatorLaunchOsLogRegistryRecord): Promi } function partitionRecordsByCommandMatch( - entries: Array<{ filePath: string; record: SimulatorLaunchOsLogRegistryRecord }>, + entries: RegistryEntry[], commandsByPid: Map, ): { - activeEntries: Array<{ filePath: string; record: SimulatorLaunchOsLogRegistryRecord }>; + activeEntries: RegistryEntry[]; stalePaths: string[]; } { - const activeEntries: Array<{ filePath: string; record: SimulatorLaunchOsLogRegistryRecord }> = []; + const activeEntries: RegistryEntry[] = []; const stalePaths: string[] = []; for (const entry of entries) { @@ -188,56 +318,65 @@ function partitionRecordsByCommandMatch( return { activeEntries, stalePaths }; } -export async function listSimulatorLaunchOsLogRegistryRecords(): Promise< - SimulatorLaunchOsLogRegistryRecord[] -> { - try { - await ensureRegistryDir(); - } catch { - return []; +export async function listSimulatorLaunchOsLogProtectedPaths( + options: ListRegistryRecordsOptions = {}, +): Promise> { + const { entries } = await readSimulatorLaunchOsLogRegistryEntries(options); + const protectedPaths = new Set(); + if (entries.length === 0) { + return protectedPaths; } - const entries: Array<{ filePath: string; record: SimulatorLaunchOsLogRegistryRecord }> = []; - const invalidPaths: string[] = []; - - try { - const dirEntries = await fs.readdir(getRegistryDir(), { withFileTypes: true }); - for (const dirEntry of dirEntries) { - if (!dirEntry.isFile() || !dirEntry.name.endsWith('.json')) { - continue; - } - - const filePath = path.join(getRegistryDir(), dirEntry.name); - try { - const content = await fs.readFile(filePath, 'utf8'); - const parsed = JSON.parse(content) as unknown; - if (!isRecord(parsed)) { - invalidPaths.push(filePath); - continue; + if (!recordActiveOverrideForTests) { + const commandsByPid = await sampleProcessCommands( + entries.map((entry) => entry.record.helperPid), + ); + if (commandsByPid !== null) { + for (const entry of entries) { + if (commandMatchesRecord(commandsByPid.get(entry.record.helperPid), entry.record)) { + protectedPaths.add(entry.record.logFilePath); } - entries.push({ filePath, record: parsed }); - } catch { - invalidPaths.push(filePath); } + return protectedPaths; } - } catch { - return []; } - if (invalidPaths.length > 0) { - await removeRegistryPaths(invalidPaths); + for (const entry of entries) { + if (await isRecordActive(entry.record)) { + protectedPaths.add(entry.record.logFilePath); + } } - if (entries.length === 0) { + return protectedPaths; +} + +export async function listSimulatorLaunchOsLogRegistryRecords( + options: ListRegistryRecordsOptions = {}, +): Promise { + const { entries, invalidEntries } = await readSimulatorLaunchOsLogRegistryEntries(options); + const scopedEntries = + options.workspaceKey && !options.includeAllWorkspaces + ? entries.filter((entry) => entry.record.owner.workspaceKey === options.workspaceKey) + : entries; + + const invalidPathsToRemove = invalidEntries.map((entry) => entry.filePath); + if (invalidPathsToRemove.length > 0) { + await removeRegistryPaths(invalidPathsToRemove); + } + + if (scopedEntries.length === 0) { return []; } if (!recordActiveOverrideForTests) { const commandsByPid = await sampleProcessCommands( - entries.map((entry) => entry.record.helperPid), + scopedEntries.map((entry) => entry.record.helperPid), ); if (commandsByPid !== null) { - const { activeEntries, stalePaths } = partitionRecordsByCommandMatch(entries, commandsByPid); + const { activeEntries, stalePaths } = partitionRecordsByCommandMatch( + scopedEntries, + commandsByPid, + ); if (stalePaths.length > 0) { await removeRegistryPaths(stalePaths); } @@ -246,8 +385,8 @@ export async function listSimulatorLaunchOsLogRegistryRecords(): Promise< } const stalePaths: string[] = []; - const activeEntries: Array<{ filePath: string; record: SimulatorLaunchOsLogRegistryRecord }> = []; - for (const entry of entries) { + const activeEntries: RegistryEntry[] = []; + for (const entry of scopedEntries) { if (await isRecordActive(entry.record)) { activeEntries.push(entry); continue; @@ -286,7 +425,14 @@ export function compareOsLogSortKeys(left: OsLogSortKey, right: OsLogSortKey): n export async function clearSimulatorLaunchOsLogRegistryForTests(): Promise { try { - await fs.rm(getRegistryDir(), { recursive: true, force: true }); + if (registryDirOverride) { + await fs.rm(registryDirOverride, { recursive: true, force: true }); + return; + } + + for (const dir of await listWorkspaceRegistryDirs()) { + await fs.rm(dir, { recursive: true, force: true }); + } } catch { // Ignore cleanup failures in tests. } diff --git a/src/utils/log-capture/simulator-launch-oslog-sessions.ts b/src/utils/log-capture/simulator-launch-oslog-sessions.ts index ad0ef7b5..4e6e13c6 100644 --- a/src/utils/log-capture/simulator-launch-oslog-sessions.ts +++ b/src/utils/log-capture/simulator-launch-oslog-sessions.ts @@ -2,6 +2,7 @@ import type { ChildProcess } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { acquireDaemonActivity } from '../../daemon/activity-registry.ts'; import { getRuntimeInstance, getRuntimeInstanceIfConfigured } from '../runtime-instance.ts'; +import { isPidAlive } from '../process-liveness.ts'; import { clearSimulatorLaunchOsLogRegistryForTests, compareOsLogSortKeys, @@ -21,6 +22,7 @@ export interface SimulatorLaunchOsLogSession { simulatorUuid: string; bundleId: string; logFilePath: string; + workspaceKey: string; startedAt: Date; hasEnded: boolean; releaseActivity?: () => void; @@ -49,6 +51,10 @@ export interface SimulatorLaunchOsLogReconciliationResult { const activeSimulatorLaunchOsLogSessions = new Map(); let ownerPidAliveOverrideForTests: ((pid: number) => boolean) | null = null; +function zeroStopResult(): { stoppedSessionCount: number; errorCount: number; errors: string[] } { + return { stoppedSessionCount: 0, errorCount: 0, errors: [] }; +} + function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -76,14 +82,7 @@ function isOwnerPidAlive(pid: number): boolean { if (ownerPidAliveOverrideForTests) { return ownerPidAliveOverrideForTests(pid); } - - try { - process.kill(pid, 0); - return true; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - return code !== 'ESRCH'; - } + return isPidAlive(pid); } function finalizeLiveSession(sessionId: string, session: SimulatorLaunchOsLogSession): void { @@ -101,7 +100,10 @@ function finalizeLiveSession(sessionId: string, session: SimulatorLaunchOsLogSes function handleLocalProcessExit(sessionId: string, session: SimulatorLaunchOsLogSession): void { finalizeLiveSession(sessionId, session); - void removeSimulatorLaunchOsLogRegistryRecord(sessionId).catch(() => { + void removeSimulatorLaunchOsLogRegistryRecord({ + sessionId, + workspaceKey: session.workspaceKey, + }).catch(() => { // Best-effort cleanup; future reads prune stale records. }); } @@ -125,7 +127,10 @@ async function confirmRecordStopped( record: SimulatorLaunchOsLogRegistryRecord, liveSession: SimulatorLaunchOsLogSession | undefined, ): Promise { - await removeSimulatorLaunchOsLogRegistryRecord(record.sessionId); + await removeSimulatorLaunchOsLogRegistryRecord({ + sessionId: record.sessionId, + workspaceKey: record.owner.workspaceKey, + }); if (liveSession) { finalizeLiveSession(record.sessionId, liveSession); } @@ -206,6 +211,7 @@ export async function registerSimulatorLaunchOsLogSession(params: { throw new Error('Simulator launch OSLog process did not provide a valid pid'); } + const owner = getRuntimeInstance(); const sessionId = randomUUID(); const session: SimulatorLaunchOsLogSession = { sessionId, @@ -213,6 +219,7 @@ export async function registerSimulatorLaunchOsLogSession(params: { simulatorUuid: params.simulatorUuid, bundleId: params.bundleId, logFilePath: params.logFilePath, + workspaceKey: owner.workspaceKey, startedAt: new Date(), hasEnded: false, releaseActivity: acquireDaemonActivity('logging.simulator.launch-oslog'), @@ -233,7 +240,7 @@ export async function registerSimulatorLaunchOsLogSession(params: { try { await writeSimulatorLaunchOsLogRegistryRecord({ sessionId, - owner: getRuntimeInstance(), + owner, simulatorUuid: params.simulatorUuid, bundleId: params.bundleId, helperPid, @@ -255,8 +262,9 @@ export async function listActiveSimulatorLaunchOsLogSessions(): Promise< if (!currentInstance) { return []; } - return (await listSimulatorLaunchOsLogRegistryRecords()) - .filter((record) => record.owner.workspaceKey === currentInstance.workspaceKey) + return ( + await listSimulatorLaunchOsLogRegistryRecords({ workspaceKey: currentInstance.workspaceKey }) + ) .map((record) => toSummary(record, currentInstance.instanceId)) .sort(compareOsLogSortKeys); } @@ -266,16 +274,19 @@ export async function getActiveSimulatorLaunchOsLogSessionCount(): Promise record.owner.workspaceKey === currentInstance.workspaceKey, + return ( + await listSimulatorLaunchOsLogRegistryRecords({ + workspaceKey: currentInstance.workspaceKey, + }) ).length; } async function stopMatchingRecords( predicate: (record: SimulatorLaunchOsLogRegistryRecord) => boolean, timeoutMs: number, + listOptions?: Parameters[0], ): Promise<{ stoppedSessionCount: number; errorCount: number; errors: string[] }> { - const records = (await listSimulatorLaunchOsLogRegistryRecords()).filter(predicate); + const records = (await listSimulatorLaunchOsLogRegistryRecords(listOptions)).filter(predicate); const errors: string[] = []; for (const record of records) { @@ -301,7 +312,7 @@ export async function stopSimulatorLaunchOsLogSessionsForApp( ): Promise<{ stoppedSessionCount: number; errorCount: number; errors: string[] }> { const currentInstance = getRuntimeInstanceIfConfigured(); if (!currentInstance) { - return { stoppedSessionCount: 0, errorCount: 0, errors: [] }; + return zeroStopResult(); } return stopMatchingRecords( (record) => @@ -313,6 +324,7 @@ export async function stopSimulatorLaunchOsLogSessionsForApp( currentInstance.instanceId, ), timeoutMs, + { workspaceKey: currentInstance.workspaceKey }, ); } @@ -321,25 +333,26 @@ export async function stopOwnedSimulatorLaunchOsLogSessions( ): Promise<{ stoppedSessionCount: number; errorCount: number; errors: string[] }> { const currentInstance = getRuntimeInstanceIfConfigured(); if (!currentInstance) { - return { stoppedSessionCount: 0, errorCount: 0, errors: [] }; + return zeroStopResult(); } return stopMatchingRecords( (record) => record.owner.instanceId === currentInstance.instanceId, timeoutMs, + { workspaceKey: currentInstance.workspaceKey }, ); } export async function stopAllSimulatorLaunchOsLogSessions( timeoutMs = 1000, ): Promise<{ stoppedSessionCount: number; errorCount: number; errors: string[] }> { - return stopMatchingRecords(() => true, timeoutMs); + return stopMatchingRecords(() => true, timeoutMs, { includeAllWorkspaces: true }); } export async function reconcileSimulatorLaunchOsLogOrphansForWorkspace( workspaceKey: string, timeoutMs = 1000, ): Promise { - const records = await listSimulatorLaunchOsLogRegistryRecords(); + const records = await listSimulatorLaunchOsLogRegistryRecords({ workspaceKey }); const errors: string[] = []; let eligibleOrphanCount = 0; let stoppedSessionCount = 0; diff --git a/src/utils/log-naming.ts b/src/utils/log-naming.ts new file mode 100644 index 00000000..fa0b0c6e --- /dev/null +++ b/src/utils/log-naming.ts @@ -0,0 +1,9 @@ +import { randomUUID } from 'node:crypto'; + +export function formatLogTimestamp(now: Date = new Date()): string { + return now.toISOString().replace(/[:.]/g, '-'); +} + +export function shortRandomSuffix(): string { + return randomUUID().slice(0, 8); +} diff --git a/src/utils/log-paths.ts b/src/utils/log-paths.ts index 605a385f..2e0c3273 100644 --- a/src/utils/log-paths.ts +++ b/src/utils/log-paths.ts @@ -2,7 +2,77 @@ import * as path from 'node:path'; import * as os from 'node:os'; export const APP_DIR = path.join(os.homedir(), 'Library', 'Developer', 'XcodeBuildMCP'); -export const STATE_DIR = path.join(APP_DIR, 'state'); -export const LOG_DIR = path.join(APP_DIR, 'logs'); -export const DERIVED_DATA_DIR = path.join(APP_DIR, 'DerivedData'); -export const SIMULATOR_LAUNCH_OSLOG_REGISTRY_DIR = path.join(STATE_DIR, 'simulator-launch-oslog'); + +let appDirOverrideForTests: string | null = null; + +export interface LogRetentionPaths { + lockDir: string; + markerPath: string; +} + +export interface WorkspaceFilesystemLifecyclePaths { + lockDir: string; + markerPath: string; +} + +export interface WorkspaceFilesystemLayout { + workspaceKey: string; + root: string; + logs: string; + state: string; + locks: string; + derivedData: string; + logRetention: LogRetentionPaths; + filesystemLifecycle: WorkspaceFilesystemLifecyclePaths; + simulatorLaunchOsLogRegistryDir: string; +} + +export function getXcodeBuildMCPAppDir(): string { + return appDirOverrideForTests ?? APP_DIR; +} + +export function getWorkspacesDir(): string { + return path.join(getXcodeBuildMCPAppDir(), 'workspaces'); +} + +function normalizeWorkspaceKey(workspaceKey: string): string { + const normalized = workspaceKey.trim(); + if (!normalized) { + throw new Error('Workspace key cannot be empty'); + } + if (normalized.includes('/') || normalized.includes('\\')) { + throw new Error(`Workspace key cannot contain path separators: ${workspaceKey}`); + } + return normalized; +} + +export function getWorkspaceFilesystemLayout(workspaceKey: string): WorkspaceFilesystemLayout { + const normalizedWorkspaceKey = normalizeWorkspaceKey(workspaceKey); + const root = path.join(getWorkspacesDir(), normalizedWorkspaceKey); + const logs = path.join(root, 'logs'); + const state = path.join(root, 'state'); + const locks = path.join(root, 'locks'); + const derivedData = path.join(root, 'DerivedData'); + + return { + workspaceKey: normalizedWorkspaceKey, + root, + logs, + state, + locks, + derivedData, + logRetention: { + lockDir: path.join(locks, 'log-retention.lock'), + markerPath: path.join(state, 'log-retention', 'last-cleanup'), + }, + filesystemLifecycle: { + lockDir: path.join(locks, 'filesystem-lifecycle.lock'), + markerPath: path.join(state, 'filesystem-lifecycle', 'last-cleanup'), + }, + simulatorLaunchOsLogRegistryDir: path.join(state, 'simulator-launch-oslog'), + }; +} + +export function setXcodeBuildMCPAppDirOverrideForTests(dir: string | null): void { + appDirOverrideForTests = dir; +} diff --git a/src/utils/process-liveness.ts b/src/utils/process-liveness.ts new file mode 100644 index 00000000..480c3b38 --- /dev/null +++ b/src/utils/process-liveness.ts @@ -0,0 +1,13 @@ +export function isPidAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code !== 'ESRCH'; + } +} diff --git a/src/utils/renderers/__tests__/event-formatting.test.ts b/src/utils/renderers/__tests__/event-formatting.test.ts index ae2d54ec..64442d62 100644 --- a/src/utils/renderers/__tests__/event-formatting.test.ts +++ b/src/utils/renderers/__tests__/event-formatting.test.ts @@ -35,7 +35,10 @@ describe('event formatting', () => { { label: 'Scheme', value: 'MyApp' }, { label: '-only-testing', value: 'MyAppTests/MyAppTests/testLaunch' }, { label: '-skip-testing', value: 'MyAppTests/MyAppTests/testFlaky' }, - { label: 'Derived Data', value: '~/Library/Developer/XcodeBuildMCP/DerivedData' }, + { + label: 'Derived Data', + value: '~/Library/Developer/XcodeBuildMCP/workspaces/abc123/DerivedData', + }, ], }), ).toBe( @@ -43,7 +46,7 @@ describe('event formatting', () => { '\u{1F9EA} Test', '', ' Scheme: MyApp', - ' Derived Data: ~/Library/Developer/XcodeBuildMCP/DerivedData', + ' Derived Data: ~/Library/Developer/XcodeBuildMCP/workspaces/abc123/DerivedData', ' Selective Testing:', ' MyAppTests/MyAppTests/testLaunch', ' Skip Testing: MyAppTests/MyAppTests/testFlaky', diff --git a/src/utils/runtime-instance.ts b/src/utils/runtime-instance.ts index 1f3f6096..b39d5898 100644 --- a/src/utils/runtime-instance.ts +++ b/src/utils/runtime-instance.ts @@ -1,39 +1,37 @@ import { randomUUID } from 'node:crypto'; +const DEFAULT_WORKSPACE_KEY = 'default'; + export interface RuntimeInstance { instanceId: string; pid: number; workspaceKey: string; } +let configuredWorkspaceKey: string | null = null; let runtimeInstance: RuntimeInstance | null = null; -let runtimeWorkspaceKey: string | null = null; export function configureRuntimeWorkspaceKey(workspaceKey: string): void { - const normalizedWorkspaceKey = workspaceKey.trim(); - if (normalizedWorkspaceKey.length === 0) { - throw new Error('Runtime workspace key cannot be empty'); + const normalized = workspaceKey.trim(); + if (!normalized) { + throw new Error('Workspace key cannot be empty'); } - - runtimeWorkspaceKey = normalizedWorkspaceKey; + configuredWorkspaceKey = normalized; if (runtimeInstance) { - runtimeInstance = { ...runtimeInstance, workspaceKey: normalizedWorkspaceKey }; + runtimeInstance = { ...runtimeInstance, workspaceKey: normalized }; } } export function getRuntimeInstance(): RuntimeInstance { - if (runtimeInstance) { - return runtimeInstance; - } - - if (!runtimeWorkspaceKey) { + const workspaceKey = configuredWorkspaceKey; + if (!workspaceKey) { throw new Error('Runtime workspace key has not been configured'); } - runtimeInstance = { + runtimeInstance ??= { instanceId: randomUUID(), pid: process.pid, - workspaceKey: runtimeWorkspaceKey, + workspaceKey, }; return runtimeInstance; } @@ -42,13 +40,23 @@ export function getRuntimeInstanceIfConfigured(): RuntimeInstance | null { if (runtimeInstance) { return runtimeInstance; } - if (!runtimeWorkspaceKey) { + if (!configuredWorkspaceKey) { return null; } return getRuntimeInstance(); } -export function setRuntimeInstanceForTests(instance: RuntimeInstance | null): void { - runtimeInstance = instance; - runtimeWorkspaceKey = instance?.workspaceKey ?? null; +export function setRuntimeInstanceForTests( + instance: + | (Omit & Partial>) + | null, +): void { + runtimeInstance = instance + ? { + instanceId: instance.instanceId, + pid: instance.pid, + workspaceKey: instance.workspaceKey ?? configuredWorkspaceKey ?? DEFAULT_WORKSPACE_KEY, + } + : null; + configuredWorkspaceKey = runtimeInstance?.workspaceKey ?? null; } diff --git a/src/utils/sentry.ts b/src/utils/sentry.ts index 992ab865..c31af55b 100644 --- a/src/utils/sentry.ts +++ b/src/utils/sentry.ts @@ -376,7 +376,8 @@ export interface McpShutdownSummaryEvent { exitCode: number; transportDisconnected: boolean; triggerError?: string; - cleanupFailureCount: number; + shutdownStepFailureCount: number; + cleanupDiagnosticCount?: number; shutdownDurationMs: number; snapshot: Record; steps: Array>; @@ -415,7 +416,7 @@ export function captureMcpShutdownSummary(summary: McpShutdownSummaryEvent): voi let level: 'error' | 'warning' | 'info'; if (isCrashReason) { level = 'error'; - } else if (summary.cleanupFailureCount > 0 || localAnomalyCount > 0) { + } else if (summary.shutdownStepFailureCount > 0 || localAnomalyCount > 0) { level = 'warning'; } else { level = 'info'; @@ -433,7 +434,8 @@ export function captureMcpShutdownSummary(summary: McpShutdownSummaryEvent): voi exitCode: summary.exitCode, transportDisconnected: summary.transportDisconnected, triggerError: summary.triggerError, - cleanupFailureCount: summary.cleanupFailureCount, + shutdownStepFailureCount: summary.shutdownStepFailureCount, + cleanupDiagnosticCount: summary.cleanupDiagnosticCount, shutdownDurationMs: summary.shutdownDurationMs, snapshot: summary.snapshot, steps: summary.steps, diff --git a/src/utils/simulator-steps.ts b/src/utils/simulator-steps.ts index b32338aa..22196267 100644 --- a/src/utils/simulator-steps.ts +++ b/src/utils/simulator-steps.ts @@ -5,7 +5,10 @@ import { log } from './logging/index.ts'; import { toErrorMessage } from './errors.ts'; import type { CommandExecutor } from './CommandExecutor.ts'; import { normalizeSimctlChildEnv } from './environment.ts'; -import { LOG_DIR } from './log-paths.ts'; +import { getWorkspaceFilesystemLayout } from './log-paths.ts'; +import { getRuntimeInstance } from './runtime-instance.ts'; +import { scheduleArtifactCreatedSweep } from './workspace-filesystem-lifecycle.ts'; +import { formatLogTimestamp, shortRandomSuffix } from './log-naming.ts'; import { registerSimulatorLaunchOsLogSession, stopSimulatorLaunchOsLogSessionsForApp, @@ -13,8 +16,18 @@ import { let logDirOverrideForTests: string | null = null; -function formatLogTimestamp(): string { - return new Date().toISOString().replace(/:/g, '-').replace('.', '-'); +interface ResolvedSimulatorLogDir { + path: string; + isOverride: boolean; +} + +function resolveSimulatorLogDir(): ResolvedSimulatorLogDir { + return { + path: + logDirOverrideForTests ?? + getWorkspaceFilesystemLayout(getRuntimeInstance().workspaceKey).logs, + isOverride: logDirOverrideForTests !== null, + }; } export interface StepResult { @@ -165,15 +178,19 @@ export async function launchSimulatorAppWithLogging( ): Promise { const spawner = deps?.spawner ?? spawn; - const logsDir = logDirOverrideForTests ?? LOG_DIR; + const logsDir = resolveSimulatorLogDir(); const ts = formatLogTimestamp(); - const logFileName = `${bundleId}_${ts}_pid${process.pid}.log`; - const logFilePath = path.join(logsDir, logFileName); + const suffix = shortRandomSuffix(); + let logFilePath = path.join( + logsDir.path, + `${bundleId}_${ts}_ownerpid${process.pid}_${suffix}.log`, + ); let fd: number | undefined; try { - fs.mkdirSync(logsDir, { recursive: true }); - fd = fs.openSync(logFilePath, 'w'); + fs.mkdirSync(logsDir.path, { recursive: true }); + scheduleArtifactCreatedSweep(logsDir); + fd = fs.openSync(logFilePath, 'wx'); const args = [ 'simctl', @@ -196,6 +213,13 @@ export async function launchSimulatorAppWithLogging( } const child = spawner('xcrun', args, spawnOpts); + if (child.pid && Number.isInteger(child.pid)) { + const helperLogFilePath = path.join( + logsDir.path, + `${bundleId}_${ts}_helperpid${child.pid}_ownerpid${process.pid}_${suffix}.log`, + ); + logFilePath = renameHelperLogPathOrThrow(logFilePath, helperLogFilePath, child); + } child.unref(); fs.closeSync(fd); fd = undefined; @@ -261,6 +285,34 @@ async function resolveAppPidViaLaunch( return pidMatch ? parseInt(pidMatch[1], 10) : undefined; } +function stopDetachedHelper(child: ChildProcess): void { + try { + child.kill?.('SIGTERM'); + } catch { + // Best-effort cleanup for detached helpers. + } + try { + child.unref(); + } catch { + // Best-effort event-loop release for detached helpers. + } +} + +function renameHelperLogPathOrThrow( + currentPath: string, + helperPath: string, + child: ChildProcess, +): string { + try { + fs.renameSync(currentPath, helperPath); + return helperPath; + } catch (error) { + stopDetachedHelper(child); + const message = toErrorMessage(error); + throw new Error(`Failed to move log file to helper-pid protected path: ${message}`); + } +} + function readLogFileSafe(filePath: string): string { try { return fs.readFileSync(filePath, 'utf-8'); @@ -272,11 +324,15 @@ function readLogFileSafe(filePath: string): string { async function startTrackedOsLogStream( simulatorUuid: string, bundleId: string, - logsDir: string, + logsDir: ResolvedSimulatorLogDir, spawner: ProcessSpawner, ): Promise { const ts = formatLogTimestamp(); - const osLogFilePath = path.join(logsDir, `${bundleId}_oslog_${ts}_pid${process.pid}.log`); + const suffix = shortRandomSuffix(); + let osLogFilePath = path.join( + logsDir.path, + `${bundleId}_oslog_${ts}_ownerpid${process.pid}_${suffix}.log`, + ); let fd: number | undefined; try { @@ -293,7 +349,8 @@ async function startTrackedOsLogStream( return undefined; } - fd = fs.openSync(osLogFilePath, 'w'); + scheduleArtifactCreatedSweep(logsDir); + fd = fs.openSync(osLogFilePath, 'wx'); const child = spawner( 'xcrun', @@ -312,6 +369,13 @@ async function startTrackedOsLogStream( detached: true, }, ); + if (child.pid && Number.isInteger(child.pid)) { + const helperOsLogFilePath = path.join( + logsDir.path, + `${bundleId}_oslog_${ts}_helperpid${child.pid}_ownerpid${process.pid}_${suffix}.log`, + ); + osLogFilePath = renameHelperLogPathOrThrow(osLogFilePath, helperOsLogFilePath, child); + } try { await registerSimulatorLaunchOsLogSession({ @@ -321,11 +385,7 @@ async function startTrackedOsLogStream( logFilePath: osLogFilePath, }); } catch (error) { - try { - child.kill?.('SIGTERM'); - } catch { - // Best-effort cleanup after failed durable registration. - } + stopDetachedHelper(child); throw error; } diff --git a/src/utils/workspace-filesystem-lifecycle.ts b/src/utils/workspace-filesystem-lifecycle.ts new file mode 100644 index 00000000..2ea1c69c --- /dev/null +++ b/src/utils/workspace-filesystem-lifecycle.ts @@ -0,0 +1,526 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { cleanupWorkspaceDaemonFiles, readDaemonRegistryEntry } from '../daemon/daemon-registry.ts'; +import { getWorkspaceFilesystemLayout } from './log-paths.ts'; +import { listSimulatorLaunchOsLogProtectedPaths } from './log-capture/simulator-launch-oslog-registry.ts'; +import { + reconcileSimulatorLaunchOsLogOrphansForWorkspace, + stopOwnedSimulatorLaunchOsLogSessions, + terminateLiveSimulatorLaunchOsLogSessionsSync, +} from './log-capture/simulator-launch-oslog-sessions.ts'; +import { log } from './logging/index.ts'; +import { getRuntimeInstance, getRuntimeInstanceIfConfigured } from './runtime-instance.ts'; +import { tryAcquireFsLock } from './fs-lock.ts'; +import { isPidAlive } from './process-liveness.ts'; + +export const WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; +export const WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_FILES = 10_000; +export const WORKSPACE_FILESYSTEM_LIFECYCLE_COOLDOWN_MS = 60 * 60 * 1000; +export const WORKSPACE_FILESYSTEM_LIFECYCLE_SCHEDULE_DELAY_MS = 250; +export const WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS = 60 * 60 * 1000; +export const WORKSPACE_FILESYSTEM_LIFECYCLE_LOCK_LEASE_MS = 10 * 60 * 1000; + +const FALLBACK_MARKER_FILE = '.last-cleanup'; +const FALLBACK_LOCK_DIR_NAME = '.filesystem-lifecycle.lock'; +const runningScheduledSweeps = new Set(); +const lastScheduledAtByScope = new Map(); +const lastScheduledAtByPreKey = new Map(); + +const HELPER_PID_PATTERN = /(?:^|_)helperpid(\d+)(?:_|\.|$)/g; +const ISO_TIMESTAMP_PATTERN = '\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z'; +const SUFFIX_PATTERN = '[a-f0-9]{8}'; +const XCODEBUILD_LOG_NAME_PATTERN = new RegExp( + `^[A-Za-z0-9][A-Za-z0-9_-]*_${ISO_TIMESTAMP_PATTERN}_pid\\d+_${SUFFIX_PATTERN}\\.log$`, +); +const SIMULATOR_LOG_NAME_PATTERN = new RegExp( + `^.+_${ISO_TIMESTAMP_PATTERN}_(?:helperpid\\d+_)?ownerpid\\d+_${SUFFIX_PATTERN}\\.log$`, +); + +export type WorkspaceFilesystemLifecycleTrigger = + | 'startup' + | 'artifact-created' + | 'shutdown' + | 'force-stop' + | 'manual'; + +export interface WorkspaceFilesystemLifecycleOptions { + workspaceKey?: string; + trigger: WorkspaceFilesystemLifecycleTrigger; + logDir?: string; + markerPath?: string; + lockDir?: string; + now?: number; + maxAgeMs?: number; + maxFiles?: number; + cooldownMs?: number; + force?: boolean; + minVisibleMs?: number; + protectedLogPaths?: string[]; + timeoutMs?: number; + lockPurpose?: string; + daemonCleanup?: { + socketPath?: string; + pid?: number; + instanceId?: string; + allowLiveOwner?: boolean; + }; +} + +export interface WorkspaceFilesystemLifecycleResult { + workspaceKey: string; + trigger: WorkspaceFilesystemLifecycleTrigger; + logDir: string; + scanned: number; + deleted: number; + stopped: number; + skippedByCooldown: boolean; + skippedByLock: boolean; + errors: string[]; +} + +interface ResolvedWorkspaceFilesystemLifecycleOptions { + workspaceKey: string; + trigger: WorkspaceFilesystemLifecycleTrigger; + logDir: string; + markerPath: string; + lockDir: string; + now: number; + maxAgeMs: number; + maxFiles: number; + cooldownMs: number; + force: boolean; + minVisibleMs: number; + protectedLogPaths: string[]; + timeoutMs: number; + lockPurpose: string; + daemonCleanup?: WorkspaceFilesystemLifecycleOptions['daemonCleanup']; +} + +interface RetainedLogFile { + path: string; + name: string; + mtimeMs: number; +} + +function resolveWorkspaceKey(options: WorkspaceFilesystemLifecycleOptions): string { + if (options.workspaceKey) { + return options.workspaceKey; + } + const runtimeInstance = getRuntimeInstanceIfConfigured(); + if (runtimeInstance) { + return runtimeInstance.workspaceKey; + } + if (options.logDir) { + return 'custom-log-dir'; + } + return getRuntimeInstance().workspaceKey; +} + +function resolveOptions( + options: WorkspaceFilesystemLifecycleOptions, +): ResolvedWorkspaceFilesystemLifecycleOptions { + const workspaceKey = resolveWorkspaceKey(options); + const layout = options.logDir ? null : getWorkspaceFilesystemLayout(workspaceKey); + const logDir = options.logDir ?? layout?.logs; + if (!logDir) { + throw new Error('Workspace filesystem lifecycle requires a log directory'); + } + + return { + workspaceKey, + trigger: options.trigger, + logDir, + markerPath: + options.markerPath ?? + layout?.filesystemLifecycle.markerPath ?? + path.join(logDir, FALLBACK_MARKER_FILE), + lockDir: + options.lockDir ?? + layout?.filesystemLifecycle.lockDir ?? + path.join(logDir, FALLBACK_LOCK_DIR_NAME), + now: options.now ?? Date.now(), + maxAgeMs: options.maxAgeMs ?? WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_AGE_MS, + maxFiles: options.maxFiles ?? WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_FILES, + cooldownMs: options.cooldownMs ?? WORKSPACE_FILESYSTEM_LIFECYCLE_COOLDOWN_MS, + force: options.force ?? false, + minVisibleMs: options.minVisibleMs ?? WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS, + protectedLogPaths: options.protectedLogPaths ?? [], + timeoutMs: options.timeoutMs ?? 1000, + lockPurpose: options.lockPurpose ?? 'filesystem-lifecycle', + daemonCleanup: options.daemonCleanup, + }; +} + +async function shouldSkipForCooldown( + markerPath: string, + now: number, + cooldownMs: number, +): Promise { + try { + const markerStat = await fs.stat(markerPath); + return now - markerStat.mtimeMs < cooldownMs; + } catch { + return false; + } +} + +async function touchCleanupMarker(markerPath: string, now: number): Promise { + await fs.mkdir(path.dirname(markerPath), { recursive: true, mode: 0o700 }); + await fs.writeFile(markerPath, String(now)); + const markerDate = new Date(now); + await fs.utimes(markerPath, markerDate, markerDate); +} + +function hasLiveHelperPidInName(fileName: string): boolean { + for (const match of fileName.matchAll(HELPER_PID_PATTERN)) { + const pid = Number(match[1]); + if (Number.isInteger(pid) && pid > 0 && isPidAlive(pid)) { + return true; + } + } + return false; +} + +function isXcodeBuildMCPManagedLogName(fileName: string): boolean { + if (fileName === 'daemon.log') { + return true; + } + return XCODEBUILD_LOG_NAME_PATTERN.test(fileName) || SIMULATOR_LOG_NAME_PATTERN.test(fileName); +} + +async function deleteFile(filePath: string): Promise { + try { + await fs.unlink(filePath); + return true; + } catch { + return false; + } +} + +function isProtectedLogFile( + file: RetainedLogFile, + options: ResolvedWorkspaceFilesystemLifecycleOptions, + protectedPaths: Set, +): boolean { + if (protectedPaths.has(file.path)) { + return true; + } + if (options.now - file.mtimeMs < options.minVisibleMs) { + return true; + } + return hasLiveHelperPidInName(file.name); +} + +async function collectProtectedLogPaths( + options: ResolvedWorkspaceFilesystemLifecycleOptions, +): Promise> { + const protectedPaths = new Set(options.protectedLogPaths); + + try { + for (const osLogPath of await listSimulatorLaunchOsLogProtectedPaths({ + workspaceKey: options.workspaceKey, + })) { + protectedPaths.add(osLogPath); + } + } catch { + // ignore + } + + const daemonEntry = readDaemonRegistryEntry(options.workspaceKey); + if (daemonEntry?.logPath && isPidAlive(daemonEntry.pid)) { + protectedPaths.add(daemonEntry.logPath); + } + + return protectedPaths; +} + +async function pruneKnownLogDirectory( + options: ResolvedWorkspaceFilesystemLifecycleOptions, + protectedPaths: Set, +): Promise<{ scanned: number; deleted: number }> { + await fs.mkdir(options.logDir, { recursive: true, mode: 0o700 }); + const entries = await fs.readdir(options.logDir, { withFileTypes: true }); + const candidates = entries + .filter((entry) => entry.isFile() && isXcodeBuildMCPManagedLogName(entry.name)) + .map((entry) => ({ name: entry.name, path: path.join(options.logDir, entry.name) })); + + const stats = await Promise.all( + candidates.map(async (candidate) => { + try { + const stat = await fs.stat(candidate.path); + return { ...candidate, mtimeMs: stat.mtimeMs } satisfies RetainedLogFile; + } catch { + return null; + } + }), + ); + + const retainedDeletable: RetainedLogFile[] = []; + const expired: RetainedLogFile[] = []; + let scanned = 0; + + for (const file of stats) { + if (!file) continue; + scanned += 1; + if (isProtectedLogFile(file, options, protectedPaths)) { + continue; + } + if (options.now - file.mtimeMs > options.maxAgeMs) { + expired.push(file); + continue; + } + retainedDeletable.push(file); + } + + const excessFileCount = retainedDeletable.length - options.maxFiles; + const overflow = + excessFileCount > 0 + ? retainedDeletable + .slice() + .sort((left, right) => left.mtimeMs - right.mtimeMs) + .slice(0, excessFileCount) + : []; + + const deletions = await Promise.all( + [...expired, ...overflow].map((file) => deleteFile(file.path)), + ); + const deleted = deletions.reduce((count, success) => count + (success ? 1 : 0), 0); + + return { scanned, deleted }; +} + +function zeroResult( + options: ResolvedWorkspaceFilesystemLifecycleOptions, + skippedByCooldown: boolean, + skippedByLock: boolean, + stopped = 0, + errors: string[] = [], +): WorkspaceFilesystemLifecycleResult { + return { + workspaceKey: options.workspaceKey, + trigger: options.trigger, + logDir: options.logDir, + scanned: 0, + deleted: 0, + stopped, + skippedByCooldown, + skippedByLock, + errors, + }; +} + +async function runStartupReconciliation( + options: ResolvedWorkspaceFilesystemLifecycleOptions, + errors: string[], +): Promise { + if (options.trigger !== 'startup') { + return 0; + } + try { + const reconciliation = await reconcileSimulatorLaunchOsLogOrphansForWorkspace( + options.workspaceKey, + options.timeoutMs, + ); + errors.push(...reconciliation.errors); + return reconciliation.stoppedSessionCount; + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + return 0; + } +} + +function runDaemonCleanup(options: ResolvedWorkspaceFilesystemLifecycleOptions): void { + if (!options.daemonCleanup) { + return; + } + cleanupWorkspaceDaemonFiles(options.workspaceKey, options.daemonCleanup); +} + +export async function runWorkspaceFilesystemLifecycleSweep( + options: WorkspaceFilesystemLifecycleOptions, +): Promise { + const resolved = resolveOptions(options); + const errors: string[] = []; + const stopped = await runStartupReconciliation(resolved, errors); + + if ( + !resolved.force && + (await shouldSkipForCooldown(resolved.markerPath, resolved.now, resolved.cooldownMs)) + ) { + return zeroResult(resolved, true, false, stopped, errors); + } + + const lock = await tryAcquireFsLock({ + lockDir: resolved.lockDir, + purpose: resolved.lockPurpose, + leaseMs: WORKSPACE_FILESYSTEM_LIFECYCLE_LOCK_LEASE_MS, + now: resolved.now, + }); + if (!lock) { + return zeroResult(resolved, false, true, stopped, errors); + } + + try { + if ( + !resolved.force && + (await shouldSkipForCooldown(resolved.markerPath, resolved.now, resolved.cooldownMs)) + ) { + return zeroResult(resolved, true, false, stopped, errors); + } + + runDaemonCleanup(resolved); + const protectedPaths = await collectProtectedLogPaths(resolved); + const { scanned, deleted } = await pruneKnownLogDirectory(resolved, protectedPaths); + await touchCleanupMarker(resolved.markerPath, resolved.now); + + return { + workspaceKey: resolved.workspaceKey, + trigger: resolved.trigger, + logDir: resolved.logDir, + scanned, + deleted, + stopped, + skippedByCooldown: false, + skippedByLock: false, + errors, + }; + } finally { + await lock.release(); + } +} + +function buildSchedulePreKey(options: WorkspaceFilesystemLifecycleOptions): string | null { + if (options.workspaceKey) { + return `workspace:${options.workspaceKey}`; + } + if (options.logDir) { + return `logDir:${options.logDir}`; + } + return null; +} + +export function scheduleWorkspaceFilesystemLifecycleSweep( + options: WorkspaceFilesystemLifecycleOptions, +): void { + const preKey = buildSchedulePreKey(options); + if (preKey !== null && !options.force) { + const lastAt = lastScheduledAtByPreKey.get(preKey); + if ( + lastAt !== undefined && + (options.now ?? Date.now()) - lastAt < WORKSPACE_FILESYSTEM_LIFECYCLE_COOLDOWN_MS + ) { + return; + } + } + + const resolved = resolveOptions(options); + const scheduleKey = `${resolved.workspaceKey}:${resolved.logDir}`; + const lastScheduledAt = lastScheduledAtByScope.get(scheduleKey); + + if ( + !resolved.force && + lastScheduledAt !== undefined && + resolved.now - lastScheduledAt < resolved.cooldownMs + ) { + return; + } + if (runningScheduledSweeps.has(scheduleKey)) { + return; + } + + runningScheduledSweeps.add(scheduleKey); + + const timer = setTimeout(() => { + void runWorkspaceFilesystemLifecycleSweep(resolved) + .then((result) => { + if (!result.skippedByCooldown && !result.skippedByLock) { + const completedAt = Date.now(); + lastScheduledAtByScope.set(scheduleKey, completedAt); + if (preKey !== null) { + lastScheduledAtByPreKey.set(preKey, completedAt); + } + if (result.deleted > 0) { + log( + 'info', + `[FilesystemLifecycle] Deleted ${result.deleted} old log files from ${result.logDir}`, + ); + } + } + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + log('warn', `[FilesystemLifecycle] Cleanup failed: ${message}`); + }) + .finally(() => { + runningScheduledSweeps.delete(scheduleKey); + }); + }, WORKSPACE_FILESYSTEM_LIFECYCLE_SCHEDULE_DELAY_MS); + timer.unref?.(); +} + +export async function cleanupOwnedWorkspaceFilesystemArtifacts( + options: Omit & { + trigger?: 'shutdown' | 'force-stop'; + } = {}, +): Promise { + const runtimeInstance = getRuntimeInstanceIfConfigured(); + const workspaceKey = options.workspaceKey ?? runtimeInstance?.workspaceKey; + if (!workspaceKey) { + return { + workspaceKey: 'unconfigured', + trigger: options.trigger ?? 'shutdown', + logDir: '', + scanned: 0, + deleted: 0, + stopped: 0, + skippedByCooldown: false, + skippedByLock: false, + errors: [], + }; + } + + const stopResult = await stopOwnedSimulatorLaunchOsLogSessions(options.timeoutMs ?? 1000); + if (options.daemonCleanup) { + cleanupWorkspaceDaemonFiles(workspaceKey, options.daemonCleanup); + } + + return { + workspaceKey, + trigger: options.trigger ?? 'shutdown', + logDir: getWorkspaceFilesystemLayout(workspaceKey).logs, + scanned: 0, + deleted: 0, + stopped: stopResult.stoppedSessionCount, + skippedByCooldown: false, + skippedByLock: false, + errors: stopResult.errors, + }; +} + +export function terminateOwnedWorkspaceFilesystemArtifactsSync(): { + attemptedCount: number; + errorCount: number; + errors: string[]; +} { + return terminateLiveSimulatorLaunchOsLogSessionsSync(); +} + +export function resetWorkspaceFilesystemLifecycleStateForTests(): void { + runningScheduledSweeps.clear(); + lastScheduledAtByScope.clear(); + lastScheduledAtByPreKey.clear(); +} + +export function scheduleArtifactCreatedSweep(logDir: { path: string; isOverride: boolean }): void { + if (logDir.isOverride) { + scheduleWorkspaceFilesystemLifecycleSweep({ + trigger: 'artifact-created', + logDir: logDir.path, + }); + return; + } + scheduleWorkspaceFilesystemLifecycleSweep({ + trigger: 'artifact-created', + workspaceKey: getRuntimeInstance().workspaceKey, + }); +} diff --git a/src/utils/workspace-identity.ts b/src/utils/workspace-identity.ts new file mode 100644 index 00000000..bfe119c0 --- /dev/null +++ b/src/utils/workspace-identity.ts @@ -0,0 +1,48 @@ +import { createHash } from 'node:crypto'; +import { realpathSync } from 'node:fs'; +import { basename, dirname } from 'node:path'; + +export interface WorkspaceIdentity { + workspaceRoot: string; + workspaceKey: string; +} + +export function resolveWorkspaceRoot(opts: { cwd: string; projectConfigPath?: string }): string { + if (opts.projectConfigPath) { + const configDir = dirname(opts.projectConfigPath); + return dirname(configDir); + } + try { + return realpathSync(opts.cwd); + } catch { + return opts.cwd; + } +} + +function workspaceNameForRoot(workspaceRoot: string): string { + const rawName = basename(workspaceRoot) || 'workspace'; + const slug = rawName + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/^[.-]+|[.-]+$/g, '') + .slice(0, 64); + return slug || 'workspace'; +} + +export function shortWorkspaceHash(input: string): string { + return createHash('sha256').update(input).digest('hex').slice(0, 12); +} + +export function workspaceKeyForRoot(workspaceRoot: string): string { + return `${workspaceNameForRoot(workspaceRoot)}-${shortWorkspaceHash(workspaceRoot)}`; +} + +export function resolveWorkspaceIdentity(opts: { + cwd: string; + projectConfigPath?: string; +}): WorkspaceIdentity { + const workspaceRoot = resolveWorkspaceRoot(opts); + return { + workspaceRoot, + workspaceKey: workspaceKeyForRoot(workspaceRoot), + }; +} diff --git a/src/utils/xcodebuild-event-parser.ts b/src/utils/xcodebuild-event-parser.ts index 2698be05..f6c19373 100644 --- a/src/utils/xcodebuild-event-parser.ts +++ b/src/utils/xcodebuild-event-parser.ts @@ -234,22 +234,24 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb function recordTestCaseResult( testCase: ParsedTestCase, - source: 'xcodebuild' | 'swift-testing' = 'xcodebuild', + source: 'xcodebuild' | 'swift-testing' | 'swift-testing-native' = 'xcodebuild', ): void { const increment = 1; completedCount += increment; const durationMs = parseDurationMs(testCase.durationText); if (testCase.status === 'failed') { - failedCount += increment; applyFailureDuration(testCase.suiteName, testCase.testName, durationMs); + if (source !== 'swift-testing-native') { + failedCount += increment; + } } else if (testCase.status === 'skipped') { skippedCount += increment; } - if (source === 'swift-testing') { + if (source !== 'xcodebuild') { testCasesCompletedSinceSwiftTestingSummary += increment; - if (testCase.status === 'failed') { + if (source === 'swift-testing' && testCase.status === 'failed') { testCasesFailedSinceSwiftTestingSummary += increment; } } @@ -265,7 +267,10 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb ...(durationMs !== undefined ? { durationMs } : {}), }); } - emitTestProgress(); + const suppressProgress = source === 'swift-testing-native' && testCase.status === 'failed'; + if (!suppressProgress) { + emitTestProgress(); + } } function flushPendingError(): void { @@ -340,7 +345,7 @@ export function createXcodebuildEventParser(options: EventParserOptions): Xcodeb const stResult = parseSwiftTestingResultLine(line); if (stResult) { - recordTestCaseResult(stResult, 'swift-testing'); + recordTestCaseResult(stResult, 'swift-testing-native'); return; } diff --git a/src/utils/xcodebuild-line-parsers.ts b/src/utils/xcodebuild-line-parsers.ts index 99072d6c..dbffe476 100644 --- a/src/utils/xcodebuild-line-parsers.ts +++ b/src/utils/xcodebuild-line-parsers.ts @@ -80,7 +80,7 @@ export function parseRawTestName(rawName: string): { suiteName?: string; testNam } const dotIndex = rawName.lastIndexOf('.'); - if (dotIndex > 0) { + if (dotIndex > 0 && dotIndex < rawName.length - 1) { return { suiteName: rawName.slice(0, dotIndex), testName: rawName.slice(dotIndex + 1) }; } diff --git a/src/utils/xcodebuild-log-capture.ts b/src/utils/xcodebuild-log-capture.ts index 9e9d1297..e2119d77 100644 --- a/src/utils/xcodebuild-log-capture.ts +++ b/src/utils/xcodebuild-log-capture.ts @@ -1,31 +1,37 @@ import * as fs from 'node:fs'; -import * as os from 'node:os'; import * as path from 'node:path'; -import { LOG_DIR } from './log-paths.ts'; +import { getWorkspaceFilesystemLayout } from './log-paths.ts'; +import { getRuntimeInstance } from './runtime-instance.ts'; +import { scheduleArtifactCreatedSweep } from './workspace-filesystem-lifecycle.ts'; +import { formatLogTimestamp, shortRandomSuffix } from './log-naming.ts'; -const FALLBACK_LOG_DIR = path.join(os.tmpdir(), 'XcodeBuildMCP', 'logs'); +let logDirOverrideForTests: string | null = null; -function resolveWritableLogDir(): string { - const candidates = [LOG_DIR, FALLBACK_LOG_DIR]; +interface ResolvedLogDir { + path: string; + isOverride: boolean; +} - for (const candidate of candidates) { - try { - fs.mkdirSync(candidate, { recursive: true }); - fs.accessSync(candidate, fs.constants.W_OK); - return candidate; - } catch { - continue; - } +function resolveWritableLogDir(): ResolvedLogDir { + const logDir = + logDirOverrideForTests ?? getWorkspaceFilesystemLayout(getRuntimeInstance().workspaceKey).logs; + + try { + fs.mkdirSync(logDir, { recursive: true }); + fs.accessSync(logDir, fs.constants.W_OK); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to create writable log directory at ${logDir}: ${message}`); } - throw new Error( - `Unable to create writable log directory in any candidate path: ${candidates.join(', ')}`, - ); + return { + path: logDir, + isOverride: logDirOverrideForTests !== null, + }; } function generateLogFileName(toolName: string): string { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - return `${toolName}_${timestamp}_pid${process.pid}.log`; + return `${toolName}_${formatLogTimestamp()}_pid${process.pid}_${shortRandomSuffix()}.log`; } export interface LogCapture { @@ -36,21 +42,38 @@ export interface LogCapture { export function createLogCapture(toolName: string): LogCapture { const logDir = resolveWritableLogDir(); - const logPath = path.join(logDir, generateLogFileName(toolName)); - const fd = fs.openSync(logPath, 'w'); + scheduleArtifactCreatedSweep(logDir); + const logPath = path.join(logDir.path, generateLogFileName(toolName)); + let fd: number | null = null; + + function ensureOpen(): number { + if (fd !== null) { + return fd; + } + fd = fs.openSync(logPath, 'wx'); + return fd; + } return { write(chunk: string): void { - fs.writeSync(fd, chunk); + if (chunk.length === 0) { + return; + } + fs.writeSync(ensureOpen(), chunk); }, get path(): string { return logPath; }, close(): void { + if (fd === null) { + return; + } try { fs.closeSync(fd); } catch { // already closed + } finally { + fd = null; } }, }; @@ -62,6 +85,10 @@ export interface ParserDebugCapture { flush(): string | null; } +export function setXcodebuildLogDirOverrideForTests(dir: string | null): void { + logDirOverrideForTests = dir; +} + export function createParserDebugCapture(toolName: string): ParserDebugCapture { const lines: string[] = []; @@ -75,11 +102,12 @@ export function createParserDebugCapture(toolName: string): ParserDebugCapture { flush(): string | null { if (lines.length === 0) return null; const logDir = resolveWritableLogDir(); - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const debugPath = path.join(logDir, `${toolName}_parser-debug_${timestamp}.log`); + scheduleArtifactCreatedSweep(logDir); + const debugPath = path.join(logDir.path, generateLogFileName(`${toolName}_parser-debug`)); fs.writeFileSync( debugPath, `Unrecognized xcodebuild output lines (${lines.length}):\n\n${lines.join('\n')}\n`, + { flag: 'wx' }, ); return debugPath; }, diff --git a/src/utils/xcresult-test-failures.ts b/src/utils/xcresult-test-failures.ts index ce3faa28..8398bef6 100644 --- a/src/utils/xcresult-test-failures.ts +++ b/src/utils/xcresult-test-failures.ts @@ -22,7 +22,7 @@ export function extractTestFailuresFromXcresult(xcresultPath: string): TestFailu { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }, ); - const results: XcresultTestResults = JSON.parse(output); + const results = JSON.parse(output) as XcresultTestResults; const fragments: TestFailureFragment[] = []; function walk(node: XcresultTestNode, suiteContext?: string): void { @@ -36,7 +36,7 @@ export function extractTestFailuresFromXcresult(xcresultPath: string): TestFailu if (node.nodeType === 'Test Case' && node.result === 'Failed' && node.children) { for (const child of node.children) { if (child.nodeType === 'Failure Message') { - const parsed = parseFailureMessage(child.name); + const parsed = parseXcresultFailureMessage(child.name); const { suiteName, testName } = parsedNodeName; fragments.push({ kind: 'test-result', @@ -69,12 +69,17 @@ export function extractTestFailuresFromXcresult(xcresultPath: string): TestFailu } } -function parseFailureMessage(raw: string): { message: string; location?: string } { - const match = raw.match(/^(.+?):(\d+): (.+)$/); +export function parseXcresultFailureMessage(raw: string): { message: string; location?: string } { + const [firstLine = '', ...continuationLines] = raw.split(/\r?\n/u); + const match = firstLine.match(/^(.+?):(\d+):\s*(.*)$/u); if (match) { + const message = [match[3], ...continuationLines] + .join('\n') + .replace(/^failed\s*-\s*/u, '') + .replace(/:\s+(?=\/\/)/u, '\n'); return { location: match[2] === '0' ? undefined : `${match[1]}:${match[2]}`, - message: match[3].replace(/^failed\s*-\s*/u, ''), + message, }; } return { message: raw };