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
package/dist/cli.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command line, built from the same tool list as the MCP server:
|
|
3
|
+
* `simtap look --find "Sign In"` calls ui_look with { find: "Sign In" }.
|
|
4
|
+
*/
|
|
5
|
+
import { writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { serve } from "./mcp.js";
|
|
9
|
+
import { callTool, TOOLS, VERSION } from "./registry.js";
|
|
10
|
+
const flag = (name) => `--${name.replace(/_/g, "-")}`;
|
|
11
|
+
/** Parses `--name value` and `--switch` into arguments the tool's schema accepts. */
|
|
12
|
+
export function parseArgs(tool, argv) {
|
|
13
|
+
const props = tool.inputSchema.properties;
|
|
14
|
+
const args = {};
|
|
15
|
+
const list = Object.entries(props).find(([, p]) => p.type === "array")?.[0];
|
|
16
|
+
const words = [...(tool.positional ?? [])];
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const raw = argv[i];
|
|
19
|
+
if (!raw.startsWith("--")) {
|
|
20
|
+
// Bare words fill the tool's positional arguments in order, then a list argument takes the rest.
|
|
21
|
+
const next = words.find((w) => !(w in args));
|
|
22
|
+
if (next) {
|
|
23
|
+
args[next] = raw;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (list) {
|
|
27
|
+
args[list] = [...(args[list] ?? []), raw];
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
return `Unexpected "${raw}". ${usage(tool)}`;
|
|
31
|
+
}
|
|
32
|
+
const [name, inline] = raw.startsWith("--") ? raw.slice(2).split(/=(.*)/s, 2) : [undefined, undefined];
|
|
33
|
+
const key = name?.replace(/-/g, "_");
|
|
34
|
+
const prop = key ? props[key] : undefined;
|
|
35
|
+
if (!key || !prop || prop.type === "array")
|
|
36
|
+
return `Unknown option ${raw}. ${usage(tool)}`;
|
|
37
|
+
if (prop.type === "boolean") {
|
|
38
|
+
args[key] = inline === undefined ? true : inline !== "false";
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const value = inline ?? argv[++i];
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
return `${flag(key)} needs a value.`;
|
|
44
|
+
args[key] = prop.type === "number" ? Number(value) : value;
|
|
45
|
+
}
|
|
46
|
+
return args;
|
|
47
|
+
}
|
|
48
|
+
export function usage(tool) {
|
|
49
|
+
const entries = Object.entries(tool.inputSchema.properties);
|
|
50
|
+
const bare = (tool.positional ?? []).map((key) => ` [${key}]`).join("");
|
|
51
|
+
const options = entries.filter(([key, p]) => p.type !== "array" && !tool.positional?.includes(key)).map(([key, p]) => `${flag(key)}${p.type === "boolean" ? "" : ` <${p.type}>`}`);
|
|
52
|
+
const words = entries.filter(([, p]) => p.type === "array").map(([key]) => ` <${key}...>`).join("");
|
|
53
|
+
return `Usage: simtap ${tool.command}${bare}${options.length ? ` [${options.join("] [")}]` : ""}${words}`;
|
|
54
|
+
}
|
|
55
|
+
/** The first sentence of a description, ending in one period. */
|
|
56
|
+
const summary = (description) => `${description.split(/\.\s/)[0].replace(/\.$/, "")}.`;
|
|
57
|
+
export function help() {
|
|
58
|
+
const width = Math.max(...TOOLS.map((t) => t.command.length), 3);
|
|
59
|
+
return [
|
|
60
|
+
`simtap ${VERSION}: give your agent a phone.`,
|
|
61
|
+
"",
|
|
62
|
+
"Commands:",
|
|
63
|
+
...TOOLS.map((t) => ` ${t.command.padEnd(width)} ${summary(t.description)}`),
|
|
64
|
+
` ${"mcp".padEnd(width)} Run the MCP server on stdio.`,
|
|
65
|
+
"",
|
|
66
|
+
"Examples:",
|
|
67
|
+
" simtap run build, install and launch the Xcode project in this folder",
|
|
68
|
+
" simtap look read the screen",
|
|
69
|
+
' simtap act "fill #email a@b.co" "tap #signIn" \'wait "Welcome"\'',
|
|
70
|
+
" simtap logs --crashes why did it crash",
|
|
71
|
+
"",
|
|
72
|
+
"Run simtap <command> --help for its options.",
|
|
73
|
+
].join("\n");
|
|
74
|
+
}
|
|
75
|
+
/** One command's help: what it does, its words and options with their choices. */
|
|
76
|
+
export function commandHelp(tool) {
|
|
77
|
+
const lines = [tool.description, "", usage(tool)];
|
|
78
|
+
for (const [key, p] of Object.entries(tool.inputSchema.properties)) {
|
|
79
|
+
const choices = p.type !== "array" && p.enum ? ` (${p.enum.join(", ")})` : "";
|
|
80
|
+
const name = p.type === "array" ? `<${key}...>` : tool.positional?.includes(key) ? `[${key}] or ${flag(key)}` : flag(key);
|
|
81
|
+
lines.push(` ${name} ${p.description}${choices}`);
|
|
82
|
+
}
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
export async function main(argv) {
|
|
86
|
+
const [command, ...rest] = argv;
|
|
87
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
88
|
+
console.log(help());
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
if (command === "--version" || command === "-v") {
|
|
92
|
+
console.log(VERSION);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
if (command === "mcp") {
|
|
96
|
+
await serve();
|
|
97
|
+
// The client closed stdin: answer what was in flight, then go, even if a child process still holds a pipe.
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
const tool = TOOLS.find((t) => t.command === command || t.name === command);
|
|
101
|
+
if (!tool) {
|
|
102
|
+
console.error(`Unknown command "${command}".\n\n${help()}`);
|
|
103
|
+
return 2;
|
|
104
|
+
}
|
|
105
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
106
|
+
console.log(commandHelp(tool));
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
const args = parseArgs(tool, rest);
|
|
110
|
+
if (typeof args === "string") {
|
|
111
|
+
console.error(args);
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
const out = await callTool(tool.name, args, { waitForJobs: true });
|
|
115
|
+
let text = out.text;
|
|
116
|
+
if (out.image) {
|
|
117
|
+
const file = join(tmpdir(), `simtap-${Date.now()}.${out.image.mimeType === "image/png" ? "png" : "jpg"}`);
|
|
118
|
+
await writeFile(file, out.image.data);
|
|
119
|
+
text += `\nscreenshot: ${file}`;
|
|
120
|
+
}
|
|
121
|
+
(out.isError ? console.error : console.log)(text);
|
|
122
|
+
return out.isError ? 1 : 0;
|
|
123
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ToolError } from "../errors.js";
|
|
5
|
+
import { isOurs, run } from "./exec.js";
|
|
6
|
+
import { deviceDir } from "./state.js";
|
|
7
|
+
const simctl = (args, timeoutMs = 60_000) => run("xcrun", ["simctl", ...args], { timeoutMs });
|
|
8
|
+
/** The value of a key in `simctl appinfo`'s old-style plist output. */
|
|
9
|
+
export function plistValue(text, key) {
|
|
10
|
+
const match = new RegExp(`\\b${key} = "?([^";\\n]+)"?;`).exec(text);
|
|
11
|
+
return match?.[1]?.trim();
|
|
12
|
+
}
|
|
13
|
+
export async function appInfo(udid, bundleId) {
|
|
14
|
+
try {
|
|
15
|
+
const { stdout } = await simctl(["appinfo", udid, bundleId], 15_000);
|
|
16
|
+
if (!stdout.includes("CFBundleIdentifier"))
|
|
17
|
+
return undefined;
|
|
18
|
+
// simctl escapes non-ASCII names (Café as Caf\U00e9): the app's own Info.plist has them as written.
|
|
19
|
+
const path = plistValue(stdout, "Path");
|
|
20
|
+
const fromPlist = async (key) => path ? (await run("plutil", ["-extract", key, "raw", join(path, "Info.plist")], { timeoutMs: 10_000 }).catch(() => undefined))?.stdout.trim() || undefined : undefined;
|
|
21
|
+
const name = (await fromPlist("CFBundleDisplayName")) ?? (await fromPlist("CFBundleName")) ?? plistValue(stdout, "CFBundleDisplayName") ?? plistValue(stdout, "CFBundleName");
|
|
22
|
+
return { executable: (await fromPlist("CFBundleExecutable")) ?? plistValue(stdout, "CFBundleExecutable"), name };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** A bundle id as Apple allows it, so it can name files safely. */
|
|
29
|
+
export const BUNDLE_ID = /^[A-Za-z0-9.-]+$/;
|
|
30
|
+
/** Reads the bundle id from a built .app, so `install` can name what it installed. */
|
|
31
|
+
export async function bundleIdOf(appPath) {
|
|
32
|
+
const plist = join(appPath, "Info.plist");
|
|
33
|
+
if (!existsSync(plist))
|
|
34
|
+
throw new ToolError(`${appPath} is not an .app bundle (no Info.plist).`, "Pass the path to a Simulator build, such as build/Build/Products/Debug-iphonesimulator/MyApp.app.");
|
|
35
|
+
const { stdout } = await run("plutil", ["-extract", "CFBundleIdentifier", "raw", plist], { timeoutMs: 10_000 });
|
|
36
|
+
return stdout.trim();
|
|
37
|
+
}
|
|
38
|
+
export async function install(udid, appPath) {
|
|
39
|
+
try {
|
|
40
|
+
await simctl(["install", udid, appPath], 120_000);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw new ToolError(`Could not install ${appPath}: ${error.message}`, "Check it is built for the Simulator (iphonesimulator), not a device.");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** The file a launched app's print() output goes to, kept while the app runs. */
|
|
47
|
+
export async function consoleFile(udid, bundleId) {
|
|
48
|
+
return join(await deviceDir(udid), `${bundleId}.console.log`);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Launches fresh (any running copy is stopped) and keeps the app's print() output for app_logs.
|
|
52
|
+
* simctl's --stdout wrote nothing on iOS 26.5, so a detached `simctl launch --console-pty` holds the app's
|
|
53
|
+
* output instead, writing to a file until the app ends; NSUnbufferedIO is what Xcode sets so lines arrive at once.
|
|
54
|
+
*/
|
|
55
|
+
export async function launch(udid, bundleId) {
|
|
56
|
+
const file = await consoleFile(udid, bundleId);
|
|
57
|
+
const fd = openSync(file, "w");
|
|
58
|
+
const child = spawn("xcrun", ["simctl", "launch", "--console-pty", "--terminate-running-process", udid, bundleId], {
|
|
59
|
+
detached: true,
|
|
60
|
+
stdio: ["ignore", fd, fd],
|
|
61
|
+
env: { ...process.env, SIMCTL_CHILD_NSUnbufferedIO: "YES" },
|
|
62
|
+
});
|
|
63
|
+
closeSync(fd);
|
|
64
|
+
// A launch that fails ends the launcher within moments; a running app keeps it alive.
|
|
65
|
+
const code = await new Promise((resolve) => {
|
|
66
|
+
const timer = setTimeout(() => resolve(undefined), 2_000);
|
|
67
|
+
child.once("exit", (c) => (clearTimeout(timer), resolve(c)));
|
|
68
|
+
child.once("error", () => (clearTimeout(timer), resolve(-1)));
|
|
69
|
+
});
|
|
70
|
+
child.unref();
|
|
71
|
+
if (code !== undefined && code !== 0) {
|
|
72
|
+
const output = readFileSync(file, "utf8").trim();
|
|
73
|
+
if (/not installed|FBSOpenApplicationServiceErrorDomain|Unable to find|could not be found/i.test(output)) {
|
|
74
|
+
throw new ToolError(`${bundleId} is not installed on this Simulator.`, 'Install it first: app_control {action: "install", app: "path/to/App.app"}.');
|
|
75
|
+
}
|
|
76
|
+
throw new ToolError(`Could not launch ${bundleId}: ${output.split("\n").slice(-3).join(" ")}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** The app's own system log lines, streamed to a file while simtap watches it. */
|
|
80
|
+
export async function streamFile(udid, bundleId) {
|
|
81
|
+
return join(await deviceDir(udid), `${bundleId}.syslog.log`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The system log predicate for what an app itself logged: its own sender or subsystem. Apple's frameworks
|
|
85
|
+
* also log faults from inside the app (on iOS 27, a keyboard queue timeout on every first focus); a grep
|
|
86
|
+
* reaches those, and a crash is in the crash report and the print output.
|
|
87
|
+
*/
|
|
88
|
+
export function ownLines(executable, bundleId) {
|
|
89
|
+
const name = executable.replace(/"/g, "");
|
|
90
|
+
return `process == "${name}" AND (sender == "${name}" OR subsystem BEGINSWITH "${bundleId.replace(/"/g, "")}")`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Streams the app's own log lines to a file from before it launches. \`log show\` is not enough: macOS keeps
|
|
94
|
+
* info lines only in memory, and the Simulator's UIKit noise pushed the sample app's "signed in" line out
|
|
95
|
+
* within 30 s (October 19). One stream per app and device; a new launch replaces the old stream.
|
|
96
|
+
*/
|
|
97
|
+
export async function startLogStream(udid, bundleId, executable) {
|
|
98
|
+
await stopLogStream(udid, bundleId);
|
|
99
|
+
const file = await streamFile(udid, bundleId);
|
|
100
|
+
const fd = openSync(file, "w");
|
|
101
|
+
const child = spawn("xcrun", ["simctl", "spawn", udid, "log", "stream", "--style", "compact", "--level", "info", "--predicate", ownLines(executable, bundleId)], {
|
|
102
|
+
detached: true,
|
|
103
|
+
stdio: ["ignore", fd, "ignore"],
|
|
104
|
+
});
|
|
105
|
+
closeSync(fd);
|
|
106
|
+
child.unref();
|
|
107
|
+
if (child.pid)
|
|
108
|
+
writeFileSync(`${file}.pid`, String(child.pid));
|
|
109
|
+
// The stream needs a moment to attach before the app's first lines.
|
|
110
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
111
|
+
}
|
|
112
|
+
export async function stopLogStream(udid, bundleId) {
|
|
113
|
+
const pidFile = `${await streamFile(udid, bundleId)}.pid`;
|
|
114
|
+
const pid = existsSync(pidFile) ? Number(readFileSync(pidFile, "utf8")) : 0;
|
|
115
|
+
if (await isOurs(pid, /simctl spawn .* log stream/)) {
|
|
116
|
+
try {
|
|
117
|
+
process.kill(pid);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Gone between the check and the signal.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
rmSync(pidFile, { force: true });
|
|
124
|
+
}
|
|
125
|
+
export async function simctlAction(args, what, timeoutMs = 30_000) {
|
|
126
|
+
try {
|
|
127
|
+
return (await simctl(args, timeoutMs)).stdout.trim();
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw new ToolError(`Could not ${what}: ${error.message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export async function pushPayload(udid, bundleId, payload) {
|
|
134
|
+
await new Promise((resolve, reject) => {
|
|
135
|
+
const child = spawn("xcrun", ["simctl", "push", udid, bundleId, "-"], { stdio: ["pipe", "ignore", "pipe"] });
|
|
136
|
+
let stderr = "";
|
|
137
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
138
|
+
child.on("error", reject);
|
|
139
|
+
child.on("close", (code) => (code === 0 ? resolve() : reject(new ToolError(`Could not send the push: ${stderr.trim()}`))));
|
|
140
|
+
child.stdin.end(payload);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** Puts text on the Simulator's clipboard, for pasting what the keyboard can't type (é, 日本, emoji). */
|
|
144
|
+
export async function setPasteboard(udid, text) {
|
|
145
|
+
await new Promise((resolve, reject) => {
|
|
146
|
+
const child = spawn("xcrun", ["simctl", "pbcopy", udid], { stdio: ["pipe", "ignore", "pipe"] });
|
|
147
|
+
let stderr = "";
|
|
148
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
149
|
+
child.on("error", reject);
|
|
150
|
+
child.on("close", (code) => (code === 0 ? resolve() : reject(new ToolError(`Could not set the Simulator's clipboard: ${stderr.trim()}`))));
|
|
151
|
+
child.stdin.end(text);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { ToolError } from "../errors.js";
|
|
7
|
+
import { run } from "./exec.js";
|
|
8
|
+
/**
|
|
9
|
+
* AXe (MIT, github.com/cameroncooke/AXe) reads the Simulator's accessibility tree and sends touches and keys,
|
|
10
|
+
* with no test runner to build. The package carries a pinned copy in vendor/axe; SIMTAP_AXE overrides it.
|
|
11
|
+
*/
|
|
12
|
+
export function axePath() {
|
|
13
|
+
if (process.env.SIMTAP_AXE)
|
|
14
|
+
return process.env.SIMTAP_AXE;
|
|
15
|
+
// src/engine and dist/engine are both two levels below the package root.
|
|
16
|
+
const bundled = fileURLToPath(new URL("../../vendor/axe/axe", import.meta.url));
|
|
17
|
+
return existsSync(bundled) ? bundled : "axe";
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* After a Simulator's first boot, its accessibility service takes a while to accept a reader: on a fresh
|
|
21
|
+
* Xcode 27 Mac, describe-ui failed with this for about 75 s, then worked.
|
|
22
|
+
*/
|
|
23
|
+
export class AccessibilityStarting extends ToolError {
|
|
24
|
+
}
|
|
25
|
+
// Also right after a boot: "No translation object returned for simulator" (iPhone 17e, 5 s after booting).
|
|
26
|
+
const STARTING = /remote automation session|No translation object returned/i;
|
|
27
|
+
export async function describeUi(udid) {
|
|
28
|
+
try {
|
|
29
|
+
const { stdout } = await run(axePath(), ["describe-ui", "--udid", udid], { timeoutMs: 30_000 });
|
|
30
|
+
const parsed = JSON.parse(stdout);
|
|
31
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code === "ENOENT")
|
|
35
|
+
throw new ToolError("AXe is missing from this install.", "Reinstall simtap, or set SIMTAP_AXE to an axe binary.");
|
|
36
|
+
if (STARTING.test(error.message)) {
|
|
37
|
+
throw new AccessibilityStarting("The Simulator's accessibility service is still starting; after a first boot this can take a minute or two.", "Call again in 30 seconds.");
|
|
38
|
+
}
|
|
39
|
+
throw new ToolError(`Could not read the screen: ${error.message}`, "Wait a second and call ui_look again; if it keeps failing, check the Simulator is booted with devices.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** A JPEG of the screen sized to points, so a point in the picture is a point to tap (402x874 is about 470 image tokens). */
|
|
43
|
+
export async function screenshot(udid, points) {
|
|
44
|
+
const dir = await mkdtemp(join(tmpdir(), "simtap-"));
|
|
45
|
+
try {
|
|
46
|
+
const png = join(dir, "screen.png");
|
|
47
|
+
const jpg = join(dir, "screen.jpg");
|
|
48
|
+
await run(axePath(), ["screenshot", "--output", png, "--udid", udid], { timeoutMs: 30_000 });
|
|
49
|
+
const size = points.width > 0 && points.height > 0 ? ["-z", String(Math.round(points.height)), String(Math.round(points.width))] : [];
|
|
50
|
+
await run("sips", [...size, "-s", "format", "jpeg", "-s", "formatOptions", "70", png, "--out", jpg], { timeoutMs: 30_000 });
|
|
51
|
+
return await readFile(jpg);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new ToolError(`Could not take a screenshot: ${error.message}`);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await rm(dir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const axe = (udid, args, timeoutMs = 30_000) => run(axePath(), [...args, "--udid", udid], { timeoutMs });
|
|
61
|
+
/** One AXe process for several input steps (tap, type, key, key-combo, swipe, sleep): about 0.2 s cheaper per step. */
|
|
62
|
+
export async function batch(udid, steps) {
|
|
63
|
+
try {
|
|
64
|
+
await axe(udid, ["batch", "--ax-cache", "none", "--type-submission", "composite", ...steps.flatMap((s) => ["--step", s])], 60_000);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
throw new ToolError(`The Simulator did not take the input: ${error.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** A drag or swipe on its own: AXe's batch takes swipe but not drag. */
|
|
71
|
+
export async function gesture(udid, args) {
|
|
72
|
+
try {
|
|
73
|
+
await axe(udid, args, 30_000);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
throw new ToolError(`The Simulator did not take the gesture: ${error.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** The single element at a point, for reading back a field right after typing (about 0.15 s). */
|
|
80
|
+
export async function elementAt(udid, x, y) {
|
|
81
|
+
try {
|
|
82
|
+
// Normally 0.15 s; on a fresh Xcode 27 VM some hung for 8 to 11 s, so a slow one is abandoned for a full read.
|
|
83
|
+
const { stdout } = await axe(udid, ["describe-ui", "--point", `${Math.round(x)},${Math.round(y)}`], 3_000);
|
|
84
|
+
const parsed = JSON.parse(stdout);
|
|
85
|
+
return Array.isArray(parsed) ? parsed[0] : parsed;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** A step string for AXe batch: text goes in single quotes, with any single quote escaped the shell way. */
|
|
92
|
+
export function typeStep(text) {
|
|
93
|
+
return `type '${text.replace(/'/g, `'\\''`)}'`;
|
|
94
|
+
}
|
|
95
|
+
export async function pressButton(udid, button) {
|
|
96
|
+
await axe(udid, ["button", button]);
|
|
97
|
+
}
|
|
98
|
+
/** Sets a slider by accessibility id or label to a percent; AXe drags it there. */
|
|
99
|
+
export async function setSlider(udid, target, percent) {
|
|
100
|
+
const by = target.id ? ["--id", target.id] : ["--label", target.label ?? ""];
|
|
101
|
+
try {
|
|
102
|
+
await axe(udid, ["slider", ...by, "--value", String(percent)]);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
throw new ToolError(`Could not set the slider: ${error.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/** Runs a program without a shell. Rejects with its stderr when it fails or runs past the timeout. */
|
|
3
|
+
export function run(file, args, options = {}) {
|
|
4
|
+
const started = Date.now();
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
execFile(file, args, { timeout: options.timeoutMs ?? 30_000, maxBuffer: options.maxBytes ?? 64 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
7
|
+
// SIMTAP_DEBUG=1 prints every program run and how long it took, to find where time goes.
|
|
8
|
+
if (process.env.SIMTAP_DEBUG)
|
|
9
|
+
process.stderr.write(`simtap: ${file.split("/").pop()} ${args.slice(0, 2).join(" ")} ${Date.now() - started} ms${error ? " (failed)" : ""}\n`);
|
|
10
|
+
if (!error)
|
|
11
|
+
return resolve({ stdout, stderr });
|
|
12
|
+
const code = error.code;
|
|
13
|
+
const reason = code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
|
14
|
+
? "printed more output than can be read"
|
|
15
|
+
: error.killed
|
|
16
|
+
? `timed out after ${Math.round((options.timeoutMs ?? 30_000) / 1000)} s`
|
|
17
|
+
: (stderr || error.message).trim();
|
|
18
|
+
reject(Object.assign(new Error(`${file.split("/").pop()} ${args[0] ?? ""}: ${reason}`), { stderr, code: error.code }));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether a pid still belongs to the process we started: a pid file can outlive its process (the Simulator shut
|
|
24
|
+
* down, the Mac restarted), and the number may by then belong to something else that must not be signalled.
|
|
25
|
+
*/
|
|
26
|
+
export async function isOurs(pid, pattern) {
|
|
27
|
+
if (!pid)
|
|
28
|
+
return false;
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await run("ps", ["-o", "command=", "-p", String(pid)], { timeoutMs: 5_000 });
|
|
31
|
+
return pattern.test(stdout);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
const jobs = new Map();
|
|
4
|
+
let latest;
|
|
5
|
+
/** Stops every running job: the server is going away, and a build nobody can follow should not keep compiling. */
|
|
6
|
+
export function cancelAllJobs() {
|
|
7
|
+
for (const job of jobs.values())
|
|
8
|
+
if (!job.result)
|
|
9
|
+
job.cancel?.();
|
|
10
|
+
}
|
|
11
|
+
// Node runs no exit handler when a signal ends the process, so the signals stop the jobs themselves.
|
|
12
|
+
process.on("exit", cancelAllJobs);
|
|
13
|
+
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
14
|
+
process.once(signal, () => {
|
|
15
|
+
cancelAllJobs();
|
|
16
|
+
process.exit(128 + { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 }[signal]);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export function startJob(title, work) {
|
|
20
|
+
const id = `job_${randomBytes(4).toString("hex")}`;
|
|
21
|
+
const job = { id, title, started: Date.now(), step: "starting", tail: [] };
|
|
22
|
+
const progress = {
|
|
23
|
+
step: (step) => {
|
|
24
|
+
job.step = step;
|
|
25
|
+
},
|
|
26
|
+
line: (line) => {
|
|
27
|
+
job.tail.push(line);
|
|
28
|
+
if (job.tail.length > 20)
|
|
29
|
+
job.tail.shift();
|
|
30
|
+
},
|
|
31
|
+
// Cleared once the part that can be stopped (the build) is over, so a late cancel doesn't claim it stopped.
|
|
32
|
+
setCancel: (cancel) => {
|
|
33
|
+
job.cancel = cancel
|
|
34
|
+
? () => {
|
|
35
|
+
job.cancelled = true;
|
|
36
|
+
cancel();
|
|
37
|
+
}
|
|
38
|
+
: undefined;
|
|
39
|
+
},
|
|
40
|
+
setLog: (file) => {
|
|
41
|
+
job.log = file;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
job.done = work(progress)
|
|
45
|
+
.catch((error) => ({ text: error.toText?.() ?? `${title} failed: ${error.message}`, isError: true }))
|
|
46
|
+
.then((result) => {
|
|
47
|
+
// Whatever the stopped work reported (a build "fails" when killed), the truth is it was stopped.
|
|
48
|
+
if (job.cancelled)
|
|
49
|
+
result = { text: `${title} was stopped by job_cancel after ${elapsed(job)}.`, isError: true };
|
|
50
|
+
job.result = result;
|
|
51
|
+
job.cancel = undefined;
|
|
52
|
+
return result;
|
|
53
|
+
});
|
|
54
|
+
jobs.set(id, job);
|
|
55
|
+
latest = id;
|
|
56
|
+
return job;
|
|
57
|
+
}
|
|
58
|
+
export const getJob = (id) => jobs.get(id ?? latest ?? "");
|
|
59
|
+
/** Waits for a job up to `ms`, so a caller can answer with the result when it comes soon, or with progress. */
|
|
60
|
+
export async function waitFor(job, ms) {
|
|
61
|
+
if (job.result)
|
|
62
|
+
return job.result;
|
|
63
|
+
return Promise.race([job.done, delay(ms, undefined, { ref: false })]);
|
|
64
|
+
}
|
|
65
|
+
export const elapsed = (job) => {
|
|
66
|
+
const s = Math.round((Date.now() - job.started) / 1000);
|
|
67
|
+
return s >= 60 ? `${Math.floor(s / 60)} min ${s % 60} s` : `${s} s`;
|
|
68
|
+
};
|
|
69
|
+
/** A call answers within this (40 s); longer work goes on as a job. */
|
|
70
|
+
export const ANSWER_WITHIN_MS = (Number(process.env.SIMTAP_JOB_AFTER_S) || 40) * 1000;
|
|
71
|
+
/** The result if it comes within 40 s, else the job to follow; the CLI just waits. */
|
|
72
|
+
export async function answer(job, waitForJobs) {
|
|
73
|
+
if (waitForJobs)
|
|
74
|
+
return job.done;
|
|
75
|
+
const done = await waitFor(job, ANSWER_WITHIN_MS);
|
|
76
|
+
if (done)
|
|
77
|
+
return done;
|
|
78
|
+
return { text: `${job.title} is still ${job.step} after ${elapsed(job)}: job ${job.id}.\nNext: job_status {job_id: "${job.id}"}; it waits up to 40 s for the result.` };
|
|
79
|
+
}
|
|
80
|
+
/** Which running job holds a project: xcodebuild runs in that project's DerivedData, which only one may use. */
|
|
81
|
+
const projects = new Map();
|
|
82
|
+
/** Claims a project for a job, or names the job already running there. Released when the job ends. */
|
|
83
|
+
export function claimProject(projectPath, job) {
|
|
84
|
+
const holder = projects.get(projectPath);
|
|
85
|
+
if (holder && !holder.result && holder !== job)
|
|
86
|
+
return holder;
|
|
87
|
+
projects.set(projectPath, job);
|
|
88
|
+
void job.done.finally(() => {
|
|
89
|
+
if (projects.get(projectPath) === job)
|
|
90
|
+
projects.delete(projectPath);
|
|
91
|
+
});
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
/** The job this one is: for work that runs inside it and needs to claim things in its name. */
|
|
95
|
+
export const currentJob = (id) => jobs.get(id);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ownLines } from "./apps.js";
|
|
5
|
+
import { run } from "./exec.js";
|
|
6
|
+
const LEVELS = { Df: "", I: "info", Db: "debug", E: "error", F: "fault" };
|
|
7
|
+
/** `2026-10-19 01:56:22.114 E Demo[94957:88904] (Sender) [subsystem:category] message`; sender and subsystem are optional. */
|
|
8
|
+
const COMPACT = /^\d{4}-\d\d-\d\d (\d\d:\d\d:\d\d\.\d+) (\w+)\s+\S+?\[\d+:\w+\] (?:\(([^)]+)\) )?(?:\[([^\]]+)\] )?(.*)$/;
|
|
9
|
+
/** Parses `log show --style compact`. Activity lines (type A) are bookkeeping, never kept. */
|
|
10
|
+
export function parseSystemLog(output, appSubsystem) {
|
|
11
|
+
const lines = [];
|
|
12
|
+
for (const raw of output.split("\n")) {
|
|
13
|
+
const m = COMPACT.exec(raw);
|
|
14
|
+
if (!m || m[2] === "A")
|
|
15
|
+
continue;
|
|
16
|
+
const [, time, type, sender, source = "", text = ""] = m;
|
|
17
|
+
const [subsystem = "", category = ""] = source.split(":");
|
|
18
|
+
const tag = subsystem && appSubsystem && subsystem.startsWith(appSubsystem) ? category : source || (sender ? `(${sender})` : "");
|
|
19
|
+
lines.push({ time: time, level: LEVELS[type] ?? type, text: `${tag ? `${tag}: ` : ""}${text}` });
|
|
20
|
+
}
|
|
21
|
+
return lines;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The app's lines from the Simulator's system log. Apple's frameworks log a great deal from inside the app's
|
|
25
|
+
* process (UIKit's event dispatch, several lines a tap; on the Simulator, a haptics error every key press), so
|
|
26
|
+
* by default only what the app itself logged is read, and faults from anywhere; `everything` reads it all.
|
|
27
|
+
*/
|
|
28
|
+
export async function systemLog(udid, executable, seconds, bundleId, everything = false) {
|
|
29
|
+
const predicate = everything ? `process == "${executable.replace(/"/g, "")}"` : ownLines(executable, bundleId);
|
|
30
|
+
const { stdout } = await run("xcrun", ["simctl", "spawn", udid, "log", "show", "--style", "compact", "--info", "--last", `${Math.max(1, Math.ceil(seconds))}s`, "--predicate", predicate], {
|
|
31
|
+
timeoutMs: 12_000,
|
|
32
|
+
});
|
|
33
|
+
return parseSystemLog(stdout, bundleId);
|
|
34
|
+
}
|
|
35
|
+
/** Lines from the app's own log stream (startLogStream) that are newer than `since` (ms since 1970). */
|
|
36
|
+
export async function streamedLines(file, since, bundleId) {
|
|
37
|
+
let text;
|
|
38
|
+
try {
|
|
39
|
+
text = await readFile(file, "utf8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const lines = [];
|
|
45
|
+
for (const raw of text.split("\n")) {
|
|
46
|
+
// The stamp is the Mac's local time, as `2026-10-19 02:04:48.818`.
|
|
47
|
+
const stamp = /^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d\.\d+)/.exec(raw);
|
|
48
|
+
if (!stamp || new Date(`${stamp[1]}T${stamp[2]}`).getTime() < since)
|
|
49
|
+
continue;
|
|
50
|
+
lines.push(...parseSystemLog(raw, bundleId));
|
|
51
|
+
}
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
/** The app's print() output, without the Simulator's own start-up noise. */
|
|
55
|
+
export function cleanConsole(text) {
|
|
56
|
+
return text
|
|
57
|
+
.replace(/\r/g, "")
|
|
58
|
+
.split("\n")
|
|
59
|
+
.filter((line) => line.trim() && !/^objc\[\d+\]: Class .* is implemented in both/.test(line));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Crash reports of a Simulator app land on the Mac itself, in ~/Library/Logs/DiagnosticReports, named after
|
|
63
|
+
* the app's executable. An .ips file is one JSON header line, then the report as JSON.
|
|
64
|
+
*/
|
|
65
|
+
export async function crashReports(executable, since, limit = 3) {
|
|
66
|
+
const dir = join(homedir(), "Library", "Logs", "DiagnosticReports");
|
|
67
|
+
let names;
|
|
68
|
+
try {
|
|
69
|
+
names = (await readdir(dir)).filter((n) => n.startsWith(`${executable}-`) && n.endsWith(".ips"));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const files = await Promise.all(names.map(async (n) => ({ file: join(dir, n), at: (await stat(join(dir, n))).mtimeMs })));
|
|
75
|
+
const recent = files.filter((f) => f.at >= since).sort((a, b) => b.at - a.at).slice(0, limit);
|
|
76
|
+
return Promise.all(recent.map(async (f) => {
|
|
77
|
+
const text = await readFile(f.file, "utf8");
|
|
78
|
+
// When it crashed, not when macOS finished writing the report (up to a minute later).
|
|
79
|
+
const time = /"captureTime"\s*:\s*"([^"]+)"/.exec(text)?.[1] ?? new Date(f.at).toISOString();
|
|
80
|
+
return { file: f.file, time, summary: summarizeIps(text, executable) };
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* What went wrong and where: the exception, the reason, and the crashing thread from the top down to just past
|
|
85
|
+
* the app's own last frame (the rest is the framework that called it).
|
|
86
|
+
*/
|
|
87
|
+
export function summarizeIps(text, executable) {
|
|
88
|
+
const body = text.slice(text.indexOf("\n") + 1);
|
|
89
|
+
let ips;
|
|
90
|
+
try {
|
|
91
|
+
ips = JSON.parse(body);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return ["(unreadable crash report)"];
|
|
95
|
+
}
|
|
96
|
+
const out = [];
|
|
97
|
+
if (ips.exception)
|
|
98
|
+
out.push(`exception: ${[ips.exception.type, ips.exception.signal].filter(Boolean).join(" ")}`);
|
|
99
|
+
const reasons = [...(ips.termination?.reasons ?? []), ...Object.values(ips.asi ?? {}).flat(), ips.termination?.indicator ?? ""].filter(Boolean);
|
|
100
|
+
for (const reason of reasons.slice(0, 3))
|
|
101
|
+
out.push(`reason: ${reason.replace(/\s+/g, " ").trim()}`);
|
|
102
|
+
const thread = ips.threads?.find((t) => t.triggered) ?? ips.threads?.[ips.faultingThread ?? 0];
|
|
103
|
+
const all = thread?.frames ?? [];
|
|
104
|
+
const imageOf = (f) => ips.usedImages?.[f.imageIndex ?? -1]?.name ?? "?";
|
|
105
|
+
const own = (f) => Boolean(executable) && (imageOf(f) === executable || imageOf(f).startsWith(`${executable}.`));
|
|
106
|
+
const lastOwn = all.reduce((last, f, i) => (own(f) ? i : last), -1);
|
|
107
|
+
const frames = all.slice(0, lastOwn >= 0 ? Math.min(lastOwn + 3, 12) : 8).map((f) => {
|
|
108
|
+
const image = imageOf(f);
|
|
109
|
+
const where = f.sourceFile ? ` (${f.sourceFile}:${f.sourceLine ?? "?"})` : "";
|
|
110
|
+
return ` ${image} ${f.symbol ?? `+${f.imageOffset ?? 0}`}${where}`;
|
|
111
|
+
});
|
|
112
|
+
if (frames.length)
|
|
113
|
+
out.push("crashed thread:", ...frames);
|
|
114
|
+
return out;
|
|
115
|
+
}
|