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
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ Use `pnpm build:macos-helper:clean` if a Swift cache was created in another work

`pnpm publish` and package-manager pack commands run `prepack`, which first checks synchronized MCP
metadata and then runs `pnpm package:npm`. This is the one completeness-oriented aggregate: it
builds the TypeScript distribution and all four Apple runner targets, clean-builds the macOS helper,
builds the TypeScript distribution and all four Apple runner targets (into a scratch directory under
`.tmp/` that it removes afterwards, not into `~/.agent-device`), clean-builds the macOS helper,
packages the Apple runner source, and rebuilds both Android helper APKs. Any failed build stops
packaging. It deliberately does not stop the worktree's development daemon; use `pnpm rebuild:cli`
when a running daemon needs to pick up a new TypeScript build.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"build:xcuitest:tvos:clean": "pnpm clean:xcuitest:tvos && pnpm build:xcuitest:tvos",
"build:xcuitest:visionos": "AGENT_DEVICE_XCUITEST_PLATFORM=visionos sh ./scripts/build-xcuitest-apple.sh",
"build:xcuitest:visionos:clean": "pnpm clean:xcuitest:visionos && pnpm build:xcuitest:visionos",
"package:xcuitest": "node scripts/build-package-xcuitest.mjs",
"build:android-snapshot-helper": "AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/build-android-helper.sh $(node -p \"require('./package.json').version\") .tmp/android-snapshot-helper",
"package:android-snapshot-helper": "AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") v$(node -p \"require('./package.json').version\") .tmp/android-snapshot-helper",
"package:android-snapshot-helper:npm": "rm -rf android/snapshot-helper/dist && AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") v$(node -p \"require('./package.json').version\") android/snapshot-helper/dist",
Expand All @@ -118,7 +119,7 @@
"test:macos-helper": "node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts swift test --package-path apple/macos-helper",
"check:macos-helper": "pnpm build:macos-helper && pnpm test:macos-helper",
"prepare:publish-assets": "node scripts/prepare-publish-assets.mjs",
"build:package": "pnpm build && pnpm build:xcuitest:ios && pnpm build:xcuitest:macos && pnpm build:xcuitest:tvos && pnpm build:xcuitest:visionos && pnpm build:macos-helper:clean && pnpm prepare:publish-assets",
"build:package": "pnpm build && pnpm package:xcuitest && pnpm build:macos-helper:clean && pnpm prepare:publish-assets",
"package:npm": "pnpm build:package && pnpm check:package",
"release:prepare": "rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release && node scripts/release-workspace.mjs pack",
"release:publish": "node scripts/release-mark-dev.mjs --check-release-version && pnpm release:prepare && node scripts/release-workspace.mjs publish",
Expand Down
260 changes: 260 additions & 0 deletions packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { afterEach, beforeEach, test, vi } from 'vitest';
import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo';
import { appleRunnerTestHost } from '../test-host.ts';
import { resolveRunnerCacheMetadataPath } from '../runner-cache.ts';
import { trimRunnerBuildScratch } from '../runner-cache-trim.ts';
import { ensureXctestrunArtifact } from '../runner-xctestrun.ts';
import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts';
import { IOS_SIMULATOR } from './device-fixtures.ts';
import { seedRunnerProductBundle } from './runner-xctestrun.fixtures.ts';
import { mkdtempForTestSync } from './tmp-dir.ts';

const KEY = 'cache-0123456789abcdef';

let base: string;
let previousDerivedOverride: string | undefined;

beforeEach(() => {
base = mkdtempForTestSync('agent-device-runner-trim-');
previousDerivedOverride = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
});

afterEach(() => {
if (previousDerivedOverride === undefined)
delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
else process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = previousDerivedOverride;
});

function seedBuiltKey(derived: string): string[] {
const products = path.join(derived, 'Build', 'Products');
const app = path.join(products, 'Debug-iphonesimulator', 'Runner.app');
const xctestrun = path.join(products, 'Runner.xctestrun');
fs.mkdirSync(app, { recursive: true });
fs.writeFileSync(path.join(app, 'Runner'), 'binary');
fs.writeFileSync(xctestrun, '<plist/>');
fs.writeFileSync(resolveRunnerCacheMetadataPath(derived), '{}');
for (const scratch of [
'Build/Intermediates.noindex/Runner.build/obj.o',
'Build/Products/Debug-iphonesimulator/Runner.app.dSYM',
'SDKExplicitPrecompiledModules/Foundation.pcm',
'ModuleCache.noindex/session.timestamp',
'Logs/Build/build.xcactivitylog',
]) {
fs.mkdirSync(path.dirname(path.join(derived, scratch)), { recursive: true });
fs.writeFileSync(path.join(derived, scratch), 'scratch');
}
return [xctestrun, app];
}

function tree(directory: string, prefix = ''): string[] {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const relative = path.join(prefix, entry.name);
return entry.isDirectory()
? [relative, ...tree(path.join(directory, entry.name), relative)]
: [relative];
});
}

test('trimming keeps the products and the metadata file and removes the rest', async () => {
const derived = path.join(base, KEY);
const protectedPaths = seedBuiltKey(derived);

const removed = await trimRunnerBuildScratch(derived, protectedPaths, derived);

assert.deepEqual(removed.sort(), [
'Build/Intermediates.noindex',
'Logs',
'ModuleCache.noindex',
'SDKExplicitPrecompiledModules',
]);
assert.deepEqual(
tree(derived).sort(),
[
'.agent-device-runner-cache.json',
'Build',
'Build/Products',
'Build/Products/Debug-iphonesimulator',
'Build/Products/Debug-iphonesimulator/Runner.app',
'Build/Products/Debug-iphonesimulator/Runner.app.dSYM',
'Build/Products/Debug-iphonesimulator/Runner.app/Runner',
'Build/Products/Runner.xctestrun',
].map((entry) => entry.replaceAll('/', path.sep)),
);
});

test('a product outside the cache root leaves the tree untouched', async () => {
const derived = path.join(base, KEY);
seedBuiltKey(derived);
const outside = path.join(base, 'elsewhere.app');
fs.mkdirSync(outside);
const before = tree(derived);

assert.deepEqual(await trimRunnerBuildScratch(derived, [outside], derived), []);

assert.deepEqual(tree(derived), before);
});

test('a directory that is not the expected key is not trimmed', async () => {
const expected = path.join(base, 'managed', KEY);
const fixed = path.join(base, 'fixed', KEY);
fs.mkdirSync(expected, { recursive: true });
const protectedPaths = seedBuiltKey(fixed);
const before = tree(fixed);

assert.deepEqual(await trimRunnerBuildScratch(fixed, protectedPaths, expected), []);

assert.deepEqual(tree(fixed), before);
});

test('a symlinked key resolving outside the expected key is not trimmed', async () => {
const managedRoot = path.join(base, 'managed');
const target = path.join(base, 'target');
fs.mkdirSync(path.join(managedRoot, 'other-key'), { recursive: true });
const protectedPaths = seedBuiltKey(target);
const link = path.join(managedRoot, KEY);
fs.symlinkSync(target, link);
const before = tree(target);

assert.deepEqual(
await trimRunnerBuildScratch(
link,
protectedPaths.map((product) => path.join(link, path.relative(target, product))),
path.join(managedRoot, 'other-key'),
Comment thread
janicduplessis marked this conversation as resolved.
),
[],
);

assert.deepEqual(tree(target), before);
});

test('a default key that is itself a symlink to an outside directory is not trimmed', async () => {
const managedRoot = path.join(base, 'managed');
const outside = path.join(base, 'outside');
fs.mkdirSync(managedRoot);
const protectedPaths = seedBuiltKey(outside);
const link = path.join(managedRoot, KEY);
fs.symlinkSync(outside, link);
const before = tree(outside);

assert.deepEqual(
await trimRunnerBuildScratch(
link,
protectedPaths.map((product) => path.join(link, path.relative(outside, product))),
link,
),
[],
);

assert.deepEqual(tree(outside), before);
});

test('a product that is a symlink keeps the unit it points into', async () => {
const derived = path.join(base, KEY);
const [xctestrun] = seedBuiltKey(derived);
const target = path.join(derived, 'Build', 'Intermediates.noindex', 'Runner.build');
const link = path.join(derived, 'Build', 'Products', 'Linked.app');
fs.symlinkSync(target, link);

const removed = await trimRunnerBuildScratch(derived, [xctestrun!, link], derived);

assert.deepEqual(removed.sort(), [
'Logs',
'ModuleCache.noindex',
'SDKExplicitPrecompiledModules',
]);
assert.equal(fs.existsSync(path.join(target, 'obj.o')), true);
});

function mockRunnerBuild() {
resetAllProcessMemosForTests();
const projectRoot = mkdtempForTestSync('agent-device-runner-trim-root-');
fs.mkdirSync(
path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner', 'AgentDeviceRunner.xcodeproj'),
{ recursive: true },
);
const runCmdStreaming = vi.fn().mockImplementation(async (_command: string, args: string[]) => {
const symroot = args.find((arg) => arg.startsWith('SYMROOT='))!.slice('SYMROOT='.length);
const derived = path.dirname(path.dirname(symroot));
await seedRunnerProductBundle(path.join(symroot, 'Debug-iphonesimulator', 'Runner.app'));
fs.writeFileSync(
path.join(symroot, 'Runner_iphonesimulator27.0-arm64.xctestrun'),
`<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict><key>ProductPaths</key><array>
<string>__TESTROOT__/Debug-iphonesimulator/Runner.app</string>
</array></dict></plist>`,
);
for (const scratch of [
'Build/Intermediates.noindex/obj.o',
'Logs/build.log',
'ModuleCache.noindex/m',
]) {
fs.mkdirSync(path.dirname(path.join(derived, scratch)), { recursive: true });
fs.writeFileSync(path.join(derived, scratch), 'scratch');
}
return { exitCode: 0, stdout: '', stderr: '' };
});
appleRunnerTestHost.update({
runCmdSync: vi.fn().mockImplementation(appleToolchainProbeResult),
runCmdStreaming,
findProjectRoot: () => projectRoot,
readVersion: () => '0.0.0-test',
});

return { runCmdStreaming };
}

test('a runner build trims its key, and the next start reuses the products without rebuilding', async () => {
const { runCmdStreaming } = mockRunnerBuild();

const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {});

assert.equal(built.artifact, 'rebuilt');
assert.deepEqual(fs.readdirSync(built.derived).sort(), [
'.agent-device-runner-cache.json',
'Build',
]);
assert.deepEqual(fs.readdirSync(path.join(built.derived, 'Build')), ['Products']);

const reused = await ensureXctestrunArtifact(IOS_SIMULATOR, {});

assert.equal(reused.artifact, 'valid');
assert.equal(reused.xctestrunPath, built.xctestrunPath);
assert.equal(runCmdStreaming.mock.calls.length, 1);
});

test('a derived path override named like a cache key keeps its build scratch', async () => {
mockRunnerBuild();
const managed = await ensureXctestrunArtifact(IOS_SIMULATOR, {});
process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = path.join(
base,
'fixed',
path.basename(managed.derived),
);

const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {});

assert.equal(built.artifact, 'rebuilt');
assert.equal(built.derived, process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH);
assert.equal(fs.existsSync(path.join(built.derived, 'Build', 'Intermediates.noindex')), true);
assert.equal(fs.existsSync(path.join(built.derived, 'Logs')), true);
});

test('a derived path override inside the managed root keeps its build scratch', async () => {
mockRunnerBuild();
const managed = await ensureXctestrunArtifact(IOS_SIMULATOR, {});
process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = path.join(
path.dirname(managed.derived),
'development',
);

const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {});

assert.equal(built.artifact, 'rebuilt');
assert.equal(built.derived, process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH);
assert.equal(fs.existsSync(path.join(built.derived, 'Build', 'Intermediates.noindex')), true);
assert.equal(fs.existsSync(path.join(built.derived, 'Logs')), true);
});
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,7 @@ function isNonEmptyArray<Item>(value: unknown): value is Item[] {
return Array.isArray(value) && value.length > 0;
}

function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean {
export function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean {
const relativePath = path.relative(path.resolve(directoryPath), path.resolve(targetPath));
return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
}
27 changes: 26 additions & 1 deletion packages/platform-apple/src/runner/runner-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ import {
resolveRunnerBuildDestination,
resolveRunnerXctestrunHints,
} from './apple-runner-platform.ts';
import { resolveRunnerCacheKey } from './runner-cache-metadata.ts';
import { resolveRunnerCacheKey, resolveRunnerKeyedDerivedPath } from './runner-cache-metadata.ts';
import { resolveAppleRunnerProjectPath } from './runner-source.ts';
export { prepareXctestrunWithEnv } from './runner-artifact-env.ts';

Expand Down Expand Up @@ -689,6 +689,11 @@ async function buildXctestrunArtifact(params: {
),
derived,
);
await trimRunnerBuildScratchBestEffort(
resolveRunnerKeyedDerivedPath(device, expectedCacheMetadata),
derived,
[built, ...builtProductPaths],
);
Comment on lines +692 to +696
emitRunnerXctestrunDecision('build', 'built_new', {
derived,
xctestrunPath: built,
Expand All @@ -705,6 +710,26 @@ async function buildXctestrunArtifact(params: {
};
}

/** Runs under the cache lock, so no rebuild or reuse of this key sees the tree mid-trim. */
async function trimRunnerBuildScratchBestEffort(
expectedKeyPath: string,
derived: string,
protectedPaths: readonly string[],
): Promise<void> {
try {
const { trimRunnerBuildScratch } = await import('./runner-cache-trim.ts');
const removed = await trimRunnerBuildScratch(derived, protectedPaths, expectedKeyPath);
if (removed.length > 0)
emitRunnerXctestrunDecision('clean', 'build_scratch_trimmed', { derived });
} catch (error) {
emitDiagnostic({
level: 'warn',
phase: 'runner_xctestrun_cache_trim_failed',
data: { derived, error: error instanceof Error ? error.message : String(error) },
});
}
}

async function tryReuseExistingXctestrun(
device: DeviceInfo,
derived: string,
Expand Down
11 changes: 8 additions & 3 deletions packages/platform-apple/src/runner/runner-cache-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,9 +486,14 @@ export function resolveRunnerDerivedPath(
if (override) {
return path.resolve(override);
}
const cacheKey = resolveRunnerCacheKey(metadata);
const base = resolveRunnerDerivedBasePath(device);
return path.join(base, cacheKey);
return resolveRunnerKeyedDerivedPath(device, metadata);
}

export function resolveRunnerKeyedDerivedPath(
device: DeviceInfo,
metadata: RunnerXctestrunCacheMetadata,
): string {
return path.join(resolveRunnerDerivedBasePath(device), resolveRunnerCacheKey(metadata));
}

function resolveRunnerDerivedBasePath(device: DeviceInfo): string {
Expand Down
Loading
Loading