From 348ac72f410fa3476177c3e6c033750b9f42ec09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 9 Jun 2026 19:11:37 +0200 Subject: [PATCH 1/6] fix: scope source daemon state by worktree --- AGENTS.md | 3 +- package.json | 1 + scripts/print-daemon-state-dir.ts | 5 ++ src/__tests__/cli-session-state-dir.test.ts | 41 +++++++++++++ src/cli.ts | 22 +++++++ src/commands/cli-grammar/apps.ts | 5 +- src/commands/command-descriptions.ts | 2 +- src/daemon/__tests__/config.test.ts | 64 +++++++++++++++++++++ src/daemon/config.ts | 54 +++++++++++++++-- src/utils/__tests__/args.test.ts | 7 +++ src/utils/cli-command-overrides.ts | 6 +- src/utils/cli-flags.ts | 3 +- website/docs/docs/commands.md | 2 +- website/docs/docs/installation.md | 2 +- 14 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 scripts/print-daemon-state-dir.ts create mode 100644 src/__tests__/cli-session-state-dir.test.ts create mode 100644 src/daemon/__tests__/config.test.ts diff --git a/AGENTS.md b/AGENTS.md index e397539fd..f9ddb085d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,7 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect ## Toolchain Snapshot - Package manager: `pnpm` only. Do not add or restore `package-lock.json`. +- Packaged installs use `~/.agent-device` as the implicit daemon state dir. Source checkouts default to a worktree-scoped daemon state dir under `~/.agent-device/dev/` so local branches do not block each other. Use `pnpm daemon:state-dir` to print the effective path for the current worktree; `--state-dir` and `AGENT_DEVICE_STATE_DIR` remain authoritative overrides. - Runtime baseline is Node >= 22. Prefer built-in Node APIs such as global `fetch`, Web Streams, and `AbortSignal.timeout` over compatibility wrappers unless the surrounding code needs a lower-level transport. - Lint/format stack is OXC: - config: `.oxlintrc.json`, `.oxfmtrc.json` @@ -187,7 +188,7 @@ Command-only flags (like `find --first`) that do not flow to the platform layer ## Manual Device Session Hygiene - Treat every manually opened `agent-device` session as a resource that must be closed, including exploratory sessions and failed verification attempts. -- For experiments, use a purpose-specific session name and, when practical, an isolated `--state-dir` under `/private/tmp` so stale metadata does not poison the default daemon. +- For experiments, use a purpose-specific session name and, when practical, an isolated `--state-dir` under `/private/tmp` when you need cleanup isolation beyond the current worktree's default daemon. - Keep track of each opened session in the working notes. Before final response, close each one with the same flags used to open it. - If `close` or a later command is blocked by stale daemon metadata, inspect running processes first with `ps -ax | rg "agent-device|xcodebuild test-without-building"`. Stop only exact stale PIDs that belong to the verification run, then run `pnpm clean:daemon`. - If cleanup cannot be completed, report the remaining session name, state dir, process IDs, and metadata paths as a blocker. diff --git a/package.json b/package.json index 35e7e787f..ebf07b37f 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "scripts": { "build": "rslib build", "clean:daemon": "node --experimental-strip-types scripts/clean-daemon.ts", + "daemon:state-dir": "node --experimental-strip-types scripts/print-daemon-state-dir.ts", "clean:xcuitest": "node scripts/clean-xcuitest-derived.mjs", "clean:xcuitest:ios": "node scripts/clean-xcuitest-derived.mjs ios", "clean:xcuitest:macos": "node scripts/clean-xcuitest-derived.mjs macos", diff --git a/scripts/print-daemon-state-dir.ts b/scripts/print-daemon-state-dir.ts new file mode 100644 index 000000000..e574daa3a --- /dev/null +++ b/scripts/print-daemon-state-dir.ts @@ -0,0 +1,5 @@ +import { resolveDaemonPaths } from '../src/daemon/config.ts'; + +const paths = resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR); + +process.stdout.write(`${paths.baseDir}\n`); diff --git a/src/__tests__/cli-session-state-dir.test.ts b/src/__tests__/cli-session-state-dir.test.ts new file mode 100644 index 000000000..17b4ed2e5 --- /dev/null +++ b/src/__tests__/cli-session-state-dir.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'vitest'; +import { runCliCapture } from './cli-capture.ts'; + +test('session state-dir prints the resolved source-checkout daemon state dir without daemon startup', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-state-dir-')); + const home = path.join(root, 'home'); + fs.mkdirSync(home, { recursive: true }); + try { + const result = await runCliCapture(['session', 'state-dir', '--json'], { + env: { HOME: home }, + }); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 0); + const payload = JSON.parse(result.stdout) as { success: boolean; data: { stateDir: string } }; + assert.equal(payload.success, true); + assert.match(payload.data.stateDir, /^.+\/\.agent-device\/dev\/agent-device-/); + assert.equal(payload.data.stateDir.startsWith(home), true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('session state-dir respects explicit state dir overrides', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-state-dir-')); + try { + const result = await runCliCapture(['session', 'state-dir', '--state-dir', './custom-state'], { + cwd: root, + }); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 0); + assert.equal(result.stdout.trim(), path.join(fs.realpathSync.native(root), 'custom-state')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/cli.ts b/src/cli.ts index e2110d2fa..307939f81 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -241,6 +241,10 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): parsedBatchSteps = readBatchSteps(flags); } + if (tryPrintSessionStateDir({ command, positionals, flags: effectiveFlags, daemonPaths })) { + return; + } + if (shouldResolveRemoteAuth(command)) { const authResolution = await resolveRemoteAuthForCli({ command, @@ -374,6 +378,24 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): ); } +function tryPrintSessionStateDir(options: { + command: string; + positionals: string[]; + flags: CliFlags; + daemonPaths: ReturnType; +}): boolean { + if (options.command !== 'session' || options.positionals[0] !== 'state-dir') return false; + if (options.positionals.length > 1) { + throw new AppError('INVALID_ARGS', 'session state-dir does not accept additional arguments.'); + } + if (options.flags.json) { + printJson({ success: true, data: { stateDir: options.daemonPaths.baseDir } }); + } else { + process.stdout.write(`${options.daemonPaths.baseDir}\n`); + } + return true; +} + function isDebugRequested(argv: string[]): boolean { try { const parsed = parseRawArgs(argv); diff --git a/src/commands/cli-grammar/apps.ts b/src/commands/cli-grammar/apps.ts index 37a1f5ca3..39fb42c35 100644 --- a/src/commands/cli-grammar/apps.ts +++ b/src/commands/cli-grammar/apps.ts @@ -110,10 +110,11 @@ function installInputFromCli( }; } -function readSessionAction(value: string | undefined): 'list' { +function readSessionAction(value: string | undefined): 'list' | 'state-dir' { const action = value ?? 'list'; if (action === 'list') return action; - throw new AppError('INVALID_ARGS', 'session only supports list'); + if (action === 'state-dir') return action; + throw new AppError('INVALID_ARGS', 'session only supports list or state-dir'); } function openPositionals(input: CommandInput): string[] { diff --git a/src/commands/command-descriptions.ts b/src/commands/command-descriptions.ts index aec34b68d..99c74593e 100644 --- a/src/commands/command-descriptions.ts +++ b/src/commands/command-descriptions.ts @@ -2,7 +2,7 @@ const COMMAND_DESCRIPTIONS = { devices: 'List available devices.', boot: 'Boot or prepare a selected device without using CLI positional arguments.', apps: 'List installed apps.', - session: 'List active sessions.', + session: 'List active sessions or print daemon state directory.', open: 'Open an app, deep link, URL, or platform surface.', prepare: 'Prepare platform helper infrastructure.', close: 'Close an app or end the active session.', diff --git a/src/daemon/__tests__/config.test.ts b/src/daemon/__tests__/config.test.ts new file mode 100644 index 000000000..084a15882 --- /dev/null +++ b/src/daemon/__tests__/config.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'vitest'; +import { resolveDaemonPaths } from '../config.ts'; + +test('resolveDaemonPaths keeps explicit state directories authoritative', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-config-home-')); + try { + const paths = resolveDaemonPaths('~/custom-daemon', { env: { HOME: home } }); + assert.equal(paths.baseDir, path.join(home, 'custom-daemon')); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test('resolveDaemonPaths keeps packaged installs on the global daemon state directory', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-config-home-')); + const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-package-root-')); + try { + fs.writeFileSync(path.join(packageRoot, 'package.json'), '{"name":"agent-device"}\n'); + + const paths = resolveDaemonPaths(undefined, { + env: { HOME: home }, + projectRoot: packageRoot, + }); + + assert.equal(paths.baseDir, path.join(home, '.agent-device')); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(packageRoot, { recursive: true, force: true }); + } +}); + +test('resolveDaemonPaths scopes source checkout defaults by project root', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-config-home-')); + const firstRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-source-a-')); + const secondRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-source-b-')); + try { + for (const root of [firstRoot, secondRoot]) { + fs.writeFileSync(path.join(root, 'package.json'), '{"name":"agent-device"}\n'); + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); + fs.writeFileSync(path.join(root, 'src', 'daemon.ts'), 'export {};\n'); + } + + const firstPaths = resolveDaemonPaths(undefined, { + env: { HOME: home }, + projectRoot: firstRoot, + }); + const secondPaths = resolveDaemonPaths(undefined, { + env: { HOME: home }, + projectRoot: secondRoot, + }); + + assert.match(firstPaths.baseDir, /^.+\/\.agent-device\/dev\/agent-device-source-a-/); + assert.match(secondPaths.baseDir, /^.+\/\.agent-device\/dev\/agent-device-source-b-/); + assert.notEqual(firstPaths.baseDir, secondPaths.baseDir); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(firstRoot, { recursive: true, force: true }); + fs.rmSync(secondRoot, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/config.ts b/src/daemon/config.ts index 51571f5f7..85877f41b 100644 --- a/src/daemon/config.ts +++ b/src/daemon/config.ts @@ -1,5 +1,8 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; import path from 'node:path'; import { expandUserHomePath, resolveUserPath } from '../utils/path-resolution.ts'; +import { findProjectRoot } from '../utils/version.ts'; export type DaemonServerMode = 'socket' | 'http' | 'dual'; export type DaemonTransportPreference = 'auto' | 'socket' | 'http'; @@ -13,8 +16,18 @@ export type DaemonPaths = { sessionsDir: string; }; -export function resolveDaemonPaths(stateDir: string | undefined): DaemonPaths { - const baseDir = resolveStateDir(stateDir); +type EnvMap = Record; + +type ResolveDaemonPathsOptions = { + env?: EnvMap; + projectRoot?: string; +}; + +export function resolveDaemonPaths( + stateDir: string | undefined, + options: ResolveDaemonPathsOptions = {}, +): DaemonPaths { + const baseDir = resolveStateDir(stateDir, options); return { baseDir, infoPath: path.join(baseDir, 'daemon.json'), @@ -24,12 +37,43 @@ export function resolveDaemonPaths(stateDir: string | undefined): DaemonPaths { }; } -function resolveStateDir(raw: string | undefined): string { +function resolveStateDir(raw: string | undefined, options: ResolveDaemonPathsOptions): string { const value = (raw ?? '').trim(); if (!value) { - return path.join(expandUserHomePath('~'), '.agent-device'); + return resolveDefaultDaemonStateDir(options); + } + return resolveUserPath(value, { env: options.env }); +} + +export function resolveDefaultDaemonStateDir(options: ResolveDaemonPathsOptions = {}): string { + const globalStateDir = path.join(expandUserHomePath('~', { env: options.env }), '.agent-device'); + const projectRoot = options.projectRoot ?? findProjectRoot(); + if (!isSourceCheckoutProjectRoot(projectRoot)) { + return globalStateDir; + } + return path.join(globalStateDir, 'dev', buildSourceCheckoutStateDirName(projectRoot)); +} + +function isSourceCheckoutProjectRoot(projectRoot: string): boolean { + return ( + fs.existsSync(path.join(projectRoot, 'package.json')) && + fs.existsSync(path.join(projectRoot, 'src', 'daemon.ts')) + ); +} + +function buildSourceCheckoutStateDirName(projectRoot: string): string { + const resolvedRoot = resolveRealPath(projectRoot); + const slug = path.basename(resolvedRoot).replaceAll(/[^a-zA-Z0-9._-]+/g, '-'); + const hash = crypto.createHash('sha1').update(resolvedRoot).digest('hex').slice(0, 12); + return `${slug || 'agent-device'}-${hash}`; +} + +function resolveRealPath(filePath: string): string { + try { + return fs.realpathSync.native(filePath); + } catch { + return path.resolve(filePath); } - return resolveUserPath(value); } export function resolveDaemonServerMode(raw: string | undefined): DaemonServerMode { diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 5424b174c..f1632b3e2 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1494,6 +1494,13 @@ test('usage renders concise commands inline with descriptions', () => { assert.doesNotMatch(help, /agent-device-proxy/); }); +test('session command help includes daemon state directory discovery', () => { + const help = usageForCommand('session'); + if (help === null) throw new Error('Expected command help text'); + assert.match(help, /Usage:\s+agent-device session list \| session state-dir/); + assert.match(help, /effective daemon state directory/); +}); + test('command usage describes test suite flags', () => { const help = usageForCommand('test'); if (help === null) throw new Error('Expected command help text'); diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index fad60b7d6..93a3f826b 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -345,8 +345,10 @@ const CLI_COMMAND_OVERRIDES = { positionalArgs: ['setting', 'state', 'target?', 'mode?'], }, session: { - usageOverride: 'session list', - positionalArgs: ['list?'], + usageOverride: 'session list | session state-dir', + listUsageOverride: 'session list', + helpDescription: 'List active sessions or print the effective daemon state directory', + positionalArgs: ['list|state-dir?'], }, } as const satisfies Partial>; diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 803313934..936a1fe7a 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -188,7 +188,8 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ names: ['--state-dir'], type: 'string', usageLabel: '--state-dir ', - usageDescription: 'Daemon state directory (defaults to ~/.agent-device)', + usageDescription: + 'Daemon state directory (defaults to ~/.agent-device for packages, or a worktree-scoped dev dir from source)', }, { key: 'daemonBaseUrl', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index f4ffc3d2c..e0456e175 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -848,4 +848,4 @@ For CLI-discoverable setup guidance, run `agent-device help physical-device`. - First-run XCTest setup/build can take longer than normal commands; keep the device connected and use `--debug` to inspect signing/build diagnostics if setup times out. - If you override the iOS runner derived-data path and also force cleanup, keep `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` under the project `.tmp/` directory. Other cleanup override paths are rejected with a recovery hint. - For daemon startup troubleshooting: - - follow stale metadata hints for `/daemon.json` and `/daemon.lock` (`state-dir` defaults to `~/.agent-device`) + - follow stale metadata hints for `/daemon.json` and `/daemon.lock` (`state-dir` defaults to `~/.agent-device` for packaged installs, or a worktree-scoped dir under `~/.agent-device/dev/` from source) diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index 7aa516ee0..a5940f01d 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -84,4 +84,4 @@ One-off `npx` usage is fine for humans and scripts that intentionally fetch from - If daemon startup reports stale metadata, remove stale files and retry: - `/daemon.json` - `/daemon.lock` - - default state dir is `~/.agent-device` unless `AGENT_DEVICE_STATE_DIR` or `--state-dir` is set + - default state dir is `~/.agent-device` for packaged installs; source checkouts default to a worktree-scoped dir under `~/.agent-device/dev/` unless `AGENT_DEVICE_STATE_DIR` or `--state-dir` is set From 8d808d74d5d40a79b09cbedee846429d8809431b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 9 Jun 2026 19:30:59 +0200 Subject: [PATCH 2/6] docs: clarify worktree daemon state tradeoffs --- AGENTS.md | 2 +- src/__tests__/cli-session-state-dir.test.ts | 2 +- website/docs/docs/installation.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f9ddb085d..b5c677784 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect ## Toolchain Snapshot - Package manager: `pnpm` only. Do not add or restore `package-lock.json`. -- Packaged installs use `~/.agent-device` as the implicit daemon state dir. Source checkouts default to a worktree-scoped daemon state dir under `~/.agent-device/dev/` so local branches do not block each other. Use `pnpm daemon:state-dir` to print the effective path for the current worktree; `--state-dir` and `AGENT_DEVICE_STATE_DIR` remain authoritative overrides. +- Packaged installs use `~/.agent-device` as the implicit daemon state dir. Source checkouts default to a worktree-scoped daemon state dir under `~/.agent-device/dev/-` so local branches do not block each other. Use `pnpm daemon:state-dir` to print the effective path for the current worktree; `--state-dir` and `AGENT_DEVICE_STATE_DIR` remain authoritative overrides. Daemons are isolated by worktree, but devices are not; target different devices or simulators when running multiple worktrees concurrently. After pulling the worktree-scoped daemon change for the first time, stop any legacy source-checkout daemon with `AGENT_DEVICE_STATE_DIR=~/.agent-device pnpm clean:daemon`. - Runtime baseline is Node >= 22. Prefer built-in Node APIs such as global `fetch`, Web Streams, and `AbortSignal.timeout` over compatibility wrappers unless the surrounding code needs a lower-level transport. - Lint/format stack is OXC: - config: `.oxlintrc.json`, `.oxfmtrc.json` diff --git a/src/__tests__/cli-session-state-dir.test.ts b/src/__tests__/cli-session-state-dir.test.ts index 17b4ed2e5..ad848b533 100644 --- a/src/__tests__/cli-session-state-dir.test.ts +++ b/src/__tests__/cli-session-state-dir.test.ts @@ -18,7 +18,7 @@ test('session state-dir prints the resolved source-checkout daemon state dir wit assert.equal(result.calls.length, 0); const payload = JSON.parse(result.stdout) as { success: boolean; data: { stateDir: string } }; assert.equal(payload.success, true); - assert.match(payload.data.stateDir, /^.+\/\.agent-device\/dev\/agent-device-/); + assert.match(payload.data.stateDir, /\/\.agent-device\/dev\//); assert.equal(payload.data.stateDir.startsWith(home), true); } finally { fs.rmSync(root, { recursive: true, force: true }); diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index a5940f01d..1d7c6d39e 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -85,3 +85,4 @@ One-off `npx` usage is fine for humans and scripts that intentionally fetch from - `/daemon.json` - `/daemon.lock` - default state dir is `~/.agent-device` for packaged installs; source checkouts default to a worktree-scoped dir under `~/.agent-device/dev/` unless `AGENT_DEVICE_STATE_DIR` or `--state-dir` is set + - after pulling the worktree-scoped daemon change in a source checkout, stop any legacy default daemon once with `AGENT_DEVICE_STATE_DIR=~/.agent-device pnpm clean:daemon` From dcadecccdfa9509713f23211baf66f7cf51d5a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 9 Jun 2026 19:40:16 +0200 Subject: [PATCH 3/6] ci: harden Apple runner cache --- .github/actions/setup-apple-replay/action.yml | 4 ++-- .github/actions/upload-agent-device-artifacts/action.yml | 6 ++++++ .github/workflows/ios.yml | 4 +++- .github/workflows/macos.yml | 4 +++- .github/workflows/perf-nightly.yml | 1 + .github/workflows/replays-nightly.yml | 8 ++++++-- 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/actions/setup-apple-replay/action.yml b/.github/actions/setup-apple-replay/action.yml index c82373032..1720f4ea9 100644 --- a/.github/actions/setup-apple-replay/action.yml +++ b/.github/actions/setup-apple-replay/action.yml @@ -56,7 +56,7 @@ runs: id: source-hash run: | set -euo pipefail - echo "value=${{ hashFiles('ios-runner/**', 'package.json', 'pnpm-lock.yaml') }}" >> "$GITHUB_OUTPUT" + echo "value=${{ hashFiles('ios-runner/**', 'scripts/build-xcuitest-apple.sh', 'scripts/patch-xcuitest-runner-icon.ts', 'scripts/write-xcuitest-cache-metadata.mjs', 'src/platforms/ios/apple-runner-platform.ts', 'src/platforms/ios/runner-icon.ts', 'src/platforms/ios/runner-xctestrun.ts', 'src/platforms/ios/runner-xctestrun-products.ts', '.github/actions/setup-apple-replay/action.yml', 'package.json', 'pnpm-lock.yaml') }}" >> "$GITHUB_OUTPUT" shell: bash - name: Cache replay prebuilt @@ -68,7 +68,7 @@ runs: - name: Resolve agent-device home id: agent-home - run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT" + run: echo "dir=${AGENT_DEVICE_STATE_DIR:-$HOME/.agent-device}" >> "$GITHUB_OUTPUT" shell: bash - name: Build replay artifacts diff --git a/.github/actions/upload-agent-device-artifacts/action.yml b/.github/actions/upload-agent-device-artifacts/action.yml index a65b5e58a..b4764ad5b 100644 --- a/.github/actions/upload-agent-device-artifacts/action.yml +++ b/.github/actions/upload-agent-device-artifacts/action.yml @@ -8,6 +8,10 @@ inputs: agent-home-dir: description: "Resolved agent-device home directory" required: true + runner-derived-path: + description: "Optional Apple runner derived data path to upload cache metadata and Xcode logs" + required: false + default: ".agent-device-no-runner-derived" runs: using: "composite" @@ -21,5 +25,7 @@ runs: ${{ inputs.agent-home-dir }}/daemon.log ${{ inputs.agent-home-dir }}/logs/** ${{ inputs.agent-home-dir }}/sessions/** + ${{ inputs.runner-derived-path }}/.agent-device-runner-cache.json + ${{ inputs.runner-derived-path }}/Logs/** test/artifacts/** test/screenshots/** diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index fc282096f..50970600a 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -28,6 +28,7 @@ jobs: timeout-minutes: 80 env: IOS_RUNTIME_VERSION: '26.2' + AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived steps: - name: Checkout @@ -76,4 +77,5 @@ jobs: uses: ./.github/actions/upload-agent-device-artifacts with: artifact-name: ios-artifacts - agent-home-dir: ${{ steps.apple-replay.outputs.agent-home-dir }} + agent-home-dir: ${{ env.AGENT_DEVICE_STATE_DIR }} + runner-derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index e17cefffd..48e0e11f9 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -27,6 +27,7 @@ jobs: runs-on: macos-26 timeout-minutes: 80 env: + AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/macos-runner-derived steps: - name: Checkout @@ -54,4 +55,5 @@ jobs: uses: ./.github/actions/upload-agent-device-artifacts with: artifact-name: macos-artifacts - agent-home-dir: ${{ steps.apple-replay.outputs.agent-home-dir }} + agent-home-dir: ${{ env.AGENT_DEVICE_STATE_DIR }} + runner-derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml index 1d3d4586d..07119dd0c 100644 --- a/.github/workflows/perf-nightly.yml +++ b/.github/workflows/perf-nightly.yml @@ -34,6 +34,7 @@ jobs: timeout-minutes: 80 env: IOS_RUNTIME_VERSION: "26.2" + AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived steps: - name: Checkout diff --git a/.github/workflows/replays-nightly.yml b/.github/workflows/replays-nightly.yml index fe1636d2b..4806c469a 100644 --- a/.github/workflows/replays-nightly.yml +++ b/.github/workflows/replays-nightly.yml @@ -51,6 +51,7 @@ jobs: timeout-minutes: 80 env: IOS_RUNTIME_VERSION: '26.2' + AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived steps: - name: Checkout @@ -97,13 +98,15 @@ jobs: uses: ./.github/actions/upload-agent-device-artifacts with: artifact-name: replay-nightly-ios-artifacts - agent-home-dir: ${{ steps.apple-replay.outputs.agent-home-dir }} + agent-home-dir: ${{ env.AGENT_DEVICE_STATE_DIR }} + runner-derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} nightly-macos: name: macOS Replay Suite runs-on: macos-26 timeout-minutes: 80 env: + AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/macos-runner-derived steps: - name: Checkout @@ -131,4 +134,5 @@ jobs: uses: ./.github/actions/upload-agent-device-artifacts with: artifact-name: replay-nightly-macos-artifacts - agent-home-dir: ${{ steps.apple-replay.outputs.agent-home-dir }} + agent-home-dir: ${{ env.AGENT_DEVICE_STATE_DIR }} + runner-derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} From 82788a44bdfbc7565eb6a19c93d12279b6b8c5cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 9 Jun 2026 19:42:35 +0200 Subject: [PATCH 4/6] chore: keep daemon state helper internal --- src/daemon/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/daemon/config.ts b/src/daemon/config.ts index 85877f41b..7e13e96f7 100644 --- a/src/daemon/config.ts +++ b/src/daemon/config.ts @@ -45,7 +45,7 @@ function resolveStateDir(raw: string | undefined, options: ResolveDaemonPathsOpt return resolveUserPath(value, { env: options.env }); } -export function resolveDefaultDaemonStateDir(options: ResolveDaemonPathsOptions = {}): string { +function resolveDefaultDaemonStateDir(options: ResolveDaemonPathsOptions = {}): string { const globalStateDir = path.join(expandUserHomePath('~', { env: options.env }), '.agent-device'); const projectRoot = options.projectRoot ?? findProjectRoot(); if (!isSourceCheckoutProjectRoot(projectRoot)) { From 5b37ac8889bf003e65e138aa35f52efa0f199d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 9 Jun 2026 19:56:21 +0200 Subject: [PATCH 5/6] ci: validate Apple runner cache restores --- .github/actions/setup-apple-replay/action.yml | 37 +++++- .github/workflows/macos.yml | 2 + .github/workflows/replays-nightly.yml | 2 + scripts/write-xcuitest-cache-metadata.mjs | 120 +++++++++++++++++- .../ios/__tests__/runner-xctestrun.test.ts | 60 +++++++++ 5 files changed, 216 insertions(+), 5 deletions(-) diff --git a/.github/actions/setup-apple-replay/action.yml b/.github/actions/setup-apple-replay/action.yml index 1720f4ea9..0e485baf5 100644 --- a/.github/actions/setup-apple-replay/action.yml +++ b/.github/actions/setup-apple-replay/action.yml @@ -37,8 +37,8 @@ outputs: description: "Resolved agent-device home directory" value: ${{ steps.agent-home.outputs.dir }} cache-hit: - description: "Whether the replay cache was restored" - value: ${{ steps.restore-prebuilt.outputs.cache-hit }} + description: "Whether a compatible replay cache was restored" + value: ${{ steps.cache-status.outputs.cache-hit }} runs: using: "composite" @@ -66,13 +66,44 @@ runs: path: ${{ inputs.derived-path }} key: ${{ inputs.cache-key-prefix }}-${{ steps.xcode.outputs.key }}${{ inputs.cache-key-suffix }}-${{ steps.source-hash.outputs.value }} + - name: Validate replay prebuilt + id: validate-prebuilt + if: steps.restore-prebuilt.outputs.cache-hit == 'true' && inputs.xcuitest-platform != '' && inputs.xcuitest-destination != '' + run: | + set -euo pipefail + if node scripts/write-xcuitest-cache-metadata.mjs --check "$XCUITEST_PLATFORM" "$DERIVED_PATH" "$XCUITEST_DESTINATION"; then + echo "valid=true" >> "$GITHUB_OUTPUT" + else + echo "valid=false" >> "$GITHUB_OUTPUT" + rm -rf "$DERIVED_PATH" + fi + shell: bash + env: + DERIVED_PATH: ${{ inputs.derived-path }} + XCUITEST_PLATFORM: ${{ inputs.xcuitest-platform }} + XCUITEST_DESTINATION: ${{ inputs.xcuitest-destination }} + + - name: Resolve replay cache status + id: cache-status + run: | + set -euo pipefail + if [ "$RESTORED" = "true" ] && [ "$VALID" != "false" ]; then + echo "cache-hit=true" >> "$GITHUB_OUTPUT" + else + echo "cache-hit=false" >> "$GITHUB_OUTPUT" + fi + shell: bash + env: + RESTORED: ${{ steps.restore-prebuilt.outputs.cache-hit }} + VALID: ${{ steps.validate-prebuilt.outputs.valid }} + - name: Resolve agent-device home id: agent-home run: echo "dir=${AGENT_DEVICE_STATE_DIR:-$HOME/.agent-device}" >> "$GITHUB_OUTPUT" shell: bash - name: Build replay artifacts - if: inputs.build-on-miss == 'true' && steps.restore-prebuilt.outputs.cache-hit != 'true' + if: inputs.build-on-miss == 'true' && steps.cache-status.outputs.cache-hit != 'true' run: ${{ inputs.build-command }} shell: bash env: diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 48e0e11f9..79a840c18 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -43,6 +43,8 @@ jobs: derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} cache-key-prefix: macos-runner-prebuilt build-command: pnpm build:xcuitest:macos + xcuitest-platform: macos + xcuitest-destination: platform=macOS,arch=arm64 - name: Build macOS helper run: pnpm build:macos-helper diff --git a/.github/workflows/replays-nightly.yml b/.github/workflows/replays-nightly.yml index 4806c469a..7585c10ed 100644 --- a/.github/workflows/replays-nightly.yml +++ b/.github/workflows/replays-nightly.yml @@ -122,6 +122,8 @@ jobs: derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} cache-key-prefix: macos-runner-prebuilt build-command: pnpm build:xcuitest:macos + xcuitest-platform: macos + xcuitest-destination: platform=macOS,arch=arm64 - name: Build macOS helper run: pnpm build:macos-helper diff --git a/scripts/write-xcuitest-cache-metadata.mjs b/scripts/write-xcuitest-cache-metadata.mjs index c2f6da80c..47e041ceb 100644 --- a/scripts/write-xcuitest-cache-metadata.mjs +++ b/scripts/write-xcuitest-cache-metadata.mjs @@ -4,10 +4,14 @@ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; -const [platform, derivedPath, destination] = process.argv.slice(2); +const args = process.argv.slice(2); +const checkMode = args[0] === '--check'; +const [platform, derivedPath, destination] = checkMode ? args.slice(1) : args; if (!platform || !derivedPath || !destination) { - console.error('Usage: write-xcuitest-cache-metadata.mjs '); + console.error( + 'Usage: write-xcuitest-cache-metadata.mjs [--check] ', + ); process.exit(1); } @@ -218,9 +222,121 @@ if (artifacts) { metadata.artifacts = artifacts; } +if (checkMode) { + const validation = validateExistingRunnerCache(metadata); + if (!validation.ok) { + console.error(`Invalid XCTest runner cache: ${validation.reason}`); + process.exit(1); + } + console.log('XCTest runner cache metadata is valid'); + process.exit(0); +} + fs.mkdirSync(path.dirname(metadataPath), { recursive: true }); fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); +function validateExistingRunnerCache(expectedMetadata) { + const actualMetadata = readRunnerCacheMetadata(); + if (!actualMetadata) { + return { ok: false, reason: 'cache_metadata_missing' }; + } + if (stableJsonStringify(comparableRunnerCacheMetadata(actualMetadata)) !== + stableJsonStringify(comparableRunnerCacheMetadata(expectedMetadata))) { + return { ok: false, reason: 'cache_metadata_mismatch' }; + } + const artifacts = actualMetadata.artifacts; + if (!isRunnerCacheArtifacts(artifacts)) { + return { ok: false, reason: 'cache_artifacts_missing' }; + } + if (!isPathInsideDirectory(artifacts.xctestrunPath, derivedPath)) { + return { ok: false, reason: 'cache_xctestrun_outside_derived_path' }; + } + if (!pathSignatureMatches(artifacts.xctestrunPath, { + mtimeMs: artifacts.xctestrunMtimeMs, + size: artifacts.xctestrunSize, + })) { + return { ok: false, reason: 'cache_xctestrun_signature_mismatch' }; + } + for (const product of artifacts.productPaths) { + if (!isPathInsideDirectory(product.path, derivedPath)) { + return { ok: false, reason: 'cache_product_outside_derived_path' }; + } + if (!pathSignatureMatches(product.path, product)) { + return { ok: false, reason: 'cache_product_signature_mismatch' }; + } + } + return { ok: true }; +} + +function readRunnerCacheMetadata() { + try { + const raw = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return null; + } + return raw; + } catch { + return null; + } +} + +function comparableRunnerCacheMetadata(cacheMetadata) { + const { artifacts: _artifacts, ...comparable } = cacheMetadata; + return comparable; +} + +function stableJsonStringify(value) { + return JSON.stringify(sortJsonKeys(value)); +} + +function sortJsonKeys(value) { + if (Array.isArray(value)) { + return value.map((item) => sortJsonKeys(item)); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, sortJsonKeys(item)]), + ); +} + +function isRunnerCacheArtifacts(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + return ( + typeof value.xctestrunPath === 'string' && + Number.isInteger(value.xctestrunMtimeMs) && + Number.isInteger(value.xctestrunSize) && + Array.isArray(value.productPaths) && + value.productPaths.length > 0 && + value.productPaths.every(isRunnerCacheProductArtifact) + ); +} + +function isRunnerCacheProductArtifact(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + return ( + typeof value.path === 'string' && + Number.isInteger(value.mtimeMs) && + Number.isInteger(value.size) + ); +} + +function pathSignatureMatches(filePath, expected) { + return readFileMtimeMs(filePath) === expected.mtimeMs && readFileSize(filePath) === expected.size; +} + +function isPathInsideDirectory(targetPath, directoryPath) { + const relativePath = path.relative(path.resolve(directoryPath), path.resolve(targetPath)); + return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); +} + function resolveRunnerCacheArtifacts() { const xctestrunPath = findXctestrun(derivedPath); if (!xctestrunPath) return null; diff --git a/src/platforms/ios/__tests__/runner-xctestrun.test.ts b/src/platforms/ios/__tests__/runner-xctestrun.test.ts index bb89aec52..9c6799d9b 100644 --- a/src/platforms/ios/__tests__/runner-xctestrun.test.ts +++ b/src/platforms/ios/__tests__/runner-xctestrun.test.ts @@ -216,6 +216,28 @@ test('setup metadata script matches expected iOS simulator cache metadata', asyn 'esac', ].join('\n'), ); + writeExecutable( + path.join(binDir, 'plutil'), + [ + '#!/bin/sh', + "cat <<'JSON'", + '{"TestConfigurations":[{"TestTargets":[{"ProductPaths":["__TESTROOT__/Debug-iphonesimulator/AgentDeviceRunner.app","__TESTROOT__/Debug-iphonesimulator/AgentDeviceRunnerUITests-Runner.app"]}]}]}', + 'JSON', + ].join('\n'), + ); + fs.writeFileSync( + path.join( + root, + 'AgentDeviceRunner_AgentDeviceRunnerUITests_iphonesimulator26.2-arm64.xctestrun', + ), + '{}', + ); + fs.mkdirSync(path.join(root, 'Debug-iphonesimulator', 'AgentDeviceRunner.app'), { + recursive: true, + }); + fs.mkdirSync(path.join(root, 'Debug-iphonesimulator', 'AgentDeviceRunnerUITests-Runner.app'), { + recursive: true, + }); const previousPath = process.env.PATH; process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; __resetRunnerToolchainFingerprintCacheForTests(); @@ -244,6 +266,44 @@ test('setup metadata script matches expected iOS simulator cache metadata', asyn resolveExpectedRunnerCacheMetadata(iosSimulator); assert.deepEqual(actualComparable, expectedComparable); + + execFileSync( + process.execPath, + [ + 'scripts/write-xcuitest-cache-metadata.mjs', + '--check', + 'ios', + root, + 'generic/platform=iOS Simulator', + ], + { + cwd: process.cwd(), + env: { ...process.env, PATH: process.env.PATH }, + stdio: ['ignore', 'ignore', 'inherit'], + }, + ); + + fs.writeFileSync( + path.join(root, '.agent-device-runner-cache.json'), + JSON.stringify({ ...actual, packageVersion: 'stale' }, null, 2), + ); + assert.throws(() => + execFileSync( + process.execPath, + [ + 'scripts/write-xcuitest-cache-metadata.mjs', + '--check', + 'ios', + root, + 'generic/platform=iOS Simulator', + ], + { + cwd: process.cwd(), + env: { ...process.env, PATH: process.env.PATH }, + stdio: ['ignore', 'ignore', 'pipe'], + }, + ), + ); } finally { __resetRunnerToolchainFingerprintCacheForTests(); restoreEnvVar('PATH', previousPath); From a4433ee93da9e496e26708a837e0e1f520fe5a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 9 Jun 2026 20:05:48 +0200 Subject: [PATCH 6/6] ci: simplify Apple runner cache setup --- .github/actions/setup-apple-replay/action.yml | 48 +------ .github/workflows/perf-nightly.yml | 1 - .github/workflows/replays-nightly.yml | 1 - scripts/write-xcuitest-cache-metadata.mjs | 119 +----------------- .../ios/__tests__/runner-xctestrun.test.ts | 38 ------ 5 files changed, 8 insertions(+), 199 deletions(-) diff --git a/.github/actions/setup-apple-replay/action.yml b/.github/actions/setup-apple-replay/action.yml index 0e485baf5..22043d265 100644 --- a/.github/actions/setup-apple-replay/action.yml +++ b/.github/actions/setup-apple-replay/action.yml @@ -13,7 +13,7 @@ inputs: required: false default: "" build-command: - description: "Command used to build replay artifacts when the cache is cold" + description: "Command used to refresh replay artifacts before use" required: true xcuitest-platform: description: "Optional AGENT_DEVICE_XCUITEST_PLATFORM value" @@ -23,12 +23,8 @@ inputs: description: "Optional AGENT_DEVICE_XCUITEST_DESTINATION value" required: false default: "" - clean-derived: - description: "Optional AGENT_DEVICE_IOS_CLEAN_DERIVED value" - required: false - default: "" - build-on-miss: - description: "Whether this setup action should build replay artifacts on cache miss" + build-before-use: + description: "Whether this setup action should refresh replay artifacts after restoring the cache" required: false default: "true" @@ -37,8 +33,8 @@ outputs: description: "Resolved agent-device home directory" value: ${{ steps.agent-home.outputs.dir }} cache-hit: - description: "Whether a compatible replay cache was restored" - value: ${{ steps.cache-status.outputs.cache-hit }} + description: "Whether a replay cache was restored before any build refresh" + value: ${{ steps.restore-prebuilt.outputs.cache-hit }} runs: using: "composite" @@ -66,48 +62,16 @@ runs: path: ${{ inputs.derived-path }} key: ${{ inputs.cache-key-prefix }}-${{ steps.xcode.outputs.key }}${{ inputs.cache-key-suffix }}-${{ steps.source-hash.outputs.value }} - - name: Validate replay prebuilt - id: validate-prebuilt - if: steps.restore-prebuilt.outputs.cache-hit == 'true' && inputs.xcuitest-platform != '' && inputs.xcuitest-destination != '' - run: | - set -euo pipefail - if node scripts/write-xcuitest-cache-metadata.mjs --check "$XCUITEST_PLATFORM" "$DERIVED_PATH" "$XCUITEST_DESTINATION"; then - echo "valid=true" >> "$GITHUB_OUTPUT" - else - echo "valid=false" >> "$GITHUB_OUTPUT" - rm -rf "$DERIVED_PATH" - fi - shell: bash - env: - DERIVED_PATH: ${{ inputs.derived-path }} - XCUITEST_PLATFORM: ${{ inputs.xcuitest-platform }} - XCUITEST_DESTINATION: ${{ inputs.xcuitest-destination }} - - - name: Resolve replay cache status - id: cache-status - run: | - set -euo pipefail - if [ "$RESTORED" = "true" ] && [ "$VALID" != "false" ]; then - echo "cache-hit=true" >> "$GITHUB_OUTPUT" - else - echo "cache-hit=false" >> "$GITHUB_OUTPUT" - fi - shell: bash - env: - RESTORED: ${{ steps.restore-prebuilt.outputs.cache-hit }} - VALID: ${{ steps.validate-prebuilt.outputs.valid }} - - name: Resolve agent-device home id: agent-home run: echo "dir=${AGENT_DEVICE_STATE_DIR:-$HOME/.agent-device}" >> "$GITHUB_OUTPUT" shell: bash - name: Build replay artifacts - if: inputs.build-on-miss == 'true' && steps.cache-status.outputs.cache-hit != 'true' + if: inputs.build-before-use == 'true' run: ${{ inputs.build-command }} shell: bash env: AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ inputs.derived-path }} AGENT_DEVICE_XCUITEST_PLATFORM: ${{ inputs.xcuitest-platform }} AGENT_DEVICE_XCUITEST_DESTINATION: ${{ inputs.xcuitest-destination }} - AGENT_DEVICE_IOS_CLEAN_DERIVED: ${{ inputs.clean-derived }} diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml index 07119dd0c..2140f62cf 100644 --- a/.github/workflows/perf-nightly.yml +++ b/.github/workflows/perf-nightly.yml @@ -53,7 +53,6 @@ jobs: build-command: sh ./scripts/build-xcuitest-apple.sh xcuitest-platform: ios xcuitest-destination: generic/platform=iOS Simulator - clean-derived: "1" - name: Boot iOS test simulator uses: ./.github/actions/boot-ios-test-simulator diff --git a/.github/workflows/replays-nightly.yml b/.github/workflows/replays-nightly.yml index 7585c10ed..8dbeff270 100644 --- a/.github/workflows/replays-nightly.yml +++ b/.github/workflows/replays-nightly.yml @@ -70,7 +70,6 @@ jobs: build-command: sh ./scripts/build-xcuitest-apple.sh xcuitest-platform: ios xcuitest-destination: generic/platform=iOS Simulator - build-on-miss: 'false' - name: Boot iOS test simulator uses: ./.github/actions/boot-ios-test-simulator diff --git a/scripts/write-xcuitest-cache-metadata.mjs b/scripts/write-xcuitest-cache-metadata.mjs index 47e041ceb..13bf818e4 100644 --- a/scripts/write-xcuitest-cache-metadata.mjs +++ b/scripts/write-xcuitest-cache-metadata.mjs @@ -5,13 +5,10 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; const args = process.argv.slice(2); -const checkMode = args[0] === '--check'; -const [platform, derivedPath, destination] = checkMode ? args.slice(1) : args; +const [platform, derivedPath, destination] = args; if (!platform || !derivedPath || !destination) { - console.error( - 'Usage: write-xcuitest-cache-metadata.mjs [--check] ', - ); + console.error('Usage: write-xcuitest-cache-metadata.mjs '); process.exit(1); } @@ -222,121 +219,9 @@ if (artifacts) { metadata.artifacts = artifacts; } -if (checkMode) { - const validation = validateExistingRunnerCache(metadata); - if (!validation.ok) { - console.error(`Invalid XCTest runner cache: ${validation.reason}`); - process.exit(1); - } - console.log('XCTest runner cache metadata is valid'); - process.exit(0); -} - fs.mkdirSync(path.dirname(metadataPath), { recursive: true }); fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); -function validateExistingRunnerCache(expectedMetadata) { - const actualMetadata = readRunnerCacheMetadata(); - if (!actualMetadata) { - return { ok: false, reason: 'cache_metadata_missing' }; - } - if (stableJsonStringify(comparableRunnerCacheMetadata(actualMetadata)) !== - stableJsonStringify(comparableRunnerCacheMetadata(expectedMetadata))) { - return { ok: false, reason: 'cache_metadata_mismatch' }; - } - const artifacts = actualMetadata.artifacts; - if (!isRunnerCacheArtifacts(artifacts)) { - return { ok: false, reason: 'cache_artifacts_missing' }; - } - if (!isPathInsideDirectory(artifacts.xctestrunPath, derivedPath)) { - return { ok: false, reason: 'cache_xctestrun_outside_derived_path' }; - } - if (!pathSignatureMatches(artifacts.xctestrunPath, { - mtimeMs: artifacts.xctestrunMtimeMs, - size: artifacts.xctestrunSize, - })) { - return { ok: false, reason: 'cache_xctestrun_signature_mismatch' }; - } - for (const product of artifacts.productPaths) { - if (!isPathInsideDirectory(product.path, derivedPath)) { - return { ok: false, reason: 'cache_product_outside_derived_path' }; - } - if (!pathSignatureMatches(product.path, product)) { - return { ok: false, reason: 'cache_product_signature_mismatch' }; - } - } - return { ok: true }; -} - -function readRunnerCacheMetadata() { - try { - const raw = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return null; - } - return raw; - } catch { - return null; - } -} - -function comparableRunnerCacheMetadata(cacheMetadata) { - const { artifacts: _artifacts, ...comparable } = cacheMetadata; - return comparable; -} - -function stableJsonStringify(value) { - return JSON.stringify(sortJsonKeys(value)); -} - -function sortJsonKeys(value) { - if (Array.isArray(value)) { - return value.map((item) => sortJsonKeys(item)); - } - if (!value || typeof value !== 'object') { - return value; - } - return Object.fromEntries( - Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, item]) => [key, sortJsonKeys(item)]), - ); -} - -function isRunnerCacheArtifacts(value) { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - return ( - typeof value.xctestrunPath === 'string' && - Number.isInteger(value.xctestrunMtimeMs) && - Number.isInteger(value.xctestrunSize) && - Array.isArray(value.productPaths) && - value.productPaths.length > 0 && - value.productPaths.every(isRunnerCacheProductArtifact) - ); -} - -function isRunnerCacheProductArtifact(value) { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - return ( - typeof value.path === 'string' && - Number.isInteger(value.mtimeMs) && - Number.isInteger(value.size) - ); -} - -function pathSignatureMatches(filePath, expected) { - return readFileMtimeMs(filePath) === expected.mtimeMs && readFileSize(filePath) === expected.size; -} - -function isPathInsideDirectory(targetPath, directoryPath) { - const relativePath = path.relative(path.resolve(directoryPath), path.resolve(targetPath)); - return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); -} - function resolveRunnerCacheArtifacts() { const xctestrunPath = findXctestrun(derivedPath); if (!xctestrunPath) return null; diff --git a/src/platforms/ios/__tests__/runner-xctestrun.test.ts b/src/platforms/ios/__tests__/runner-xctestrun.test.ts index 9c6799d9b..f0a903027 100644 --- a/src/platforms/ios/__tests__/runner-xctestrun.test.ts +++ b/src/platforms/ios/__tests__/runner-xctestrun.test.ts @@ -266,44 +266,6 @@ test('setup metadata script matches expected iOS simulator cache metadata', asyn resolveExpectedRunnerCacheMetadata(iosSimulator); assert.deepEqual(actualComparable, expectedComparable); - - execFileSync( - process.execPath, - [ - 'scripts/write-xcuitest-cache-metadata.mjs', - '--check', - 'ios', - root, - 'generic/platform=iOS Simulator', - ], - { - cwd: process.cwd(), - env: { ...process.env, PATH: process.env.PATH }, - stdio: ['ignore', 'ignore', 'inherit'], - }, - ); - - fs.writeFileSync( - path.join(root, '.agent-device-runner-cache.json'), - JSON.stringify({ ...actual, packageVersion: 'stale' }, null, 2), - ); - assert.throws(() => - execFileSync( - process.execPath, - [ - 'scripts/write-xcuitest-cache-metadata.mjs', - '--check', - 'ios', - root, - 'generic/platform=iOS Simulator', - ], - { - cwd: process.cwd(), - env: { ...process.env, PATH: process.env.PATH }, - stdio: ['ignore', 'ignore', 'pipe'], - }, - ), - ); } finally { __resetRunnerToolchainFingerprintCacheForTests(); restoreEnvVar('PATH', previousPath);