mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
perf(ios): speed up deep snapshots and keep first taps reliable (#2414)
* perf(ios): recover deep snapshots and isolate optional tap probes * chore(gates): enforce snapshot assets and optional probe lifecycle * fix(ios): preserve capture bounds and local probe recovery * chore(gates): validate base package assets with its own policy * chore(gates): verify recovery failures respect launch observation policy * fix(ios): fail closed on unknown snapshot frontier completeness
This commit is contained in:
committed by
GitHub
parent
4d7d9be21e
commit
0dfd65f6a2
@@ -147,6 +147,7 @@ jobs:
|
||||
|
||||
- name: Run targeted iOS runner XCTest regressions
|
||||
run: |
|
||||
set -o pipefail
|
||||
XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)"
|
||||
test -n "$XCTESTRUN_PATH"
|
||||
xcodebuild test-without-building \
|
||||
@@ -164,6 +165,12 @@ jobs:
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testEmptyReplacementWithoutResolvableTargetFailsClosed \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextEntryTapWitnessIsBoundToTargetIdentity \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testCoordinateTapTextInputProbeSkipsPenalizedXCTestChannel \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testFreshCoordinateTapContainsUnavailableTextInputProbe \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextInputProbeIssueScopeIsThreadBound \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextInputProbePreservesEnclosingRunnerWait \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSuppressedAxIssueMakesTextInputProbeUnavailable \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testHealthyCoordinateTapPreservesBareTypingWitness \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextInputProbeContainmentExcludesRequiredReadsAndLaterIssues \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testCoordinateTextInputCandidateMustBeEnabledAndContainTheTouchPoint \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testQuerySelectorPrefersHittableMatchOverNonHittableDuplicate \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testActivateTargetSkipsForegroundAndActivatesNonForegroundApplication \
|
||||
@@ -234,7 +241,14 @@ jobs:
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testCustomActionCoverageParsesOnlyCompletePairs \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPartialCustomActionPassIsDisclosedAndCompleteOneIsNot \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testActionNamesAreCappedPerElementAndReported \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testHungCustomActionReadIsContainedAndRecovers
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testHungCustomActionReadIsContainedAndRecovers 2>&1 | tee /tmp/agent-device-runner-regressions.log
|
||||
node --input-type=module -e '
|
||||
import { readFileSync } from "node:fs";
|
||||
const log = readFileSync("/tmp/agent-device-runner-regressions.log", "utf8");
|
||||
if (!/\] AGENT_DEVICE_RUNNER_OPTIONAL_PROBE_WAIT_COMPLETED$/m.test(log)) {
|
||||
throw new Error("Optional observation ended the runner test before its wait completed");
|
||||
}
|
||||
'
|
||||
|
||||
- name: Preflight iOS runner through public CLI
|
||||
run: |
|
||||
|
||||
@@ -73,6 +73,7 @@ jobs:
|
||||
- name: Measure base size
|
||||
run: |
|
||||
git checkout --detach "${{ github.event.pull_request.base.sha }}"
|
||||
cp scripts/size-report-package.mjs /tmp/agent-device-size-report/
|
||||
pnpm install --frozen-lockfile
|
||||
if [ "${{ steps.base-dist-cache.outputs.cache-hit }}" != "true" ]; then
|
||||
pnpm build
|
||||
|
||||
+1
-1
@@ -1795,7 +1795,7 @@ extension RunnerTests {
|
||||
)
|
||||
let textInput: XCUIElement?
|
||||
if !xCTestTextInputProbeSkipped {
|
||||
textInput = textInputAt(app: activeApp, x: x, y: y)
|
||||
textInput = coordinateTapTextInputAt(app: activeApp, x: x, y: y)
|
||||
} else {
|
||||
// A process-scoped tap cannot authorize later typing without concrete element identity.
|
||||
textInput = nil
|
||||
|
||||
@@ -318,37 +318,6 @@ extension RunnerTests {
|
||||
return nil
|
||||
}
|
||||
|
||||
func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? {
|
||||
return textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y)).first
|
||||
}
|
||||
|
||||
private func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] {
|
||||
safely("TEXT_INPUT_AT_POINT", []) {
|
||||
// Query the text-input element types directly instead of enumerating the entire tree
|
||||
// (app.descendants(.any).allElementsBoundByIndex snapshots every element and is ~10x
|
||||
// slower — it dominated fill latency because resolveTextEntryElement re-runs this on
|
||||
// each verify/repair poll once the focused field reference goes stale).
|
||||
// Prefer the smallest matching field so nested editable controls win over large containers.
|
||||
[
|
||||
app.textFields,
|
||||
app.secureTextFields,
|
||||
app.searchFields,
|
||||
app.textViews,
|
||||
]
|
||||
.flatMap { $0.allElementsBoundByIndex }
|
||||
.filter { element in
|
||||
guard element.exists else { return false }
|
||||
let frame = element.frame
|
||||
return isCoordinateTextInputCandidate(
|
||||
enabled: element.isEnabled,
|
||||
frame: frame,
|
||||
point: point
|
||||
)
|
||||
}
|
||||
.sorted(by: smallestElementFirst)
|
||||
}
|
||||
}
|
||||
|
||||
private func readableText(for element: XCUIElement) -> String? {
|
||||
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import XCTest
|
||||
|
||||
final class TextInputProbeIssues {
|
||||
let thread = Thread.current
|
||||
var count = 0
|
||||
}
|
||||
|
||||
enum TextInputProbeFailure: String {
|
||||
case recordedIssue = "text_input_probe_recorded_issue"
|
||||
case exception = "text_input_probe_exception"
|
||||
}
|
||||
|
||||
enum TextInputProbeOutcome {
|
||||
case matches([XCUIElement])
|
||||
case absent
|
||||
case unavailable(TextInputProbeFailure)
|
||||
}
|
||||
|
||||
extension RunnerTests {
|
||||
func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? {
|
||||
textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y)).first
|
||||
}
|
||||
|
||||
func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] {
|
||||
safely("TEXT_INPUT_AT_POINT", []) {
|
||||
queryTextInputs(app: app, point: point, shouldStop: { false })
|
||||
}
|
||||
}
|
||||
|
||||
func coordinateTapTextInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? {
|
||||
switch probeTextInputs(app: app, point: CGPoint(x: x, y: y)) {
|
||||
case .matches(let elements):
|
||||
return elements.first
|
||||
case .absent:
|
||||
return nil
|
||||
case .unavailable:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func probeTextInputs(app: XCUIApplication, point: CGPoint) -> TextInputProbeOutcome {
|
||||
precondition(Thread.isMainThread)
|
||||
let issues = TextInputProbeIssues()
|
||||
suppressedIssueLock.lock()
|
||||
let previous = textInputProbeIssues
|
||||
textInputProbeIssues = issues
|
||||
suppressedIssueLock.unlock()
|
||||
defer {
|
||||
suppressedIssueLock.lock()
|
||||
textInputProbeIssues = previous
|
||||
suppressedIssueLock.unlock()
|
||||
}
|
||||
let (elements, exception) = catchingObjCException(fallback: []) {
|
||||
queryTextInputs(app: app, point: point, shouldStop: { self.hasTextInputProbeIssues(issues) })
|
||||
}
|
||||
if hasTextInputProbeIssues(issues) { return .unavailable(.recordedIssue) }
|
||||
if exception != nil { return .unavailable(.exception) }
|
||||
return elements.isEmpty ? .absent : .matches(elements)
|
||||
}
|
||||
|
||||
private func hasTextInputProbeIssues(_ scope: TextInputProbeIssues) -> Bool {
|
||||
suppressedIssueLock.lock()
|
||||
defer { suppressedIssueLock.unlock() }
|
||||
return scope.count > 0
|
||||
}
|
||||
|
||||
func containTextInputProbeIssue(_ issue: XCTIssue) -> Bool {
|
||||
suppressedIssueLock.lock()
|
||||
guard let scope = textInputProbeIssues, scope.thread === Thread.current else {
|
||||
suppressedIssueLock.unlock()
|
||||
return false
|
||||
}
|
||||
scope.count += 1
|
||||
suppressedIssueLock.unlock()
|
||||
NSLog("AGENT_DEVICE_RUNNER_TEXT_INPUT_PROBE_UNAVAILABLE issue=%@", issue.compactDescription)
|
||||
return true
|
||||
}
|
||||
|
||||
private func queryTextInputs(
|
||||
app: XCUIApplication,
|
||||
point: CGPoint,
|
||||
shouldStop: () -> Bool
|
||||
) -> [XCUIElement] {
|
||||
var candidates: [XCUIElement] = []
|
||||
for query in [app.textFields, app.secureTextFields, app.searchFields, app.textViews] {
|
||||
if shouldStop() { break }
|
||||
candidates.append(contentsOf: query.allElementsBoundByIndex)
|
||||
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
|
||||
if let issue = textInputProbeIssueForTesting {
|
||||
textInputProbeIssueForTesting = nil
|
||||
record(issue)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
guard !shouldStop() else { return [] }
|
||||
return candidates.filter { element in
|
||||
guard !shouldStop(), element.exists else { return false }
|
||||
return isCoordinateTextInputCandidate(enabled: element.isEnabled, frame: element.frame, point: point)
|
||||
}.sorted(by: smallestElementFirst)
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,8 @@ final class RunnerTests: XCTestCase {
|
||||
// The injection records a real XCTIssue AFTER the real gesture, so
|
||||
// `xctestRecordedFailureResponse` and target invalidation fire byte-for-byte
|
||||
// like a field failure. Production builds compile none of this.
|
||||
var textInputProbeIssueForTesting: XCTIssue?
|
||||
|
||||
static let injectedTapFailureFlagPathForTesting =
|
||||
"/tmp/agent-device-inject-tap-recorded-failure-for-testing"
|
||||
|
||||
@@ -182,6 +184,7 @@ final class RunnerTests: XCTestCase {
|
||||
#endif
|
||||
// Observability for the record(_:) suppression below: how many AX-broken-screen snapshot
|
||||
// issues this session muted, so wedge investigations see the volume without grepping logs.
|
||||
var textInputProbeIssues: TextInputProbeIssues?
|
||||
let suppressedIssueLock = NSLock()
|
||||
var suppressedAxSnapshotIssueCount = 0
|
||||
// Keep blocker actions narrow to avoid false positives from generic hittable containers.
|
||||
@@ -221,6 +224,7 @@ final class RunnerTests: XCTestCase {
|
||||
/// outcomes stay honest through their own error paths — only this issue side-channel is
|
||||
/// muted. Everything else still records (and still drives XCTEST_RECORDED_FAILURE).
|
||||
override func record(_ issue: XCTIssue) {
|
||||
if containTextInputProbeIssue(issue) { return }
|
||||
let description = issue.compactDescription
|
||||
if Self.isSuppressedAxSnapshotIssueDescription(description) {
|
||||
suppressedIssueLock.lock()
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import XCTest
|
||||
|
||||
extension RunnerTests {
|
||||
#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS)
|
||||
func testTextInputProbePreservesEnclosingRunnerWait() {
|
||||
app.launchArguments = ["--agent-device-text-entry-regression"]
|
||||
app.launch()
|
||||
defer {
|
||||
textInputProbeIssueForTesting = nil
|
||||
app.terminate()
|
||||
}
|
||||
let field = app.textFields["agent-device-hardware-keyboard-input"]
|
||||
XCTAssertTrue(field.waitForExistence(timeout: appExistenceTimeout))
|
||||
let point = CGPoint(x: field.frame.midX, y: field.frame.midY)
|
||||
let completed = expectation(description: "optional probe completed inside runner wait")
|
||||
DispatchQueue.main.async {
|
||||
self.textInputProbeIssueForTesting = XCTIssue(type: .assertionFailure, compactDescription: "Optional probe issue during runner wait")
|
||||
_ = self.probeTextInputs(app: self.app, point: point)
|
||||
completed.fulfill()
|
||||
}
|
||||
guard XCTWaiter.wait(for: [completed], timeout: 5) == .completed else {
|
||||
return XCTFail("Optional probe interrupted the runner wait")
|
||||
}
|
||||
XCTAssertNil(textInputProbeIssues)
|
||||
NSLog("AGENT_DEVICE_RUNNER_OPTIONAL_PROBE_WAIT_COMPLETED")
|
||||
}
|
||||
|
||||
func testTextInputProbeIssueScopeIsThreadBound() {
|
||||
let issue = XCTIssue(type: .assertionFailure, compactDescription: "Issue scope thread check")
|
||||
XCTAssertFalse(containTextInputProbeIssue(issue))
|
||||
let scope = TextInputProbeIssues()
|
||||
suppressedIssueLock.lock()
|
||||
textInputProbeIssues = scope
|
||||
suppressedIssueLock.unlock()
|
||||
defer {
|
||||
suppressedIssueLock.lock()
|
||||
textInputProbeIssues = nil
|
||||
suppressedIssueLock.unlock()
|
||||
}
|
||||
let finished = DispatchSemaphore(value: 0)
|
||||
let result = ProbeThreadResult()
|
||||
Thread.detachNewThread {
|
||||
result.contained = self.containTextInputProbeIssue(issue)
|
||||
finished.signal()
|
||||
}
|
||||
guard finished.wait(timeout: .now() + 2) == .success else {
|
||||
return XCTFail("Background issue classification did not finish")
|
||||
}
|
||||
XCTAssertFalse(result.contained)
|
||||
XCTAssertEqual(scope.count, 0)
|
||||
XCTAssertTrue(containTextInputProbeIssue(issue))
|
||||
XCTAssertEqual(scope.count, 1)
|
||||
}
|
||||
|
||||
func testHealthyCoordinateTapPreservesBareTypingWitness() throws {
|
||||
app.launchArguments = ["--agent-device-text-entry-regression"]
|
||||
app.launch()
|
||||
defer {
|
||||
invalidateCachedTarget(reason: "unit_test_cleanup")
|
||||
app.terminate()
|
||||
}
|
||||
let field = app.textFields["agent-device-hardware-keyboard-input"]
|
||||
XCTAssertTrue(field.waitForExistence(timeout: appExistenceTimeout))
|
||||
let frame = field.frame
|
||||
currentApp = app
|
||||
currentBundleId = "com.callstack.agentdevice.runner"
|
||||
currentAppProcessIdentifier = try XCTUnwrap(Self.processIdentifier(of: app))
|
||||
clearSnapshotXCTestChannelPenalty(reason: "fresh-runner")
|
||||
let failures = currentXCTestFailureCount()
|
||||
let tap = try runnerCommandFixture(
|
||||
#"{"appBundleId":"com.callstack.agentdevice.runner","command":"tap","commandId":"tap-healthy-probe","x":\#(frame.midX),"y":\#(frame.midY),"synthesized":true}"#
|
||||
)
|
||||
let tapped = try execute(command: tap)
|
||||
XCTAssertTrue(tapped.ok, String(describing: tapped.error))
|
||||
XCTAssertNotNil(textEntryTapWitness)
|
||||
XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: currentBundleId))
|
||||
try XCTSkipIf(isKeyboardVisible(app: app), "software keyboard is up; hidden-keyboard witness cannot be exercised")
|
||||
let type = try runnerCommandFixture(#"{"appBundleId":"com.callstack.agentdevice.runner","command":"type","commandId":"type-healthy-probe","text":"probe-witness"}"#)
|
||||
let typed = try execute(command: type)
|
||||
XCTAssertTrue(typed.ok, String(describing: typed.error))
|
||||
XCTAssertEqual(typed.data?.textEntryRoute, "synthesized-first-responder")
|
||||
XCTAssertEqual(field.value as? String, "probe-witness")
|
||||
XCTAssertFalse(didRecordXCTestFailure(since: failures))
|
||||
}
|
||||
|
||||
func testTextInputProbeContainmentExcludesRequiredReadsAndLaterIssues() {
|
||||
app.launchArguments = ["--agent-device-text-entry-regression"]
|
||||
app.launch()
|
||||
defer {
|
||||
textInputProbeIssueForTesting = nil
|
||||
app.terminate()
|
||||
}
|
||||
let field = app.textFields["agent-device-hardware-keyboard-input"]
|
||||
XCTAssertTrue(field.waitForExistence(timeout: appExistenceTimeout))
|
||||
let point = CGPoint(x: field.frame.midX, y: field.frame.midY)
|
||||
let expected = XCTIssue(type: .assertionFailure, compactDescription: "Required query failure must escape optional containment")
|
||||
let options = XCTExpectedFailure.Options()
|
||||
var observed = 0
|
||||
options.issueMatcher = { issue in
|
||||
guard issue.type == expected.type, issue.compactDescription == expected.compactDescription else { return false }
|
||||
observed += 1
|
||||
return true
|
||||
}
|
||||
XCTExpectFailure("Required read and later issue belong to their caller", options: options) {
|
||||
textInputProbeIssueForTesting = expected
|
||||
_ = textInputAt(app: app, x: point.x, y: point.y)
|
||||
_ = probeTextInputs(app: app, point: point)
|
||||
record(expected)
|
||||
}
|
||||
XCTAssertEqual(observed, 2)
|
||||
}
|
||||
|
||||
func testSuppressedAxIssueMakesTextInputProbeUnavailable() throws {
|
||||
app.launchArguments = ["--agent-device-text-entry-regression"]
|
||||
app.launch()
|
||||
defer {
|
||||
textInputProbeIssueForTesting = nil
|
||||
app.terminate()
|
||||
}
|
||||
let field = app.textFields["agent-device-hardware-keyboard-input"]
|
||||
XCTAssertTrue(field.waitForExistence(timeout: appExistenceTimeout))
|
||||
let frame = field.frame
|
||||
textInputProbeIssueForTesting = XCTIssue(type: .assertionFailure, compactDescription: "Failed to get matching snapshot: kAXErrorIllegalArgument")
|
||||
let outcome = probeTextInputs(app: app, point: CGPoint(x: frame.midX, y: frame.midY))
|
||||
guard case .unavailable = outcome else {
|
||||
return XCTFail("A suppressed AX issue must discard the matching candidate")
|
||||
}
|
||||
}
|
||||
|
||||
func testFreshCoordinateTapContainsUnavailableTextInputProbe() throws {
|
||||
app.launchArguments = ["--agent-device-text-entry-regression"]
|
||||
app.launch()
|
||||
defer {
|
||||
textInputProbeIssueForTesting = nil
|
||||
clearSnapshotXCTestChannelPenalty(reason: "test-cleanup")
|
||||
invalidateCachedTarget(reason: "unit_test_cleanup")
|
||||
app.terminate()
|
||||
}
|
||||
let target = app.staticTexts["Agent Device Runner"]
|
||||
XCTAssertTrue(target.waitForExistence(timeout: appExistenceTimeout))
|
||||
let frame = target.frame
|
||||
currentApp = app
|
||||
currentBundleId = "com.callstack.agentdevice.runner"
|
||||
currentAppProcessIdentifier = try XCTUnwrap(Self.processIdentifier(of: app))
|
||||
clearSnapshotXCTestChannelPenalty(reason: "fresh-runner")
|
||||
let failures = currentXCTestFailureCount()
|
||||
textInputProbeIssueForTesting = XCTIssue(type: .assertionFailure, compactDescription: "Injected optional text input query failure")
|
||||
let command = try runnerCommandFixture(
|
||||
#"{"appBundleId":"com.callstack.agentdevice.runner","command":"tap","commandId":"tap-probe-unavailable","x":\#(frame.midX),"y":\#(frame.midY),"synthesized":true}"#
|
||||
)
|
||||
let response = try execute(command: command)
|
||||
XCTAssertTrue(response.ok, String(describing: response.error))
|
||||
XCTAssertFalse(didRecordXCTestFailure(since: failures))
|
||||
XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: currentBundleId))
|
||||
XCTAssertNil(textEntryTapWitness)
|
||||
let type = try runnerCommandFixture(#"{"appBundleId":"com.callstack.agentdevice.runner","command":"type","commandId":"type-after-unavailable-probe","text":"must-not-type"}"#)
|
||||
let typed = try execute(command: type)
|
||||
XCTAssertFalse(typed.ok)
|
||||
XCTAssertEqual(typed.error?.code, "TEXT_INPUT_NOT_FOCUSED")
|
||||
let field = app.textFields["agent-device-hardware-keyboard-input"]
|
||||
let fieldFrame = field.frame
|
||||
let nextTap = try runnerCommandFixture(
|
||||
#"{"appBundleId":"com.callstack.agentdevice.runner","command":"tap","commandId":"tap-after-probe-recovery","x":\#(fieldFrame.midX),"y":\#(fieldFrame.midY),"synthesized":true}"#
|
||||
)
|
||||
XCTAssertTrue(try execute(command: nextTap).ok)
|
||||
XCTAssertNotNil(textEntryTapWitness)
|
||||
XCTAssertTrue(try execute(command: type).ok)
|
||||
XCTAssertEqual(field.value as? String, "must-not-type")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS)
|
||||
private final class ProbeThreadResult: @unchecked Sendable {
|
||||
var contained = false
|
||||
}
|
||||
#endif
|
||||
@@ -8,7 +8,7 @@ and is never downloaded, pre-signed, or built by npm installation.
|
||||
|
||||
The guest process uses the `XCTAccessibilityFramework` remote-access client
|
||||
from the simulator runtime and the `userTestingSnapshotForElement:options:error:`
|
||||
single-fetch API. Requests and responses are length-prefixed JSON frames:
|
||||
snapshot API. Requests and responses are length-prefixed JSON frames:
|
||||
|
||||
```text
|
||||
uint32 big-endian byte length
|
||||
@@ -47,3 +47,19 @@ app tree. The existing route then uses XCTest, which owns system-modal
|
||||
resolution. Secondary owners such as the return-to-app status-bar control do
|
||||
not replace the native primary owner. The route's generation circuit remains
|
||||
disabled after fallback until that app relaunches.
|
||||
|
||||
## Bounded depth recovery
|
||||
|
||||
A healthy capture uses one native request. If native acquisition rejects it,
|
||||
`SnapshotBridgeCapture.m` retries supported native failure codes at lower depths
|
||||
and fetches withheld children from their accessibility elements. The completed
|
||||
tree keeps the original depth and node limits; partial trees disclose truncation.
|
||||
The traversal depth counts edges below the root; native requests count the root
|
||||
as one level. Each acquisition allows two lower-depth retries, and recovery
|
||||
allows at most 32 native requests within the existing capture deadline,
|
||||
checks foreground ownership on every request, and returns a failure when it
|
||||
cannot complete a continuation. Budget exhaustion and malformed continuations
|
||||
use non-launch failure codes, so the route falls back without launch re-polling.
|
||||
At each native fragment boundary, an absent or invalid child count means unknown
|
||||
completeness and fails closed. Natural leaves above that boundary need no
|
||||
continuation evidence. Unchanged native dictionaries and child arrays are reused.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef id _Nullable (^SnapshotElementReader)(id element, NSUInteger depth, NSUInteger nodes, NSError **error);
|
||||
|
||||
/// Materializes one bounded tree; retries bounded native acquisition failures and re-roots withheld children.
|
||||
NSDictionary *_Nullable captureSnapshotTree(id element, NSUInteger maxDepth, NSUInteger maxNodes,
|
||||
SnapshotElementReader reader, BOOL *truncated, NSError **error);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,129 @@
|
||||
#import "SnapshotBridgeCapture.h"
|
||||
|
||||
static NSString *const attributesKey = @"UIAccessibilitySnapshotKeyAttributes";
|
||||
static NSString *const childrenKey = @"UIAccessibilitySnapshotKeyChildren";
|
||||
static NSString *const childCountKey = @"UIAccessibilitySnapshotKeyChildrenCount";
|
||||
static NSString *const elementKey = @"UIAccessibilitySnapshotKeyElement";
|
||||
static const NSUInteger maximumRequests = 32;
|
||||
|
||||
@interface SnapshotTreeCapture : NSObject
|
||||
@property(nonatomic, copy) SnapshotElementReader reader;
|
||||
@property(nonatomic) NSUInteger acceptedDepth;
|
||||
@property(nonatomic) NSUInteger remainingNodes;
|
||||
@property(nonatomic) NSUInteger maximumNodes;
|
||||
@property(nonatomic) NSUInteger requests;
|
||||
@property(nonatomic) BOOL truncated;
|
||||
- (nullable NSDictionary *)read:(id)element depth:(NSUInteger)depth error:(NSError **)error;
|
||||
- (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)depth nativeLevels:(NSUInteger)nativeLevels error:(NSError **)error;
|
||||
@end
|
||||
|
||||
@implementation SnapshotTreeCapture
|
||||
- (nullable NSDictionary *)read:(id)element depth:(NSUInteger)depth error:(NSError **)error
|
||||
{
|
||||
NSUInteger attemptDepth = MIN(depth, self.acceptedDepth);
|
||||
for (NSUInteger retries = 0;; retries++) {
|
||||
if (self.requests >= maximumRequests) {
|
||||
if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:1
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"snapshot continuation request budget exhausted"}];
|
||||
return nil;
|
||||
}
|
||||
self.requests++;
|
||||
NSError *failure = nil;
|
||||
id tree = self.reader(element, attemptDepth, MIN(self.maximumNodes, self.remainingNodes + 1), &failure);
|
||||
if (tree) return tree;
|
||||
NSNumber *nativeCode = failure.userInfo[@"accessibility-error"];
|
||||
BOOL rejected = ([nativeCode isKindOfClass:NSNumber.class] && nativeCode.integerValue == -25201) ||
|
||||
([failure.domain isEqualToString:@"com.apple.dt.xctest.automation-support.error"] && failure.code == 5);
|
||||
if (!rejected || attemptDepth <= 1 || retries >= 2) {
|
||||
if (error) *error = failure;
|
||||
return nil;
|
||||
}
|
||||
attemptDepth = MAX(1, attemptDepth / 2);
|
||||
self.acceptedDepth = attemptDepth;
|
||||
}
|
||||
}
|
||||
|
||||
- (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)depth nativeLevels:(NSUInteger)nativeLevels error:(NSError **)error
|
||||
{
|
||||
if (![tree isKindOfClass:NSDictionary.class] ||
|
||||
![tree[attributesKey] isKindOfClass:NSDictionary.class] ||
|
||||
![tree[childrenKey] isKindOfClass:NSArray.class]) {
|
||||
if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:2
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"malformed snapshot continuation"}];
|
||||
return nil;
|
||||
}
|
||||
if (self.remainingNodes == 0) {
|
||||
self.truncated = YES;
|
||||
return nil;
|
||||
}
|
||||
self.remainingNodes--;
|
||||
NSArray *children = tree[childrenKey];
|
||||
NSNumber *childCount = tree[childCountKey];
|
||||
BOOL knownChildCount = [childCount isKindOfClass:NSNumber.class] && childCount.doubleValue >= 0 &&
|
||||
childCount.doubleValue == (double)childCount.unsignedIntegerValue;
|
||||
if (nativeLevels <= 1 && !knownChildCount && depth <= 1) self.truncated = YES;
|
||||
if (depth > 1 && nativeLevels <= 1 && !knownChildCount) {
|
||||
if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:6
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"snapshot boundary child count unavailable"}];
|
||||
return nil;
|
||||
}
|
||||
BOOL withheld = knownChildCount && childCount.unsignedIntegerValue > children.count;
|
||||
if (depth <= 1 || self.remainingNodes == 0) {
|
||||
self.truncated |= children.count > 0 || withheld;
|
||||
if (children.count == 0) return tree;
|
||||
NSMutableDictionary *bounded = [tree mutableCopy];
|
||||
bounded[childrenKey] = @[];
|
||||
return bounded;
|
||||
}
|
||||
if (withheld && children.count < self.remainingNodes) {
|
||||
id element = tree[elementKey];
|
||||
if (!element) {
|
||||
if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:3
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"snapshot continuation element unavailable"}];
|
||||
return nil;
|
||||
}
|
||||
NSDictionary *continuation = [self read:element depth:depth error:error];
|
||||
if (!continuation) return nil;
|
||||
nativeLevels = MIN(depth, self.acceptedDepth);
|
||||
children = continuation[childrenKey];
|
||||
if (![children isKindOfClass:NSArray.class] || children.count < MIN(childCount.unsignedIntegerValue, self.remainingNodes)) {
|
||||
if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:4
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"snapshot continuation children unavailable"}];
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
if (withheld && children.count < childCount.unsignedIntegerValue) self.truncated = YES;
|
||||
NSMutableArray *materialized = nil;
|
||||
NSUInteger index = 0;
|
||||
for (NSDictionary *child in children) {
|
||||
if (self.remainingNodes == 0) {
|
||||
self.truncated = YES;
|
||||
if (!materialized) materialized = [[children subarrayWithRange:NSMakeRange(0, index)] mutableCopy];
|
||||
break;
|
||||
}
|
||||
NSDictionary *node = [self materialize:child depth:depth - 1 nativeLevels:(nativeLevels > 0 ? nativeLevels - 1 : 0) error:error];
|
||||
if (!node) return nil;
|
||||
if (node != child && !materialized) materialized = [[children subarrayWithRange:NSMakeRange(0, index)] mutableCopy];
|
||||
[materialized addObject:node];
|
||||
index++;
|
||||
}
|
||||
if (!materialized && children == tree[childrenKey]) return tree;
|
||||
NSMutableDictionary *result = [tree mutableCopy];
|
||||
result[childrenKey] = materialized ?: children;
|
||||
return result;
|
||||
}
|
||||
@end
|
||||
|
||||
NSDictionary *captureSnapshotTree(id element, NSUInteger maxDepth, NSUInteger maxNodes,
|
||||
SnapshotElementReader reader, BOOL *truncated, NSError **error)
|
||||
{
|
||||
SnapshotTreeCapture *capture = [SnapshotTreeCapture new];
|
||||
capture.reader = reader;
|
||||
capture.acceptedDepth = maxDepth + 1;
|
||||
capture.remainingNodes = maxNodes;
|
||||
capture.maximumNodes = maxNodes;
|
||||
NSDictionary *tree = [capture read:element depth:maxDepth + 1 error:error];
|
||||
NSDictionary *result = tree ? [capture materialize:tree depth:maxDepth + 1 nativeLevels:capture.acceptedDepth error:error] : nil;
|
||||
*truncated = capture.truncated;
|
||||
return result;
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
#import "SnapshotBridgeRuntime.h"
|
||||
#import "SnapshotBridgeCapture.h"
|
||||
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
#import <objc/message.h>
|
||||
@@ -17,7 +18,7 @@
|
||||
NSString *const kProtocolVersionKey = @"protocolVersion";
|
||||
NSString *const kSourceVersionKey = @"sourceVersion";
|
||||
NSString *const kRequestIdKey = @"requestId";
|
||||
NSString *const kSourceVersion = @"agent-device-simulator-ax-v1.5.3";
|
||||
NSString *const kSourceVersion = @"agent-device-simulator-ax-v1.5.4";
|
||||
const NSUInteger kProtocolVersion = 1;
|
||||
const uint32_t kMaximumFrameBytes = 16 * 1024 * 1024;
|
||||
const NSUInteger kMaximumDepth = 128;
|
||||
@@ -311,13 +312,26 @@ static void finishRequestWatchdog(dispatch_source_t watchdog, SnapshotWatchdogSt
|
||||
BOOL automationEnabled = [self assertAutomationMode:YES];
|
||||
NSError *runtimeError = nil;
|
||||
id snapshot = nil;
|
||||
BOOL acquisitionTruncated = NO;
|
||||
@try {
|
||||
if (![self isPrimaryForegroundProcess:pid]) {
|
||||
if (error) *error = failureResponse(requestId, @"unsupported", @"foreground-owner-unverified", @"target app is not the primary foreground accessibility owner");
|
||||
finishRequestWatchdog(watchdog, watchdogState);
|
||||
return nil;
|
||||
}
|
||||
snapshot = [_framework userTestingSnapshotForElement:(__bridge id)raw options:options error:&runtimeError];
|
||||
snapshot = captureSnapshotTree((__bridge id)raw, maxDepth, maxNodes,
|
||||
^id(id element, NSUInteger depth, NSUInteger nodes, NSError **captureError) {
|
||||
if (![self isPrimaryForegroundProcess:pid]) {
|
||||
if (captureError) *captureError = [NSError errorWithDomain:@"agent-device.snapshot" code:5
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"foreground owner changed during continuation"}];
|
||||
return nil;
|
||||
}
|
||||
NSMutableDictionary *bounded = [options mutableCopy];
|
||||
bounded[@"maxDepth"] = @(depth);
|
||||
bounded[@"maxChildren"] = @(nodes);
|
||||
bounded[@"maxArrayCount"] = @(nodes);
|
||||
return [_framework userTestingSnapshotForElement:element options:bounded error:captureError];
|
||||
}, &acquisitionTruncated, &runtimeError);
|
||||
if (![self isPrimaryForegroundProcess:pid]) {
|
||||
if (error) *error = failureResponse(requestId, @"unsupported", @"foreground-owner-changed", @"foreground accessibility ownership changed during acquisition");
|
||||
finishRequestWatchdog(watchdog, watchdogState);
|
||||
@@ -328,6 +342,13 @@ static void finishRequestWatchdog(dispatch_source_t watchdog, SnapshotWatchdogSt
|
||||
finishRequestWatchdog(watchdog, watchdogState);
|
||||
return nil;
|
||||
}
|
||||
if (!snapshot && [runtimeError.domain isEqualToString:@"agent-device.snapshot"]) {
|
||||
BOOL exhausted = runtimeError.code == 1;
|
||||
if (error) *error = failureResponse(requestId, exhausted ? @"reader_unavailable" : @"malformed_tree",
|
||||
exhausted ? @"continuation-budget-exhausted" : @"snapshot-tree-malformed", runtimeError.localizedDescription);
|
||||
finishRequestWatchdog(watchdog, watchdogState);
|
||||
return nil;
|
||||
}
|
||||
if (!snapshot) {
|
||||
NSNumber *axError = runtimeError.userInfo[kAccessibilityErrorKey];
|
||||
NSInteger code = [axError respondsToSelector:@selector(integerValue)] ? axError.integerValue : runtimeError.code;
|
||||
@@ -362,7 +383,7 @@ static void finishRequestWatchdog(dispatch_source_t watchdog, SnapshotWatchdogSt
|
||||
@"ok" : @YES,
|
||||
@"pid" : @(pid),
|
||||
@"tree" : tree,
|
||||
@"truncated" : @(truncated),
|
||||
@"truncated" : @((BOOL)(truncated || acquisitionTruncated)),
|
||||
@"automationEnabled" : @(automationEnabled),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -326,3 +326,24 @@ Each step lands green and independently useful:
|
||||
- **More integration tests without the registry**: this is the status quo
|
||||
plus effort. Without the matrix as code, nothing forces a new path to
|
||||
acquire the existing suite, which is exactly how this week's bugs happened.
|
||||
|
||||
### Optional observation before an iOS coordinate tap
|
||||
|
||||
A coordinate tap must not depend on a preceding XCTest snapshot failure. Its
|
||||
optional text-input lookup may establish a concrete identity for a later bare
|
||||
`type`; an absent or unavailable lookup establishes no typing witness. A runner
|
||||
snapshot penalty can skip this work, but is only a performance optimization.
|
||||
|
||||
The lookup owns a thread-bound issue scope in the runner recorder and returns a
|
||||
typed result. Any recorded issue, including one otherwise handled by AX suppression,
|
||||
discards partial candidates. The scope excludes gesture dispatch and required
|
||||
text-entry reads. Those failures retain the existing mutation-outcome rules.
|
||||
|
||||
The iOS PR lane exercises a fresh runner with an unavailable probe, a suppressed
|
||||
AX issue with a matching candidate, healthy coordinate tap followed by typing,
|
||||
and failures outside the optional observation scope. These tests must not seed a
|
||||
snapshot penalty to make the first tap safe.
|
||||
|
||||
The recorder consumes optional-read issues before forwarding to XCTest. XCTest's
|
||||
expected-failure API must not own this scope: in a long-lived command test it can
|
||||
complete the enclosing test even when the command response succeeds.
|
||||
|
||||
@@ -134,17 +134,20 @@ test('the last poll is capped to the remaining window', async () => {
|
||||
expect(sleeps.reduce((sum, ms) => sum + ms, 0)).toBeLessThanOrEqual(1_000);
|
||||
});
|
||||
|
||||
test('a failure outside the launch transition ends the wait at once', async () => {
|
||||
const { observe, acquire, sleep } = probe(
|
||||
[failed('bridge-disconnected', 'transport-failure'), acquired()],
|
||||
{ now: () => 0, sleep: async () => {} },
|
||||
);
|
||||
await expect(observe.awaitObservable(simulator, 'com.example.app', signal())).resolves.toBe(
|
||||
'unobservable',
|
||||
);
|
||||
expect(acquire).toHaveBeenCalledOnce();
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
test.each(['bridge-disconnected', 'continuation-budget-exhausted', 'snapshot-tree-malformed'])(
|
||||
'a %s failure ends the launch wait at once',
|
||||
async (code) => {
|
||||
const { observe, acquire, sleep } = probe([failed(code, 'transport-failure'), acquired()], {
|
||||
now: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
await expect(observe.awaitObservable(simulator, 'com.example.app', signal())).resolves.toBe(
|
||||
'unobservable',
|
||||
);
|
||||
expect(acquire).toHaveBeenCalledOnce();
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
test('a generation whose bridge circuit is open is unobservable without a bridge round trip', async () => {
|
||||
const { observe, acquire, sleep, gate } = probe(
|
||||
|
||||
@@ -269,13 +269,17 @@ test('a slow app discovery yields to a live runner within its wait slice, then s
|
||||
}
|
||||
});
|
||||
|
||||
test('an open whose generation already failed the bridge skips the launch-observation poll', async () => {
|
||||
test.each([
|
||||
'application-server-unavailable',
|
||||
'continuation-budget-exhausted',
|
||||
'snapshot-tree-malformed',
|
||||
])('an open whose generation failed with %s skips the launch-observation poll', async (code) => {
|
||||
// #2199: `application-server-unavailable` is a launch-transition code, so an ungated probe would
|
||||
// re-read the bridge every 150 ms for its whole 5 s window on a generation the circuit already
|
||||
// gave up on — ~33 acquisitions per `open`, each a fresh connect.
|
||||
const source = sourceReturning({
|
||||
stage: 'failed',
|
||||
failure: { kind: 'transport-failure', code: 'application-server-unavailable' },
|
||||
failure: { kind: 'transport-failure', code },
|
||||
});
|
||||
const route = createAppleSnapshotRoute(
|
||||
{ ...platformRuntimeHostFixture(), clock: steppingClock() },
|
||||
|
||||
@@ -25,6 +25,8 @@ test('the Simulator AX source returns raw acquisition facts and discloses unsupp
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header');
|
||||
const fixture = createAdapterHost();
|
||||
const source = createSimulatorSnapshotSource({
|
||||
host: fixture.host,
|
||||
@@ -74,6 +76,11 @@ test('the Simulator AX source returns raw acquisition facts and discloses unsupp
|
||||
});
|
||||
assert.equal(rawDepthOne.stage, 'acquired');
|
||||
assert.equal(fixture.requestedDepths.at(-1), 1);
|
||||
assert.ok(
|
||||
rawDepthOne.acquisition.residue.some(
|
||||
(item) => item.kind === 'truncated' && item.dimension === 'depth',
|
||||
),
|
||||
);
|
||||
|
||||
fixture.responsePid = 999;
|
||||
const outcome = await source.acquire({
|
||||
@@ -97,6 +104,8 @@ test('preparation consumes the same acquisition deadline as bridge I/O', async (
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header');
|
||||
const fixture = createAdapterHost(150);
|
||||
const source = createSimulatorSnapshotSource({ host: fixture.host, sourceRoot, cacheRoot });
|
||||
const request = createIosSnapshotRequest();
|
||||
@@ -235,12 +244,15 @@ class AdapterSocket extends EventEmitter implements SnapshotSourceSocket {
|
||||
ok: true,
|
||||
pid: this.readResponsePid(),
|
||||
generation: request.generation,
|
||||
truncated: false,
|
||||
truncated: request.maxDepth === 1,
|
||||
automationEnabled: true,
|
||||
tree: {
|
||||
XC_kAXXCAttributeElementType: 'Application',
|
||||
XC_kAXXCAttributeFrame: { X: 0, Y: 0, Width: 390, Height: 844 },
|
||||
XC_kAXXCAttributeChildren: [],
|
||||
XC_kAXXCAttributeChildren:
|
||||
request.maxDepth === 1
|
||||
? [{ XC_kAXXCAttributeElementType: 'Button', XC_kAXXCAttributeChildren: [] }]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,10 +17,13 @@ export const SNAPSHOT_BRIDGE_SOURCE_FILENAMES = [
|
||||
'SnapshotBridge.m',
|
||||
'SnapshotBridgeRuntime.m',
|
||||
'SnapshotBridgeRuntime.h',
|
||||
'SnapshotBridgeCapture.h',
|
||||
'SnapshotBridgeCapture.m',
|
||||
] as const;
|
||||
export const SNAPSHOT_BRIDGE_COMPILE_FILENAMES = [
|
||||
'SnapshotBridge.m',
|
||||
'SnapshotBridgeRuntime.m',
|
||||
'SnapshotBridgeCapture.m',
|
||||
] as const;
|
||||
|
||||
export async function fingerprintSnapshotBridgeSource(
|
||||
|
||||
@@ -19,6 +19,8 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt
|
||||
await writeFile(sourceFile, 'native source v1');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime v1');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header v1');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header v1');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header v1');
|
||||
|
||||
let builds = 0;
|
||||
let xcodeVersion = 'Xcode 16.4\nBuild version 16F6';
|
||||
@@ -127,6 +129,8 @@ test('concurrent snapshot bridge preparation publishes one cache entry', async (
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header');
|
||||
let builds = 0;
|
||||
const host = createFakeBuildHost(async () => {
|
||||
builds += 1;
|
||||
@@ -163,6 +167,8 @@ test('an aborted cache waiter does not cancel an independent preparation', async
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridge.m'), 'native source');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header');
|
||||
await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header');
|
||||
let builds = 0;
|
||||
let buildStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#import "SnapshotBridgeRuntime.h"
|
||||
#import "SnapshotBridgeCapture.h"
|
||||
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
#import <dlfcn.h>
|
||||
@@ -6,6 +7,7 @@
|
||||
static id primaryApplication;
|
||||
static id replacementApplication;
|
||||
static NSUInteger captureCount;
|
||||
static NSString *captureScenario;
|
||||
|
||||
@interface AXElement : NSObject
|
||||
@property(nonatomic) pid_t pid;
|
||||
@@ -36,6 +38,64 @@ static NSUInteger captureCount;
|
||||
- (id)userTestingSnapshotForElement:(id)element options:(NSDictionary *)options error:(NSError **)error
|
||||
{
|
||||
captureCount++;
|
||||
if ([captureScenario isEqual:@"runtime-budget"]) {
|
||||
BOOL root = ![element isKindOfClass:NSNumber.class];
|
||||
NSMutableArray *children = [NSMutableArray array];
|
||||
for (NSUInteger i = 0; i < (root ? 40 : 1); i++) [children addObject:@{
|
||||
@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": @[],
|
||||
@"UIAccessibilitySnapshotKeyElement": @(i), @"UIAccessibilitySnapshotKeyChildrenCount": @(root ? 1 : 0)}];
|
||||
return @{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": children};
|
||||
}
|
||||
if ([captureScenario isEqual:@"rejected"]) {
|
||||
if (error) *error = [NSError errorWithDomain:@"AX" code:-25201 userInfo:@{@"accessibility-error": @(-25201)}];
|
||||
return nil;
|
||||
}
|
||||
if ([captureScenario isEqual:@"unavailable"]) {
|
||||
if (error) *error = [NSError errorWithDomain:@"unavailable" code:5
|
||||
userInfo:@{NSLocalizedDescriptionKey:@"Error kAXErrorIllegalArgument"}];
|
||||
return nil;
|
||||
}
|
||||
if ([captureScenario hasPrefix:@"wide-"] || [captureScenario isEqual:@"zero-depth"]) {
|
||||
NSMutableArray *children = [NSMutableArray array];
|
||||
NSUInteger limit = [options[@"maxChildren"] unsignedIntegerValue];
|
||||
if ([captureScenario isEqual:@"wide-continuation"] && captureCount == 1) limit = 0;
|
||||
for (NSUInteger i = 0; i < MIN(10, limit); i++) {
|
||||
[children addObject:@{@"UIAccessibilitySnapshotKeyAttributes": @{@2: @(i).stringValue},
|
||||
@"UIAccessibilitySnapshotKeyChildren": @[]}];
|
||||
}
|
||||
return @{@"UIAccessibilitySnapshotKeyAttributes": @{@2: @"fixture app"},
|
||||
@"UIAccessibilitySnapshotKeyElement": element,
|
||||
@"UIAccessibilitySnapshotKeyChildrenCount": @10,
|
||||
@"UIAccessibilitySnapshotKeyChildren": children};
|
||||
}
|
||||
if ([captureScenario hasPrefix:@"depth-"]) {
|
||||
NSUInteger requested = [options[@"maxDepth"] unsignedIntegerValue];
|
||||
if (requested > 4) {
|
||||
if (error) *error = [NSError errorWithDomain:@"AX" code:-25201 userInfo:@{@"accessibility-error": @(-25201)}];
|
||||
if ([captureScenario isEqual:@"depth-wrapper"]) {
|
||||
if (error) *error = [NSError errorWithDomain:@"com.apple.dt.xctest.automation-support.error" code:5 userInfo:nil];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
NSUInteger level = [element isKindOfClass:NSNumber.class] ? [element unsignedIntegerValue] : 0;
|
||||
NSMutableDictionary *tree = nil;
|
||||
for (NSInteger i = MIN(level + requested, 7) - 1; i >= (NSInteger)level; i--) {
|
||||
tree = [@{@"UIAccessibilitySnapshotKeyAttributes": @{@2: @(i).stringValue},
|
||||
@"UIAccessibilitySnapshotKeyElement": @(i),
|
||||
@"UIAccessibilitySnapshotKeyChildrenCount": @(i < 6 ? 1 : 0),
|
||||
@"UIAccessibilitySnapshotKeyChildren": tree ? @[tree] : @[]} mutableCopy];
|
||||
}
|
||||
if ([@[@"depth-missing-element", @"depth-missing-count", @"depth-invalid-count", @"depth-fractional-count", @"depth-nan-count", @"depth-negative-count"] containsObject:captureScenario] || ([captureScenario isEqual:@"depth-continuation-count"] && level > 0)) {
|
||||
NSMutableDictionary *frontier = tree;
|
||||
while ([frontier[@"UIAccessibilitySnapshotKeyChildren"] count]) frontier = [frontier[@"UIAccessibilitySnapshotKeyChildren"] firstObject];
|
||||
if ([captureScenario isEqual:@"depth-missing-element"]) [frontier removeObjectForKey:@"UIAccessibilitySnapshotKeyElement"];
|
||||
else if ([captureScenario isEqual:@"depth-missing-count"] || [captureScenario isEqual:@"depth-continuation-count"]) [frontier removeObjectForKey:@"UIAccessibilitySnapshotKeyChildrenCount"];
|
||||
else frontier[@"UIAccessibilitySnapshotKeyChildrenCount"] = [captureScenario isEqual:@"depth-fractional-count"] ? @0.5 : [captureScenario isEqual:@"depth-nan-count"] ? @(NAN) : [captureScenario isEqual:@"depth-negative-count"] ? @(-1) : [NSNull null];
|
||||
}
|
||||
if ([captureScenario isEqual:@"depth-incomplete"] && level > 0) tree[@"UIAccessibilitySnapshotKeyChildren"] = @[];
|
||||
if ([captureScenario isEqual:@"depth-owner-change"] && level > 0) primaryApplication = replacementApplication;
|
||||
return tree;
|
||||
}
|
||||
if (replacementApplication) primaryApplication = replacementApplication;
|
||||
return @{ @"UIAccessibilitySnapshotKeyAttributes": @{ @2: @"fixture app" },
|
||||
@"UIAccessibilitySnapshotKeyChildren": @[] };
|
||||
@@ -85,6 +145,39 @@ int main(int argc, const char *argv[])
|
||||
@autoreleasepool {
|
||||
require(argc == 2, @"one capture scenario is required");
|
||||
NSString *scenario = @(argv[1]);
|
||||
if ([scenario isEqual:@"identity"] || [scenario isEqual:@"request-budget"] || [scenario hasPrefix:@"api-depth-"]) {
|
||||
BOOL budget = [scenario isEqual:@"request-budget"];
|
||||
NSUInteger depth = [scenario hasPrefix:@"api-depth-"] ? [[scenario substringFromIndex:10] integerValue] : 64;
|
||||
NSMutableArray *children = [NSMutableArray array];
|
||||
for (NSUInteger i = 0; i < (budget ? 40 : 2); i++) {
|
||||
[children addObject:@{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": @[],
|
||||
@"UIAccessibilitySnapshotKeyElement": @(i), @"UIAccessibilitySnapshotKeyChildrenCount": @(budget ? 1 : 0)}];
|
||||
}
|
||||
NSDictionary *root = @{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": children};
|
||||
__block NSUInteger requests = 0;
|
||||
BOOL truncated = NO;
|
||||
NSError *failure = nil;
|
||||
NSDictionary *result = captureSnapshotTree(@"root", depth, 1000, ^id(id element, NSUInteger levels, NSUInteger nodes, NSError **error) {
|
||||
requests++;
|
||||
if ([scenario hasPrefix:@"api-depth-"]) {
|
||||
require(levels == depth + 1, @"native levels must include the root exactly once");
|
||||
NSDictionary *tree = nil;
|
||||
for (NSUInteger i = 0; i < levels; i++) tree = @{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildrenCount": [scenario isEqual:@"api-depth-unknown"] ? [NSNull null] : @(tree ? 1 : 0), @"UIAccessibilitySnapshotKeyChildren": tree ? @[tree] : @[]};
|
||||
return tree;
|
||||
}
|
||||
if ([element isEqual:@"root"]) return root;
|
||||
return @{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": @[@{@"UIAccessibilitySnapshotKeyAttributes": @{}, @"UIAccessibilitySnapshotKeyChildren": @[]}]};
|
||||
}, &truncated, &failure);
|
||||
if (budget) require(!result && failure.code == 1 && requests == 32, @"request budget must fail without publishing partial content");
|
||||
else if ([scenario isEqual:@"identity"]) require(result == root && requests == 1, @"healthy capture must reuse the native tree");
|
||||
else {
|
||||
NSUInteger count = 0;
|
||||
for (NSDictionary *node = result; node; node = [node[@"UIAccessibilitySnapshotKeyChildren"] firstObject]) count++;
|
||||
require(count == depth + 1 && truncated == [scenario isEqual:@"api-depth-unknown"] && requests == 1, @"every requested depth must include root plus permitted descendants");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
captureScenario = scenario;
|
||||
AXElement *target = [AXElement new];
|
||||
target.pid = 42;
|
||||
AXElement *system = [AXElement new];
|
||||
@@ -93,7 +186,25 @@ int main(int argc, const char *argv[])
|
||||
primaryApplication = target;
|
||||
NSString *expectedCode = nil;
|
||||
NSUInteger expectedCaptures = 0;
|
||||
if ([scenario isEqualToString:@"stable"]) {
|
||||
if ([scenario isEqual:@"runtime-budget"]) {
|
||||
expectedCaptures = 32;
|
||||
expectedCode = @"continuation-budget-exhausted";
|
||||
} else if ([scenario hasPrefix:@"wide-"] || [scenario isEqual:@"zero-depth"]) {
|
||||
expectedCaptures = [scenario isEqual:@"wide-continuation"] ? 2 : 1;
|
||||
} else if ([scenario hasPrefix:@"depth-"]) {
|
||||
expectedCaptures = [scenario isEqual:@"depth-bound"] ? 5 : [scenario isEqual:@"depth-nodes"] ? 2 : 3;
|
||||
if ([@[@"depth-missing-element", @"depth-incomplete", @"depth-missing-count", @"depth-invalid-count", @"depth-continuation-count", @"depth-fractional-count", @"depth-nan-count", @"depth-negative-count"] containsObject:scenario]) {
|
||||
expectedCode = @"snapshot-tree-malformed";
|
||||
if (![scenario isEqual:@"depth-incomplete"] && ![scenario isEqual:@"depth-continuation-count"]) expectedCaptures = 2;
|
||||
}
|
||||
if ([scenario isEqual:@"depth-owner-change"]) {
|
||||
replacementApplication = system;
|
||||
expectedCode = @"foreground-owner-changed";
|
||||
}
|
||||
} else if ([scenario isEqual:@"unavailable"] || [scenario isEqual:@"rejected"]) {
|
||||
expectedCaptures = [scenario isEqual:@"rejected"] ? 3 : 1;
|
||||
expectedCode = @"application-server-unavailable";
|
||||
} else if ([scenario isEqualToString:@"stable"]) {
|
||||
expectedCaptures = 1;
|
||||
} else if ([scenario isEqualToString:@"changed"]) {
|
||||
replacementApplication = system;
|
||||
@@ -110,16 +221,32 @@ int main(int argc, const char *argv[])
|
||||
BridgeRuntime *runtime = [[FixtureRuntime alloc] initWithError:&setupError];
|
||||
require(runtime != nil, setupError ?: @"fixture initialization failed");
|
||||
NSDictionary *error = nil;
|
||||
NSDictionary *result = [runtime snapshotForProcess:42 maxDepth:8 maxNodes:10
|
||||
NSDictionary *result = [runtime snapshotForProcess:42 maxDepth:([scenario isEqual:@"zero-depth"] ? 0 : [scenario isEqual:@"depth-bound"] ? 4 : 8) maxNodes:(([scenario isEqual:@"depth-nodes"] || [scenario hasPrefix:@"wide-"]) ? 3 : [scenario isEqual:@"runtime-budget"] ? 1000 : 10)
|
||||
requestId:@"capture-1" generation:@"generation-1" maxDurationMs:4000 error:&error];
|
||||
if (expectedCode) {
|
||||
require(result == nil, @"refused capture must not publish the app tree");
|
||||
require([error[@"error_kind"] isEqual:@"unsupported"], @"refusal must preserve the typed failure kind");
|
||||
require([error[@"error_kind"] isEqual:([expectedCode isEqual:@"application-server-unavailable"] ? @"application_unavailable" : [expectedCode isEqual:@"snapshot-tree-malformed"] ? @"malformed_tree" : [expectedCode isEqual:@"continuation-budget-exhausted"] ? @"reader_unavailable" : @"unsupported")], @"refusal must preserve the typed failure kind");
|
||||
require([error[@"error_code"] isEqual:expectedCode], @"refusal must name the ownership phase");
|
||||
require([error[@"requestId"] isEqual:@"capture-1"], @"refusal must preserve request identity");
|
||||
} else {
|
||||
require(error == nil && [result[@"ok"] boolValue], @"stable foreground must publish successfully");
|
||||
require([result[@"tree"][@"XC_kAXXCAttributeLabel"] isEqual:@"fixture app"], @"stable capture must publish the materialized app tree");
|
||||
if ([scenario hasPrefix:@"wide-"] || [scenario isEqual:@"zero-depth"]) {
|
||||
require([result[@"truncated"] boolValue], @"bounded capture must disclose omitted content");
|
||||
NSArray *children = result[@"tree"][@"XC_kAXXCAttributeChildren"];
|
||||
require(children.count == ([scenario isEqual:@"zero-depth"] ? 0 : 2), @"bounded capture must retain the permitted children");
|
||||
if (children.count) require([children[1][@"XC_kAXXCAttributeLabel"] isEqual:@"1"], @"bounded capture must preserve sibling order");
|
||||
} else if ([scenario isEqual:@"depth-bound"] || [scenario isEqual:@"depth-nodes"]) {
|
||||
require([result[@"truncated"] boolValue], @"bounded capture must disclose omitted content");
|
||||
NSDictionary *node = result[@"tree"];
|
||||
NSUInteger count = 1;
|
||||
while ([node[@"XC_kAXXCAttributeChildren"] count]) {node = [node[@"XC_kAXXCAttributeChildren"] firstObject]; count++;}
|
||||
require(count == ([scenario isEqual:@"depth-bound"] ? 5 : 3), @"bounded capture must retain every allowed node");
|
||||
} else if ([scenario hasPrefix:@"depth-"]) {
|
||||
NSDictionary *node = result[@"tree"];
|
||||
for (NSUInteger i = 0; i < 6; i++) node = [node[@"XC_kAXXCAttributeChildren"] firstObject];
|
||||
require([node[@"XC_kAXXCAttributeLabel"] isEqual:@"6"], @"recovery must retain the deepest content");
|
||||
require(![result[@"truncated"] boolValue], @"complete recovered tree must remain complete");
|
||||
} else require([result[@"tree"][@"XC_kAXXCAttributeLabel"] isEqual:@"fixture app"], @"stable capture must publish the materialized app tree");
|
||||
}
|
||||
require(captureCount == expectedCaptures, @"covered apps must be refused before native acquisition");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"sourceVersion": "agent-device-simulator-ax-v1.5.3",
|
||||
"sourceVersion": "agent-device-simulator-ax-v1.5.4",
|
||||
"requestKeys": [
|
||||
"verb",
|
||||
"requestId",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeAll, describe, test } from 'vitest';
|
||||
import { runCmd } from '@agent-device/host-kit/command';
|
||||
import { mkdtempForTest } from '../__tests__/tmp-dir.ts';
|
||||
|
||||
describe.skipIf(process.platform !== 'darwin')('native snapshot foreground ownership', () => {
|
||||
describe.skipIf(process.platform !== 'darwin')('native snapshot capture', () => {
|
||||
let binary: string;
|
||||
beforeAll(async () => {
|
||||
binary = path.join(await mkdtempForTest('snapshot-foreground-'), 'foreground-owner');
|
||||
@@ -25,6 +25,7 @@ describe.skipIf(process.platform !== 'darwin')('native snapshot foreground owner
|
||||
'-I',
|
||||
nativeRoot,
|
||||
path.join(nativeRoot, 'SnapshotBridgeRuntime.m'),
|
||||
path.join(nativeRoot, 'SnapshotBridgeCapture.m'),
|
||||
path.join(import.meta.dirname, 'fixtures/foreground-owner.m'),
|
||||
'-o',
|
||||
binary,
|
||||
@@ -34,11 +35,40 @@ describe.skipIf(process.platform !== 'darwin')('native snapshot foreground owner
|
||||
assert.equal(compiled.exitCode, 0, compiled.stderr);
|
||||
}, 60_000);
|
||||
|
||||
test.each(['stable', 'covered', 'changed', 'missing', 'malformed'])(
|
||||
'snapshot capture enforces %s foreground ownership',
|
||||
async (scenario) => {
|
||||
const result = await runCmd(binary, [scenario], { allowFailure: true, timeoutMs: 5_000 });
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
},
|
||||
);
|
||||
test.each([
|
||||
'stable',
|
||||
'identity',
|
||||
'request-budget',
|
||||
'runtime-budget',
|
||||
'api-depth-0',
|
||||
'api-depth-unknown',
|
||||
'api-depth-1',
|
||||
'api-depth-4',
|
||||
'api-depth-128',
|
||||
'rejected',
|
||||
'wide-nodes',
|
||||
'wide-continuation',
|
||||
'zero-depth',
|
||||
'covered',
|
||||
'changed',
|
||||
'missing',
|
||||
'malformed',
|
||||
'depth-recovery',
|
||||
'depth-wrapper',
|
||||
'depth-missing-element',
|
||||
'depth-missing-count',
|
||||
'depth-invalid-count',
|
||||
'depth-fractional-count',
|
||||
'depth-nan-count',
|
||||
'depth-negative-count',
|
||||
'depth-continuation-count',
|
||||
'depth-incomplete',
|
||||
'depth-bound',
|
||||
'depth-nodes',
|
||||
'depth-owner-change',
|
||||
'unavailable',
|
||||
])('snapshot capture enforces %s', async (scenario) => {
|
||||
const result = await runCmd(binary, [scenario], { allowFailure: true, timeoutMs: 5_000 });
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +122,13 @@ test('snapshot bridge failures stay typed at the guest boundary', () => {
|
||||
|
||||
test('wire vocabulary guard keeps TS and Objective-C literals aligned', async () => {
|
||||
const native = await Promise.all(
|
||||
['SnapshotBridge.m', 'SnapshotBridgeRuntime.m', 'SnapshotBridgeRuntime.h'].map((fileName) =>
|
||||
[
|
||||
'SnapshotBridge.m',
|
||||
'SnapshotBridgeRuntime.m',
|
||||
'SnapshotBridgeRuntime.h',
|
||||
'SnapshotBridgeCapture.h',
|
||||
'SnapshotBridgeCapture.m',
|
||||
].map((fileName) =>
|
||||
readFile(
|
||||
path.join(import.meta.dirname, '../../../../apple/snapshot-bridge', fileName),
|
||||
'utf8',
|
||||
@@ -136,7 +142,7 @@ test('wire vocabulary guard keeps TS and Objective-C literals aligned', async ()
|
||||
assert.deepEqual(wireVocabulary.responseKeys, SNAPSHOT_SOURCE_RESPONSE_KEYS);
|
||||
assert.deepEqual(wireVocabulary.attributeKeys, SNAPSHOT_SOURCE_ATTRIBUTE_KEYS);
|
||||
assert.match(nativeSource, /kProtocolVersion = 1/);
|
||||
assert.match(nativeSource, /kSourceVersion = @"agent-device-simulator-ax-v1\.5\.3"/);
|
||||
assert.match(nativeSource, /kSourceVersion = @"agent-device-simulator-ax-v1\.5\.4"/);
|
||||
for (const key of [
|
||||
...wireVocabulary.requestKeys,
|
||||
...wireVocabulary.responseKeys,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { snapshotSourceError } from './errors.ts';
|
||||
import type { SnapshotSourceLimits } from './types.ts';
|
||||
|
||||
export const SNAPSHOT_SOURCE_PROTOCOL_VERSION = 1;
|
||||
export const SNAPSHOT_SOURCE_VERSION = 'agent-device-simulator-ax-v1.5.3';
|
||||
export const SNAPSHOT_SOURCE_VERSION = 'agent-device-simulator-ax-v1.5.4';
|
||||
const FRAME_HEADER_BYTES = 4;
|
||||
|
||||
export const SNAPSHOT_SOURCE_WIRE_KEYS = Object.freeze([
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
{ "path": "apple/snapshot-bridge/SnapshotBridge.m", "size": 0 },
|
||||
{ "path": "apple/snapshot-bridge/SnapshotBridgeRuntime.m", "size": 0 },
|
||||
{ "path": "apple/snapshot-bridge/SnapshotBridgeRuntime.h", "size": 0 },
|
||||
{ "path": "apple/snapshot-bridge/SnapshotBridgeCapture.h", "size": 0 },
|
||||
{ "path": "apple/snapshot-bridge/SnapshotBridgeCapture.m", "size": 0 },
|
||||
{ "path": "apple/macos-helper/Sources/main.swift", "size": 211 },
|
||||
{ "path": "android/snapshot-helper/dist/helper.apk", "size": 307 },
|
||||
{ "path": "android/snapshot-helper/dist/helper.manifest.json", "size": 99 },
|
||||
|
||||
@@ -74,3 +74,17 @@ test('Markdown emphasizes total install size and startup without duplicate break
|
||||
/\| Installed \(including dependencies\) \| - \| 350 B \| - \|/,
|
||||
);
|
||||
});
|
||||
|
||||
test('base measurement uses the measured revision package asset policy', async () => {
|
||||
const workflow = await readFile(
|
||||
join(import.meta.dirname, '../../.github/workflows/size.yml'),
|
||||
'utf8',
|
||||
);
|
||||
const baseStep = workflow
|
||||
.split(' - name: Measure base size\n')[1]!
|
||||
.split(' - name: Save base dist cache')[0]!;
|
||||
assert.match(
|
||||
baseStep,
|
||||
/git checkout --detach[\s\S]*cp scripts\/size-report-package\.mjs \/tmp\/agent-device-size-report\/[\s\S]*node \/tmp\/agent-device-size-report\/size-report\.mjs/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,6 +57,8 @@ test('clean-installed snapshot bridge validates all native assets when present',
|
||||
await writeFile(join(bridge, 'SnapshotBridge.m'), 'native source');
|
||||
await writeFile(join(bridge, 'SnapshotBridgeRuntime.m'), 'native runtime');
|
||||
await writeFile(join(bridge, 'SnapshotBridgeRuntime.h'), 'native header');
|
||||
await writeFile(join(bridge, 'SnapshotBridgeCapture.h'), 'capture header');
|
||||
await writeFile(join(bridge, 'SnapshotBridgeCapture.m'), 'capture source');
|
||||
assert.doesNotThrow(() => assertInstalledSnapshotBridge(root));
|
||||
await rm(join(bridge, 'SnapshotBridgeRuntime.h'));
|
||||
assert.throws(() => assertInstalledSnapshotBridge(root), /SnapshotBridgeRuntime\.h/);
|
||||
|
||||
@@ -5,6 +5,8 @@ export const SNAPSHOT_BRIDGE_ASSET_PATHS = Object.freeze([
|
||||
'apple/snapshot-bridge/SnapshotBridge.m',
|
||||
'apple/snapshot-bridge/SnapshotBridgeRuntime.m',
|
||||
'apple/snapshot-bridge/SnapshotBridgeRuntime.h',
|
||||
'apple/snapshot-bridge/SnapshotBridgeCapture.h',
|
||||
'apple/snapshot-bridge/SnapshotBridgeCapture.m',
|
||||
]);
|
||||
|
||||
export function assertSnapshotBridgeAssets(presentPaths, context) {
|
||||
|
||||
Reference in New Issue
Block a user