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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 186 additions & 2 deletions src/platforms/ios/__tests__/runner-xctestrun.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { test } from 'vitest';
import { test, vi } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { DeviceInfo } from '../../../utils/device.ts';
import { findXctestrun, scoreXctestrunCandidate } from '../runner-xctestrun.ts';
import {
acquireXcodebuildSimulatorSetRedirect,
findXctestrun,
resolveXcodebuildSimulatorDeviceSetPath,
scoreXctestrunCandidate,
} from '../runner-xctestrun.ts';

const iosSimulator: DeviceInfo = {
platform: 'ios',
Expand Down Expand Up @@ -92,3 +97,182 @@ test('scoreXctestrunCandidate penalizes macos and env xctestrun files for simula

assert.ok(simulatorScore > macosEnvScore);
});

test('resolveXcodebuildSimulatorDeviceSetPath uses XCTestDevices under the user home', () => {
assert.equal(
resolveXcodebuildSimulatorDeviceSetPath('/tmp/agent-device-home'),
'/tmp/agent-device-home/Library/Developer/XCTestDevices',
);
});

test('acquireXcodebuildSimulatorSetRedirect swaps XCTestDevices to the requested simulator set', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runner-xctestrun-redirect-'));
let handle: Awaited<ReturnType<typeof acquireXcodebuildSimulatorSetRedirect>> | null = null;
try {
const requestedSetPath = path.join(root, 'requested');
const xctestDeviceSetPath = path.join(root, 'Library', 'Developer', 'XCTestDevices');
const lockDirPath = path.join(root, '.agent-device', 'xctest-device-set.lock');
const originalMarkerPath = path.join(root, 'original-marker.txt');
fs.mkdirSync(requestedSetPath, { recursive: true });
fs.mkdirSync(xctestDeviceSetPath, { recursive: true });
fs.writeFileSync(path.join(xctestDeviceSetPath, 'original.txt'), originalMarkerPath, 'utf8');

handle = await acquireXcodebuildSimulatorSetRedirect(
{
...iosSimulator,
simulatorSetPath: requestedSetPath,
},
{ lockDirPath, xctestDeviceSetPath },
);

assert.notEqual(handle, null);
assert.equal(fs.lstatSync(xctestDeviceSetPath).isSymbolicLink(), true);
assert.equal(
fs.realpathSync.native(xctestDeviceSetPath),
fs.realpathSync.native(requestedSetPath),
);

await handle?.release();
handle = null;

assert.equal(fs.lstatSync(xctestDeviceSetPath).isDirectory(), true);
assert.equal(
fs.readFileSync(path.join(xctestDeviceSetPath, 'original.txt'), 'utf8'),
originalMarkerPath,
);
} finally {
await handle?.release();
fs.rmSync(root, { recursive: true, force: true });
}
});

test('acquireXcodebuildSimulatorSetRedirect is a no-op for simulators without a scoped device set', async () => {
const handle = await acquireXcodebuildSimulatorSetRedirect(iosSimulator);
assert.equal(handle, null);
});

test('acquireXcodebuildSimulatorSetRedirect restores stale redirected XCTestDevices before applying a new one', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runner-xctestrun-redirect-'));
let handle: Awaited<ReturnType<typeof acquireXcodebuildSimulatorSetRedirect>> | null = null;
try {
const requestedSetPath = path.join(root, 'requested');
const staleRequestedSetPath = path.join(root, 'stale-requested');
const xctestDeviceSetPath = path.join(root, 'Library', 'Developer', 'XCTestDevices');
const backupPath = `${xctestDeviceSetPath}.agent-device-backup`;
const lockDirPath = path.join(root, '.agent-device', 'xctest-device-set.lock');
fs.mkdirSync(requestedSetPath, { recursive: true });
fs.mkdirSync(staleRequestedSetPath, { recursive: true });
fs.mkdirSync(path.dirname(xctestDeviceSetPath), { recursive: true });
fs.mkdirSync(backupPath, { recursive: true });
fs.writeFileSync(path.join(backupPath, 'original.txt'), 'restored', 'utf8');
fs.symlinkSync(staleRequestedSetPath, xctestDeviceSetPath, 'dir');

handle = await acquireXcodebuildSimulatorSetRedirect(
{
...iosSimulator,
simulatorSetPath: requestedSetPath,
},
{ backupPath, lockDirPath, xctestDeviceSetPath },
);

assert.notEqual(handle, null);
assert.equal(
fs.realpathSync.native(xctestDeviceSetPath),
fs.realpathSync.native(requestedSetPath),
);

await handle?.release();
handle = null;

assert.equal(fs.existsSync(backupPath), false);
assert.equal(
fs.readFileSync(path.join(xctestDeviceSetPath, 'original.txt'), 'utf8'),
'restored',
);
} finally {
await handle?.release();
fs.rmSync(root, { recursive: true, force: true });
}
});

test('acquireXcodebuildSimulatorSetRedirect clears stale lock directories from dead owners', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runner-xctestrun-redirect-'));
let handle: Awaited<ReturnType<typeof acquireXcodebuildSimulatorSetRedirect>> | null = null;
try {
const requestedSetPath = path.join(root, 'requested');
const xctestDeviceSetPath = path.join(root, 'Library', 'Developer', 'XCTestDevices');
const lockDirPath = path.join(root, '.agent-device', 'xctest-device-set.lock');
fs.mkdirSync(requestedSetPath, { recursive: true });
fs.mkdirSync(lockDirPath, { recursive: true });
fs.writeFileSync(
path.join(lockDirPath, 'owner.json'),
JSON.stringify({ pid: 999_999, startTime: null, acquiredAtMs: Date.now() - 60_000 }),
'utf8',
);

handle = await acquireXcodebuildSimulatorSetRedirect(
{
...iosSimulator,
simulatorSetPath: requestedSetPath,
},
{ lockDirPath, xctestDeviceSetPath },
);

assert.notEqual(handle, null);
assert.equal(fs.lstatSync(xctestDeviceSetPath).isSymbolicLink(), true);

await handle?.release();
handle = null;

assert.equal(fs.existsSync(lockDirPath), false);
} finally {
await handle?.release();
fs.rmSync(root, { recursive: true, force: true });
}
});

test('acquireXcodebuildSimulatorSetRedirect preserves the backup when XCTestDevices is recreated mid-swap', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runner-xctestrun-redirect-'));
const renameSync = fs.renameSync.bind(fs);
const xctestDeviceSetPath = path.join(root, 'Library', 'Developer', 'XCTestDevices');
const backupPath = `${xctestDeviceSetPath}.agent-device-backup`;
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation((oldPath, newPath) => {
if (
typeof oldPath === 'string' &&
typeof newPath === 'string' &&
newPath === xctestDeviceSetPath &&
oldPath.includes('.agent-device-link-')
) {
fs.mkdirSync(xctestDeviceSetPath, { recursive: true });
fs.writeFileSync(path.join(xctestDeviceSetPath, 'collision.txt'), 'collision', 'utf8');
}
return renameSync(oldPath, newPath);
});
try {
const requestedSetPath = path.join(root, 'requested');
const lockDirPath = path.join(root, '.agent-device', 'xctest-device-set.lock');
fs.mkdirSync(requestedSetPath, { recursive: true });
fs.mkdirSync(xctestDeviceSetPath, { recursive: true });
fs.writeFileSync(path.join(xctestDeviceSetPath, 'original.txt'), 'original', 'utf8');

await assert.rejects(
acquireXcodebuildSimulatorSetRedirect(
{
...iosSimulator,
simulatorSetPath: requestedSetPath,
},
{ backupPath, lockDirPath, xctestDeviceSetPath },
),
/Failed to redirect XCTest device set path/,
);

assert.equal(fs.readFileSync(path.join(backupPath, 'original.txt'), 'utf8'), 'original');
assert.equal(
fs.readFileSync(path.join(xctestDeviceSetPath, 'collision.txt'), 'utf8'),
'collision',
);
} finally {
renameSpy.mockRestore();
fs.rmSync(root, { recursive: true, force: true });
}
});
71 changes: 44 additions & 27 deletions src/platforms/ios/runner-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
RUNNER_DESTINATION_TIMEOUT_SECONDS,
} from './runner-transport.ts';
import {
acquireXcodebuildSimulatorSetRedirect,
ensureXctestrun,
IOS_RUNNER_CONTAINER_BUNDLE_IDS,
prepareXctestrunWithEnv,
Expand All @@ -37,6 +38,7 @@ export type RunnerSession = {
testPromise: Promise<ExecResult>;
child: ExecBackgroundResult['child'];
ready: boolean;
simulatorSetRedirect?: { release: () => Promise<void> };
};

const runnerSessions = new Map<string, RunnerSession>();
Expand Down Expand Up @@ -70,33 +72,41 @@ export async function ensureRunnerSession(
{ AGENT_DEVICE_RUNNER_PORT: String(port) },
`session-${device.id}-${port}`,
);
const { child, wait: testPromise } = runCmdBackground(
'xcodebuild',
[
'test-without-building',
'-only-testing',
'AgentDeviceRunnerUITests/RunnerTests/testCommand',
'-parallel-testing-enabled',
'NO',
'-test-timeouts-enabled',
'NO',
'-collect-test-diagnostics',
'never',
resolveRunnerMaxConcurrentDestinationsFlag(device),
'1',
'-destination-timeout',
String(RUNNER_DESTINATION_TIMEOUT_SECONDS),
'-xctestrun',
xctestrunPath,
'-destination',
resolveRunnerDestination(device),
],
{
allowFailure: true,
env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
detached: true,
},
);
const simulatorSetRedirect = await acquireXcodebuildSimulatorSetRedirect(device);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Release simulator-set redirect on abort path

ensureRunnerSession now acquires a long-lived acquireXcodebuildSimulatorSetRedirect handle, but abortAllIosRunnerSessions only kills child processes and never invokes releaseXcodebuildSimulatorSetRedirect. In the client-disconnect path (daemon/transport.ts), this leaves xctest-device-set.lock owned by the still-alive daemon PID, so later scoped simulator commands can block until the 30s lock timeout and fail even though no runner session is active anymore. Please ensure the abort flow also releases redirect handles (or routes through the same stop path that does).

Useful? React with 👍 / 👎.

let child: ExecBackgroundResult['child'];
let testPromise: Promise<ExecResult>;
try {
({ child, wait: testPromise } = runCmdBackground(
'xcodebuild',
[
'test-without-building',
'-only-testing',
'AgentDeviceRunnerUITests/RunnerTests/testCommand',
'-parallel-testing-enabled',
'NO',
'-test-timeouts-enabled',
'NO',
'-collect-test-diagnostics',
'never',
resolveRunnerMaxConcurrentDestinationsFlag(device),
'1',
'-destination-timeout',
String(RUNNER_DESTINATION_TIMEOUT_SECONDS),
'-xctestrun',
xctestrunPath,
'-destination',
resolveRunnerDestination(device),
],
{
allowFailure: true,
env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
detached: true,
},
));
} catch (error) {
await simulatorSetRedirect?.release();
throw error;
}
child.stdout?.on('data', (chunk: string) => {
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
});
Expand All @@ -114,6 +124,7 @@ export async function ensureRunnerSession(
testPromise,
child,
ready: false,
simulatorSetRedirect: simulatorSetRedirect ?? undefined,
};
runnerSessions.set(device.id, session);
return session;
Expand Down Expand Up @@ -196,6 +207,7 @@ async function stopRunnerSessionInternal(
await killRunnerProcessTree(session.child.pid, 'SIGKILL');
cleanupTempFile(session.xctestrunPath);
cleanupTempFile(session.jsonPath);
await session.simulatorSetRedirect?.release();
if (runnerSessions.get(deviceId) === session) {
runnerSessions.delete(deviceId);
}
Expand Down Expand Up @@ -241,6 +253,11 @@ export async function abortAllIosRunnerSessions(): Promise<void> {
runnerPrepProcesses.delete(child);
}),
);
await Promise.allSettled(
activeSessions.map(async (session) => {
await session.simulatorSetRedirect?.release();
}),
);
}

export async function stopAllIosRunnerSessions(): Promise<void> {
Expand Down
Loading
Loading