simulator-use 0.0.0-stage → 0.8.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 +209 -2
- package/THIRD_PARTY_LICENSES +61 -0
- package/bin/simulator-use.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 +201 -0
- package/dist/cloud-hint.js +14 -0
- package/dist/engine/apps.js +153 -0
- package/dist/engine/axe.js +109 -0
- package/dist/engine/exec.js +37 -0
- package/dist/engine/jobs.js +108 -0
- package/dist/engine/lock.js +68 -0
- package/dist/engine/logs.js +115 -0
- package/dist/engine/simctl.js +156 -0
- package/dist/engine/state.js +63 -0
- package/dist/engine/xcode.js +259 -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 +459 -0
- package/dist/mcp.js +104 -0
- package/dist/registry.js +73 -0
- package/dist/settings.js +5 -0
- package/dist/tools/app-control.js +127 -0
- package/dist/tools/app-logs.js +128 -0
- package/dist/tools/app-run.js +97 -0
- package/dist/tools/device-use.js +69 -0
- package/dist/tools/devices.js +27 -0
- package/dist/tools/jobs.js +58 -0
- package/dist/tools/launch-app.js +68 -0
- package/dist/tools/screen-record.js +79 -0
- package/dist/tools/tests-run.js +84 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/ui-act.js +444 -0
- package/dist/tools/ui-look.js +39 -0
- package/package.json +50 -4
- package/skill/SKILL.md +24 -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,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
|
+
}
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns the Simulator's accessibility tree (AXe describe-ui JSON) into the compact screen an agent reads:
|
|
3
|
+
* one line per element with its kind, label, #id, state and @x,y center in points. Groups, decorations,
|
|
4
|
+
* scroll bars and the on-screen keyboard's keys are folded away; the tab bar and scrolling become one line each.
|
|
5
|
+
*
|
|
6
|
+
* Only what can be tapped is listed: a sheet, a full-screen cover or an alert hides the screen behind it,
|
|
7
|
+
* and content under the navigation bar, the tab bar or the keyboard is counted as hidden instead.
|
|
8
|
+
*/
|
|
9
|
+
/** UIKit's keyboard, its suggestion bar and the view holding both: shown to the agent as one line. */
|
|
10
|
+
const KEYBOARD_IDS = new Set(["inputView", "UIKeyboardLayoutStar Preview", "SystemInputAssistantView"]);
|
|
11
|
+
const STATUS_BAR_BOTTOM = 50;
|
|
12
|
+
const SCROLL_BAR = /^(Vertical|Horizontal) scroll bar, (\d+) pages?$/;
|
|
13
|
+
const CONTROLS = new Set(["button", "back", "tab", "segment", "link", "cell", "switch", "checkbox", "radio", "slider", "stepper", "popup", "menu", "picker", "textfield", "securefield", "searchfield", "textview"]);
|
|
14
|
+
const FIELDS = new Set(["textfield", "securefield", "searchfield", "textview"]);
|
|
15
|
+
/** UIKit names a navigation bar after its view controller's class when it has no title. */
|
|
16
|
+
const MANGLED = /^_Tt|^[A-Za-z0-9]+(Controller|Hosting)$/;
|
|
17
|
+
const ROLE_KINDS = {
|
|
18
|
+
AXButton: "button",
|
|
19
|
+
AXLink: "link",
|
|
20
|
+
AXCell: "cell",
|
|
21
|
+
AXStaticText: "text",
|
|
22
|
+
AXHeading: "heading",
|
|
23
|
+
AXImage: "image",
|
|
24
|
+
AXTextField: "textfield",
|
|
25
|
+
AXSearchField: "searchfield",
|
|
26
|
+
AXTextView: "textview",
|
|
27
|
+
AXTextArea: "textview",
|
|
28
|
+
AXSlider: "slider",
|
|
29
|
+
AXIncrementor: "stepper",
|
|
30
|
+
AXPopUpButton: "popup",
|
|
31
|
+
AXMenuButton: "menu",
|
|
32
|
+
AXPicker: "picker",
|
|
33
|
+
AXPickerWheel: "picker",
|
|
34
|
+
AXProgressIndicator: "progress",
|
|
35
|
+
AXRadioButton: "radio",
|
|
36
|
+
AXCheckBox: "checkbox",
|
|
37
|
+
AXSheet: "alert",
|
|
38
|
+
AXGenericElement: "element",
|
|
39
|
+
};
|
|
40
|
+
/** Roles that only hold other elements. */
|
|
41
|
+
const CONTAINERS = new Set(["", "AXGroup", "AXApplication", "AXScrollArea", "AXWindow", "AXTabGroup", "AXSegmentedControl"]);
|
|
42
|
+
function kindOf(node, inTabGroup) {
|
|
43
|
+
const role = node.role ?? "";
|
|
44
|
+
if (role === "AXButton" && node.role_description === "back button")
|
|
45
|
+
return "back";
|
|
46
|
+
if (role === "AXTextField" && node.subrole === "AXSecureTextField")
|
|
47
|
+
return "securefield";
|
|
48
|
+
if (role === "AXCheckBox" && node.subrole === "AXSwitch")
|
|
49
|
+
return "switch";
|
|
50
|
+
// A tab button inside a tab group is a segment of a segmented control; any other is a tab: in the iPhone's
|
|
51
|
+
// tab bar, or in the iPad's, which sits at the top with no "Tab Bar" group around it.
|
|
52
|
+
if (role === "AXRadioButton" && node.subrole === "AXTabButton")
|
|
53
|
+
return inTabGroup ? "segment" : "tab";
|
|
54
|
+
if (ROLE_KINDS[role])
|
|
55
|
+
return ROLE_KINDS[role];
|
|
56
|
+
if (CONTAINERS.has(role))
|
|
57
|
+
return null;
|
|
58
|
+
// Anything else with a name still matters (maps, web links): name it after its role.
|
|
59
|
+
return (node.role_description || role.replace(/^AX/, "")).toLowerCase().replace(/\s+/g, "");
|
|
60
|
+
}
|
|
61
|
+
const text = (value) => (value === null || value === undefined ? "" : String(value).trim());
|
|
62
|
+
const center = (f) => ({ x: Math.round(f.x + f.width / 2), y: Math.round(f.y + f.height / 2) });
|
|
63
|
+
const inside = (point, f) => point.x >= f.x && point.x < f.x + f.width && point.y >= f.y && point.y < f.y + f.height;
|
|
64
|
+
const area = (f) => Math.max(0, f.width) * Math.max(0, f.height);
|
|
65
|
+
const isTabBar = (n) => n.AXLabel === "Tab Bar" || n.role_description === "tab bar";
|
|
66
|
+
const isNavBar = (n) => n.role_description?.toLowerCase() === "nav bar";
|
|
67
|
+
/** The part of a frame on screen, below the navigation bar and above the tab bar, or null when none of it is. */
|
|
68
|
+
function visible(f, screen, floor, ceiling = screen.y) {
|
|
69
|
+
const x = Math.max(f.x, screen.x);
|
|
70
|
+
const y = Math.max(f.y, ceiling);
|
|
71
|
+
const right = Math.min(f.x + f.width, screen.x + screen.width);
|
|
72
|
+
const bottom = Math.min(f.y + f.height, floor);
|
|
73
|
+
if (right - x < 1 || bottom - y < 1)
|
|
74
|
+
return null;
|
|
75
|
+
return { x, y, width: right - x, height: bottom - y };
|
|
76
|
+
}
|
|
77
|
+
function* walkAll(nodes) {
|
|
78
|
+
for (const n of nodes) {
|
|
79
|
+
yield n;
|
|
80
|
+
yield* walkAll(n.children ?? []);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function isKeyboard(node) {
|
|
84
|
+
if (node.AXUniqueId && KEYBOARD_IDS.has(node.AXUniqueId))
|
|
85
|
+
return true;
|
|
86
|
+
// Other layouts: a group holding a row of single-character keys.
|
|
87
|
+
const keys = (node.children ?? []).filter((c) => c.role === "AXButton" && text(c.AXLabel).length === 1).length;
|
|
88
|
+
return keys >= 8;
|
|
89
|
+
}
|
|
90
|
+
/** Whether a subtree holds anything an agent could read: a labeled element that isn't a scroll bar or the keyboard. */
|
|
91
|
+
function readable(node) {
|
|
92
|
+
if (isKeyboard(node))
|
|
93
|
+
return false;
|
|
94
|
+
const label = text(node.AXLabel);
|
|
95
|
+
if (!CONTAINERS.has(node.role ?? "") && (label || text(node.AXUniqueId)) && !SCROLL_BAR.test(label))
|
|
96
|
+
return true;
|
|
97
|
+
return (node.children ?? []).some(readable);
|
|
98
|
+
}
|
|
99
|
+
/** Whether a node itself (not its children) has something to read. */
|
|
100
|
+
function readableOwn(n) {
|
|
101
|
+
const label = text(n.AXLabel);
|
|
102
|
+
return !CONTAINERS.has(n.role ?? "") && Boolean(label || text(n.AXUniqueId)) && !SCROLL_BAR.test(label) && !isKeyboard(n);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The box a presented layer occupies: down through the containers with one readable child, to the last one
|
|
106
|
+
* that still holds everything readable. A sheet's is its card (from y 62 on an iPhone), a cover's the screen.
|
|
107
|
+
*/
|
|
108
|
+
function box(node) {
|
|
109
|
+
let at = node;
|
|
110
|
+
let frame = node.frame;
|
|
111
|
+
for (;;) {
|
|
112
|
+
const inner = (at.children ?? []).filter(readable);
|
|
113
|
+
if (inner.length !== 1 || !CONTAINERS.has(inner[0].role ?? ""))
|
|
114
|
+
return frame;
|
|
115
|
+
at = inner[0];
|
|
116
|
+
if (at.frame && area(at.frame) > 0)
|
|
117
|
+
frame = at.frame;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* What is in front. An alert (an AXSheet: the app's own, or iOS's permission prompts) always is. Otherwise a
|
|
122
|
+
* presented sheet or full-screen cover is a later sibling of the screen it covers, near the top of the tree:
|
|
123
|
+
* at the first place the tree splits into readable parts, a later part that covers most of the screen and has
|
|
124
|
+
* something to tap or a navigation bar is in front. A later part that is small (a tab bar, a toast) is not.
|
|
125
|
+
*/
|
|
126
|
+
function frontLayer(roots, screen) {
|
|
127
|
+
let alert;
|
|
128
|
+
for (const n of walkAll(roots))
|
|
129
|
+
if (n.role === "AXSheet" && readable(n))
|
|
130
|
+
alert = n;
|
|
131
|
+
if (alert)
|
|
132
|
+
return { nodes: [alert], modal: "alert" };
|
|
133
|
+
let level = roots;
|
|
134
|
+
for (let depth = 0; depth < 40; depth++) {
|
|
135
|
+
const parts = level.filter(readable);
|
|
136
|
+
if (parts.length >= 2) {
|
|
137
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
138
|
+
const part = parts[i];
|
|
139
|
+
const frame = box(part);
|
|
140
|
+
const large = frame && area(frame) >= area(screen) * 0.5;
|
|
141
|
+
const actionable = [...walkAll([part])].some((n) => isNavBar(n) || CONTROLS.has(kindOf(n, false) ?? ""));
|
|
142
|
+
// Something must be behind it: on an iPad the tab bar is its own part before the screen, and a screen
|
|
143
|
+
// after a part that only holds tabs is just the screen. What it covers is a screen too: iOS 27's large
|
|
144
|
+
// title is its own small part before the list, and a list after a title strip is just the screen.
|
|
145
|
+
const behind = parts
|
|
146
|
+
.slice(0, i)
|
|
147
|
+
.some((p) => (area(box(p) ?? { x: 0, y: 0, width: 0, height: 0 }) >= area(screen) * 0.3) && [...walkAll([p])].some((n) => readableOwn(n) && n.subrole !== "AXTabButton" && !isTabBar(n)));
|
|
148
|
+
if (large && actionable && behind && ![...walkAll([part])].some(isTabBar))
|
|
149
|
+
return { nodes: [part], modal: "screen" };
|
|
150
|
+
}
|
|
151
|
+
return { nodes: roots };
|
|
152
|
+
}
|
|
153
|
+
if (parts.length === 0)
|
|
154
|
+
return { nodes: roots };
|
|
155
|
+
level = parts[0].children ?? [];
|
|
156
|
+
}
|
|
157
|
+
return { nodes: roots };
|
|
158
|
+
}
|
|
159
|
+
/** The on-screen keyboard's frame, or undefined when there is none or it is off screen (a hardware keyboard). */
|
|
160
|
+
function keyboardFrame(nodes, screen) {
|
|
161
|
+
let found;
|
|
162
|
+
for (const n of walkAll(nodes)) {
|
|
163
|
+
if (!isKeyboard(n) || !n.frame)
|
|
164
|
+
continue;
|
|
165
|
+
const part = visible(n.frame, screen, screen.y + screen.height);
|
|
166
|
+
if (part && (!found || part.y < found.y))
|
|
167
|
+
found = { ...part, height: screen.y + screen.height - part.y };
|
|
168
|
+
}
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
/** A switch row's unnamed twin is its knob: tapping the row's middle doesn't toggle it, tapping the knob does. */
|
|
172
|
+
function knob(owner, point) {
|
|
173
|
+
if (owner.kind === "switch") {
|
|
174
|
+
owner.x = point.x;
|
|
175
|
+
owner.y = point.y;
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
function stateOf(kind, node) {
|
|
180
|
+
const value = text(node.AXValue);
|
|
181
|
+
const parts = [];
|
|
182
|
+
if (kind === "switch" || kind === "checkbox")
|
|
183
|
+
parts.push(value === "1" ? "on" : "off");
|
|
184
|
+
if ((kind === "tab" || kind === "segment" || kind === "radio") && value === "1")
|
|
185
|
+
parts.push("selected");
|
|
186
|
+
if (node.enabled === false)
|
|
187
|
+
parts.push("disabled");
|
|
188
|
+
return parts.length ? parts.join(" ") : undefined;
|
|
189
|
+
}
|
|
190
|
+
/** A slider's 0.30000001192092896 reads as 0.3. */
|
|
191
|
+
function tidy(value) {
|
|
192
|
+
return /^-?\d+\.\d{4,}$/.test(value) ? String(Number(Number(value).toFixed(2))) : value;
|
|
193
|
+
}
|
|
194
|
+
/** The app's name; SpringBoard (the home screen, and iOS's own alerts over an app) has none. */
|
|
195
|
+
function appName(app, roots, modal) {
|
|
196
|
+
const own = text(app.AXLabel);
|
|
197
|
+
if (own)
|
|
198
|
+
return own;
|
|
199
|
+
// SpringBoard's tree with an app's card in it: iOS is showing something over that app. A permission prompt
|
|
200
|
+
// is an alert; the contacts or photos pickers that follow are drawn by other processes and can't be read.
|
|
201
|
+
const card = [...walkAll(roots)].find((n) => text(n.AXUniqueId).startsWith("card:"));
|
|
202
|
+
const name = card ? text(card.AXLabel) : "";
|
|
203
|
+
if (modal === "alert")
|
|
204
|
+
return name ? `${name} · system alert` : "System alert";
|
|
205
|
+
return name ? `${name} · system screen` : "Home screen";
|
|
206
|
+
}
|
|
207
|
+
export function snapshot(tree) {
|
|
208
|
+
const roots = Array.isArray(tree) ? tree : [tree];
|
|
209
|
+
const app = roots.find((n) => n.role === "AXApplication") ?? roots[0] ?? {};
|
|
210
|
+
const screen = app.frame ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
211
|
+
const front = frontLayer(roots, screen);
|
|
212
|
+
const result = {
|
|
213
|
+
app: appName(app, roots, front.modal),
|
|
214
|
+
elements: [],
|
|
215
|
+
tabs: [],
|
|
216
|
+
keyboard: false,
|
|
217
|
+
hidden: { above: 0, below: 0, left: 0, right: 0 },
|
|
218
|
+
unreadable: [],
|
|
219
|
+
screen,
|
|
220
|
+
...(front.modal ? { modal: front.modal } : {}),
|
|
221
|
+
};
|
|
222
|
+
const nodes = front.nodes;
|
|
223
|
+
// Content scrolls under the navigation bar and the tab bar: a tap there hits the bar, not the content.
|
|
224
|
+
// With navigation inside navigation (UIKit in SwiftUI), the innermost bar is the lowest.
|
|
225
|
+
const all = [...walkAll(nodes)];
|
|
226
|
+
const tabBar = all.find(isTabBar)?.frame;
|
|
227
|
+
const navBars = all.filter((n) => isNavBar(n) && n.frame && n.frame.y < screen.y + screen.height / 3);
|
|
228
|
+
const navBar = navBars.reduce((low, n) => (!low || n.frame.y + n.frame.height > low.frame.y + low.frame.height ? n : low), undefined);
|
|
229
|
+
const barFrames = new Set([tabBar, ...navBars.map((n) => n.frame)].filter(Boolean));
|
|
230
|
+
// The title: the heading in the innermost navigation bar, or its name when that is a real title.
|
|
231
|
+
const navTitle = navBar && (navBar.children ?? []).find((c) => c.role === "AXHeading" && text(c.AXLabel));
|
|
232
|
+
const navName = text(navBar?.AXUniqueId);
|
|
233
|
+
if (navTitle)
|
|
234
|
+
result.title = text(navTitle.AXLabel);
|
|
235
|
+
else if (navName && !MANGLED.test(navName))
|
|
236
|
+
result.title = navName;
|
|
237
|
+
// An on-screen keyboard covers the bottom of the screen, tab bar included; its own bottom row (Emoji,
|
|
238
|
+
// Dictate) sits outside its groups on iOS 27, so everything whose middle is under it is left out.
|
|
239
|
+
const keyboard = keyboardFrame(roots, screen);
|
|
240
|
+
result.keyboard = Boolean(keyboard);
|
|
241
|
+
if (keyboard)
|
|
242
|
+
result.keyboardTop = Math.round(keyboard.y);
|
|
243
|
+
const bottom = keyboard ? keyboard.y : screen.y + screen.height;
|
|
244
|
+
const floor = tabBar ? Math.min(tabBar.y, bottom) : bottom;
|
|
245
|
+
const ceiling = navBar?.frame ? navBar.frame.y + navBar.frame.height : screen.y;
|
|
246
|
+
// The main scroller: the tallest vertical scroll bar. Carousels and an alert's own scrolling are not it.
|
|
247
|
+
let mainScroll;
|
|
248
|
+
for (const n of all) {
|
|
249
|
+
const m = SCROLL_BAR.exec(text(n.AXLabel));
|
|
250
|
+
if (!m || m[1] !== "Vertical" || !n.frame || n.frame.height < screen.height * 0.4)
|
|
251
|
+
continue;
|
|
252
|
+
if (!mainScroll || n.frame.height > mainScroll.height)
|
|
253
|
+
mainScroll = { pages: Number(m[2]), at: text(n.AXValue), height: n.frame.height };
|
|
254
|
+
}
|
|
255
|
+
// An alert's own scrolling text is not the screen's.
|
|
256
|
+
if (mainScroll && mainScroll.pages > 1 && front.modal !== "alert")
|
|
257
|
+
result.scroll = `${mainScroll.pages} pages, at ${mainScroll.at === "0%" ? "top" : mainScroll.at === "100%" ? "bottom" : mainScroll.at}`;
|
|
258
|
+
const hide = (f) => {
|
|
259
|
+
const c = center(f);
|
|
260
|
+
if (c.x < screen.x)
|
|
261
|
+
result.hidden.left++;
|
|
262
|
+
else if (c.x > screen.x + screen.width)
|
|
263
|
+
result.hidden.right++;
|
|
264
|
+
else if (c.y < (ceiling + floor) / 2)
|
|
265
|
+
result.hidden.above++;
|
|
266
|
+
else
|
|
267
|
+
result.hidden.below++;
|
|
268
|
+
};
|
|
269
|
+
const walk = (node, owner, inBar, inTabGroup, parentLabel) => {
|
|
270
|
+
if (isKeyboard(node))
|
|
271
|
+
return;
|
|
272
|
+
const nowInBar = inBar || barFrames.has(node.frame);
|
|
273
|
+
const kind = kindOf(node, inTabGroup);
|
|
274
|
+
const label = text(node.AXLabel);
|
|
275
|
+
const id = text(node.AXUniqueId);
|
|
276
|
+
if (SCROLL_BAR.test(label))
|
|
277
|
+
return;
|
|
278
|
+
// A large area with nothing readable inside: a web view, a map, a game, custom drawing.
|
|
279
|
+
if ((node.role === "AXScrollArea" || node.role === "AXWebArea") && node.frame && area(node.frame) >= area(screen) * 0.15 && !readable(node)) {
|
|
280
|
+
const shown = visible(node.frame, screen, floor, ceiling);
|
|
281
|
+
if (shown)
|
|
282
|
+
result.unreadable.push(shown);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
let element = null;
|
|
286
|
+
// A zero-size node is a container (a home screen page, a folder) whose children have their own frames.
|
|
287
|
+
const sized = node.frame && node.frame.width > 0 && node.frame.height > 0;
|
|
288
|
+
if (kind && node.frame && sized) {
|
|
289
|
+
// Bars' own buttons, and tabs (an iPad's float over the navigation bar), are never under a bar.
|
|
290
|
+
const onBar = nowInBar || kind === "tab";
|
|
291
|
+
const shown = onBar ? visible(node.frame, screen, bottom) : visible(node.frame, screen, floor, ceiling);
|
|
292
|
+
const point = shown ? center(shown) : null;
|
|
293
|
+
if (keyboard && (!shown || inside(center(node.frame), keyboard)))
|
|
294
|
+
return;
|
|
295
|
+
const covered = point && !onBar && tabBar && inside(point, tabBar);
|
|
296
|
+
if (!shown || covered) {
|
|
297
|
+
if (label || id)
|
|
298
|
+
hide(node.frame);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
let value = tidy(text(node.AXValue));
|
|
302
|
+
if (kind === "switch" || kind === "checkbox" || kind === "tab" || kind === "segment" || kind === "radio")
|
|
303
|
+
value = "";
|
|
304
|
+
// A field with no label shows its placeholder or its text as the value; name it by that.
|
|
305
|
+
const name = label || (FIELDS.has(kind) ? value : "");
|
|
306
|
+
if (name === value)
|
|
307
|
+
value = "";
|
|
308
|
+
const skip =
|
|
309
|
+
// The title is in the header line.
|
|
310
|
+
(kind === "heading" && name === result.title) ||
|
|
311
|
+
(kind === "image" && (!label || owner !== null)) ||
|
|
312
|
+
// Inside a control: its own text and icons repeat its label; an unnamed twin (a switch's knob) adds nothing.
|
|
313
|
+
(owner !== null && !CONTROLS.has(kind) && (!name || Boolean(owner.label?.includes(name)))) ||
|
|
314
|
+
(owner !== null && kind === owner.kind && !name && knob(owner, point)) ||
|
|
315
|
+
// A control nested in its twin (an iPad tab inside a tab of the same name) is the same control.
|
|
316
|
+
(owner !== null && kind === owner.kind && name === owner.label) ||
|
|
317
|
+
// An alert's title repeats as its first text.
|
|
318
|
+
(kind === "text" && name === parentLabel) ||
|
|
319
|
+
// UIKit's disclosure arrow in a table cell is a disabled button named "chevron".
|
|
320
|
+
(kind === "button" && node.enabled === false && /^chevron/.test(name)) ||
|
|
321
|
+
(!name && !id && !CONTROLS.has(kind)) ||
|
|
322
|
+
// The status bar (time, signal, battery) only appears in SpringBoard's tree; it is noise there.
|
|
323
|
+
(owner === null && (kind === "text" || kind === "element") && node.frame.y + node.frame.height <= STATUS_BAR_BOTTOM);
|
|
324
|
+
if (!skip) {
|
|
325
|
+
element = { kind, x: point.x, y: point.y, frame: node.frame };
|
|
326
|
+
if (name)
|
|
327
|
+
element.label = name;
|
|
328
|
+
if (id)
|
|
329
|
+
element.id = id;
|
|
330
|
+
if (value)
|
|
331
|
+
element.value = value;
|
|
332
|
+
const state = stateOf(kind, node);
|
|
333
|
+
if (state)
|
|
334
|
+
element.state = state;
|
|
335
|
+
if (kind === "tab")
|
|
336
|
+
result.tabs.push(element);
|
|
337
|
+
else {
|
|
338
|
+
const last = result.elements.at(-1);
|
|
339
|
+
if (!last || line(last) !== line(element))
|
|
340
|
+
result.elements.push(element);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const nextOwner = element && CONTROLS.has(element.kind) ? element : owner;
|
|
345
|
+
for (const child of node.children ?? [])
|
|
346
|
+
walk(child, nextOwner, nowInBar, node.role === "AXTabGroup", label || parentLabel);
|
|
347
|
+
};
|
|
348
|
+
nodes.forEach((root) => walk(root, null, false, false, ""));
|
|
349
|
+
result.elements = withoutLetterRuns(withoutRepeatedLabels(result.elements));
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Text animated letter by letter is one element per character ("E", "v", "e", ...): a run of four or more
|
|
354
|
+
* one-character texts on one line becomes one line, or goes when another element already says the phrase.
|
|
355
|
+
*/
|
|
356
|
+
function withoutLetterRuns(elements) {
|
|
357
|
+
const out = [];
|
|
358
|
+
const letter = (e) => e?.kind === "text" && e.label !== undefined && [...e.label].length <= 2 && !e.id;
|
|
359
|
+
for (let i = 0; i < elements.length;) {
|
|
360
|
+
let j = i;
|
|
361
|
+
while (letter(elements[j]) && Math.abs(elements[j].y - elements[i].y) <= 4)
|
|
362
|
+
j++;
|
|
363
|
+
if (j - i < 4) {
|
|
364
|
+
out.push(elements[i]);
|
|
365
|
+
i++;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const run = elements.slice(i, j);
|
|
369
|
+
const word = run.map((e) => e.label).join("");
|
|
370
|
+
const squash = (s) => s.replace(/\s+/g, "").toLowerCase();
|
|
371
|
+
if (!elements.some((e) => e.label && !letter(e) && squash(e.label) === squash(word))) {
|
|
372
|
+
const left = Math.min(...run.map((e) => e.frame.x));
|
|
373
|
+
const right = Math.max(...run.map((e) => e.frame.x + e.frame.width));
|
|
374
|
+
const top = Math.min(...run.map((e) => e.frame.y));
|
|
375
|
+
const frame = { x: left, y: top, width: right - left, height: Math.max(...run.map((e) => e.frame.height)) };
|
|
376
|
+
out.push({ kind: "text", label: word, x: Math.round(left + frame.width / 2), y: run[0].y, frame });
|
|
377
|
+
}
|
|
378
|
+
i = j;
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* A text beside a control that says the same thing ("Count: 2" next to its stepper, "Flavor" next to the
|
|
384
|
+
* "Flavor, Vanilla" picker, "Date" next to its date picker) is the control's own label: one line is enough.
|
|
385
|
+
*/
|
|
386
|
+
function withoutRepeatedLabels(elements) {
|
|
387
|
+
const controls = elements.filter((e) => CONTROLS.has(e.kind) && e.label);
|
|
388
|
+
return elements.filter((e) => {
|
|
389
|
+
// Plain text, and the unnamed-role elements a picker leaves after its menu closes.
|
|
390
|
+
if ((e.kind !== "text" && e.kind !== "element") || !e.label)
|
|
391
|
+
return true;
|
|
392
|
+
const t = e.label;
|
|
393
|
+
return !controls.some((c) => Math.abs(c.y - e.y) <= 12 && (c.label === t || c.label.startsWith(`${t},`) || c.label.startsWith(`${t} `)));
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
/** App text printed without quotes (the header, tab names) stays on its line: a newline in it can't start a fake one. */
|
|
397
|
+
export const oneLine = (s) => s.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ").trim();
|
|
398
|
+
export const quote = (s) => JSON.stringify(s.length > 80 ? `${s.slice(0, 79)}…` : s);
|
|
399
|
+
/** `#signIn`, or `#"pay now"` when the id isn't a plain word. */
|
|
400
|
+
export const ref = (id) => `#${/^[\w.:-]+$/.test(id) ? id : quote(id)}`;
|
|
401
|
+
/** One element as the agent reads it: `button "Sign In" #signIn @201,350`. */
|
|
402
|
+
export function line(e) {
|
|
403
|
+
let out = e.kind;
|
|
404
|
+
if (e.label !== undefined)
|
|
405
|
+
out += ` ${quote(e.label)}`;
|
|
406
|
+
if (e.id && e.id !== e.label)
|
|
407
|
+
out += ` ${ref(e.id)}`;
|
|
408
|
+
if (e.value)
|
|
409
|
+
out += ` =${quote(e.value)}`;
|
|
410
|
+
if (e.state)
|
|
411
|
+
out += ` ${e.state}`;
|
|
412
|
+
return `${out} @${e.x},${e.y}`;
|
|
413
|
+
}
|
|
414
|
+
export function render(snap, options = {}) {
|
|
415
|
+
// A screen titled like its app (Settings · Settings) is named once.
|
|
416
|
+
const header = [snap.app, snap.title === snap.app ? undefined : snap.title, options.device].filter(Boolean).map((part) => oneLine(part)).join(" · ");
|
|
417
|
+
let elements = snap.elements;
|
|
418
|
+
const lines = [header];
|
|
419
|
+
if (options.find) {
|
|
420
|
+
const needle = options.find.toLowerCase();
|
|
421
|
+
const matches = (e) => [e.label, e.id, e.value].some((v) => v?.toLowerCase().includes(needle));
|
|
422
|
+
elements = [...elements, ...snap.tabs].filter(matches);
|
|
423
|
+
if (!elements.length) {
|
|
424
|
+
lines.push(`No element matches ${quote(options.find)} (${snap.elements.length + snap.tabs.length} on screen).\nNext: ui_look without find lists them.`);
|
|
425
|
+
return lines.join("\n");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (snap.modal === "alert")
|
|
429
|
+
lines.push("An alert is in front: answer it with one of its buttons.");
|
|
430
|
+
else if (snap.modal === "screen")
|
|
431
|
+
lines.push("Something is in front (a sheet, menu or full-screen view); the screen behind it is not listed.");
|
|
432
|
+
const max = options.maxLines ?? 150;
|
|
433
|
+
for (const e of elements.slice(0, max))
|
|
434
|
+
lines.push(line(e));
|
|
435
|
+
if (elements.length > max)
|
|
436
|
+
lines.push(`… ${elements.length - max} more; use find to narrow`);
|
|
437
|
+
if (options.find)
|
|
438
|
+
return lines.join("\n");
|
|
439
|
+
for (const f of snap.unreadable) {
|
|
440
|
+
const c = center(f);
|
|
441
|
+
lines.push(`unreadable @${c.x},${c.y} (${Math.round(f.width)}x${Math.round(f.height)}): a web view, custom drawing or an iOS system screen; ui_look with screenshot shows it, then tap @x,y`);
|
|
442
|
+
}
|
|
443
|
+
// React Native apps often set a test id as the accessibility label: the screen then shows other text.
|
|
444
|
+
const idLike = elements.filter((e) => e.kind === "text" && e.label && /^[a-z0-9]+(?:[-_][a-z0-9]+)+$/.test(e.label)).map((e) => e.label);
|
|
445
|
+
if (idLike.length)
|
|
446
|
+
lines.push(`note: ${idLike.slice(0, 3).map((l) => quote(l)).join(", ")} ${idLike.length === 1 ? "is a test id the app set as its label" : "are test ids the app set as labels"}; the text on screen differs, and ui_look with screenshot shows it`);
|
|
447
|
+
if (snap.tabs.length)
|
|
448
|
+
lines.push(`tabs: ${snap.tabs.map((t) => `${oneLine(t.label ?? t.id ?? "")}${t.state ? ` (${t.state})` : ""} @${t.x},${t.y}`).join(", ")}`);
|
|
449
|
+
const { above, below, left, right } = snap.hidden;
|
|
450
|
+
const more = [snap.scroll, above && `${above} above`, below && `${below} below`, left && `${left} to the left`, right && `${right} to the right`].filter(Boolean);
|
|
451
|
+
if (more.length)
|
|
452
|
+
lines.push(`scroll: ${more.join("; ")}`);
|
|
453
|
+
if (snap.keyboard)
|
|
454
|
+
lines.push(`keyboard: shown, covering y ${snap.keyboardTop ?? "?"} and below`);
|
|
455
|
+
if (!snap.elements.length && !snap.tabs.length && !snap.unreadable.length) {
|
|
456
|
+
lines.push("Nothing readable on screen yet. The app may still be launching, or it shows no accessibility elements; a screenshot shows what is there.");
|
|
457
|
+
}
|
|
458
|
+
return lines.join("\n");
|
|
459
|
+
}
|