simtap 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +186 -2
- package/THIRD_PARTY_LICENSES +61 -0
- package/bin/simtap.js +4 -0
- package/dist/act/diff.js +67 -0
- package/dist/act/resolve.js +49 -0
- package/dist/act/steps.js +188 -0
- package/dist/cli.js +123 -0
- package/dist/engine/apps.js +153 -0
- package/dist/engine/axe.js +107 -0
- package/dist/engine/exec.js +36 -0
- package/dist/engine/jobs.js +95 -0
- package/dist/engine/logs.js +115 -0
- package/dist/engine/simctl.js +191 -0
- package/dist/engine/state.js +30 -0
- package/dist/engine/xcode.js +253 -0
- package/dist/engine/xcresult.js +95 -0
- package/dist/errors.js +11 -0
- package/dist/look/read.js +50 -0
- package/dist/look/snapshot.js +455 -0
- package/dist/mcp.js +108 -0
- package/dist/registry.js +65 -0
- package/dist/tools/app-control.js +127 -0
- package/dist/tools/app-logs.js +128 -0
- package/dist/tools/app-run.js +94 -0
- package/dist/tools/device-use.js +68 -0
- package/dist/tools/devices.js +26 -0
- package/dist/tools/jobs.js +55 -0
- package/dist/tools/launch-app.js +68 -0
- package/dist/tools/screen-record.js +79 -0
- package/dist/tools/tests-run.js +81 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/ui-act.js +435 -0
- package/dist/tools/ui-look.js +40 -0
- package/package.json +45 -4
- package/skill/SKILL.md +23 -0
- package/vendor/axe/.version +1 -0
- package/vendor/axe/AXe_AXe.bundle/skills/axe/SKILL.md +87 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/FBControlCore +0 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBAccessibilityTraits.h +23 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBArchitecture.h +20 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBArchiveOperations.h +136 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBBinaryDescriptor.h +73 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBControlCore-Swift.h +3232 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBControlCore-SwiftImport.h +28 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBControlCore.h +47 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBControlCoreFrameworkLoader.h +104 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBControlCoreLogger+OSLog.h +30 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBControlCoreLogger.h +107 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBCrashLog.h +20 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBDataBuffer.h +75 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBDataConsumer.h +173 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBFileContainer.h +46 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBFileReader.h +25 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBFourCharCode.h +30 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBFuture+Sync.h +104 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBFuture.h +580 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBFutureContextManager.h +61 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBInstalledApplication.h +30 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBInstrumentsCommands.h +29 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBInstrumentsOperation.h +83 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBObjCExceptionGuard.h +59 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBProcessBuilder.h +272 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBProcessFetcher.h +128 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBProcessIO.h +126 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBProcessStream.h +175 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBProcessTerminationStrategy.h +25 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBScreenshotCommands.h +15 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBSettingsCommands.h +22 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBSocketServer.h +63 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBSubprocess.h +131 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBVideoStream.h +255 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBVideoStreamConfiguration.h +51 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBXCTraceOperation.h +13 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBiOSTarget.h +69 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBiOSTargetConfiguration.h +322 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBiOSTargetConstants.h +48 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Headers/FBiOSTargetOperation.h +18 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/FBControlCore.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo +0 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/FBControlCore.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo +0 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/FBControlCore.swiftmodule/arm64-apple-macos.abi.json +51890 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/FBControlCore.swiftmodule/arm64-apple-macos.private.swiftinterface +1341 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/FBControlCore.swiftmodule/arm64-apple-macos.swiftdoc +0 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/FBControlCore.swiftmodule/arm64-apple-macos.swiftinterface +1341 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/FBControlCore.swiftmodule/arm64-apple-macos.swiftmodule +0 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/FBControlCore.swiftmodule/x86_64-apple-macos.abi.json +51890 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/FBControlCore.swiftmodule/x86_64-apple-macos.private.swiftinterface +1341 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/FBControlCore.swiftmodule/x86_64-apple-macos.swiftdoc +0 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/FBControlCore.swiftmodule/x86_64-apple-macos.swiftinterface +1341 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/FBControlCore.swiftmodule/x86_64-apple-macos.swiftmodule +0 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Modules/module.modulemap +10 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/Resources/Info.plist +50 -0
- package/vendor/axe/Frameworks/FBControlCore.framework/Versions/A/_CodeSignature/CodeResources +492 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/FBDeviceControl +0 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBAFCConnection.h +135 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBAMDServiceConnection.h +239 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBAMDefines.h +256 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBAMDevice.h +105 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBAMDeviceManager.h +59 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBAMRestorableDevice.h +72 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBAMRestorableDeviceManager.h +32 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBDevice.h +89 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBDeviceCommands.h +147 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBDeviceControl-Swift.h +1500 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBDeviceControl.h +24 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBDeviceControlFrameworkLoader.h +25 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBDeviceDebugSymbolsCommands.h +29 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBDeviceManager.h +118 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Headers/FBInstrumentsClient.h +49 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/FBDeviceControl.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo +0 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/FBDeviceControl.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo +0 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/FBDeviceControl.swiftmodule/arm64-apple-macos.abi.json +16073 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/FBDeviceControl.swiftmodule/arm64-apple-macos.private.swiftinterface +398 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/FBDeviceControl.swiftmodule/arm64-apple-macos.swiftdoc +0 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/FBDeviceControl.swiftmodule/arm64-apple-macos.swiftinterface +398 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/FBDeviceControl.swiftmodule/arm64-apple-macos.swiftmodule +0 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/FBDeviceControl.swiftmodule/x86_64-apple-macos.abi.json +16073 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/FBDeviceControl.swiftmodule/x86_64-apple-macos.private.swiftinterface +398 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/FBDeviceControl.swiftmodule/x86_64-apple-macos.swiftdoc +0 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/FBDeviceControl.swiftmodule/x86_64-apple-macos.swiftinterface +398 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/FBDeviceControl.swiftmodule/x86_64-apple-macos.swiftmodule +0 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Modules/module.modulemap +10 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/Resources/Info.plist +50 -0
- package/vendor/axe/Frameworks/FBDeviceControl.framework/Versions/A/_CodeSignature/CodeResources +324 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/FBSimulatorControl +0 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Headers/FBFramebuffer.h +24 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Headers/FBPeriodicStatsTimer.h +43 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Headers/FBSimDeviceWrapper.h +86 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Headers/FBSimulator.h +160 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Headers/FBSimulatorBootConfiguration.h +18 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Headers/FBSimulatorControl-Swift.h +2270 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Headers/FBSimulatorControl.h +21 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Headers/FBSimulatorControlFrameworkLoader.h +45 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Headers/FBSimulatorVideoStream.h +153 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/FBSimulatorControl.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo +0 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/FBSimulatorControl.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo +0 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/FBSimulatorControl.swiftmodule/arm64-apple-macos.abi.json +46980 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/FBSimulatorControl.swiftmodule/arm64-apple-macos.private.swiftinterface +1072 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/FBSimulatorControl.swiftmodule/arm64-apple-macos.swiftdoc +0 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/FBSimulatorControl.swiftmodule/arm64-apple-macos.swiftinterface +1072 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/FBSimulatorControl.swiftmodule/arm64-apple-macos.swiftmodule +0 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/FBSimulatorControl.swiftmodule/x86_64-apple-macos.abi.json +46980 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/FBSimulatorControl.swiftmodule/x86_64-apple-macos.private.swiftinterface +1072 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/FBSimulatorControl.swiftmodule/x86_64-apple-macos.swiftdoc +0 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/FBSimulatorControl.swiftmodule/x86_64-apple-macos.swiftinterface +1072 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/FBSimulatorControl.swiftmodule/x86_64-apple-macos.swiftmodule +0 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Modules/module.modulemap +10 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Resources/Info.plist +50 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/Resources/README.md +124 -0
- package/vendor/axe/Frameworks/FBSimulatorControl.framework/Versions/A/_CodeSignature/CodeResources +293 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Headers/FBActivityRecord.h +33 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Headers/FBAttachment.h +25 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Headers/FBCodeCoverageConfiguration.h +15 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Headers/FBTestBundleConnection.h +37 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Headers/FBTestConfiguration.h +74 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Headers/FBTestManagerAPIMediator.h +46 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Headers/FBTestManagerResultSummary.h +19 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Headers/FBTestReporterAdapter.h +27 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Headers/FBXCTestConfiguration.h +30 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Headers/XCTestBootstrap-Swift.h +1570 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Headers/XCTestBootstrap.h +22 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Headers/XCTestBootstrapError.h +25 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/XCTestBootstrap.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo +0 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/XCTestBootstrap.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo +0 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/XCTestBootstrap.swiftmodule/arm64-apple-macos.abi.json +15972 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/XCTestBootstrap.swiftmodule/arm64-apple-macos.private.swiftinterface +397 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/XCTestBootstrap.swiftmodule/arm64-apple-macos.swiftdoc +0 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/XCTestBootstrap.swiftmodule/arm64-apple-macos.swiftinterface +397 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/XCTestBootstrap.swiftmodule/arm64-apple-macos.swiftmodule +0 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/XCTestBootstrap.swiftmodule/x86_64-apple-macos.abi.json +15972 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/XCTestBootstrap.swiftmodule/x86_64-apple-macos.private.swiftinterface +397 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/XCTestBootstrap.swiftmodule/x86_64-apple-macos.swiftdoc +0 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/XCTestBootstrap.swiftmodule/x86_64-apple-macos.swiftinterface +397 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/XCTestBootstrap.swiftmodule/x86_64-apple-macos.swiftmodule +0 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Modules/module.modulemap +13 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/Resources/Info.plist +50 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/XCTestBootstrap +0 -0
- package/vendor/axe/Frameworks/XCTestBootstrap.framework/Versions/A/_CodeSignature/CodeResources +303 -0
- package/vendor/axe/axe +0 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ToolError } from "../errors.js";
|
|
5
|
+
import { run } from "./exec.js";
|
|
6
|
+
const NO_SIMULATOR = "This computer can't run the iOS Simulator: it needs macOS with Xcode and a Simulator runtime.";
|
|
7
|
+
/** "com.apple.CoreSimulator.SimRuntime.iOS-26-5" becomes "iOS 26.5". */
|
|
8
|
+
export function runtimeName(identifier) {
|
|
9
|
+
const match = /SimRuntime\.([A-Za-z]+)-(\d+)-(\d+)(?:-(\d+))?$/.exec(identifier);
|
|
10
|
+
if (!match)
|
|
11
|
+
return identifier;
|
|
12
|
+
const [, os, major, minor, patch] = match;
|
|
13
|
+
return `${os === "xrOS" ? "visionOS" : os} ${major}.${minor}${patch ? `.${patch}` : ""}`;
|
|
14
|
+
}
|
|
15
|
+
/** Parses `simctl list devices --json`: available devices only, booted first, then by runtime and name. */
|
|
16
|
+
export function parseDevices(json) {
|
|
17
|
+
const parsed = JSON.parse(json);
|
|
18
|
+
const devices = [];
|
|
19
|
+
for (const [runtime, list] of Object.entries(parsed.devices ?? {})) {
|
|
20
|
+
for (const d of list) {
|
|
21
|
+
if (d.isAvailable === false)
|
|
22
|
+
continue;
|
|
23
|
+
devices.push({ udid: d.udid, name: d.name, state: d.state, runtime: runtimeName(runtime), booted: d.state === "Booted" });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// iOS before watchOS, tvOS and visionOS (by name, "watchOS 26" would sort first); the newest iOS first;
|
|
27
|
+
// iPhones before iPads: most agents want a phone.
|
|
28
|
+
const platform = (d) => (d.runtime.startsWith("iOS ") ? 0 : 1);
|
|
29
|
+
const version = (d) => d.runtime.replace(/^\S+\s/, "");
|
|
30
|
+
const family = (d) => ["iPhone", "iPad"].findIndex((p) => d.name.startsWith(p)) >>> 0;
|
|
31
|
+
return devices.sort((a, b) => Number(b.booted) - Number(a.booted) ||
|
|
32
|
+
platform(a) - platform(b) ||
|
|
33
|
+
version(b).localeCompare(version(a), undefined, { numeric: true }) ||
|
|
34
|
+
family(a) - family(b) ||
|
|
35
|
+
a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
36
|
+
}
|
|
37
|
+
/** The MCP server lives for a whole session: one list serves the calls of the next few seconds (0.35 s saved each). */
|
|
38
|
+
const CACHE_MS = 5_000;
|
|
39
|
+
let cached;
|
|
40
|
+
export async function listDevices(fresh = false) {
|
|
41
|
+
if (process.platform !== "darwin")
|
|
42
|
+
throw new ToolError(NO_SIMULATOR);
|
|
43
|
+
if (!fresh && cached && Date.now() - cached.at < CACHE_MS)
|
|
44
|
+
return cached.devices;
|
|
45
|
+
try {
|
|
46
|
+
const { stdout } = await run("xcrun", ["simctl", "list", "devices", "--json"], { timeoutMs: 20_000 });
|
|
47
|
+
cached = { at: Date.now(), devices: parseDevices(stdout) };
|
|
48
|
+
return cached.devices;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new ToolError(`${NO_SIMULATOR} (${error.message})`, "Install Xcode from the App Store, open it once, then add an iOS Simulator runtime in Settings > Components.");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function xcodeVersion() {
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await run("xcodebuild", ["-version"], { timeoutMs: 15_000 });
|
|
57
|
+
return stdout.split("\n")[0]?.trim();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** A device by name or UDID; a name on several runtimes means the booted one, else the newest. */
|
|
64
|
+
export function findDevice(devices, wanted) {
|
|
65
|
+
const want = wanted.trim().toLowerCase();
|
|
66
|
+
const matches = devices.filter((d) => d.udid.toLowerCase() === want || d.name.toLowerCase() === want);
|
|
67
|
+
return matches.find((d) => d.booted) ?? matches[0];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The device a call works on: the one named (by name or UDID), else the one this agent holds (device_use),
|
|
71
|
+
* else the only booted one.
|
|
72
|
+
*/
|
|
73
|
+
export function pickDevice(devices, wanted, held) {
|
|
74
|
+
const booted = devices.filter((d) => d.booted);
|
|
75
|
+
if (wanted) {
|
|
76
|
+
const device = findDevice(devices, wanted);
|
|
77
|
+
if (!device)
|
|
78
|
+
throw new ToolError(`No Simulator named "${wanted}".`, "Call devices to see the names and UDIDs on this Mac.");
|
|
79
|
+
if (!device.booted)
|
|
80
|
+
throw new ToolError(`${device.name} (${device.runtime}) is not booted.`, `Call device_use {device: "${device.udid}"} to boot it.`);
|
|
81
|
+
return device;
|
|
82
|
+
}
|
|
83
|
+
const mine = held && booted.find((d) => d.udid === held);
|
|
84
|
+
if (mine)
|
|
85
|
+
return mine;
|
|
86
|
+
// The device this agent chose was shut down (by another agent's boot, say): never slide onto someone else's.
|
|
87
|
+
const gone = held && devices.find((d) => d.udid === held);
|
|
88
|
+
if (gone)
|
|
89
|
+
throw new ToolError(`${gone.name} (${gone.runtime}), the Simulator this agent was using, is no longer booted.`, `Call device_use {device: "${gone.udid}"} to boot it again, or device_use another.`);
|
|
90
|
+
if (booted.length === 1)
|
|
91
|
+
return booted[0];
|
|
92
|
+
if (!booted.length) {
|
|
93
|
+
const suggestion = devices.find((d) => d.name.startsWith("iPhone")) ?? devices[0];
|
|
94
|
+
throw new ToolError("No Simulator is booted.", suggestion ? `Call device_use {device: "${suggestion.name}"} to boot ${suggestion.name} (${suggestion.runtime}).` : "Call devices to see what this Mac has.");
|
|
95
|
+
}
|
|
96
|
+
throw new ToolError(`${booted.length} Simulators are booted: ${booted.map((d) => `${d.name} (${d.udid})`).join(", ")}.`, "Call device_use with the one to use, or pass device.");
|
|
97
|
+
}
|
|
98
|
+
/** A call may take 45 s: waiting for another boot and this boot share 40 of them. */
|
|
99
|
+
const BOOT_BUDGET_MS = 40_000;
|
|
100
|
+
const LOCK_WAIT_MS = 20_000;
|
|
101
|
+
const alive = (pid) => {
|
|
102
|
+
try {
|
|
103
|
+
process.kill(pid, 0);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return error.code === "EPERM";
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* One boot at a time on this Mac, across every simtap process (each agent runs its own). The lock is a
|
|
112
|
+
* directory, made atomically, holding the owner's pid; a lock whose owner died, or that was left without a pid
|
|
113
|
+
* for 60 s, is taken over; and only its owner removes it.
|
|
114
|
+
*/
|
|
115
|
+
async function withBootLock(work) {
|
|
116
|
+
const dir = join(tmpdir(), "simtap");
|
|
117
|
+
await mkdir(dir, { recursive: true });
|
|
118
|
+
const lock = join(dir, "boot.lock.d");
|
|
119
|
+
const owner = join(lock, "pid");
|
|
120
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
121
|
+
for (;;) {
|
|
122
|
+
try {
|
|
123
|
+
await mkdir(lock);
|
|
124
|
+
await writeFile(owner, String(process.pid));
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error.code !== "EEXIST")
|
|
129
|
+
throw error;
|
|
130
|
+
const holder = Number(await readFile(owner, "utf8").catch(() => "0"));
|
|
131
|
+
const age = Date.now() - ((await stat(lock).catch(() => undefined))?.mtimeMs ?? Date.now());
|
|
132
|
+
// An empty pid is a lock being made right now, unless it has been empty for a minute.
|
|
133
|
+
if ((holder && !alive(holder)) || (!holder && age > 60_000)) {
|
|
134
|
+
await rm(lock, { recursive: true, force: true });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (Date.now() > deadline)
|
|
138
|
+
throw new ToolError("Another Simulator is still booting, and boots never overlap.", "Call device_use again in 30 s.");
|
|
139
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
return await work();
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
if (Number(await readFile(owner, "utf8").catch(() => "0")) === process.pid)
|
|
147
|
+
await rm(lock, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Boots a device and waits until it is ready, within the time a call may take. Two Simulators booting at once
|
|
152
|
+
* on an 8 GB Mac pushed it into swap for minutes (measured October 18), so boots never overlap. With a limit on
|
|
153
|
+
* booted devices (SIMTAP_MAX_BOOTED), others are shut down first, inside the lock so two calls can't both
|
|
154
|
+
* see room.
|
|
155
|
+
*/
|
|
156
|
+
export async function bootDevice(device, maxBooted = Infinity) {
|
|
157
|
+
const asked = Date.now();
|
|
158
|
+
return withBootLock(async () => {
|
|
159
|
+
const started = Date.now();
|
|
160
|
+
// A boot started outside simtap (Xcode, simctl) is waited out too.
|
|
161
|
+
while (Date.now() - asked < LOCK_WAIT_MS && (await listDevices(true)).some((d) => d.state === "Booting" && d.udid !== device.udid)) {
|
|
162
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
163
|
+
}
|
|
164
|
+
const others = (await listDevices(true)).filter((d) => d.booted && d.udid !== device.udid);
|
|
165
|
+
const shutDown = [];
|
|
166
|
+
for (const d of others.slice(0, Math.max(0, others.length - (maxBooted - 1)))) {
|
|
167
|
+
await shutdownDevice(d);
|
|
168
|
+
shutDown.push(d.name);
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
await run("xcrun", ["simctl", "boot", device.udid], { timeoutMs: 20_000 });
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (!/current state: Booted/i.test(error.message))
|
|
175
|
+
throw new ToolError(`Could not boot ${device.name}: ${error.message}`);
|
|
176
|
+
}
|
|
177
|
+
let ready = true;
|
|
178
|
+
try {
|
|
179
|
+
await run("xcrun", ["simctl", "bootstatus", device.udid, "-b"], { timeoutMs: Math.max(5_000, BOOT_BUDGET_MS - (Date.now() - asked)) });
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
ready = false;
|
|
183
|
+
}
|
|
184
|
+
cached = undefined;
|
|
185
|
+
return { ms: Date.now() - started, ready, waitedMs: started - asked, shutDown };
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
export async function shutdownDevice(device) {
|
|
189
|
+
await run("xcrun", ["simctl", "shutdown", device.udid], { timeoutMs: 15_000 }).catch(() => undefined);
|
|
190
|
+
cached = undefined;
|
|
191
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* What a call can leave for the next one.
|
|
6
|
+
* - The device this agent holds lives in memory: each agent runs its own MCP server, so two agents never share it.
|
|
7
|
+
* - The app last launched on a device is a small file next to its log files, so the CLI and MCP see the same.
|
|
8
|
+
*/
|
|
9
|
+
let held;
|
|
10
|
+
export const holdDevice = (udid) => {
|
|
11
|
+
held = udid;
|
|
12
|
+
};
|
|
13
|
+
export const heldDevice = () => held;
|
|
14
|
+
/** Where simtap keeps a device's app output: the print() lines of apps it launched. */
|
|
15
|
+
export async function deviceDir(udid) {
|
|
16
|
+
const dir = join(tmpdir(), "simtap", udid);
|
|
17
|
+
await mkdir(dir, { recursive: true });
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
export async function currentApp(udid) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(await readFile(join(await deviceDir(udid), "app.json"), "utf8"));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function setCurrentApp(udid, app) {
|
|
29
|
+
await writeFile(join(await deviceDir(udid), "app.json"), JSON.stringify(app));
|
|
30
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createWriteStream, existsSync, readdirSync, realpathSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
7
|
+
import { ToolError } from "../errors.js";
|
|
8
|
+
import { run } from "./exec.js";
|
|
9
|
+
/**
|
|
10
|
+
* The Xcode project in a folder: a workspace first (CocoaPods and multi-project setups need it), then a
|
|
11
|
+
* project; an XcodeGen spec is generated first, and again before every build. Other kinds say what is coming.
|
|
12
|
+
*/
|
|
13
|
+
export async function findProject(where) {
|
|
14
|
+
const path = resolve(where);
|
|
15
|
+
if (!existsSync(path))
|
|
16
|
+
throw new ToolError(`${path} does not exist.`, "Pass path: the folder with the Xcode project.");
|
|
17
|
+
const ext = extname(path);
|
|
18
|
+
if (ext === ".xcworkspace")
|
|
19
|
+
return { kind: "workspace", path, name: basename(path, ext), dir: dirname(path) };
|
|
20
|
+
if (ext === ".xcodeproj") {
|
|
21
|
+
const dir = dirname(path);
|
|
22
|
+
const note = existsSync(join(dir, "project.yml")) ? await xcodegen(dir, true) : undefined;
|
|
23
|
+
return { kind: "project", path, name: basename(path, ext), dir, note };
|
|
24
|
+
}
|
|
25
|
+
const entries = readdirSync(path);
|
|
26
|
+
// A workspace is left as it is: regenerating its project would undo what CocoaPods added to it.
|
|
27
|
+
const workspace = entries.find((e) => e.endsWith(".xcworkspace"));
|
|
28
|
+
if (workspace)
|
|
29
|
+
return { kind: "workspace", path: join(path, workspace), name: basename(workspace, ".xcworkspace"), dir: path };
|
|
30
|
+
const project = entries.find((e) => e.endsWith(".xcodeproj"));
|
|
31
|
+
if (entries.includes("project.yml")) {
|
|
32
|
+
const note = await xcodegen(path, Boolean(project));
|
|
33
|
+
const made = readdirSync(path).find((e) => e.endsWith(".xcodeproj"));
|
|
34
|
+
if (made)
|
|
35
|
+
return { kind: "project", path: join(path, made), name: basename(made, ".xcodeproj"), dir: path, generated: !project, note };
|
|
36
|
+
}
|
|
37
|
+
if (project)
|
|
38
|
+
return { kind: "project", path: join(path, project), name: basename(project, ".xcodeproj"), dir: path };
|
|
39
|
+
if (entries.includes("app.json") || entries.includes("pubspec.yaml") || entries.some((e) => e.startsWith("capacitor.config"))) {
|
|
40
|
+
throw new ToolError("Expo, React Native, Flutter and Capacitor projects are not built by app_run yet; Xcode projects are.", "Build it with its own tool for the iOS Simulator, then app_control {action: \"install\", app: \"path/to/App.app\"}.");
|
|
41
|
+
}
|
|
42
|
+
if (entries.includes("ios") && existsSync(join(path, "ios")))
|
|
43
|
+
return findProject(join(path, "ios"));
|
|
44
|
+
throw new ToolError(`No Xcode project in ${path}.`, "Pass path: the folder with the .xcodeproj or .xcworkspace.");
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* XcodeGen makes the project from project.yml, and a spec edit or a new, moved or deleted source file needs a new
|
|
48
|
+
* project, or the build misses it. So the project is regenerated before every build; --use-cache skips the work when
|
|
49
|
+
* neither the spec nor the files changed. Answers a note when an existing project had to be built as it was.
|
|
50
|
+
*/
|
|
51
|
+
async function xcodegen(dir, hasProject) {
|
|
52
|
+
try {
|
|
53
|
+
await run("xcodegen", ["generate", "--quiet", "--use-cache", "--spec", join(dir, "project.yml")], { timeoutMs: 60_000 });
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const why = (error.message.split("\n")[0] ?? "").slice(0, 200);
|
|
58
|
+
if (!hasProject)
|
|
59
|
+
throw new ToolError(`This folder has an XcodeGen spec (project.yml) but no project, and xcodegen failed: ${why}`, "Install XcodeGen (brew install xcodegen), or generate the project yourself.");
|
|
60
|
+
return `XcodeGen could not regenerate the project from project.yml (${why}), so the existing project was built; spec edits and new files are not in it.`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The scheme to build: the one asked for, the only one, or the one named like the project. */
|
|
64
|
+
export async function pickScheme(project, wanted) {
|
|
65
|
+
const { stdout } = await run("xcodebuild", ["-list", "-json", project.kind === "workspace" ? "-workspace" : "-project", project.path], { timeoutMs: 60_000 });
|
|
66
|
+
const list = JSON.parse(stdout);
|
|
67
|
+
const schemes = list.workspace?.schemes ?? list.project?.schemes ?? [];
|
|
68
|
+
if (wanted) {
|
|
69
|
+
if (!schemes.includes(wanted))
|
|
70
|
+
throw new ToolError(`No scheme "${wanted}" in ${basename(project.path)}. Schemes: ${schemes.join(", ") || "none"}.`);
|
|
71
|
+
return wanted;
|
|
72
|
+
}
|
|
73
|
+
const own = schemes.filter((s) => !/^Pods-|Tests$|UITests$/.test(s));
|
|
74
|
+
if (own.length === 1)
|
|
75
|
+
return own[0];
|
|
76
|
+
const named = own.find((s) => s === project.name);
|
|
77
|
+
if (named)
|
|
78
|
+
return named;
|
|
79
|
+
if (!schemes.length)
|
|
80
|
+
throw new ToolError(`${basename(project.path)} has no shared schemes.`, "Open it in Xcode once, or share a scheme (Product > Scheme > Manage Schemes).");
|
|
81
|
+
throw new ToolError(`${basename(project.path)} has several schemes: ${own.join(", ")}.`, "Pass scheme.");
|
|
82
|
+
}
|
|
83
|
+
const LOCATED = /^(\/[^:]+):(\d+):(?:(\d+):)? (error|warning|fatal error): (.+)$/;
|
|
84
|
+
const BARE = /^(?:xcodebuild: |clang: |ld: |\s*)?(error|fatal error): (.+)$/;
|
|
85
|
+
/** One line of xcodebuild output as an error or warning, or nothing. */
|
|
86
|
+
export function parseIssue(line) {
|
|
87
|
+
const located = LOCATED.exec(line);
|
|
88
|
+
if (located) {
|
|
89
|
+
const [, file, row, col, severity, message] = located;
|
|
90
|
+
return { file, line: Number(row), column: col ? Number(col) : undefined, severity: severity === "warning" ? "warning" : "error", message: message.trim() };
|
|
91
|
+
}
|
|
92
|
+
const bare = BARE.exec(line);
|
|
93
|
+
if (bare)
|
|
94
|
+
return { severity: "error", message: bare[2].trim() };
|
|
95
|
+
if (/^ld: /.test(line) || /^Undefined symbols? for architecture/.test(line))
|
|
96
|
+
return { severity: "error", message: line.trim() };
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
/** `Sources/App.swift:49:37`, relative to the project when the file is inside it, else absolute. */
|
|
100
|
+
export function place(file, line, column, base) {
|
|
101
|
+
const real = (p) => {
|
|
102
|
+
try {
|
|
103
|
+
return realpathSync(p);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return p;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
// /tmp and /private/tmp are the same folder; compare real paths so the file reads relative.
|
|
110
|
+
const rel = relative(real(base), real(file));
|
|
111
|
+
return `${rel && !rel.startsWith("..") ? rel : file}:${line ?? "?"}${column ? `:${column}` : ""}`;
|
|
112
|
+
}
|
|
113
|
+
/** `Sources/App.swift:49:37: error: ...`. */
|
|
114
|
+
export function formatIssue(issue, base) {
|
|
115
|
+
return `${issue.file ? `${place(issue.file, issue.line, issue.column, base)}: ` : ""}${issue.severity}: ${issue.message}`;
|
|
116
|
+
}
|
|
117
|
+
/** Each worktree builds into its own DerivedData, so builds stay incremental and never share products. */
|
|
118
|
+
export function derivedDataFor(project) {
|
|
119
|
+
const key = createHash("sha1").update(project.path).digest("hex").slice(0, 12);
|
|
120
|
+
return join(tmpdir(), "simtap", "DerivedData", `${project.name}-${key}`);
|
|
121
|
+
}
|
|
122
|
+
export async function build(project, scheme, udid, options) {
|
|
123
|
+
const derived = derivedDataFor(project);
|
|
124
|
+
await mkdir(derived, { recursive: true });
|
|
125
|
+
const log = join(derived, `${options.action ?? "build"}-${Date.now()}.log`);
|
|
126
|
+
const args = [
|
|
127
|
+
project.kind === "workspace" ? "-workspace" : "-project",
|
|
128
|
+
project.path,
|
|
129
|
+
"-scheme",
|
|
130
|
+
scheme,
|
|
131
|
+
"-configuration",
|
|
132
|
+
options.configuration ?? "Debug",
|
|
133
|
+
"-destination",
|
|
134
|
+
`id=${udid}`,
|
|
135
|
+
"-derivedDataPath",
|
|
136
|
+
derived,
|
|
137
|
+
...(options.extra ?? []),
|
|
138
|
+
...(options.clean ? ["clean"] : []),
|
|
139
|
+
options.action ?? "build",
|
|
140
|
+
];
|
|
141
|
+
const started = Date.now();
|
|
142
|
+
const out = createWriteStream(log);
|
|
143
|
+
// Its own process group, so a cancel stops the compilers it started too.
|
|
144
|
+
const child = spawn("xcodebuild", args, { cwd: project.dir, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
145
|
+
options.onCancel?.(() => {
|
|
146
|
+
try {
|
|
147
|
+
process.kill(-child.pid, "SIGTERM");
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Already finished.
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
const errors = [];
|
|
155
|
+
let warnings = 0;
|
|
156
|
+
let pending = "";
|
|
157
|
+
const tail = [];
|
|
158
|
+
const take = (chunk) => {
|
|
159
|
+
out.write(chunk);
|
|
160
|
+
pending += chunk.toString("utf8");
|
|
161
|
+
const lines = pending.split("\n");
|
|
162
|
+
pending = lines.pop() ?? "";
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
options.onLine?.(line);
|
|
165
|
+
if (line.trim()) {
|
|
166
|
+
tail.push(line.length > 200 ? `${line.slice(0, 199)}…` : line);
|
|
167
|
+
if (tail.length > 8)
|
|
168
|
+
tail.shift();
|
|
169
|
+
}
|
|
170
|
+
const issue = parseIssue(line);
|
|
171
|
+
if (!issue)
|
|
172
|
+
continue;
|
|
173
|
+
// The same diagnostic repeats (once per architecture or compile batch).
|
|
174
|
+
const key = `${issue.file}:${issue.line}:${issue.severity}:${issue.message}`;
|
|
175
|
+
if (seen.has(key))
|
|
176
|
+
continue;
|
|
177
|
+
seen.add(key);
|
|
178
|
+
if (issue.severity === "warning")
|
|
179
|
+
warnings++;
|
|
180
|
+
else
|
|
181
|
+
errors.push(issue);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
child.stdout.on("data", take);
|
|
185
|
+
child.stderr.on("data", take);
|
|
186
|
+
const code = await new Promise((resolveCode) => child.on("close", resolveCode));
|
|
187
|
+
out.end();
|
|
188
|
+
return { ok: code === 0, errors, warnings, log, ms: Date.now() - started, tail };
|
|
189
|
+
}
|
|
190
|
+
/** The .app a Simulator build made: the scheme's own, never a test runner. */
|
|
191
|
+
export function builtApp(project, scheme, configuration = "Debug") {
|
|
192
|
+
const products = join(derivedDataFor(project), "Build", "Products", `${configuration}-iphonesimulator`);
|
|
193
|
+
if (!existsSync(products))
|
|
194
|
+
return undefined;
|
|
195
|
+
const apps = readdirSync(products).filter((e) => e.endsWith(".app") && !e.endsWith("-Runner.app"));
|
|
196
|
+
const named = apps.find((a) => a === `${scheme}.app`);
|
|
197
|
+
return named || apps.length === 1 ? join(products, named ?? apps[0]) : undefined;
|
|
198
|
+
}
|
|
199
|
+
const memoryFile = async (dir) => {
|
|
200
|
+
const folder = join(tmpdir(), "simtap", "projects");
|
|
201
|
+
await mkdir(folder, { recursive: true });
|
|
202
|
+
return join(folder, `${createHash("sha1").update(resolve(dir)).digest("hex").slice(0, 16)}.json`);
|
|
203
|
+
};
|
|
204
|
+
export async function remembered(dir) {
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(await readFile(await memoryFile(dir), "utf8"));
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
export async function remember(dir, value) {
|
|
213
|
+
await writeFile(await memoryFile(dir), JSON.stringify(value));
|
|
214
|
+
}
|
|
215
|
+
/** The project the last app_run or tests_run used, for calls that name none from a folder without one. */
|
|
216
|
+
export async function lastProject() {
|
|
217
|
+
try {
|
|
218
|
+
return JSON.parse(await readFile(join(tmpdir(), "simtap", "last-project.json"), "utf8")).project;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
export async function setLastProject(project) {
|
|
225
|
+
await mkdir(join(tmpdir(), "simtap"), { recursive: true });
|
|
226
|
+
await writeFile(join(tmpdir(), "simtap", "last-project.json"), JSON.stringify({ project }));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Which project a call means: the path given; else the current folder's (or what app_run remembered for it);
|
|
230
|
+
* else the one the last call used, so an agent that named a path once can drop it.
|
|
231
|
+
*/
|
|
232
|
+
export async function projectFor(path, scheme) {
|
|
233
|
+
const dir = path ?? process.cwd();
|
|
234
|
+
const memory = await remembered(dir);
|
|
235
|
+
let project;
|
|
236
|
+
let note = "";
|
|
237
|
+
try {
|
|
238
|
+
project = await findProject(memory && !path ? memory.project : dir);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
const last = path ? undefined : await lastProject();
|
|
242
|
+
if (!last || !existsSync(last))
|
|
243
|
+
throw error;
|
|
244
|
+
project = await findProject(last);
|
|
245
|
+
note = `No project in ${dir}; using ${project.name} from the last call (${project.dir}).\n`;
|
|
246
|
+
}
|
|
247
|
+
if (project.note)
|
|
248
|
+
note += `${project.note}\n`;
|
|
249
|
+
const rememberedScheme = memory?.project === project.path ? memory.scheme : (await remembered(project.dir))?.scheme;
|
|
250
|
+
const chosen = await pickScheme(project, scheme ?? rememberedScheme);
|
|
251
|
+
await setLastProject(project.path);
|
|
252
|
+
return { project, scheme: chosen, dir: path ? dir : project.dir, note };
|
|
253
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { run } from "./exec.js";
|
|
4
|
+
/** Failures as Xcode records them: the test, and "File.swift:12: message" for each failed assertion. */
|
|
5
|
+
export function failuresIn(nodes, bundle = "") {
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const node of nodes) {
|
|
8
|
+
if (node.nodeType === "Test Case" && node.result === "Failed") {
|
|
9
|
+
const messages = [];
|
|
10
|
+
const collect = (n) => {
|
|
11
|
+
if (n.nodeType === "Failure Message" && n.name)
|
|
12
|
+
messages.push(n.name);
|
|
13
|
+
n.children?.forEach(collect);
|
|
14
|
+
};
|
|
15
|
+
node.children?.forEach(collect);
|
|
16
|
+
// The identifier already holds the suite (SignInTests/testX()); the bundle makes it -only-testing ready.
|
|
17
|
+
const id = node.nodeIdentifier ?? node.name ?? "?";
|
|
18
|
+
const test = [bundle, id].filter(Boolean).join("/");
|
|
19
|
+
for (const message of messages.length ? messages : ["failed (no message recorded)"])
|
|
20
|
+
out.push({ test, id, message });
|
|
21
|
+
}
|
|
22
|
+
else if (node.children) {
|
|
23
|
+
out.push(...failuresIn(node.children, /test bundle$/i.test(node.nodeType ?? "") ? (node.name ?? bundle) : bundle));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
export async function readTestReport(bundle) {
|
|
29
|
+
// Right after xcodebuild exits, one read of a finished bundle failed once; the same read a moment later worked.
|
|
30
|
+
const get = async (what, more = []) => {
|
|
31
|
+
const args = ["xcresulttool", "get", "test-results", what, ...more, "--path", bundle];
|
|
32
|
+
const out = await run("xcrun", args, { timeoutMs: 60_000 }).catch(async () => {
|
|
33
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
34
|
+
return run("xcrun", args, { timeoutMs: 60_000 });
|
|
35
|
+
});
|
|
36
|
+
return JSON.parse(out.stdout);
|
|
37
|
+
};
|
|
38
|
+
const [summary, tests] = await Promise.all([get("summary"), get("tests")]);
|
|
39
|
+
const failures = failuresIn(tests.testNodes ?? []);
|
|
40
|
+
// Xcode 26 starts a failure message with "File.swift:12: "; Xcode 27 keeps the place in the test's details.
|
|
41
|
+
for (const f of failures.filter((x) => !/^[^\s/:]+\.\w+:\d+: /.test(x.message)).slice(0, 10)) {
|
|
42
|
+
const details = await get(`test-details`, ["--test-id", f.id]).catch(() => undefined);
|
|
43
|
+
const place = details && sourceLocation(details.testRuns ?? []);
|
|
44
|
+
if (place?.filePath)
|
|
45
|
+
Object.assign(f, { file: place.filePath, line: place.lineNumber });
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
result: summary.result,
|
|
49
|
+
passed: summary.passedTests ?? 0,
|
|
50
|
+
failed: summary.failedTests ?? 0,
|
|
51
|
+
skipped: summary.skippedTests ?? 0,
|
|
52
|
+
total: summary.totalTestCount ?? 0,
|
|
53
|
+
failures,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** The first source location in a test's details: where the failed assertion is. */
|
|
57
|
+
export function sourceLocation(nodes) {
|
|
58
|
+
for (const node of nodes) {
|
|
59
|
+
if (node.sourceLocation?.filePath)
|
|
60
|
+
return node.sourceLocation;
|
|
61
|
+
const inner = sourceLocation(node.children ?? []);
|
|
62
|
+
if (inner)
|
|
63
|
+
return inner;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
/** "SignInTests.swift:12: ..." names the file only; this finds it in the project to give its path. */
|
|
68
|
+
export function locate(message, projectDir) {
|
|
69
|
+
const match = /^([^\s/:]+\.(?:swift|m|mm)):(\d+): /.exec(message);
|
|
70
|
+
if (!match)
|
|
71
|
+
return message;
|
|
72
|
+
const found = [];
|
|
73
|
+
const walk = (dir, depth) => {
|
|
74
|
+
if (depth > 6 || found.length > 1)
|
|
75
|
+
return;
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = readdirSync(dir);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
if (entry.startsWith(".") || /^(DerivedData|build|Pods|node_modules|Carthage)$/.test(entry) || entry.endsWith(".xcodeproj") || entry.endsWith(".xcworkspace"))
|
|
85
|
+
continue;
|
|
86
|
+
const full = join(dir, entry);
|
|
87
|
+
if (entry === match[1])
|
|
88
|
+
found.push(full);
|
|
89
|
+
else if (statSync(full).isDirectory())
|
|
90
|
+
walk(full, depth + 1);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
walk(projectDir, 0);
|
|
94
|
+
return found.length === 1 ? `${relative(projectDir, found[0])}${message.slice(match[1].length)}` : message;
|
|
95
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** A failure the agent can act on: what went wrong, and the next step. */
|
|
2
|
+
export class ToolError extends Error {
|
|
3
|
+
next;
|
|
4
|
+
constructor(message, next) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.next = next;
|
|
7
|
+
}
|
|
8
|
+
toText() {
|
|
9
|
+
return this.next ? `${this.message}\nNext: ${this.next}` : this.message;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { AccessibilityStarting, describeUi } from "../engine/axe.js";
|
|
3
|
+
import { render, snapshot } from "./snapshot.js";
|
|
4
|
+
/** Right after a launch the app's accessibility tree is empty for a moment (seen on Xcode 27). */
|
|
5
|
+
const NOT_READY_RETRIES = 4;
|
|
6
|
+
const NOT_READY_WAIT_MS = 750;
|
|
7
|
+
const SETTLE_START_MS = 120;
|
|
8
|
+
const SETTLE_TIMEOUT_MS = 4_000;
|
|
9
|
+
/** How long a call keeps trying while accessibility starts, leaving room in the 45 s a call may take. */
|
|
10
|
+
const STARTING_BUDGET_MS = 35_000;
|
|
11
|
+
/** Reads the tree, waiting out a starting accessibility service for up to 35 s before giving up. */
|
|
12
|
+
async function describeWhenReady(udid) {
|
|
13
|
+
const deadline = Date.now() + STARTING_BUDGET_MS;
|
|
14
|
+
for (;;) {
|
|
15
|
+
try {
|
|
16
|
+
return await describeUi(udid);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (!(error instanceof AccessibilityStarting) || Date.now() > deadline)
|
|
20
|
+
throw error;
|
|
21
|
+
await sleep(1_000);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function readScreen(udid, waitUntilReady = true) {
|
|
26
|
+
let tree = await describeWhenReady(udid);
|
|
27
|
+
let snap = snapshot(tree);
|
|
28
|
+
for (let i = 0; waitUntilReady && i < NOT_READY_RETRIES && !snap.elements.length && !snap.tabs.length; i++) {
|
|
29
|
+
await sleep(NOT_READY_WAIT_MS);
|
|
30
|
+
tree = await describeUi(udid);
|
|
31
|
+
snap = snapshot(tree);
|
|
32
|
+
}
|
|
33
|
+
return { tree, snap };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Waits for the screen to stop changing after an action: two reads in a row that look the same.
|
|
37
|
+
* `settled: false` means it was still moving at the timeout (an animation, a ticker, a spinner).
|
|
38
|
+
*/
|
|
39
|
+
export async function settle(udid) {
|
|
40
|
+
const deadline = Date.now() + SETTLE_TIMEOUT_MS;
|
|
41
|
+
await sleep(SETTLE_START_MS);
|
|
42
|
+
let last = (await readScreen(udid, false)).snap;
|
|
43
|
+
while (Date.now() < deadline) {
|
|
44
|
+
const next = (await readScreen(udid, false)).snap;
|
|
45
|
+
if (render(next) === render(last))
|
|
46
|
+
return { snap: next, settled: true };
|
|
47
|
+
last = next;
|
|
48
|
+
}
|
|
49
|
+
return { snap: last, settled: false };
|
|
50
|
+
}
|