Skip to content

Commit e5ad866

Browse files
committed
test(ios): pin the capture-refusal contracts behind the code that produces them
- testRecordStartThrowsTheCaptureRefusalItReceived drives ScreenRecorder.start with an always-refusing capture and maps the actual thrown error through recordingStartErrorPayload, so the bootstrap forwarding is covered (iOS PR lane). - navigationFallbackSample(resolvingApp:systemSurface:encoding:) now holds the back fallback's app-only decision; the test hands it a system surface that fails if consulted, so a revert to SpringBoard sampling turns it red. - navigationVisualSample + inAppBackOutcome keep the refusal->code mapping and the unknown-outcome decision testable on the macOS host lane.
1 parent ffd7e88 commit e5ad866

4 files changed

Lines changed: 170 additions & 25 deletions

File tree

‎.github/workflows/ios.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ jobs:
154154
-xctestrun "$XCTESTRUN_PATH" \
155155
-destination "platform=iOS Simulator,id=${{ steps.ios-simulator.outputs.simulator-udid }}" \
156156
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSinglePointerFlingFallsBackToXCTestCoordinateDragWhenPrivateSynthesisFails \
157+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testRecordStartThrowsTheCaptureRefusalItReceived \
157158
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDispatchResolvesItsOwnModalWithoutCoordinateTapRoutingProbe \
158159
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertResolutionCannotBypassRequestedDeadline \
159160
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testTypeWithoutResolvedInputReturnsTypedFailureBeforeDispatchingText \

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,21 @@ enum RunnerAppScreenCaptureFailure: String, Error {
5454
}
5555
}
5656

57+
extension RunnerTests {
58+
/// The target rule, kept apart from the two queries so the rule itself is testable: an unresolved
59+
/// window asks the system surface, and nothing else does.
60+
func selectObservedScreenCapture(
61+
resolving: () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>,
62+
fallingBack: () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>
63+
) -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure> {
64+
let outcome = resolving()
65+
if case .failure(.unresolvedWindow) = outcome {
66+
return fallingBack()
67+
}
68+
return outcome
69+
}
70+
}
71+
5772
#if canImport(UIKit) && os(iOS)
5873
extension RunnerTests {
5974
/// Captures the display hosting `app` instead of `XCUIScreen.main`.
@@ -103,19 +118,6 @@ extension RunnerTests {
103118
)
104119
}
105120

106-
/// The target rule, kept apart from the two queries so the rule itself is testable: an unresolved
107-
/// window asks the system surface, and nothing else does.
108-
func selectObservedScreenCapture(
109-
resolving: () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>,
110-
fallingBack: () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>
111-
) -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure> {
112-
let outcome = resolving()
113-
if case .failure(.unresolvedWindow) = outcome {
114-
return fallingBack()
115-
}
116-
return outcome
117-
}
118-
119121
private static func resolveCapturedAppScreen(
120122
app: XCUIApplication
121123
) -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure> {

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift‎

Lines changed: 117 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -177,17 +177,49 @@ extension RunnerTests {
177177

178178
private func captureNavigationFallbackVisualState(app: XCUIApplication) -> NavigationVisualSample {
179179
#if os(iOS)
180-
// A visual check is optional evidence, and the check is about THIS app's screen after the tap.
181-
// It uses the app's own resolved screen, never the system-surface fallback: sampling SpringBoard's
182-
// home screen twice would read as "unchanged" for an app that resolved no window, laundering a
183-
// wrong-process sample into the same false "no back control" verdict a `nil` sample produces (#2728).
184-
switch captureResolvedAppScreen(app: app) {
180+
return Self.navigationFallbackSample(
181+
resolvingApp: { self.captureResolvedAppScreen(app: app) },
182+
systemSurface: { self.captureResolvedAppScreen(app: self.springboard) },
183+
encoding: { runnerPngData(for: $0.image) }
184+
)
185+
#else
186+
return NavigationVisualSample(data: nil)
187+
#endif
188+
}
189+
190+
/// The decision the in-app `back` fallback makes about WHAT to sample, kept apart from the live app
191+
/// so the decision itself is testable. It samples only the app's own resolved screen: consulting the
192+
/// system surface for an app that resolved no window would capture SpringBoard's home screen, which
193+
/// reads as "unchanged" across a before/after pair and launders a wrong-process frame into a false
194+
/// "no back control" — the opposite of the unknown-outcome answer the fallback owes (#2728). The
195+
/// system surface is still threaded in, so sampling it is the tested contract: a caller that started
196+
/// to consult it fails the fallback's test. `navigationVisualSample` then names the refusal.
197+
static func navigationFallbackSample(
198+
resolvingApp: () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>,
199+
systemSurface: () -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>,
200+
encoding: (CapturedAppScreen) -> Data?
201+
) -> NavigationVisualSample {
202+
_ = systemSurface
203+
return navigationVisualSample(from: resolvingApp(), encoding: encoding)
204+
}
205+
206+
/// Turns a capture answer into a navigation sample: the encoded frame when the display answered,
207+
/// and the typed reason it refused otherwise. A resolved display whose image would not encode is
208+
/// named as the capture failure it is, not as an unnamed no-sample that would default to "no
209+
/// display resolved" (#2728). Kept apart from the query so the mapping itself is testable.
210+
static func navigationVisualSample(
211+
from outcome: Result<CapturedAppScreen, RunnerAppScreenCaptureFailure>,
212+
encoding: (CapturedAppScreen) -> Data?
213+
) -> NavigationVisualSample {
214+
switch outcome {
185215
case .success(let captured):
186-
guard let png = runnerPngData(for: captured.image) else {
187-
// The display resolved but the image would not encode: name it as the capture failure it is,
188-
// not as an unnamed no-sample that would default to "no display resolved" (#2728).
216+
guard let png = encoding(captured) else {
189217
let refusal = RunnerAppScreenCaptureFailure.unrenderableImage
190-
return NavigationVisualSample(data: nil, refusalCode: refusal.rawValue, refusalHint: refusal.hint)
218+
return NavigationVisualSample(
219+
data: nil,
220+
refusalCode: refusal.rawValue,
221+
refusalHint: refusal.hint
222+
)
191223
}
192224
return NavigationVisualSample(data: png)
193225
case .failure(let failure):
@@ -197,9 +229,6 @@ extension RunnerTests {
197229
refusalHint: failure.hint
198230
)
199231
}
200-
#else
201-
return NavigationVisualSample(data: nil)
202-
#endif
203232
}
204233

205234
private func verifyNavigationFallbackOutcome(
@@ -217,6 +246,16 @@ extension RunnerTests {
217246
after.data?.count ?? -1,
218247
observation.logToken
219248
)
249+
return Self.inAppBackOutcome(observation: observation, before: before, after: after)
250+
}
251+
252+
/// What an observation of the fallback's before/after samples concludes. Kept apart from the sleep
253+
/// and the capture so the three-way decision is testable without a running app.
254+
static func inAppBackOutcome(
255+
observation: NavigationVisualObservation,
256+
before: NavigationVisualSample,
257+
after: NavigationVisualSample
258+
) -> InAppBackOutcome {
220259
switch observation {
221260
case .changed:
222261
return .performed
@@ -363,5 +402,71 @@ extension RunnerTests {
363402
XCTAssertEqual(unnamed.code, "APP_SCREEN_UNRESOLVED")
364403
XCTAssertTrue(unnamed.message.contains("unknown outcome"))
365404
}
405+
406+
func testVerifyNavigationFallbackOutcomeReportsUnresolvedWindowWithoutSystemSurface() {
407+
// The in-app `back` fallback ran its tap but the app resolved no window. It must name that refusal
408+
// as an unknown outcome AND must not have sampled the system surface: capturing SpringBoard's home
409+
// screen twice reads as "unchanged" and launders a wrong-process frame into "no back control
410+
// exists" (#2728). This drives the SAME `navigationFallbackSample` production calls, handing it a
411+
// system surface that fails the test if consulted — so reverting the fallback to sample SpringBoard
412+
// (or dropping the refusal code) turns this red, which an inline `.never` re-creation could not.
413+
var askedSystemSurface = false
414+
let sample = Self.navigationFallbackSample(
415+
resolvingApp: { .failure(.unresolvedWindow) },
416+
systemSurface: {
417+
askedSystemSurface = true
418+
return .failure(.unresolvedWindow)
419+
},
420+
encoding: { _ in Data([1, 2, 3]) }
421+
)
422+
XCTAssertFalse(askedSystemSurface, "the in-app fallback samples the app only, never SpringBoard")
423+
424+
XCTAssertNil(sample.data)
425+
XCTAssertEqual(sample.refusalCode, "APP_SCREEN_WINDOW_UNRESOLVED")
426+
427+
let observation = Self.navigationVisualObservation(before: sample.data, after: sample.data)
428+
XCTAssertEqual(observation, .unobserved)
429+
430+
switch Self.inAppBackOutcome(observation: observation, before: sample, after: sample) {
431+
case .unverified(let payload):
432+
XCTAssertEqual(payload.code, "APP_SCREEN_WINDOW_UNRESOLVED")
433+
case .performed, .unavailable:
434+
XCTFail("a refused capture must report an unknown outcome, not 'no back control'")
435+
}
436+
}
437+
438+
func testNavigationVisualSampleDistinguishesEncodedFrameFromRefusal() {
439+
// The same capture entry point yields three different samples, and only the refusal ones may carry
440+
// a code: an encoded frame is evidence, a resolved-but-unencodable image and a refusal are not
441+
// (#2728). Reverting the mapping to a plain no-sample loses the reason a host keys on.
442+
let captured = CapturedAppScreen(
443+
image: RunnerImage(),
444+
displayID: 3,
445+
pixelWidth: 12,
446+
pixelHeight: 24,
447+
pixelsPerPoint: 3
448+
)
449+
450+
let encoded = Self.navigationVisualSample(
451+
from: .success(captured),
452+
encoding: { _ in Data([7, 7]) }
453+
)
454+
XCTAssertEqual(encoded.data, Data([7, 7]))
455+
XCTAssertNil(encoded.refusalCode)
456+
457+
let unencodable = Self.navigationVisualSample(
458+
from: .success(captured),
459+
encoding: { _ in nil }
460+
)
461+
XCTAssertNil(unencodable.data)
462+
XCTAssertEqual(unencodable.refusalCode, "APP_SCREEN_CAPTURE_UNRENDERABLE")
463+
464+
let refused = Self.navigationVisualSample(
465+
from: .failure(.unresolvedScreen),
466+
encoding: { _ in Data([7, 7]) }
467+
)
468+
XCTAssertNil(refused.data)
469+
XCTAssertEqual(refused.refusalCode, "APP_SCREEN_UNRESOLVED")
470+
}
366471
#endif
367472
}

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+RecordingTests.swift‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,40 @@ extension RunnerTests {
5959
}
6060
#endif
6161
}
62+
63+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS)
64+
extension RunnerTests {
65+
func testRecordStartThrowsTheCaptureRefusalItReceived() throws {
66+
// The bootstrap frame is required, so `record start` must surface the exact refusal its capture
67+
// saw rather than the generic "failed to capture initial frame" every refusal used to collapse
68+
// into, and that refusal must reach the host as its own code. Driving `start` with an always-
69+
// refusing capture and mapping the ACTUAL thrown error — not a re-typed literal — proves the
70+
// bootstrap forwards its last refusal end to end; the pure mapping tests cannot catch that wiring.
71+
let outputPath = (NSTemporaryDirectory() as NSString).appendingPathComponent(
72+
"record-refusal-\(UUID().uuidString).mp4"
73+
)
74+
let recorder = ScreenRecorder(outputPath: outputPath, fps: 30)
75+
76+
var captureCalls = 0
77+
var thrown: Error?
78+
XCTAssertThrowsError(
79+
try recorder.start(capture: {
80+
captureCalls += 1
81+
return .failure(.unresolvedWindow)
82+
})
83+
) { error in
84+
thrown = error
85+
XCTAssertEqual(
86+
(error as? RunnerAppScreenCaptureFailure)?.rawValue,
87+
"APP_SCREEN_WINDOW_UNRESOLVED",
88+
"the thrown bootstrap error is the refusal the capture returned"
89+
)
90+
}
91+
XCTAssertGreaterThan(captureCalls, 0, "the bootstrap must poll the injected capture")
92+
93+
// The refusal the bootstrap actually threw maps to its own host code, not the generic record error.
94+
let payload = RunnerTests.recordingStartErrorPayload(for: try XCTUnwrap(thrown))
95+
XCTAssertEqual(payload.code, "APP_SCREEN_WINDOW_UNRESOLVED")
96+
}
97+
}
98+
#endif

0 commit comments

Comments
 (0)