diff --git a/packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts b/packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts index 90569f7703..2398095260 100644 --- a/packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts +++ b/packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts @@ -1,6 +1,8 @@ import { beforeEach, vi } from 'vitest'; import fs from 'node:fs'; -import { XCODE_SELECT_LINK_PATH } from '../runner-toolchain-identity.ts'; +import path from 'node:path'; +import { DEFAULT_DEVELOPER_DIR, XCODE_SELECT_LINK_PATH } from '../runner-toolchain-identity.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; export { STUBBED_APPLE_TOOLCHAIN, @@ -9,30 +11,48 @@ export { } from '@agent-device/test-kit'; /** - * The host's default selection link as the toolchain identity sees it. Undefined keeps the link - * unreadable; a case that owns a link points it at a path it created. + * The host's selection link and default Xcode as the toolchain identity sees them. Undefined keeps + * a path absent; a case that owns one points it at a path it created. `targetErrorCode` makes the + * link's target fail to resolve with that code. */ -export const appleSelectionLink: { target: string | undefined } = { target: undefined }; +export const appleSelectionLink: { + target: string | undefined; + targetErrorCode: string | undefined; + defaultDeveloperDir: string | undefined; +} = { target: undefined, targetErrorCode: undefined, defaultDeveloperDir: undefined }; /** - * Hides the selection link of the machine running the suite, so a case decides the host's selection - * rather than inheriting it. + * Hides the selection link and default Xcode of the machine running the suite, so a case decides the + * host's selection rather than inheriting it. */ export function stubAppleSelectionLink(): void { const realRealpathSync = fs.realpathSync; const realReadlinkSync = fs.readlinkSync; - const absent = () => - Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); + const failure = (code: string) => Object.assign(new Error(code), { code }); + const absent = () => failure('ENOENT'); beforeEach(() => { + // The host's own default Xcode stays out of reach: an absent default resolves to a path that + // does not exist, so every read under it fails. + const missingDefaultDeveloperDir = path.join( + mkdtempForTestSync('no-default-xcode-'), + 'Xcode.app/Contents/Developer', + ); appleSelectionLink.target = undefined; + appleSelectionLink.targetErrorCode = undefined; + appleSelectionLink.defaultDeveloperDir = undefined; vi.spyOn(fs, 'readlinkSync').mockImplementation(((target: fs.PathLike) => { if (String(target) !== XCODE_SELECT_LINK_PATH) return realReadlinkSync(target); if (appleSelectionLink.target === undefined) throw absent(); return appleSelectionLink.target; }) as typeof fs.readlinkSync); vi.spyOn(fs, 'realpathSync').mockImplementation((target: fs.PathLike) => { + if (String(target) === DEFAULT_DEVELOPER_DIR) { + if (appleSelectionLink.defaultDeveloperDir === undefined) return missingDefaultDeveloperDir; + return realRealpathSync(appleSelectionLink.defaultDeveloperDir); + } if (String(target) !== XCODE_SELECT_LINK_PATH) return realRealpathSync(target); if (appleSelectionLink.target === undefined) throw absent(); + if (appleSelectionLink.targetErrorCode) throw failure(appleSelectionLink.targetErrorCode); return realRealpathSync(appleSelectionLink.target); }); }); diff --git a/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts b/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts index df97a5a23f..889ba6abf6 100644 --- a/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts +++ b/packages/apple-runner/src/__tests__/runner-toolchain-identity.test.ts @@ -65,16 +65,33 @@ test('without DEVELOPER_DIR the host selection link names the Xcode, and a moved assert.deepEqual(readFingerprint(), FINGERPRINT_B); }); -test('a selection link whose Xcode is gone fails instead of reading another Xcode', () => { +test('a selection link whose Xcode is gone falls back to the default Xcode, as xcode-select -p does', () => { + // #4145: an uninstalled Xcode leaves the link dangling; Apple's tools then use the default Xcode. appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); appleSelectionLink.target = path.join(mkdtempForTestSync('deleted-xcode-'), 'Xcode.app'); + appleSelectionLink.defaultDeveloperDir = xcodeB; + assert.deepEqual(readFingerprint(), FINGERPRINT_B); + assert.equal(readRunnerXcodeVersion(), '27.0'); +}); + +test('without DEVELOPER_DIR or a selection link the default Xcode names the fingerprint', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); + appleSelectionLink.defaultDeveloperDir = xcodeB; + assert.deepEqual(readFingerprint(), FINGERPRINT_B); +}); + +test('a selection link whose Xcode this process cannot read fails instead of reading the default Xcode', () => { + appleRunnerTestHost.update({ commandDeveloperDir: () => undefined }); + appleSelectionLink.target = xcodeA; + appleSelectionLink.targetErrorCode = 'EACCES'; + appleSelectionLink.defaultDeveloperDir = xcodeB; assert.throws( readFingerprint, (error: unknown) => error instanceof AppError && error.details?.reason === 'apple_toolchain_unreadable' && error.details?.file === XCODE_SELECT_LINK_PATH && - error.details?.detail === 'dangling selection link', + error.details?.detail === 'EACCES', ); }); diff --git a/packages/apple-runner/src/runner-toolchain-identity.ts b/packages/apple-runner/src/runner-toolchain-identity.ts index 3aad2fb18a..6cb6254670 100644 --- a/packages/apple-runner/src/runner-toolchain-identity.ts +++ b/packages/apple-runner/src/runner-toolchain-identity.ts @@ -44,10 +44,9 @@ const SDK_DIRECTORY_NAMES: Record = { export const XCODE_SELECT_LINK_PATH = '/var/db/xcode_select_link'; /** Where `xcrun` looks when neither `DEVELOPER_DIR` nor the selection link names a developer dir. */ -const DEFAULT_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; +export const DEFAULT_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'; -const TOOLCHAIN_FILE_HINT = - 'The Apple runner cache is keyed on the toolchain version, so a cache decision cannot be made without it. Check that `xcode-select -p` or DEVELOPER_DIR names a full Xcode install.'; +const TOOLCHAIN_FILE_HINT = `The Apple runner cache is keyed on the toolchain version, so a cache decision cannot be made without it. Check that \`xcode-select -p\` or DEVELOPER_DIR names a full Xcode install. If ${XCODE_SELECT_LINK_PATH} names a deleted Xcode, run \`sudo xcode-select -s \`.`; type BundleVersion = Readonly<{ version: string; buildVersion: string }>; @@ -113,10 +112,10 @@ export function readRunnerXcodeVersion(): string | undefined { /** * The developer dir `xcrun` uses, resolved to its real path: the client's `DEVELOPER_DIR`, else the - * host's selection link, else the default Xcode when no link exists. A tool like xcodes swaps the - * Xcode.app bundle behind a stable path, so the real path is the one that names the toolchain. Like - * `xcrun`, it accepts an `Xcode.app` bundle for its `Contents/Developer`, and a link whose target is - * gone is a broken selection rather than a reason to read another Xcode. + * host's selection link, else the default Xcode. This order must match `xcode-select -p` on the + * host, because the cache key must name the Xcode that builds the runner. A tool like xcodes swaps + * the Xcode.app bundle behind a stable path, so the real path is the one that names the toolchain. + * Like `xcrun`, it accepts an `Xcode.app` bundle for its `Contents/Developer`. */ function resolveSelectedDeveloperDir(chosenDir: string | undefined): string { const candidate = chosenDir?.trim() || selectionLinkTarget() || DEFAULT_DEVELOPER_DIR; @@ -126,20 +125,27 @@ function resolveSelectedDeveloperDir(chosenDir: string | undefined): string { } function selectionLinkTarget(): string | undefined { + // No selection link: `xcrun` falls back to the default Xcode as well. + if (readSelectionLinkStep(() => fs.readlinkSync(XCODE_SELECT_LINK_PATH)) === undefined) { + return undefined; + } + // A link whose target was deleted (an uninstalled Xcode) is not a selection: `xcode-select -p` + // and `xcrun` skip it and use the default Xcode (#4145). + return readSelectionLinkStep(() => fs.realpathSync(XCODE_SELECT_LINK_PATH)); +} + +/** + * One read of the selection link. A missing path is undefined; any other failure is a selection + * this process cannot read, which must not stand in for the default Xcode. + */ +function readSelectionLinkStep(read: () => string): string | undefined { try { - fs.readlinkSync(XCODE_SELECT_LINK_PATH); + return read(); } catch (error) { - // No selection link: `xcrun` falls back to the default Xcode as well. Any other failure is a - // selection this process cannot read, which must not stand in for the default Xcode. const code = (error as NodeJS.ErrnoException).code; if (code === 'ENOENT') return undefined; throw new ToolchainFileError(XCODE_SELECT_LINK_PATH, code ?? String(error)); } - const target = realPath(XCODE_SELECT_LINK_PATH); - if (target === undefined) { - throw new ToolchainFileError(XCODE_SELECT_LINK_PATH, 'dangling selection link'); - } - return target; } /** What `xcodebuild -version` prints for the Xcode that owns `developerDir`. */