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
36 changes: 28 additions & 8 deletions packages/apple-runner/src/__tests__/apple-toolchain-fixtures.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Comment thread
thymikee marked this conversation as resolved.
Resolved
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',
);
});

Expand Down
36 changes: 21 additions & 15 deletions packages/apple-runner/src/runner-toolchain-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,9 @@ const SDK_DIRECTORY_NAMES: Record<AppleSdkName, string> = {
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. */
Comment thread
thymikee marked this conversation as resolved.
Resolved
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 <Xcode.app>\`.`;

type BundleVersion = Readonly<{ version: string; buildVersion: string }>;

Expand Down Expand Up @@ -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;
Comment thread
thymikee marked this conversation as resolved.
Resolved
Expand All @@ -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));
}

/**
Comment thread
thymikee marked this conversation as resolved.
Resolved
* 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 {
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
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`. */
Expand Down
Loading