Repository navigation
chore: print alert corpus surfaces through the Swift owner #4186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eba41a8
3c61725
791e1ca
b98908d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import CoreGraphics | ||
| import Foundation | ||
|
|
||
| /// A rect as a `contracts/fixtures/` table spells it. JSON has no infinity, so the two unusable | ||
| /// boxes are named: `{"infinite": true}` is `CGRect.infinite`, and `{"nonFinite": true}` is a | ||
| /// box whose components are infinite. | ||
| package struct FixtureRect: Decodable { | ||
| private enum CodingKeys: String, CodingKey { | ||
| case x, y, width, height, infinite, nonFinite | ||
| } | ||
|
|
||
| package let cgRect: CGRect | ||
|
|
||
| package init(from decoder: Decoder) throws { | ||
| let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| if try container.decodeIfPresent(Bool.self, forKey: .infinite) == true { | ||
| self.cgRect = .infinite | ||
| } else if try container.decodeIfPresent(Bool.self, forKey: .nonFinite) == true { | ||
| self.cgRect = CGRect(x: -.infinity, y: -.infinity, width: .infinity, height: .infinity) | ||
| } else { | ||
| self.cgRect = CGRect( | ||
| x: try container.decode(Double.self, forKey: .x), | ||
| y: try container.decode(Double.self, forKey: .y), | ||
| width: try container.decode(Double.self, forKey: .width), | ||
| height: try container.decode(Double.self, forKey: .height) | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import Foundation | ||
| import AgentDeviceSnapshotPresentation | ||
|
|
||
| package struct RawAlertRecording: Decodable { | ||
| package struct Node: Decodable { | ||
| package let index: Int | ||
| package let parentIndex: Int? | ||
| package let element: String | ||
| package let type: String | ||
| package let identifier: String | ||
| package let label: String | ||
| package let rect: FixtureRect | ||
| } | ||
|
|
||
| let format: Int | ||
| package let plain: [Node] | ||
| package let honorModal: [Node] | ||
|
|
||
| package static func load(_ file: URL) throws -> RawAlertRecording { | ||
| func invalid(_ detail: String) -> NSError { | ||
| NSError(domain: "alert-recording", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(file.path): \(detail)"]) | ||
| } | ||
| let recording: Self | ||
| do { | ||
| recording = try JSONDecoder().decode(Self.self, from: Data(contentsOf: file)) | ||
| } catch let error as DecodingError { | ||
| let context: DecodingError.Context | ||
| switch error { | ||
| case let .keyNotFound(_, value), let .valueNotFound(_, value), | ||
| let .typeMismatch(_, value), let .dataCorrupted(value): context = value | ||
| @unknown default: throw error | ||
| } | ||
| let path = context.codingPath.map { $0.intValue.map { "node \($0)" } ?? $0.stringValue }.joined(separator: ".") | ||
| throw invalid("\(path): \(context.debugDescription)") | ||
| } | ||
| guard recording.format == 2 else { throw invalid("format \(recording.format) is not 2. Capture it again.") } | ||
| for (name, nodes) in [("plain", recording.plain), ("honorModal", recording.honorModal)] { | ||
| guard !nodes.isEmpty else { throw invalid("\(name): empty tree") } | ||
| for (position, node) in nodes.enumerated() { | ||
| guard node.index == position, node.parentIndex.map({ $0 >= 0 && $0 < position }) ?? (position == 0) else { | ||
| throw invalid("\(name) node \(position): invalid preorder index or parent") | ||
| } | ||
| } | ||
| } | ||
| return recording | ||
| } | ||
|
|
||
| package static func tree(_ nodes: [Node], at root: Int = 0) -> AlertSurfaceNode { | ||
| var children: [Int: [Int]] = [:] | ||
| for node in nodes { | ||
| if let parent = node.parentIndex { children[parent, default: []].append(node.index) } | ||
| } | ||
| func build(_ index: Int) -> AlertSurfaceNode { | ||
| let node = nodes[index] | ||
| return AlertSurfaceNode( | ||
| type: node.type, | ||
| identifier: node.identifier, | ||
| label: node.label, | ||
| frame: node.rect.cgRect, | ||
| children: (children[index] ?? []).map(build) | ||
| ) | ||
| } | ||
| return build(root) | ||
| } | ||
|
|
||
| package static func modalScopeNodes(_ nodes: [Node]) -> [ModalScopeNode] { | ||
| nodes.map { node in | ||
| ModalScopeNode( | ||
| element: node.element, | ||
| parentElement: node.parentIndex.map { nodes[$0].element }, | ||
| frame: node.rect.cgRect | ||
| ) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,50 @@ | ||||||||||||||||
| import AgentDeviceSnapshotPresentation | ||||||||||||||||
| import CoreGraphics | ||||||||||||||||
| import Foundation | ||||||||||||||||
|
|
||||||||||||||||
| import RecordingSupport | ||||||||||||||||
|
|
||||||||||||||||
| func printAlertSurface(_ arguments: [String]) throws { | ||||||||||||||||
| guard let file = arguments.first, arguments.count <= 2, | ||||||||||||||||
| arguments.count == 1 || ["honorModal", "plain"].contains(arguments[1]) | ||||||||||||||||
| else { | ||||||||||||||||
| throw NSError(domain: "print:alert-surface", code: 1, userInfo: [ | ||||||||||||||||
| NSLocalizedDescriptionKey: "Usage: pnpm print:alert-surface <recording.json> [honorModal|plain]" | ||||||||||||||||
| ]) | ||||||||||||||||
| } | ||||||||||||||||
| let recording = try RawAlertRecording.load(URL(fileURLWithPath: file)) | ||||||||||||||||
| let plain = RawAlertRecording.modalScopeNodes(recording.plain) | ||||||||||||||||
| let honorModal = RawAlertRecording.modalScopeNodes(recording.honorModal) | ||||||||||||||||
| let reading = ModalSurface.read( | ||||||||||||||||
| plain: plain, honorModal: honorModal, confirmingHonorModal: honorModal, truncated: false | ||||||||||||||||
| ) | ||||||||||||||||
| let answer: String | ||||||||||||||||
| var markers: [Int: String] = [:] | ||||||||||||||||
| switch reading { | ||||||||||||||||
| case let .modal(surfaces): | ||||||||||||||||
| answer = "modal" | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Minor · Reading line prints a module-qualified reflection description for
Suggested change
Suggested change Print the reason in the stable form the README documents: switch over DetailsThe node index is also gone. The TS printer returned Also raised hereAlso raised at this location by senior-developer: unknown(...) printout drops the node index / element id the README tells contributors to search for — The old TS printer emitted the failing node (and, for duplicatedChild, the raw AX element id) in the first line: persona: Reply Useful? Reply |
||||||||||||||||
| for surface in surfaces { | ||||||||||||||||
| markers[surface.host] = " [surface roots: \(surface.roots.map(String.init).joined(separator: ", "))]" | ||||||||||||||||
| } | ||||||||||||||||
| case .noExclusion: | ||||||||||||||||
| answer = "noExclusion" | ||||||||||||||||
| case let .unknown(.unstable(instability)): | ||||||||||||||||
| answer = "unknown(unstable(\(instability.rawValue)))" | ||||||||||||||||
| case let .unknown(reason): | ||||||||||||||||
| answer = "unknown(\(reason))" | ||||||||||||||||
|
thymikee marked this conversation as resolved.
Resolved
|
||||||||||||||||
| } | ||||||||||||||||
| print("ModalSurface reads (the rule's answer, not ground truth): \(answer)") | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nit · Tree choice reads
Suggested change
Detailspersona: Reply Useful? Reply |
||||||||||||||||
| let printPlain = arguments.count == 2 && arguments[1] == "plain" | ||||||||||||||||
| let nodes = printPlain ? recording.plain : recording.honorModal | ||||||||||||||||
| var depths: [Int] = [] | ||||||||||||||||
| let encoder = JSONEncoder() | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nit · Label is quoted by encoding a top-level String with JSONEncoder, which throws unless .fragmentsAllowed is set
Suggested change
Suggested change Quote the label without a JSON encoder, or set the option the encode needs: either use Detailspersona: Reply Useful? Reply |
||||||||||||||||
| encoder.outputFormatting = [.withoutEscapingSlashes] | ||||||||||||||||
| for node in nodes { | ||||||||||||||||
| let depth = node.parentIndex.map { depths[$0] + 1 } ?? 0 | ||||||||||||||||
| depths.append(depth) | ||||||||||||||||
| let label = String(decoding: try encoder.encode(node.label), as: UTF8.self) | ||||||||||||||||
| let index = String(repeating: " ", count: max(0, 4 - String(node.index).count)) + String(node.index) | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nit · Hand-rolled
Suggested change
Detailspersona: Reply Useful? Reply |
||||||||||||||||
| let marker = printPlain ? "" : markers[node.index] ?? "" | ||||||||||||||||
| print("\(index) \(String(repeating: " ", count: depth))\(node.type) \(label)\(marker)") | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -144,12 +144,21 @@ private func reason(for failure: SnapshotPresentationFailure) -> String { | |
| } | ||
| } | ||
|
|
||
| private let input = try JSONDecoder().decode( | ||
| BatchInput.self, | ||
| from: FileHandle.standardInput.readDataToEndOfFile() | ||
| ) | ||
| private let output = try JSONEncoder().encode( | ||
| BatchOutput(cases: input.cases.map(present)) | ||
| ) | ||
| FileHandle.standardOutput.write(output) | ||
| FileHandle.standardOutput.write(Data([0x0a])) | ||
| if CommandLine.arguments.dropFirst().first == "print-alert-surface" { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nit · The mode word appears as the dispatch comparison in Suggested change Export one Detailspersona: Reply Useful? Reply |
||
| do { | ||
| try printAlertSurface(Array(CommandLine.arguments.dropFirst(2))) | ||
| } catch { | ||
| FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) | ||
| exit(1) | ||
| } | ||
| } else { | ||
| let input = try JSONDecoder().decode( | ||
| BatchInput.self, | ||
| from: FileHandle.standardInput.readDataToEndOfFile() | ||
| ) | ||
| let output = try JSONEncoder().encode( | ||
| BatchOutput(cases: input.cases.map(present)) | ||
| ) | ||
| FileHandle.standardOutput.write(output) | ||
| FileHandle.standardOutput.write(Data([0x0a])) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import RecordingSupport | ||
| import AgentDeviceSnapshotPresentation | ||
| import CoreGraphics | ||
| import Foundation | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import RecordingSupport | ||
| import AgentDeviceSnapshotPresentation | ||
| import CoreGraphics | ||
| import Foundation | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import Foundation | ||
| import XCTest | ||
|
|
||
| final class PrintAlertSurfaceTests: XCTestCase { | ||
| func testPrintModeDispatchAndCorpusOutput() throws { | ||
| let package = URL(fileURLWithPath: #filePath) | ||
| .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() | ||
| let process = Process() | ||
| process.executableURL = package.appendingPathComponent(".build/debug/snapshot-presentation-conformance") | ||
| process.arguments = [ | ||
| "print-alert-surface", | ||
| package.appendingPathComponent("Tests/AlertSurfaceCorpus/iphone-duo-folded-safari-open-this-page-in-app.json").path, | ||
| ] | ||
| let output = Pipe() | ||
| process.standardOutput = output | ||
| try process.run() | ||
| let text = String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) | ||
| process.waitUntilExit() | ||
| XCTAssertEqual(process.terminationStatus, 0) | ||
| XCTAssertTrue(text.hasPrefix("ModalSurface reads (the rule's answer, not ground truth): modal\n")) | ||
| XCTAssertTrue(text.contains("[surface roots:")) | ||
| XCTAssertTrue(text.contains("Application")) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 Nit ·
"print:alert-surface"appears twice as an NSError domain and once as script-name textNSError(domain: "print:alert-surface", code: 1, ...)is written out on both error sites (the usage error and the format error). A pnpm script name is not an error domain, and copying the literal plus thecode: 1on each site means a third error added later will most likely drift. Lift oneusageError(_ message: String) -> NSErrorhelper (or a shared domain constant) and call it from both throws.Details
persona:
maintainabilityReply
@apex-sentinel-staging note ...to add context, or@apex-sentinel-staging pivot ...if this is wrong — I will re-check the cited code.Useful? Reply
@apex-sentinel-staging like, or@apex-sentinel-staging dislike <why>if it is not.