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
10 changes: 7 additions & 3 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,23 +126,27 @@ jobs:
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
preferred-device-name: iPhone 17 Pro

- name: Verify clean-installed Simulator snapshot bridge preparation
- name: Verify clean-installed Simulator snapshot bridge preparation and the fold-helper -Werror gate
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
git fetch origin "$BASE_SHA" --depth=1
if git diff --quiet "$BASE_SHA"...HEAD -- \
apple/snapshot-bridge \
apple/fold-helper \
packages/platform-apple/src/snapshot-source \
packages/platform-apple/src/foldable \
scripts/check-package.ts \
scripts/size-report-install.mjs \
scripts/size-report-package.mjs; then
echo "Snapshot bridge packaging is unchanged; skipping preparation proof."
echo "Snapshot bridge and fold-helper sources are unchanged; skipping preparation proof."
exit 0
fi
pnpm build
pnpm exec vitest run packages/platform-apple/src/snapshot-source/native-runtime.test.ts
pnpm exec vitest run \
packages/platform-apple/src/snapshot-source/native-runtime.test.ts \
packages/platform-apple/src/foldable/fold-helper-cache.test.ts
pnpm check:package -- --verify-snapshot-bridge-preparation

- name: Run targeted iOS runner XCTest regressions
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@
report no UIKit class names — the XCTest runner, whose own queries answered 76 nodes for that same
state, plus `appium-source` and `limrun-ios-tree` — never trigger the cut. All 39 flows of React Navigation's Maestro suite pass on an iPhone 17 Simulator running
iOS 26.2 with this change, including two that never passed on the bridge.
- Fixed (ios): runtime clang builds no longer compile with `-Werror`, so a new warning from a future
Xcode SDK cannot break the AX bridge or fold on a user's machine that this repository cannot fix
for them. The fold helper is now built through the same content- and toolchain-keyed build cache
as the AX bridge, so a fold call after the first serves a cached binary instead of recompiling
`Fold.m` on every call, and switching `DEVELOPER_DIR` busts the cache instead of serving a binary
built against a different SDK. A darwin-only CI step (`.github/workflows/ios.yml`) compiles each
build's production argv with `-Werror` appended whenever its sources change, so a new warning still
fails CI (#2796).
- Fixed (ios): a local Simulator snapshot taken through the host AX bridge once again publishes the
geometric `hittable` fact, so `is hittable` and a `hittable:` selector resolve the same controls on
the bridge and the XCTest runner. The snapshot capability table has declared `hittable =
Expand Down
13 changes: 9 additions & 4 deletions packages/command-registry/src/timeout-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,16 @@ export const DEFAULT_TIMEOUT_POLICY: CommandTimeoutPolicy = {
};

/**
* `fold` spends up to four bounded CoreDevice hinge reads (`IOS_HINGE_ANGLE_TIMEOUT_MS` each on a
* wedged host) after a 30s helper build and up to 60s of timed HID motion, which can sum past the
* standard envelope; the envelope covers that worst case with the usual margin.
* `fold`'s worst case sums every step budget on the route (platform-apple owns the constants;
* command-registry does not import them, so the figures below are copied, not derived):
* - display-inventory query (foldable check): 5s
* - fold-helper preparation (toolchain probe + build): 60s
* - HID dispatch (60s max keyframe duration + 10s): 70s
* - hinge settle reads (4 attempts x 20s): 80s
* - lit-panel display-inventory query: 5s
* total: 220s. The envelope below covers that with margin.
*/
const FOLD_REQUEST_TIMEOUT_MS = 210_000;
const FOLD_REQUEST_TIMEOUT_MS = 240_000;

export const FOLD_TIMEOUT_POLICY: CommandTimeoutPolicy = {
budget: { source: 'none' },
Expand Down
182 changes: 182 additions & 0 deletions packages/platform-apple/src/foldable/fold-helper-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import assert from 'node:assert/strict';
import { readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { beforeAll, describe, test } from 'vitest';
import { runCmd } from '@agent-device/host-kit/command';
import { mkdtempForTest } from '../__tests__/tmp-dir.ts';
import { execKillTimeoutError } from '../snapshot-source/__tests__/exec-timeout-fixture.ts';
import { createSnapshotSourceHost } from '../snapshot-source/host.ts';
import type { SnapshotSourceHost } from '../snapshot-source/types.ts';
import {
buildFoldHelperCompileArgv,
ensureFoldHelperBinary,
FOLD_HELPER_BUILD_TIMEOUT_MS,
} from './fold-helper-cache.ts';

function fakeFoldHelperHost(
binary: () => string,
xcodeVersion: () => string = () => 'Xcode 16.4\nBuild version 16F6',
): SnapshotSourceHost {
const real = createSnapshotSourceHost();
return {
...real,
run: async (command, args) => {
if (command === 'xcrun' && args.includes('clang')) {
const outputPath = args.at(-1)!;
await writeFile(outputPath, binary());
return { stdout: '', stderr: '', exitCode: 0 };
}
const stdout =
command === 'xcodebuild'
? xcodeVersion()
: command === 'sw_vers'
? args.includes('-buildVersion')
? '24G90'
: '15.6'
: command === 'uname'
? 'arm64'
: '';
return { stdout, stderr: '', exitCode: 0 };
},
};
}

test('a cache hit does not build, and a source or toolchain change does', async () => {
const root = await mkdtempForTest('agent-device-fold-helper-cache-');
const sourceRoot = path.join(root, 'source');
const cacheRoot = path.join(root, 'cache');
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
await writeFile(path.join(sourceRoot, 'Fold.m'), 'fold source v1');

let builds = 0;
let xcodeVersion = 'Xcode 16.4\nBuild version 16F6';
const host = fakeFoldHelperHost(
() => {
builds += 1;
return `binary-${builds}`;
},
() => xcodeVersion,
);

try {
const first = await ensureFoldHelperBinary({ host, sourceRoot, cacheRoot });
assert.equal(builds, 1);
assert.equal(await readFile(first.path, 'utf8'), 'binary-1');

const hit = await ensureFoldHelperBinary({ host, sourceRoot, cacheRoot });
assert.equal(hit.path, first.path);
assert.equal(builds, 1);

await writeFile(path.join(sourceRoot, 'Fold.m'), 'fold source v2');
const sourceChanged = await ensureFoldHelperBinary({ host, sourceRoot, cacheRoot });
assert.notEqual(sourceChanged.path, first.path);
assert.equal(builds, 2);

xcodeVersion = 'Xcode 16.5\nBuild version 16F5';
const toolchainChanged = await ensureFoldHelperBinary({ host, sourceRoot, cacheRoot });
assert.notEqual(toolchainChanged.path, sourceChanged.path);
assert.equal(builds, 3);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test('the runtime clang build never uses -Werror', () => {
assert.ok(!buildFoldHelperCompileArgv({ sourceRoot: '', outputPath: '' }).includes('-Werror'));
});

test('a failed compile reports fold-helper-build-failed with the compiler output', async () => {
const root = await mkdtempForTest('agent-device-fold-helper-cache-failure-');
const sourceRoot = path.join(root, 'source');
const cacheRoot = path.join(root, 'cache');
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
await writeFile(path.join(sourceRoot, 'Fold.m'), 'fold source');
const okHost = fakeFoldHelperHost(() => 'binary');
const host: SnapshotSourceHost = {
...okHost,
run: async (command, args, options) => {
if (command === 'xcrun' && args.includes('clang')) {
return { stdout: '', stderr: 'compiler detail', exitCode: 1 };
}
return await okHost.run(command, args, options);
},
};

try {
await assert.rejects(ensureFoldHelperBinary({ host, sourceRoot, cacheRoot }), {
code: 'COMMAND_FAILED',
details: {
stdout: '',
stderr: 'compiler detail',
exitCode: 1,
processExitError: true,
hint: 'Select an Xcode with the iOS simulator SDK and foldable HID support using DEVELOPER_DIR.',
reason: 'fold-helper-build-failed',
},
});
} finally {
await rm(root, { recursive: true, force: true });
}
});

test('a compile exec killed at its budget reports the fold-helper build, not the exec layer', async () => {
const root = await mkdtempForTest('agent-device-fold-helper-cache-stall-');
const sourceRoot = path.join(root, 'source');
const cacheRoot = path.join(root, 'cache');
await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot);
await writeFile(path.join(sourceRoot, 'Fold.m'), 'fold source');
const okHost = fakeFoldHelperHost(() => 'binary');
const host: SnapshotSourceHost = {
...okHost,
run: async (command, args, options) => {
if (command === 'xcrun' && args.includes('clang')) throw await execKillTimeoutError();
return await okHost.run(command, args, options);
},
};

try {
await assert.rejects(
ensureFoldHelperBinary({ host, sourceRoot, cacheRoot }),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal((error as { code?: string }).code, 'COMMAND_FAILED');
assert.equal(
(error as { details?: { reason?: string } }).details?.reason,
'fold-helper-build-failed',
);
const details = (error as { details?: Record<string, unknown> }).details;
assert.equal(details?.cause, 'native-build-stalled');
assert.equal(details?.timeoutMs, FOLD_HELPER_BUILD_TIMEOUT_MS);
assert.match(String(details?.hint), /stopped the fold helper build/);
return true;
},
);
} finally {
await rm(root, { recursive: true, force: true });
}
});

// #2796: the production compile drops -Werror so a stale toolchain warning cannot fail a build;
// this is the gate that keeps a new Fold.m warning from passing CI unnoticed. It runs the
// production argv (`buildFoldHelperCompileArgv`) against the real iphonesimulator SDK with
// -Werror appended, so a warning fails here instead of nowhere.
//
// The compile runs in beforeAll, not in the test body: it is a real clang invocation (see the
// unit slow-test budget in docs/agents/testing.md), and the snapshot-bridge sibling gate in
// native-runtime.test.ts keeps its compile out of test-case wall time the same way.
describe.skipIf(process.platform !== 'darwin')('fold helper warning gate', () => {
let compiled: { exitCode: number; stderr: string };
beforeAll(async () => {
const sourceRoot = path.resolve(import.meta.dirname, '../../../../apple/fold-helper');
const binary = path.join(await mkdtempForTest('fold-helper-werror-'), 'fold-helper');
const argv = buildFoldHelperCompileArgv({ sourceRoot, outputPath: binary });
compiled = await runCmd('xcrun', [...argv, '-Werror'], {
allowFailure: true,
timeoutMs: FOLD_HELPER_BUILD_TIMEOUT_MS,
});
}, FOLD_HELPER_BUILD_TIMEOUT_MS + 30_000);

test('the production fold helper argv compiles clean under -Werror', () => {
assert.equal(compiled.exitCode, 0, compiled.stderr);
});
});
Loading
Loading