mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
fix(ios): confirm alerts without repeating activation (#2326)
This commit is contained in:
@@ -169,6 +169,10 @@ jobs:
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testCachedTargetInvalidationClearsProcessBoundState \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertResolutionCannotBypassRequestedDeadline \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertAcceptTreatsOpenAsAffirmative \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertAcceptDoesNotActivateAReplacementWithASharedButton \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDismissDoesNotActivateAReplacementWithTheSameTitle \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDeadlineBeforeActivationLeavesTheOriginalUntouched \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSystemModalProbeSliceSharesAndClampsToPlanDeadline \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain \
|
||||
|
||||
@@ -58,10 +58,62 @@ int main(int argc, const char *argv[]) {
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface AgentDeviceRunnerViewController : UIViewController
|
||||
@property(nonatomic, strong) UILabel *alertActionStatus;
|
||||
@property(nonatomic, assign) NSUInteger firstAlertActions;
|
||||
@property(nonatomic, assign) NSUInteger replacementAlertActions;
|
||||
@property(nonatomic, assign) BOOL alertFixtureStarted;
|
||||
@end
|
||||
|
||||
@implementation AgentDeviceRunnerViewController
|
||||
|
||||
#if TARGET_OS_IOS
|
||||
- (void)updateAlertActionStatus {
|
||||
self.alertActionStatus.text = [NSString stringWithFormat:@"First actions: %lu; replacement actions: %lu",
|
||||
(unsigned long)self.firstAlertActions,
|
||||
(unsigned long)self.replacementAlertActions];
|
||||
}
|
||||
|
||||
- (void)presentAlertFixtureReplacement:(BOOL)replacement {
|
||||
NSArray<NSString *> *arguments = NSProcessInfo.processInfo.arguments;
|
||||
BOOL sameTitle = [arguments containsObject:@"--agent-device-alert-same-title"];
|
||||
BOOL sameBody = [arguments containsObject:@"--agent-device-alert-same-body"];
|
||||
NSString *title = replacement && !sameTitle ? @"Next confirmation" : @"First confirmation";
|
||||
NSString *body = replacement && !sameBody ? @"Second request" : @"First request";
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:title
|
||||
message:body
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
__weak UIAlertController *weakAlert = alert;
|
||||
for (NSString *buttonTitle in @[@"Cancel", @"OK"]) {
|
||||
UIAlertActionStyle style = [buttonTitle isEqualToString:@"Cancel"]
|
||||
? UIAlertActionStyleCancel : UIAlertActionStyleDefault;
|
||||
[alert addAction:[UIAlertAction actionWithTitle:buttonTitle style:style handler:^(UIAlertAction *action) {
|
||||
(void)action;
|
||||
if (replacement) {
|
||||
self.replacementAlertActions += 1;
|
||||
} else {
|
||||
self.firstAlertActions += 1;
|
||||
}
|
||||
[self updateAlertActionStatus];
|
||||
if (!replacement) {
|
||||
[weakAlert dismissViewControllerAnimated:NO completion:^{
|
||||
[self presentAlertFixtureReplacement:YES];
|
||||
}];
|
||||
}
|
||||
}]];
|
||||
}
|
||||
[self presentViewController:alert animated:NO completion:nil];
|
||||
}
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
[super viewDidAppear:animated];
|
||||
if (!self.alertFixtureStarted &&
|
||||
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-replacement-regression"]) {
|
||||
self.alertFixtureStarted = YES;
|
||||
[self presentAlertFixtureReplacement:NO];
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
- (void)agentDeviceTextEntryDidChange:(UITextField *)textField {
|
||||
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-disappear-after-input"] &&
|
||||
textField.text.length > 0) {
|
||||
@@ -88,6 +140,12 @@ int main(int argc, const char *argv[]) {
|
||||
|
||||
// Keep the fixture behind a launch argument so normal runner snapshots remain unchanged.
|
||||
#if TARGET_OS_IOS
|
||||
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-replacement-regression"]) {
|
||||
self.alertActionStatus = label;
|
||||
label.accessibilityIdentifier = @"agent-device-alert-actions";
|
||||
[self updateAlertActionStatus];
|
||||
}
|
||||
|
||||
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-regression"]) {
|
||||
UITextField *textField = [[UITextField alloc] init];
|
||||
textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input";
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
struct RunnerAlertPresentation: Equatable {
|
||||
let title: String
|
||||
let content: [String]
|
||||
let buttons: [String]
|
||||
}
|
||||
|
||||
enum RunnerAlertObservation {
|
||||
case visible(RunnerAlertPresentation)
|
||||
case absent
|
||||
case unavailable
|
||||
case deadlineExceeded
|
||||
}
|
||||
|
||||
enum RunnerAlertVerification: Equatable {
|
||||
case disappeared
|
||||
case presentationChanged
|
||||
case stillVisible
|
||||
case unconfirmed
|
||||
case timedOut
|
||||
|
||||
static func verify(
|
||||
original: RunnerAlertPresentation,
|
||||
observation: RunnerAlertObservation
|
||||
) -> RunnerAlertVerification {
|
||||
switch observation {
|
||||
case .visible(let current):
|
||||
return original == current ? .stillVisible : .presentationChanged
|
||||
case .absent:
|
||||
return .disappeared
|
||||
case .unavailable:
|
||||
return .unconfirmed
|
||||
case .deadlineExceeded:
|
||||
return .timedOut
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,50 +55,34 @@ extension RunnerTests {
|
||||
guard let button = chooseAlertButton(alert.buttons, action: action) else {
|
||||
return Response(ok: false, error: ErrorPayload(message: "alert \(action) button not found"))
|
||||
}
|
||||
let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons)
|
||||
let actionButtonLabel = button.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let actionButtonFrame = button.frame
|
||||
guard Date() < deadline else {
|
||||
return alertVerificationResponse(.timedOut, action: action, activated: false)
|
||||
}
|
||||
guard let original = captureAlertPresentation(alert.root) else {
|
||||
return alertVerificationResponse(.unconfirmed, action: action, activated: false)
|
||||
}
|
||||
let observesApplicationRoot = alert.root.elementType == .application
|
||||
guard Date() < deadline else {
|
||||
return alertVerificationResponse(.timedOut, action: action, activated: false)
|
||||
}
|
||||
let outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)")
|
||||
if let response = unsupportedResponse(for: outcome) {
|
||||
return response
|
||||
}
|
||||
sleepFor(0.2)
|
||||
if alertStillVisible(
|
||||
in: alert.ownerApp,
|
||||
source: alert.source,
|
||||
previousTitle: previousTitle,
|
||||
actionButtonLabel: actionButtonLabel,
|
||||
deadline: deadline
|
||||
) {
|
||||
if !actionButtonFrame.isNull && !actionButtonFrame.isEmpty {
|
||||
let coordinateOutcome = tapAt(
|
||||
app: alert.ownerApp,
|
||||
x: actionButtonFrame.midX,
|
||||
y: actionButtonFrame.midY
|
||||
)
|
||||
if let response = unsupportedResponse(for: coordinateOutcome) {
|
||||
return response
|
||||
}
|
||||
sleepFor(0.2)
|
||||
}
|
||||
}
|
||||
if alertStillVisible(
|
||||
in: alert.ownerApp,
|
||||
source: alert.source,
|
||||
previousTitle: previousTitle,
|
||||
actionButtonLabel: actionButtonLabel,
|
||||
deadline: deadline
|
||||
) {
|
||||
return Response(
|
||||
ok: false,
|
||||
error: ErrorPayload(
|
||||
code: "INTERACTION_FAILED",
|
||||
message: "alert \(action) did not dismiss the visible alert",
|
||||
hint: "The alert button was found but the system still reports the alert after tapping it."
|
||||
while true {
|
||||
sleepFor(min(0.2, max(0, deadline.timeIntervalSinceNow)))
|
||||
let verification = RunnerAlertVerification.verify(
|
||||
original: original,
|
||||
observation: observeAlert(
|
||||
in: alert.ownerApp,
|
||||
source: alert.source,
|
||||
observesApplicationRoot: observesApplicationRoot,
|
||||
deadline: deadline
|
||||
)
|
||||
)
|
||||
if verification == .stillVisible { continue }
|
||||
return alertVerificationResponse(verification, action: action, activated: true)
|
||||
}
|
||||
return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
|
||||
}
|
||||
|
||||
return Response(
|
||||
@@ -135,57 +119,6 @@ extension RunnerTests {
|
||||
return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons, source: source)
|
||||
}
|
||||
|
||||
private func alertStillVisible(
|
||||
in ownerApp: XCUIApplication,
|
||||
source: RunnerAlertSource,
|
||||
previousTitle: String,
|
||||
actionButtonLabel: String,
|
||||
deadline: Date
|
||||
) -> Bool {
|
||||
guard Date() < deadline,
|
||||
let current = resolveAlert(source: source, app: ownerApp, deadline: deadline)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
let currentTitle = preferredAlertTitle(current.root, buttons: current.buttons)
|
||||
if previousTitle == currentTitle {
|
||||
return true
|
||||
}
|
||||
return current.buttons.contains { button in
|
||||
button.label.trimmingCharacters(in: .whitespacesAndNewlines) == actionButtonLabel
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveAlert(
|
||||
source: RunnerAlertSource,
|
||||
app: XCUIApplication,
|
||||
deadline: Date
|
||||
) -> RunnerAlert? {
|
||||
switch source {
|
||||
case .blockingSystemModal:
|
||||
#if os(macOS)
|
||||
return nil
|
||||
#else
|
||||
guard case .resolved(let modal) = resolveBlockingSystemModal(deadline: deadline) else {
|
||||
return nil
|
||||
}
|
||||
return runnerAlert(modal)
|
||||
#endif
|
||||
case .appAlert:
|
||||
guard let alert = firstExistingElement(
|
||||
in: safeElementsQuery { app.alerts.allElementsBoundByIndex }
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return runnerAlert(root: alert, ownerApp: app, source: .appAlert)
|
||||
case .dismissPopup:
|
||||
guard let popup = firstDismissPopupWindow(in: app) else {
|
||||
return nil
|
||||
}
|
||||
return runnerAlert(root: popup, ownerApp: app, source: .dismissPopup)
|
||||
}
|
||||
}
|
||||
|
||||
private func firstExistingElement(in elements: [XCUIElement]) -> XCUIElement? {
|
||||
elements.first { isVisibleElement($0) }
|
||||
}
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import XCTest
|
||||
|
||||
extension RunnerTests {
|
||||
func captureAlertPresentation(_ element: XCUIElement) -> RunnerAlertPresentation? {
|
||||
var presentation: RunnerAlertPresentation?
|
||||
_ = RunnerObjCExceptionCatcher.catchException {
|
||||
guard let snapshot = try? element.snapshot() else { return }
|
||||
presentation = self.alertPresentation(snapshot)
|
||||
}
|
||||
return presentation
|
||||
}
|
||||
|
||||
func observeAlert(
|
||||
in ownerApp: XCUIApplication,
|
||||
source: RunnerAlertSource,
|
||||
observesApplicationRoot: Bool,
|
||||
deadline: Date
|
||||
) -> RunnerAlertObservation {
|
||||
guard Date() < deadline else { return .deadlineExceeded }
|
||||
var observation = RunnerAlertObservation.unavailable
|
||||
_ = RunnerObjCExceptionCatcher.catchException {
|
||||
switch ownerApp.state {
|
||||
case .notRunning:
|
||||
observation = .absent
|
||||
return
|
||||
case .runningForeground, .runningBackground:
|
||||
break
|
||||
#if !os(macOS)
|
||||
case .runningBackgroundSuspended:
|
||||
break
|
||||
#endif
|
||||
case .unknown:
|
||||
return
|
||||
@unknown default:
|
||||
return
|
||||
}
|
||||
guard Date() < deadline, let snapshot = try? ownerApp.snapshot(),
|
||||
!snapshot.children.isEmpty, !snapshot.frame.isNull, !snapshot.frame.isEmpty else { return }
|
||||
if observesApplicationRoot {
|
||||
observation = .visible(self.alertPresentation(snapshot))
|
||||
return
|
||||
}
|
||||
let candidates = self.alertSnapshots(in: snapshot, source: source, viewport: snapshot.frame)
|
||||
guard candidates.count <= 1 else { return }
|
||||
observation = candidates.first.map { .visible(self.alertPresentation($0)) } ?? .absent
|
||||
}
|
||||
return Date() < deadline ? observation : .deadlineExceeded
|
||||
}
|
||||
|
||||
private func alertSnapshots(
|
||||
in snapshot: XCUIElementSnapshot,
|
||||
source: RunnerAlertSource,
|
||||
viewport: CGRect
|
||||
) -> [XCUIElementSnapshot] {
|
||||
let frame = snapshot.frame
|
||||
let visible = !frame.isNull && !frame.isEmpty && viewport.contains(CGPoint(x: frame.midX, y: frame.midY))
|
||||
let matches: Bool
|
||||
switch source {
|
||||
case .blockingSystemModal:
|
||||
matches = snapshot.elementType == .alert || snapshot.elementType == .sheet
|
||||
case .appAlert:
|
||||
matches = snapshot.elementType == .alert
|
||||
case .dismissPopup:
|
||||
matches = snapshot.elementType == .window && containsDismissPopupMarker(snapshot)
|
||||
}
|
||||
if matches && visible { return [snapshot] }
|
||||
return snapshot.children.flatMap { alertSnapshots(in: $0, source: source, viewport: viewport) }
|
||||
}
|
||||
|
||||
private func containsDismissPopupMarker(_ snapshot: XCUIElementSnapshot) -> Bool {
|
||||
[snapshot.label, snapshot.identifier].contains {
|
||||
$0.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare("dismiss popup") == .orderedSame
|
||||
} || snapshot.children.contains { containsDismissPopupMarker($0) }
|
||||
}
|
||||
|
||||
private func alertPresentation(_ snapshot: XCUIElementSnapshot) -> RunnerAlertPresentation {
|
||||
var content: [String] = []
|
||||
var buttons: [String] = []
|
||||
func collect(_ node: XCUIElementSnapshot) {
|
||||
let label = node.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if actionableTypes.contains(node.elementType) {
|
||||
buttons.append(label)
|
||||
} else if !label.isEmpty {
|
||||
content.append(label)
|
||||
}
|
||||
node.children.forEach(collect)
|
||||
}
|
||||
snapshot.children.forEach(collect)
|
||||
return RunnerAlertPresentation(
|
||||
title: snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
content: content,
|
||||
buttons: buttons
|
||||
)
|
||||
}
|
||||
|
||||
func alertVerificationResponse(
|
||||
_ verification: RunnerAlertVerification,
|
||||
action: String,
|
||||
activated: Bool
|
||||
) -> Response {
|
||||
let code: String
|
||||
let message: String
|
||||
switch verification {
|
||||
case .disappeared, .presentationChanged:
|
||||
return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
|
||||
case .timedOut:
|
||||
code = "ALERT_DEADLINE_EXCEEDED"
|
||||
message = "alert \(action) exhausted its deadline"
|
||||
case .stillVisible:
|
||||
code = "INTERACTION_FAILED"
|
||||
message = "alert \(action) still observes an unchanged alert presentation"
|
||||
case .unconfirmed:
|
||||
code = "ALERT_CONFIRMATION_UNAVAILABLE"
|
||||
message = "alert \(action) could not read the alert presentation"
|
||||
}
|
||||
return Response(ok: false, error: ErrorPayload(
|
||||
code: code,
|
||||
message: message,
|
||||
hint: activated
|
||||
? "The button was activated once. Inspect the current alert before deciding on another action."
|
||||
: "No alert button was activated. Inspect the current alert before deciding on an action."
|
||||
))
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import XCTest
|
||||
|
||||
extension RunnerTests {
|
||||
#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS)
|
||||
func testAlertAcceptDoesNotActivateAReplacementWithASharedButton() throws {
|
||||
try assertReplacementAlertUntouched(action: "accept", arguments: [], confirmed: true)
|
||||
}
|
||||
|
||||
func testAlertDismissDoesNotActivateAReplacementWithTheSameTitle() throws {
|
||||
try assertReplacementAlertUntouched(action: "dismiss", arguments: ["--agent-device-alert-same-title"], confirmed: true)
|
||||
}
|
||||
|
||||
func testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt() throws {
|
||||
try assertReplacementAlertUntouched(
|
||||
action: "accept",
|
||||
arguments: ["--agent-device-alert-same-title", "--agent-device-alert-same-body"],
|
||||
confirmed: false
|
||||
)
|
||||
}
|
||||
|
||||
func testAlertDeadlineBeforeActivationLeavesTheOriginalUntouched() throws {
|
||||
app.launchArguments = ["--agent-device-alert-replacement-regression"]
|
||||
app.launch()
|
||||
defer {
|
||||
invalidateCachedTarget(reason: "unit_test_cleanup")
|
||||
app.terminate()
|
||||
}
|
||||
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
|
||||
let alert = try XCTUnwrap(resolveAlert(app: app, deadline: Date().addingTimeInterval(10)))
|
||||
let response = handleAlert(alert, action: "accept", deadline: .distantPast)
|
||||
XCTAssertFalse(response.ok)
|
||||
XCTAssertEqual(response.error?.code, "ALERT_DEADLINE_EXCEEDED")
|
||||
XCTAssertTrue(app.alerts.firstMatch.exists)
|
||||
XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 0; replacement actions: 0")
|
||||
}
|
||||
|
||||
private func assertReplacementAlertUntouched(action: String, arguments: [String], confirmed: Bool) throws {
|
||||
app.launchArguments = ["--agent-device-alert-replacement-regression"] + arguments
|
||||
app.launch()
|
||||
defer {
|
||||
invalidateCachedTarget(reason: "unit_test_cleanup")
|
||||
app.terminate()
|
||||
}
|
||||
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
|
||||
let command = try runnerCommandFixture(
|
||||
#"{"command":"alert","commandId":"alert-replacement","action":"\#(action)","timeoutMs":10000}"#
|
||||
)
|
||||
let response = try executeOnMainPrepared(command: command, activeApp: app)
|
||||
XCTAssertEqual(response.ok, confirmed, String(describing: response.error))
|
||||
if !confirmed { XCTAssertEqual(response.error?.code, "ALERT_DEADLINE_EXCEEDED") }
|
||||
XCTAssertTrue(app.alerts.firstMatch.exists, "the replacement must remain visible")
|
||||
XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 1; replacement actions: 0")
|
||||
let current = try XCTUnwrap(resolveAlert(app: app, deadline: Date().addingTimeInterval(10)))
|
||||
let inspection = handleAlert(current, action: "get", deadline: Date().addingTimeInterval(10))
|
||||
XCTAssertTrue(inspection.ok)
|
||||
XCTAssertEqual(inspection.data?.items?.sorted(), ["Cancel", "OK"])
|
||||
XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 1; replacement actions: 0")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import XCTest
|
||||
|
||||
extension RunnerTests {
|
||||
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
|
||||
func testAlertVerificationDoesNotConfuseASharedButtonWithTheOriginalPresentation() {
|
||||
let original = RunnerAlertPresentation(title: "First permission", content: ["First body"], buttons: ["Allow"])
|
||||
let replacement = RunnerAlertPresentation(title: "Next permission", content: ["Next body"], buttons: ["Allow"])
|
||||
XCTAssertEqual(
|
||||
RunnerAlertVerification.verify(original: original, observation: .visible(replacement)),
|
||||
.presentationChanged
|
||||
)
|
||||
}
|
||||
|
||||
func testAlertVerificationComparesMoreThanTheTitle() {
|
||||
let original = RunnerAlertPresentation(title: "Confirmation", content: ["First body"], buttons: ["Cancel", "OK"])
|
||||
let changedBody = RunnerAlertPresentation(title: "Confirmation", content: ["Next body"], buttons: ["Cancel", "OK"])
|
||||
let changedButtons = RunnerAlertPresentation(title: "Confirmation", content: ["First body"], buttons: ["Not now", "OK"])
|
||||
for replacement in [changedBody, changedButtons] {
|
||||
XCTAssertEqual(
|
||||
RunnerAlertVerification.verify(original: original, observation: .visible(replacement)),
|
||||
.presentationChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testAlertVerificationKeepsIdenticalOrTitlelessPresentationsUnconfirmed() {
|
||||
for title in ["Confirmation", ""] {
|
||||
let original = RunnerAlertPresentation(title: title, content: [], buttons: ["OK"])
|
||||
XCTAssertEqual(
|
||||
RunnerAlertVerification.verify(original: original, observation: .visible(original)),
|
||||
.stillVisible
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testAlertVerificationRequiresProvenAbsence() {
|
||||
let original = RunnerAlertPresentation(title: "Confirmation", content: [], buttons: ["OK"])
|
||||
XCTAssertEqual(
|
||||
RunnerAlertVerification.verify(original: original, observation: .absent),
|
||||
.disappeared
|
||||
)
|
||||
XCTAssertEqual(
|
||||
RunnerAlertVerification.verify(original: original, observation: .unavailable),
|
||||
.unconfirmed
|
||||
)
|
||||
XCTAssertEqual(
|
||||
RunnerAlertVerification.verify(original: original, observation: .deadlineExceeded),
|
||||
.timedOut
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { AppError } from '@agent-device/kernel/errors';
|
||||
const ALERT_COMMAND_NAME = 'alert';
|
||||
|
||||
const alertCommandDescription =
|
||||
'Inspect, wait for, accept, or dismiss a platform alert. Use get before acting when the alert content matters; accept and dismiss change the active alert state.';
|
||||
'Inspect, wait for, accept, or dismiss a platform alert. Use get before acting when the alert content matters; accept and dismiss change the active alert state. Each iOS XCTest execution activates once, then only observes. Inspect again after an unconfirmed action; never assume a timeout means nothing happened.';
|
||||
|
||||
const alertCommandMetadata = defineFieldCommandMetadata(
|
||||
ALERT_COMMAND_NAME,
|
||||
|
||||
@@ -423,6 +423,8 @@ agent-device alert dismiss
|
||||
- `alert` without an action is equivalent to `alert get`.
|
||||
- `accept` and `dismiss` are sent once on every platform. A lost or unconfirmed response is reported as an error and never replayed; run `alert get` before acting again.
|
||||
- Use `alert get` for an immediate cheap check. Use `alert wait <short-ms>` only when a prompt may appear after async work.
|
||||
- Within an iOS XCTest execution, `accept` and `dismiss` activate the selected button once, then only observe until the alert disappears, its presentation changes, or the deadline expires. A shared button label never triggers a second coordinate tap. A changed presentation can be an updated original alert or a replacement; it does not prove a permission was granted. Verify the application outcome separately.
|
||||
- An unreadable or ambiguous post-action capture fails with `error.details.runnerErrorCode: ALERT_CONFIRMATION_UNAVAILABLE`; an expired runner deadline uses `ALERT_DEADLINE_EXCEEDED` (the outer command watchdog can also report a timeout). Neither proves absence or that no action occurred. Identical-looking alerts remain unconfirmed. Inspect the current alert before deciding whether to act again.
|
||||
- Android support is snapshot-derived. If `alert` reports no alert but a sheet is visible, treat it as app-owned UI and use `snapshot -i` plus `press` by visible label/ref.
|
||||
- If an iOS permission sheet is visible in `snapshot` or `screenshot` but `alert accept` reports no alert, fall back to a scoped `snapshot -i -s "<visible label>"` plus `press @ref`; not every simulator permission surface is exposed as a native XCTest alert.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user