Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ extension RunnerTests {
DataPayload(nodes: $0, truncated: result.truncated)
},
qualityNotes: result.qualityNotes,
presentedSurface: SnapshotPresentedSurface.classify(acquisition, modalScope: nil)
presentedSurface: SnapshotPresentedSurface.classify(acquisition)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,27 @@ extension ModalSurface {
case .paired:
break
}
// An empty honor-modal read also reports `rootMismatch`. That is only a label: the result is
// unknown either way.
guard plain.first?.element == honorModal.first?.element else { return .unknown(.rootMismatch) }
guard let plainChildren = children(of: plain), let honorModalChildren = children(of: honorModal)
else { return .unknown(.unorderedRead) }

// Pairing matched elements under parent elements. This walk matches them under the parent node,
// which also tells apart the occurrences of an element that one read lists under two parents.
// An element listed more than once under one parent pairs in order when the honor-modal read keeps
// every occurrence, and counts as removed when it keeps none. When it keeps some but not all, the
// walk reports `duplicatedChild`.
var failure = ModalSurfaceUnknownReason.unstable(.reparentedElement)
func surfaces(honorModalNode: Int, plainNode: Int) -> [ModalSurface]? {
var removed = plainChildren[plainNode]
var kept: [(honorModal: Int, plain: Int)] = []
let listed = occurrences(of: plainChildren[plainNode], in: plain)
let keptListed = occurrences(of: honorModalChildren[honorModalNode], in: honorModal)
for child in honorModalChildren[honorModalNode] {
let element = honorModal[child].element
let listed = plainChildren[plainNode].filter { plain[$0].element == element }.count
let keptListed = honorModalChildren[honorModalNode].filter { honorModal[$0].element == element }.count
guard listed < 2 || keptListed >= listed else {
let listedCount = listed[element, default: 0]
guard listedCount < 2 || keptListed[element, default: 0] >= listedCount else {
failure = .duplicatedChild
return nil
}
Expand Down Expand Up @@ -109,6 +115,10 @@ extension ModalSurface {
return (isAction ? [node] : []) + node.children.flatMap { actions(in: $0, keyboardOwned: keyboardOwned) }
}

private static func occurrences(of positions: [Int], in nodes: [ModalScopeNode]) -> [String: Int] {
positions.reduce(into: [:]) { counts, position in counts[nodes[position].element, default: 0] += 1 }
}

/// Each node's children, in order. A read lists every node after its parent, so a node's parent
/// is its nearest listed ancestor with the parent element.
private static func children(of nodes: [ModalScopeNode]) -> [[Int]]? {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import Foundation

/// Reads a modal presentation out of a runner acquisition (#3565). A runner node carries no UIKit class
/// name, so its evidence is an element type that names a presentation, or a modal-scope pair whose
/// honor-modal read found a modal surface. Its answer is `presented` or `unknown`, never `absent`: a pair
/// that removed nothing is not evidence of absence. This is the class-blind column of the golden table
/// `contracts/fixtures/ios-presented-surface.json`, with `modalScopeExpected` for the pair; the
/// class-aware rule is `collectPresentedSurface` in
/// name, so its only evidence is an element type that names a presentation. Its answer is `presented` or
/// `unknown`, never `absent`. This is the class-blind column of the golden table
/// `contracts/fixtures/ios-presented-surface.json`; the class-aware rule is `collectPresentedSurface` in
/// `packages/capture-kit/src/ios-snapshot-engine/geometry-policy.ts`.
public enum SnapshotPresentedSurface {
/// Element types that name a presentation, keyed by the element-type numbering every iOS producer
Expand All @@ -17,10 +15,8 @@ public enum SnapshotPresentedSurface {
"Dialog": 8,
]

/// `modalScope` is nil when the capture read no modal-scope pair.
public static func classify(_ acquisition: SnapshotAcquisition, modalScope: ModalSurfaceReading?) -> PresentedSurface {
if case .modal = modalScope { return .presented }
return acquisition.nodes.contains { isPresentingElementType($0.type) } ? .presented : .unknown
public static func classify(_ acquisition: SnapshotAcquisition) -> PresentedSurface {
acquisition.nodes.contains { isPresentingElementType($0.type) } ? .presented : .unknown
}

private static func isPresentingElementType(_ elementType: String) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ final class ModalSurfaceTests: XCTestCase {
excluded.remove(at: try XCTUnwrap(excluded.firstIndex(of: action), "\(name): \(action.label)"))
}
XCTAssertEqual(excluded.map(\.label), expected.excludedActions, "\(name): excluded actions")
case .unknown(.duplicatedChild):
XCTAssertEqual(expected.reading, "unknown", name)
XCTAssertEqual(expected.reason, "duplicatedChild", name)
case .unknown:
XCTFail("\(name): \(reading)")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ struct RecordedAlertSurface: Decodable {
let buttons: [String]
}

/// `reading` is `modal` or `noExclusion`. A `modal` reading lists each surface by its host and root
/// positions in the honor-modal tree, with the labels of the actions it offers, and the labels of
/// the honor-modal tree's actions that no surface offers.
/// `reading` is `modal`, `noExclusion`, or `unknown` with the `reason` `duplicatedChild`. A
/// `modal` reading lists each surface by its host and root positions in the honor-modal tree, with
/// the labels of the actions it offers, and the labels of the honor-modal tree's actions that no
/// surface offers.
struct ModalScopeExpectation: Decodable {
struct Surface: Decodable {
let host: Int
Expand All @@ -35,6 +36,7 @@ struct RecordedAlertSurface: Decodable {
}

let reading: String
let reason: String?
let surfaces: [Surface]?
let excludedActions: [String]?
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import XCTest

/// Cross-language parity for the presented-surface rule (#3565): every case in
/// `contracts/fixtures/ios-presented-surface.json` runs through the runner entry point, which reads no
/// class names, so each must answer the table's `classBlindExpected`. A case that states
/// `modalScopeExpected` must give the answer for all three modal-scope readings: `modal`,
/// `noExclusion` and `unknown`.
/// class names, so each must answer the table's `classBlindExpected`.
final class SnapshotPresentedSurfaceTests: XCTestCase {
private struct Vocabulary: Decodable {
let presentingElementTypes: [PresentingElementType]
Expand All @@ -27,7 +25,6 @@ final class SnapshotPresentedSurfaceTests: XCTestCase {
let name: String
let truncated: Bool?
let classBlindExpected: String
let modalScopeExpected: [String: String]?
let nodes: [Node]
}

Expand All @@ -54,40 +51,19 @@ final class SnapshotPresentedSurfaceTests: XCTestCase {
XCTAssertFalse(cases.isEmpty)
for testCase in cases {
XCTAssertEqual(
SnapshotPresentedSurface.classify(acquisition(of: testCase), modalScope: nil),
SnapshotPresentedSurface.classify(acquisition(of: testCase)),
try XCTUnwrap(PresentedSurface(rawValue: testCase.classBlindExpected)),
testCase.name
)
}
}

func testEveryModalScopeReadingClassifiesAsTheGoldenTableStates() throws {
let readings: [String: ModalSurfaceReading] = [
"modal": .modal([ModalSurface(host: 1, roots: [6])]),
"noExclusion": .noExclusion,
"unknown": .unknown(.unstable(.screenChanged)),
]
let cases = try readFixture().cases.filter { $0.modalScopeExpected != nil }
XCTAssertFalse(cases.isEmpty)
for testCase in cases {
let expected = try XCTUnwrap(testCase.modalScopeExpected)
XCTAssertEqual(Set(expected.keys), Set(readings.keys), testCase.name)
for (key, reading) in readings {
XCTAssertEqual(
SnapshotPresentedSurface.classify(acquisition(of: testCase), modalScope: reading),
try XCTUnwrap(expected[key].flatMap(PresentedSurface.init(rawValue:))),
"\(testCase.name): \(key)"
)
}
}
}

func testACapDoesNotDefeatPositiveEvidence() throws {
let alert = try XCTUnwrap(
try readFixture().cases.first { $0.name == "the same alert through a producer whose name table stops short" }
)
XCTAssertEqual(SnapshotPresentedSurface.classify(acquisition(of: alert, truncated: false), modalScope: nil), .presented)
XCTAssertEqual(SnapshotPresentedSurface.classify(acquisition(of: alert, truncated: true), modalScope: nil), .presented)
XCTAssertEqual(SnapshotPresentedSurface.classify(acquisition(of: alert, truncated: false)), .presented)
XCTAssertEqual(SnapshotPresentedSurface.classify(acquisition(of: alert, truncated: true)), .presented)
}

private func acquisition(of testCase: FixtureCase, truncated: Bool? = nil) -> SnapshotAcquisition {
Expand Down
159 changes: 138 additions & 21 deletions apple/snapshot-presentation/Tests/AlertSurfaceCorpus/README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,144 @@
# Alert surface corpus

Each file records two accessibility trees of one screen from a real simulator, and what the alert
rules in `AlertSurface` must find on that screen. `AlertSurfaceTests` replays every file on the host
with `swift test --package-path apple/snapshot-presentation`, so a rule change that loses a real
prompt fails without a device.

## Add a fixture

1. Put the screen in the state you want, using an agent-device session: open the app, `fold`, and
raise the prompt. For prompt recipes, see `examples/test-app/README.md`.
2. Capture the app that hosts the prompt (`com.apple.springboard` for a system alert):
`pnpm capture:alert-surface --udid <udid> --bundle <bundle id> --name <device>-<pose>-<app>-<prompt>`.
The capture builds its runner with its own bundle id, so it runs beside the session's runner.
3. Add `expect` from the screenshot. For each source that must find the prompt, give its title and
buttons. A source you leave out must find nothing, and `{}` marks a screen with no prompt.
4. Add `expectModalScope`, which `ModalSurfaceTests` replays through `ModalSurface`. For a screen
whose honor-modal tree removes nothing, write `{"reading": "noExclusion"}`. Otherwise write
`"reading": "modal"` and give each surface's `host` and `roots` as `honorModal` indexes, with the
labels of the `actions` it offers. Then give the `excludedActions`: the labels of the
`honorModal` actions that no surface offers.

Don't edit the trees by hand. Capture again instead. The script keeps the existing `expect` and
`expectModalScope`, but a new capture can move the indexes in `expectModalScope`.
rules in `AlertSurface` and `ModalSurface` must find on that screen. `AlertSurfaceTests` and
`ModalSurfaceTests` replay every file on the host, so a rule change that loses a real prompt fails
without a device.

## Before you start

You need:

- Xcode with an iOS simulator runtime. The capture builds its runner with the Xcode that
`xcode-select -p` prints.
- A booted simulator. `xcrun simctl list devices booted` prints its UDID.
- An agent-device session on that simulator that shows the screen you want to record. For the
"Open in app" prompts, see `examples/test-app/README.md`.

## Add a recording

**Write `expect` and `expectModalScope` from the screenshot: which node is the prompt on the
screen, and which buttons it offers.** The replay tests `ModalSurface` against them, so they
must not come from `ModalSurface`. `pnpm print:alert-surface` prints the rule's reading and
marks nodes. Use it only to find node indexes. Never copy its reading, a host, or a root without
checking each root and action against the screenshot.

1. Put the screen in the state you want with the session: open the app, `fold` for a folded pose,
and raise the prompt.
2. Record the app that hosts the prompt. For a system alert, that is `com.apple.springboard`:

```bash
pnpm capture:alert-surface --udid <udid> --bundle com.apple.springboard --name iphone-duo-folded-springboard-open-in-app
```

Name the recording `<device>-<pose>-<app>-<prompt>`. The capture builds a runner with its own
bundle id, so it runs beside the session's runner. If the screen changes between its reads, the
capture fails. Wait until the prompt stops animating, then run it again.

3. Print the honor-modal tree, so you can find the prompt's nodes:

```bash
pnpm print:alert-surface apple/snapshot-presentation/Tests/AlertSurfaceCorpus/iphone-duo-folded-springboard-open-in-app.json
```

Each line after the first shows a node's index, type, and label. `[kept 1 of 3 children]`
marks a node where the honor-modal tree removed children. The first line is what
`ModalSurface` reads, labeled as the rule's answer, not ground truth. It is `modal`,
`noExclusion`, or `unknown(<reason>)` with the node where the read failed, and nodes are
marked only when it is `modal`. To print the whole tree, add `plain` after the path. If the
recording does not load in the replay, the command stops with one line that names the problem.

4. Add `expect` from the screenshot. For each source that must find the prompt, give its title and
buttons. The sources are `blockingSystemModal`, `appAlert`, `safariDialog`, and `dismissPopup`.
A source you leave out must find nothing. Write `{}` for a screen with no prompt.
5. Add `expectModalScope` from the screenshot:
- If the screen shows no prompt, write `{"reading": "noExclusion"}`.
- If the screen shows a prompt, write `"reading": "modal"` and one surface for each prompt.
Find the prompt in the printed tree by its type and labels. Its `roots` are the kept nodes
that hold the prompt, and its `host` is their parent: the node where the honor-modal tree
removed the rest of the screen. `actions` are the labels of the prompt's controls, in the
printed order. Empty labels count. Add `excludedActions`: the labels of the honor-modal
tree's controls that the prompt does not offer, such as a toolbar that stays visible.
- Then compare the rule's reading on the first line with what you wrote. If they differ, look
at the screenshot again. If the screenshot supports what you wrote, the rule is wrong for this
screen. Don't add the recording. Report the screen in an issue, and attach the recording.
- If the first line reads `unknown(duplicatedChild)`, a node lists one element more than once
and the honor-modal tree kept only some of the copies, so the rule can't tell which copy
lost children. The line shows the element as its raw AX `element` id, such as
`99943:4829729792.1219`. Search the recording for that id to find the copies. Write
`{"reading": "unknown", "reason": "duplicatedChild"}` only after you confirm those copies in
the recording.
- If the first line reads `unknown` with any other reason, the replay rejects the recording.
Capture the screen again. If the line stays, report it in an issue.
6. Run the replay:

```bash
swift test --package-path apple/snapshot-presentation
```

Don't edit the trees by hand. Capture again instead. A recapture keeps `expect` and
`expectModalScope`, but it can move the indexes in `expectModalScope`. After a recapture, print the
recording again and review each index.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved

## Example: the SpringBoard "Open in app" alert

`iphone-duo-folded-springboard-open-in-app.json` records the alert that
`xcrun simctl openurl <udid> "agent-device-test-app://x"` raises on a folded iPhone Duo. The start
of its printout:

```text
ModalSurface reads (the rule's answer, not ground truth): modal
0 Application "" [kept 1 of 40 children]
1 Window "" [kept 1 of 3 children]
2 Other "" [kept 1 of 3 children]
3 Alert "Open in “Agent Device Tester”?"
4 Other ""
5 Other ""
6 Other ""
7 Other ""
8 ScrollView ""
9 Other ""
10 StaticText "Open in “Agent Device Tester”?"
11 Other ""
12 Other "Vertical scroll bar, 2 pages"
13 Other ""
14 Other "Horizontal scroll bar, 1 page"
15 Other ""
16 Other "Vertical scroll bar, 2 pages"
17 Other ""
18 Other "Horizontal scroll bar, 1 page"
19 Other ""
20 ScrollView ""
21 Other ""
22 Other ""
23 Button "Cancel"
24 Button "Open"
...
```

The screenshot shows the alert `Open in “Agent Device Tester”?` with Cancel and Open. Both the
system-modal source and the app-alert source find it, so `expect` names both:

```json
"expect": {
"blockingSystemModal": { "title": "Open in “Agent Device Tester”?", "buttons": ["Cancel", "Open"] },
"appAlert": { "title": "Open in “Agent Device Tester”?", "buttons": ["Cancel", "Open"] }
}
```

In the tree, node 3 is that alert: its type is `Alert` and its label is the title. Its buttons are
nodes 23 and 24, Cancel and Open, as on the screen. Node 2 holds the alert, and the honor-modal
tree removed node 2's other children, so `host` is 2 and `roots` is `[3]`. The screen shows no
other controls, so `excludedActions` is empty. The rule's reading on the first line is `modal`,
which agrees:

```json
"expectModalScope": {
"reading": "modal",
"surfaces": [{ "host": 2, "roots": [3], "actions": ["Cancel", "Open"] }],
"excludedActions": []
}
```

## Format

Expand Down
Loading
Loading