|
| 1 | +import assert from 'node:assert/strict'; |
| 2 | +import { beforeEach, test, vi } from 'vitest'; |
| 3 | +import { IOS_SIMULATOR } from './device-fixtures.ts'; |
| 4 | +import { appleRunnerTestHost } from '../test-host.ts'; |
| 5 | +import { |
| 6 | + makeBackgroundRunner, |
| 7 | + makeClassifyOwnerLivenessViaMocks, |
| 8 | + runnerResponse, |
| 9 | +} from './runner-session-fixtures.ts'; |
| 10 | +import { mkdtempForTestSync } from './tmp-dir.ts'; |
| 11 | + |
| 12 | +const { |
| 13 | + mockAcquireXcodebuildSimulatorSetRedirect, |
| 14 | + mockCleanupTempFile, |
| 15 | + mockEnsureXctestrunArtifact, |
| 16 | + mockGetFreePort, |
| 17 | + mockIsProcessAlive, |
| 18 | + mockIsProcessGroupAlive, |
| 19 | + mockPrepareXctestrunWithEnv, |
| 20 | + mockReadProcessCommand, |
| 21 | + mockReadProcessStartTime, |
| 22 | + mockResolveExpectedRunnerCacheMetadata, |
| 23 | + mockResolveRunnerDerivedPath, |
| 24 | + mockRunAppleToolCommand, |
| 25 | + mockRunCmdBackground, |
| 26 | + mockRunXcrun, |
| 27 | + mockSendRunnerCommandOnce, |
| 28 | + mockSignalPidsBestEffort, |
| 29 | + mockSignalProcessGroupBestEffort, |
| 30 | + mockWaitForRunner, |
| 31 | + mockRedirectRelease, |
| 32 | +} = vi.hoisted(() => ({ |
| 33 | + mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), |
| 34 | + mockCleanupTempFile: vi.fn(), |
| 35 | + mockEnsureXctestrunArtifact: vi.fn(), |
| 36 | + mockGetFreePort: vi.fn(), |
| 37 | + mockIsProcessAlive: vi.fn(), |
| 38 | + mockIsProcessGroupAlive: vi.fn(), |
| 39 | + mockPrepareXctestrunWithEnv: vi.fn(), |
| 40 | + // Non-empty default: RUNNER_OWNER_START_TIME below is computed at module |
| 41 | + // load (before beforeEach), and readProcessStartTime's real implementation |
| 42 | + // shells out to `ps` with a 1s timeout that can miss under CPU contention, |
| 43 | + // flipping a live owner to 'owner-process-dead'. Deterministic value, no |
| 44 | + // shell-out; identity is still enforced by pid in beforeEach below. |
| 45 | + mockReadProcessCommand: vi.fn((_pid: number) => null as string | null), |
| 46 | + mockReadProcessStartTime: vi.fn((_pid: number) => 'fixed-test-owner-start-time' as string | null), |
| 47 | + mockResolveExpectedRunnerCacheMetadata: vi.fn(), |
| 48 | + mockResolveRunnerDerivedPath: vi.fn(), |
| 49 | + mockRunAppleToolCommand: vi.fn(), |
| 50 | + mockRunCmdBackground: vi.fn(), |
| 51 | + mockRunXcrun: vi.fn(), |
| 52 | + mockSendRunnerCommandOnce: vi.fn(), |
| 53 | + // The runner child pid below is fabricated (4242), so the signal writes are |
| 54 | + // mocked next to the liveness reads: a real signal to a made-up pid can hit a |
| 55 | + // sibling vitest fork (#1824), and the shared setup refuses it outright. |
| 56 | + mockSignalPidsBestEffort: vi.fn(), |
| 57 | + mockSignalProcessGroupBestEffort: vi.fn(), |
| 58 | + mockWaitForRunner: vi.fn(), |
| 59 | + mockRedirectRelease: vi.fn(), |
| 60 | +})); |
| 61 | + |
| 62 | +vi.mock('../runner-io.ts', async () => { |
| 63 | + const actual = await vi.importActual<typeof import('../runner-io.ts')>('../runner-io.ts'); |
| 64 | + return { |
| 65 | + ...actual, |
| 66 | + cleanupTempFile: mockCleanupTempFile, |
| 67 | + getFreePort: mockGetFreePort, |
| 68 | + }; |
| 69 | +}); |
| 70 | + |
| 71 | +vi.mock('../runner-transport.ts', async () => { |
| 72 | + const actual = await vi.importActual<typeof import('../runner-transport.ts')>( |
| 73 | + '../runner-transport.ts', |
| 74 | + ); |
| 75 | + return { |
| 76 | + ...actual, |
| 77 | + sendRunnerCommandOnce: mockSendRunnerCommandOnce, |
| 78 | + }; |
| 79 | +}); |
| 80 | + |
| 81 | +vi.mock('../runner-xctestrun.ts', async () => { |
| 82 | + const actual = await vi.importActual<typeof import('../runner-xctestrun.ts')>( |
| 83 | + '../runner-xctestrun.ts', |
| 84 | + ); |
| 85 | + return { |
| 86 | + ...actual, |
| 87 | + acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, |
| 88 | + ensureXctestrunArtifact: mockEnsureXctestrunArtifact, |
| 89 | + prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, |
| 90 | + resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata, |
| 91 | + resolveRunnerDerivedPath: mockResolveRunnerDerivedPath, |
| 92 | + }; |
| 93 | +}); |
| 94 | + |
| 95 | +vi.mock('../runner-startup-transport.ts', async () => { |
| 96 | + const actual = await vi.importActual<typeof import('../runner-startup-transport.ts')>( |
| 97 | + '../runner-startup-transport.ts', |
| 98 | + ); |
| 99 | + return { ...actual, waitForRunner: mockWaitForRunner }; |
| 100 | +}); |
| 101 | + |
| 102 | +import { abortAllIosRunnerSessions, ensureRunnerSession } from '../runner-session.ts'; |
| 103 | +import { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../runner-xctestrun.ts'; |
| 104 | +import { AppError } from '@agent-device/kernel/errors'; |
| 105 | + |
| 106 | +const TEST_OWNER_START_TIME = 'fixed-test-owner-start-time'; |
| 107 | +let leaseOwnerStateDirOverride: string | undefined; |
| 108 | + |
| 109 | +// Split from runner-session.test.ts: that file is over the test-size tripwire |
| 110 | +// and may only shrink, so the stale-bundle cleanup family answers its own |
| 111 | +// domain question here with the same seam scaffolding. |
| 112 | +beforeEach(async () => { |
| 113 | + appleRunnerTestHost.update({ |
| 114 | + runCmdBackground: mockRunCmdBackground, |
| 115 | + isProcessAlive: mockIsProcessAlive, |
| 116 | + isProcessGroupAlive: mockIsProcessGroupAlive, |
| 117 | + readProcessCommand: mockReadProcessCommand, |
| 118 | + readProcessStartTime: mockReadProcessStartTime, |
| 119 | + signalPidsBestEffort: mockSignalPidsBestEffort, |
| 120 | + signalProcessGroupBestEffort: mockSignalProcessGroupBestEffort, |
| 121 | + runAppleToolCommand: mockRunAppleToolCommand, |
| 122 | + runXcrun: mockRunXcrun, |
| 123 | + leaseOwnerStateDir: () => leaseOwnerStateDirOverride, |
| 124 | + classifyOwnerLiveness: makeClassifyOwnerLivenessViaMocks({ |
| 125 | + isProcessAlive: (pid) => Boolean(mockIsProcessAlive(pid)), |
| 126 | + readProcessStartTime: (pid) => (mockReadProcessStartTime(pid) as string | null) ?? null, |
| 127 | + }), |
| 128 | + }); |
| 129 | + await abortAllIosRunnerSessions(); |
| 130 | + vi.resetAllMocks(); |
| 131 | + leaseOwnerStateDirOverride = undefined; |
| 132 | + process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = mkdtempForTestSync( |
| 133 | + 'agent-device-runner-lease-test-', |
| 134 | + ); |
| 135 | + mockRunXcrun.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); |
| 136 | + mockEnsureXctestrunArtifact.mockResolvedValue({ |
| 137 | + xctestrunPath: '/tmp/base-runner.xctestrun', |
| 138 | + derived: '/tmp/derived', |
| 139 | + cache: 'miss', |
| 140 | + artifact: 'rebuilt', |
| 141 | + buildMs: 12, |
| 142 | + xctestrunPathSource: 'build', |
| 143 | + }); |
| 144 | + mockGetFreePort.mockResolvedValue(8123); |
| 145 | + mockPrepareXctestrunWithEnv.mockResolvedValue({ |
| 146 | + xctestrunPath: '/tmp/session-runner.xctestrun', |
| 147 | + jsonPath: '/tmp/session-runner.json', |
| 148 | + }); |
| 149 | + mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 }); |
| 150 | + mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived'); |
| 151 | + mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({ release: mockRedirectRelease }); |
| 152 | + mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); |
| 153 | + mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); |
| 154 | + mockIsProcessAlive.mockReturnValue(true); |
| 155 | + mockIsProcessGroupAlive.mockReturnValue(false); |
| 156 | + mockReadProcessCommand.mockReturnValue(null); |
| 157 | + // Our pid reads back its fixed start time; any other pid reads as |
| 158 | + // not-found, same as a real `ps` miss. |
| 159 | + mockReadProcessStartTime.mockImplementation((pid: number) => |
| 160 | + pid === process.pid ? TEST_OWNER_START_TIME : null, |
| 161 | + ); |
| 162 | + mockWaitForRunner.mockResolvedValue(runnerResponse({ uptimeMs: 1 })); |
| 163 | +}); |
| 164 | + |
| 165 | +test('runner session keeps boot and stale bundle cleanup available when needed', async () => { |
| 166 | + const device = { ...IOS_SIMULATOR, id: 'runner-session-clean-sim', booted: false }; |
| 167 | + |
| 168 | + await ensureRunnerSession(device, { |
| 169 | + cleanStaleBundles: true, |
| 170 | + }); |
| 171 | + |
| 172 | + assert.equal( |
| 173 | + mockRunXcrun.mock.calls.some((call) => call[0]?.includes('bootstatus')), |
| 174 | + true, |
| 175 | + ); |
| 176 | + assert.equal( |
| 177 | + mockRunXcrun.mock.calls.some((call) => call[0]?.includes('uninstall')), |
| 178 | + true, |
| 179 | + ); |
| 180 | + const uninstallCalls = mockRunXcrun.mock.calls.filter((call) => call[0]?.includes('uninstall')); |
| 181 | + assert.equal( |
| 182 | + uninstallCalls.every((call) => call[1]?.timeoutMs === 10_000), |
| 183 | + true, |
| 184 | + ); |
| 185 | +}); |
| 186 | + |
| 187 | +test('runner session stale bundle cleanup is best-effort when simctl stalls', async () => { |
| 188 | + const device = { ...IOS_SIMULATOR, id: 'runner-session-clean-timeout-sim' }; |
| 189 | + |
| 190 | + mockRunXcrun |
| 191 | + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'simctl uninstall timed out')) |
| 192 | + .mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); |
| 193 | + |
| 194 | + const session = await ensureRunnerSession(device, { |
| 195 | + cleanStaleBundles: true, |
| 196 | + }); |
| 197 | + |
| 198 | + assert.equal(session.deviceId, device.id); |
| 199 | + assert.equal(mockRunCmdBackground.mock.calls.length, 1); |
| 200 | +}); |
| 201 | + |
| 202 | +test('stale bundle uninstalls start concurrently', async () => { |
| 203 | + const device = { ...IOS_SIMULATOR, id: 'runner-session-clean-concurrent-sim' }; |
| 204 | + const uninstallGates: Array< |
| 205 | + (result: { exitCode: number; stdout: string; stderr: string }) => void |
| 206 | + > = []; |
| 207 | + mockRunXcrun.mockImplementation(async (args: string[]) => { |
| 208 | + if (args.includes('uninstall')) { |
| 209 | + return await new Promise((resolve) => { |
| 210 | + uninstallGates.push(resolve); |
| 211 | + }); |
| 212 | + } |
| 213 | + return { exitCode: 0, stdout: '', stderr: '' }; |
| 214 | + }); |
| 215 | + |
| 216 | + const sessionPromise = ensureRunnerSession(device, { cleanStaleBundles: true }); |
| 217 | + // Both container bundle uninstalls must be in flight before either resolves; |
| 218 | + // against the sequential pre-fix loop this wait times out with one gate held. |
| 219 | + await vi.waitFor(() => |
| 220 | + assert.equal(uninstallGates.length, IOS_RUNNER_CONTAINER_BUNDLE_IDS.length), |
| 221 | + ); |
| 222 | + for (const resolve of uninstallGates) { |
| 223 | + resolve({ exitCode: 0, stdout: '', stderr: '' }); |
| 224 | + } |
| 225 | + const session = await sessionPromise; |
| 226 | + |
| 227 | + assert.equal(session.deviceId, device.id); |
| 228 | +}); |
0 commit comments