Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import { afterEach, beforeEach, test, vi } from 'vitest';
import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo';
import { IOS_DEVICE, IOS_SIMULATOR, TVOS_SIMULATOR } from './device-fixtures.ts';
import { mkdtempForTestSync } from './tmp-dir.ts';
import { appleRunnerTestHost } from '../test-host.ts';
import { STUBBED_APPLE_TOOLCHAIN, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts';
import {
isSameRunnerSimulator,
restoreLegacyXctestDeviceSetRedirect,
runnerSimulatorSetFailureDetails,
xcodebuildDestinationArgs,
} from '../runner-device-set.ts';
import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts';

const toolchainProbe = stubAppleToolchainProbes();
beforeEach(resetAllProcessMemosForTests);
Expand Down Expand Up @@ -54,6 +54,7 @@ test('the default set, a blank set path and a physical device leave the destinat

test('a failure reports the scoped set and the selected Xcode only for a scoped-set simulator', () => {
const scoped = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/simulators' };
resolveExpectedRunnerCacheMetadata(scoped);

assert.deepEqual(runnerSimulatorSetFailureDetails(scoped), {
simulatorSetPath: '/tmp/tenant-a/simulators',
Expand All @@ -62,13 +63,13 @@ test('a failure reports the scoped set and the selected Xcode only for a scoped-
assert.deepEqual(runnerSimulatorSetFailureDetails(IOS_SIMULATOR), {});
});

test('a failure whose Xcode cannot be read still names the scoped set, and no Xcode key', () => {
toolchainProbe.mockReturnValue({ exitCode: 1, stdout: '', stderr: 'xcode-select: error' });

test('a failure before any cache decision read the Xcode names the scoped set, never probing', () => {
toolchainProbe.mockClear();
assert.deepEqual(
runnerSimulatorSetFailureDetails({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-a/sims' }),
{ simulatorSetPath: '/tmp/tenant-a/sims' },
);
assert.equal(toolchainProbe.mock.calls.length, 0);
});

test('one udid in two simulator sets names two simulators', () => {
Expand Down Expand Up @@ -109,9 +110,8 @@ test('an older redirect is undone: the symlink goes and the host set comes back'
const paths = makeLegacyPaths();
fs.mkdirSync(path.join(paths.backupPath, 'HOST-UDID'), { recursive: true });
fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir');
const phases = recordDiagnosticPhases();

restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath);
const phases = restoreRecordingPhases(paths);

assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), false);
assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'HOST-UDID')));
Expand All @@ -133,11 +133,22 @@ test('a symlink an older redirect left without a backup is removed, whatever it
: path.join(path.dirname(paths.scopedSetPath), 'external-volume');
fs.mkdirSync(linkTarget, { recursive: true });
fs.symlinkSync(linkTarget, paths.xctestDeviceSetPath, 'dir');
const diagnostics: unknown[] = [];

restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath);
restoreLegacyXctestDeviceSetRedirect(
(diagnostic) => diagnostics.push(diagnostic),
paths.xctestDeviceSetPath,
);

assert.equal(fs.lstatSync(paths.xctestDeviceSetPath, { throwIfNoEntry: false }), undefined);
assert.ok(fs.existsSync(linkTarget), target);
assert.deepEqual(diagnostics, [
{
phase: 'ios_runner_legacy_xctest_device_set_link_removed',
resourcePath: paths.xctestDeviceSetPath,
data: { linkTarget },
},
]);
}
});

Expand All @@ -146,17 +157,16 @@ test('a backup never replaces a host set that is already in place', () => {
fs.mkdirSync(path.join(paths.xctestDeviceSetPath, 'CURRENT-UDID'), { recursive: true });
fs.mkdirSync(path.join(paths.backupPath, 'OLD-UDID'), { recursive: true });

restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath);
restoreRecordingPhases(paths);

assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'CURRENT-UDID')));
assert.ok(fs.existsSync(path.join(paths.backupPath, 'OLD-UDID')));
});

test('a host with no leftovers is left untouched and reports nothing', () => {
const paths = makeLegacyPaths();
const phases = recordDiagnosticPhases();

restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath);
const phases = restoreRecordingPhases(paths);

assert.equal(fs.existsSync(paths.xctestDeviceSetPath), false);
assert.equal(fs.existsSync(paths.backupPath), false);
Expand All @@ -167,13 +177,13 @@ test('a host with no leftovers is left untouched and reports nothing', () => {
function withOtherDaemonFinishingBefore(
call: 'unlinkSync' | 'renameSync',
paths: LegacyRedirect,
): void {
): string[] {
const original = fs[call] as (...args: unknown[]) => unknown;
vi.spyOn(fs, call).mockImplementationOnce(((...args: unknown[]) => {
restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath);
restoreRecordingPhases(paths);
return original.apply(fs, args);
}) as never);
restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath);
return restoreRecordingPhases(paths);
}

test('a daemon whose unlink or rename the other daemon already made finishes the restore', () => {
Expand All @@ -182,8 +192,13 @@ test('a daemon whose unlink or rename the other daemon already made finishes the
fs.mkdirSync(path.join(paths.backupPath, 'HOST-UDID'), { recursive: true });
fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir');

withOtherDaemonFinishingBefore(call, paths);
const phases = withOtherDaemonFinishingBefore(call, paths);

assert.equal(
phases.includes('ios_runner_legacy_xctest_device_set_restore_failed'),
false,
call,
);
assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), false, call);
assert.ok(fs.existsSync(path.join(paths.xctestDeviceSetPath, 'HOST-UDID')), call);
assert.equal(fs.existsSync(paths.backupPath), false, call);
Expand All @@ -192,19 +207,36 @@ test('a daemon whose unlink or rename the other daemon already made finishes the
}
});

test('an unlink that fails while the link is still there is reported', () => {
test('an unlink that fails while the link is still there is reported, not thrown', () => {
const paths = makeLegacyPaths();
fs.mkdirSync(paths.backupPath);
fs.symlinkSync(paths.scopedSetPath, paths.xctestDeviceSetPath, 'dir');
vi.spyOn(fs, 'unlinkSync').mockImplementationOnce(() => {
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
});
const diagnostics: unknown[] = [];

restoreLegacyXctestDeviceSetRedirect(
(diagnostic) => diagnostics.push(diagnostic),
paths.xctestDeviceSetPath,
);

assert.throws(() => restoreLegacyXctestDeviceSetRedirect(paths.xctestDeviceSetPath), /EACCES/);
assert.deepEqual(diagnostics, [
{
phase: 'ios_runner_legacy_xctest_device_set_restore_failed',
resourcePath: paths.xctestDeviceSetPath,
data: { error: 'EACCES: permission denied' },
},
]);
assert.equal(fs.lstatSync(paths.xctestDeviceSetPath).isSymbolicLink(), true);
assert.ok(fs.existsSync(paths.backupPath));
});

function recordDiagnosticPhases(): string[] {
function restoreRecordingPhases(paths: LegacyRedirect): string[] {
const phases: string[] = [];
appleRunnerTestHost.update({ emitDiagnostic: (event) => phases.push(event.phase) });
restoreLegacyXctestDeviceSetRedirect(
(diagnostic) => phases.push(diagnostic.phase),
paths.xctestDeviceSetPath,
);
return phases;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo';
import type { ExecBackgroundResult } from '@agent-device/host-kit/command';
import { buildRunnerEarlyExitError } from '../runner-startup-transport.ts';
import { readRunnerLogTail } from '../runner-io.ts';
import { resolveExpectedRunnerCacheMetadata } from '../runner-cache-metadata.ts';
import type { RunnerSession } from '../runner-session-types.ts';
import { mkdtempForTestSync } from './tmp-dir.ts';
import { STUBBED_APPLE_TOOLCHAIN, stubAppleToolchainProbes } from './apple-toolchain-fixtures.ts';
Expand Down Expand Up @@ -177,8 +178,8 @@ test('a session that never probed the device publishes no disk-image claim (#268

const SET_WITHOUT_UDID = CAPTURED_SCOPED_SIMULATOR.setWithoutUdid;

// An external xctestrun: the session carries no build of its own, so the Xcode it names comes from
// the toolchain the host selects.
// An external xctestrun: the session carries no build of its own, so the Xcode it names is the one a
// runner cache decision in this process already read, if any.
function simulatorSessionFailingWith(log: string, simulatorSetPath?: string): RunnerSession {
return {
...sessionFailingWith(log),
Expand All @@ -204,28 +205,26 @@ function simulatorSessionFailingWith(log: string, simulatorSetPath?: string): Ru
}

test('a scoped-set simulator whose destination is missing names its set and the Xcode', async () => {
const error = (await buildRunnerEarlyExitError({
session: simulatorSessionFailingWith(
CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT,
SET_WITHOUT_UDID,
),
port: 8100,
})) as AppError;
const session = simulatorSessionFailingWith(
CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT,
SET_WITHOUT_UDID,
);
resolveExpectedRunnerCacheMetadata(session.device);

const error = (await buildRunnerEarlyExitError({ session, port: 8100 })) as AppError;

assert.equal(error.details?.reason, 'simulator_set_destination_not_found');
assert.match(String(error.details?.hint), /-DVTSimulatorSetLocation/);
assert.equal(error.details?.simulatorSetPath, SET_WITHOUT_UDID);
assert.equal(error.details?.xcodeVersion, STUBBED_APPLE_TOOLCHAIN.xcodeVersion);
assert.ok(
error.message.endsWith(
`simulator set ${SET_WITHOUT_UDID} with Xcode ${STUBBED_APPLE_TOOLCHAIN.xcodeVersion}`,
),
assert.equal(
error.message,
`Runner did not accept connection (xcodebuild exited early): xcodebuild found no simulator ${CAPTURED_SCOPED_SIMULATOR.udid} in simulator set ${SET_WITHOUT_UDID} with Xcode ${STUBBED_APPLE_TOOLCHAIN.xcodeVersion}`,
);
});

test('a scoped-set destination error whose Xcode cannot be read still names the set', async () => {
toolchainProbe.mockReturnValue({ exitCode: 1, stdout: '', stderr: 'xcode-select: error' });

test('a scoped-set destination error before any cache decision read the Xcode still names the set', async () => {
toolchainProbe.mockClear();
const error = (await buildRunnerEarlyExitError({
session: simulatorSessionFailingWith(
CAPTURED_LAUNCH_DESTINATION_NOT_FOUND_OUTPUT,
Expand All @@ -237,9 +236,11 @@ test('a scoped-set destination error whose Xcode cannot be read still names the
assert.equal(error.details?.reason, 'simulator_set_destination_not_found');
assert.equal(error.details?.simulatorSetPath, SET_WITHOUT_UDID);
assert.equal('xcodeVersion' in (error.details ?? {}), false);
assert.ok(
error.message.endsWith(`simulator set ${SET_WITHOUT_UDID} with Xcode (version unreadable)`),
assert.equal(
error.message,
`Runner did not accept connection (xcodebuild exited early): xcodebuild found no simulator ${CAPTURED_SCOPED_SIMULATOR.udid} in simulator set ${SET_WITHOUT_UDID} with Xcode (version unreadable)`,
);
assert.equal(toolchainProbe.mock.calls.length, 0);
});

test('a default-set simulator early exit keeps its boot-failure reason', async () => {
Expand Down
Loading
Loading