Skip to content

Commit 66fe0f3

Browse files
authored
Merge pull request #37 from NeptuneKit/feat/20260608-ios-media-playback-helpers
Add iOS media playback snapshot helpers
2 parents e6bfb76 + 431a69a commit 66fe0f3

16 files changed

Lines changed: 656 additions & 14 deletions

File tree

CLI/Tests/TritonKitCLITests/SchemaFactSourceTests.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,15 @@ struct SchemaFactSourceTests {
275275
#expect(connectedObserveIOS.nextAction?.args == ["current", "--platform", "ios", "--json"])
276276
#expect(connectedObserveIOS.evidence == ["surface-tree", "runtime-ax", "host-layout"])
277277

278+
let mediaPlayback = try #require(connected["media-playback"])
279+
#expect(mediaPlayback.supported)
280+
#expect(mediaPlayback.group == "observe")
281+
#expect(mediaPlayback.requiredBy.contains("assert"))
282+
#expect(mediaPlayback.requiredBy.contains("evidence"))
283+
#expect(mediaPlayback.nextAction?.command == "snapshot")
284+
#expect(mediaPlayback.nextAction?.args == ["--include", "media,ax,screenshot-metadata", "--json"])
285+
#expect(mediaPlayback.evidence == ["runtime-media", "runtime-ax", "screenshot-metadata"])
286+
278287
let webViewList = try #require(disconnected["webview-list"])
279288
#expect(webViewList.supported)
280289
#expect(webViewList.group == "webview")
@@ -536,7 +545,7 @@ struct SchemaFactSourceTests {
536545
let schemas = commandSchemaMap()
537546
let observeSchema = try #require(schemas["observe"])
538547
let nodeSchema = try #require(schemas["node"])
539-
#expect(observeSchema.providedCapabilities == ["observe", "observe-ios", "observe-android", "observe-harmony"])
548+
#expect(observeSchema.providedCapabilities == ["observe", "observe-ios", "media-playback", "observe-android", "observe-harmony"])
540549
#expect(nodeSchema.providedCapabilities == ["node", "node-resolve"])
541550

542551
let connected = connectedCapabilityMap()
@@ -556,6 +565,7 @@ struct SchemaFactSourceTests {
556565
)] = [
557566
("observe", "observe", ["action", "assert", "evidence"], ["surface-tree", "runtime-ax", "host-layout"], true, true, "observe", ["current", "--json"], "observe", ["current", "--json"]),
558567
("observe-ios", "observe", ["action", "assert", "evidence"], ["surface-tree", "runtime-ax", "host-layout"], true, false, "observe", ["current", "--platform", "ios", "--json"], "observe", ["current", "--platform", "ios", "--json"]),
568+
("media-playback", "observe", ["assert", "evidence", "observe"], ["runtime-media", "runtime-ax", "screenshot-metadata"], true, false, "snapshot", ["--include", "media,ax,screenshot-metadata", "--json"], "status", ["--json"]),
559569
("observe-android", "observe", ["action", "assert", "evidence"], ["surface-tree", "runtime-ax", "host-layout"], true, true, "observe", ["tree", "--platform", "android", "--device", "<selector>", "--json"], "observe", ["tree", "--platform", "android", "--device", "<selector>", "--json"]),
560570
("observe-harmony", "observe", ["action", "assert", "evidence"], ["surface-tree", "runtime-ax", "host-layout"], true, true, "observe", ["tree", "--platform", "harmony", "--device", "<selector>", "--json"], "observe", ["tree", "--platform", "harmony", "--device", "<selector>", "--json"]),
561571
("node", "observe", ["action", "assert", "evidence"], ["hierarchy-node", "surface-tree"], true, false, "node", ["--oid", "<oid>", "--json"], "status", ["--json"]),
@@ -1273,7 +1283,7 @@ struct SchemaFactSourceTests {
12731283

12741284
let runtimeReasonCapabilities = Set([
12751285
"runtime-manifest", "state-app", "state-scene", "state-route", "state-responder",
1276-
"snapshot", "focus", "set-text", "select-segment", "set-switch", "semantic-action", "ledger",
1286+
"snapshot", "media-playback", "focus", "set-text", "select-segment", "set-switch", "semantic-action", "ledger",
12771287
"observe-ios",
12781288
"inspect", "hierarchy", "nodes", "node", "attrs", "object",
12791289
"export-json", "export-archive", "geometry", "ax", "hit", "screenshot",
@@ -1529,7 +1539,7 @@ struct SchemaFactSourceTests {
15291539
"target": ["target"],
15301540
"runtime": ["runtime", "state", "snapshot", "focus", "set-text", "select-segment", "set-switch", "ledger", "schema", "status", "serve"],
15311541
"host": ["device", "sim", "app", "ax"],
1532-
"observe": ["observe", "list", "inspect", "hierarchy", "nodes", "node", "attrs", "object", "export", "geometry", "ax", "screenshot", "hit", "wait", "status", "serve"],
1542+
"observe": ["observe", "snapshot", "list", "inspect", "hierarchy", "nodes", "node", "attrs", "object", "export", "geometry", "ax", "screenshot", "hit", "wait", "status", "serve"],
15331543
"webview": ["webview"],
15341544
"route": ["route"],
15351545
"evidence": ["evidence", "status", "serve"],
@@ -3713,7 +3723,7 @@ struct SchemaFactSourceTests {
37133723
#expect(snapshot.nextCommands.contains("triton assert text-exists <text> --json"))
37143724
expectContract(snapshot, selector: "runtime.snapshot", fields: [
37153725
"ok", "capturedAt", "runtime", "targetConnectionState", "include", "app", "scene",
3716-
"route", "responder", "geometry", "ax", "screenshot", "artifacts", "skipped", "truncation",
3726+
"route", "responder", "media", "geometry", "ax", "screenshot", "artifacts", "skipped", "truncation",
37173727
])
37183728

37193729
#expect(observe.failureCodes.contains("target_not_found"))
@@ -4023,7 +4033,7 @@ private func capabilityEvidenceTaxonomy() -> Set<String> {
40234033
"host-layout", "host-targets.json", "hierarchy-node", "input.result",
40244034
"page-events", "provider-url", "route-assertion", "runtime-ax",
40254035
"runtime-ledger", "runtime-manifest", "runtime-provider",
4026-
"runtime-samples", "runtime-snapshot", "screenshot", "screenshot-metadata",
4036+
"runtime-media", "runtime-samples", "runtime-snapshot", "screenshot", "screenshot-metadata",
40274037
"smoke-summary", "snapshot-json", "status-json", "stdout-json",
40284038
"surface-tree", "target.resolution", "trace", "tritonplan",
40294039
"unsupported-envelope", "wait.result", "wait-samples", "webview-candidates",

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,16 @@ triton ledger --limit 50 --jsonl
563563

564564
`set-text --secure` redacts the text value in command output and runtime ledger while preserving inserted length. `ledger --jsonl` is the recent embedded request/action replay stream for debugging selector resolution, runtime errors, elapsed time, and redaction state.
565565

566+
For iOS video regression with `AVPlayer` or `AVPlayerViewController`, include the media snapshot section before asserting playback behavior:
567+
568+
```bash
569+
triton snapshot --include media,ax,screenshot-metadata --json
570+
triton screenshot --json
571+
triton evidence --output /tmp/video-regression.tritonevidence --json
572+
```
573+
574+
The `media` section reports visible AVPlayer-backed surfaces, player status/rate/time metadata when public APIs expose it, AX playback-control candidates, automation confidence, fallback advice, and evidence commands. System `AVPlayerViewController` controls are not guaranteed to expose stable actionable AX nodes in every route; when the snapshot is `surface-only`, add app-owned DEBUG overlay controls with stable accessibility identifiers for play, pause, seek, progress, elapsed time, and duration, then assert those controls with `wait`, `find`, `tap`, and `assert`.
575+
566576
When a pass/fail decision needs attachable evidence, export a bundle with a machine-readable manifest:
567577

568578
```bash
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import Foundation
2+
import TritonKitShared
3+
#if canImport(UIKit)
4+
import UIKit
5+
#endif
6+
#if canImport(AVFoundation)
7+
import AVFoundation
8+
#endif
9+
#if canImport(AVKit)
10+
import AVKit
11+
#endif
12+
13+
#if canImport(UIKit)
14+
@MainActor
15+
func currentMediaState(axNodes: [TKAXNode]?) -> TKRuntimeMediaStateResponse {
16+
var surfaces: [TKRuntimeMediaSurface] = []
17+
let windows = keyWindows()
18+
19+
for (windowIndex, window) in windows.enumerated() {
20+
surfaces.append(contentsOf: mediaLayerSurfaces(in: window, windowIndex: windowIndex))
21+
}
22+
23+
#if canImport(AVKit)
24+
let controllers = windows.compactMap(\.rootViewController).flatMap(mediaControllerTree)
25+
for controller in controllers {
26+
guard let playerController = controller as? AVPlayerViewController else { continue }
27+
let frame = playerController.view.window.map { tkRect(playerController.view.convert(playerController.view.bounds, to: $0)) }
28+
surfaces.append(mediaSurface(
29+
id: "media-controller-\(surfaces.count + 1)",
30+
kind: "avplayer-view-controller",
31+
className: NSStringFromClass(type(of: playerController)),
32+
frame: frame,
33+
visible: isAXVisible(playerController.view),
34+
player: playerController.player,
35+
controllerClassName: NSStringFromClass(type(of: playerController))
36+
))
37+
}
38+
#endif
39+
40+
let controls = TKRuntimeMediaControlCandidates(from: axNodes ?? [])
41+
let warnings = surfaces.isEmpty
42+
? ["No AVPlayer-backed media surface was discovered from public runtime APIs"]
43+
: []
44+
45+
return TKRuntimeMediaStateResponse(
46+
capturedAt: currentStateTimestamp(),
47+
surfaces: deduplicateMediaSurfaces(surfaces),
48+
controls: controls,
49+
warnings: warnings
50+
)
51+
}
52+
53+
@MainActor
54+
private func mediaLayerSurfaces(in window: UIWindow, windowIndex: Int) -> [TKRuntimeMediaSurface] {
55+
var surfaces: [TKRuntimeMediaSurface] = []
56+
57+
func walk(view: UIView) {
58+
surfaces.append(contentsOf: mediaLayerSurfaces(
59+
layer: view.layer,
60+
window: window,
61+
prefix: "window-\(windowIndex)-view-\(oid(for: view) ?? 0)",
62+
controllerClassName: nearestViewControllerClassName(from: view)
63+
))
64+
for subview in view.subviews {
65+
walk(view: subview)
66+
}
67+
}
68+
69+
walk(view: window)
70+
return surfaces
71+
}
72+
73+
@MainActor
74+
private func mediaLayerSurfaces(
75+
layer: CALayer,
76+
window: UIWindow,
77+
prefix: String,
78+
controllerClassName: String?
79+
) -> [TKRuntimeMediaSurface] {
80+
var surfaces: [TKRuntimeMediaSurface] = []
81+
82+
#if canImport(AVFoundation)
83+
if let playerLayer = layer as? AVPlayerLayer {
84+
let frame = tkRect(window.layer.convert(playerLayer.bounds, from: playerLayer))
85+
surfaces.append(mediaSurface(
86+
id: "\(prefix)-layer-\(ObjectIdentifier(playerLayer).hashValue)",
87+
kind: "avplayer-layer",
88+
className: NSStringFromClass(type(of: playerLayer)),
89+
frame: frame,
90+
visible: !playerLayer.isHidden && playerLayer.opacity > 0 && playerLayer.bounds.width > 0 && playerLayer.bounds.height > 0,
91+
player: playerLayer.player,
92+
controllerClassName: controllerClassName
93+
))
94+
}
95+
#endif
96+
97+
for sublayer in layer.sublayers ?? [] {
98+
surfaces.append(contentsOf: mediaLayerSurfaces(
99+
layer: sublayer,
100+
window: window,
101+
prefix: prefix,
102+
controllerClassName: controllerClassName
103+
))
104+
}
105+
106+
return surfaces
107+
}
108+
109+
#if canImport(AVFoundation)
110+
private func mediaSurface(
111+
id: String,
112+
kind: String,
113+
className: String,
114+
frame: TKRect?,
115+
visible: Bool,
116+
player: AVPlayer?,
117+
controllerClassName: String?
118+
) -> TKRuntimeMediaSurface {
119+
let elapsed = player.map { seconds(from: $0.currentTime()) }
120+
let duration = player?.currentItem.map { seconds(from: $0.duration) }
121+
let boundedDuration = duration.flatMap { $0.isFinite && $0 > 0 ? $0 : nil }
122+
let progress = if let elapsed, let boundedDuration {
123+
min(max(elapsed / boundedDuration, 0), 1)
124+
} else {
125+
Optional<Double>.none
126+
}
127+
128+
return TKRuntimeMediaSurface(
129+
id: id,
130+
kind: kind,
131+
className: className,
132+
frame: frame,
133+
visible: visible,
134+
playerStatus: player?.currentItem.map { playerItemStatusName($0.status) },
135+
playbackState: playerPlaybackState(player),
136+
rate: player.map { Double($0.rate) },
137+
elapsedTimeSeconds: elapsed?.isFinite == true ? elapsed : nil,
138+
durationSeconds: boundedDuration,
139+
progress: progress,
140+
controllerClassName: controllerClassName
141+
)
142+
}
143+
144+
private func seconds(from time: CMTime) -> Double {
145+
guard time.isValid, !time.isIndefinite else { return .nan }
146+
return CMTimeGetSeconds(time)
147+
}
148+
149+
private func playerItemStatusName(_ status: AVPlayerItem.Status) -> String {
150+
switch status {
151+
case .unknown: return "unknown"
152+
case .readyToPlay: return "readyToPlay"
153+
case .failed: return "failed"
154+
@unknown default: return "unknown"
155+
}
156+
}
157+
158+
private func playerPlaybackState(_ player: AVPlayer?) -> String? {
159+
guard let player else { return nil }
160+
if player.rate > 0 {
161+
return "playing"
162+
}
163+
switch player.timeControlStatus {
164+
case .paused: return "paused"
165+
case .waitingToPlayAtSpecifiedRate: return "waiting"
166+
case .playing: return "playing"
167+
@unknown default: return "unknown"
168+
}
169+
}
170+
#else
171+
private func mediaSurface(
172+
id: String,
173+
kind: String,
174+
className: String,
175+
frame: TKRect?,
176+
visible: Bool,
177+
player: Any?,
178+
controllerClassName: String?
179+
) -> TKRuntimeMediaSurface {
180+
TKRuntimeMediaSurface(
181+
id: id,
182+
kind: kind,
183+
className: className,
184+
frame: frame,
185+
visible: visible,
186+
controllerClassName: controllerClassName
187+
)
188+
}
189+
#endif
190+
191+
@MainActor
192+
private func mediaControllerTree(_ root: UIViewController) -> [UIViewController] {
193+
var controllers = [root]
194+
controllers.append(contentsOf: root.children.flatMap(mediaControllerTree))
195+
if let presented = root.presentedViewController {
196+
controllers.append(contentsOf: mediaControllerTree(presented))
197+
}
198+
return controllers
199+
}
200+
201+
@MainActor
202+
private func nearestViewControllerClassName(from view: UIView) -> String? {
203+
var responder: UIResponder? = view
204+
while let current = responder {
205+
if let controller = current as? UIViewController {
206+
return NSStringFromClass(type(of: controller))
207+
}
208+
responder = current.next
209+
}
210+
return nil
211+
}
212+
213+
private func deduplicateMediaSurfaces(_ surfaces: [TKRuntimeMediaSurface]) -> [TKRuntimeMediaSurface] {
214+
var seen = Set<String>()
215+
return surfaces.filter { surface in
216+
let key = [
217+
surface.kind,
218+
surface.className,
219+
surface.controllerClassName ?? "",
220+
surface.frame.map { "\($0.x),\($0.y),\($0.width),\($0.height)" } ?? "",
221+
].joined(separator: "|")
222+
return seen.insert(key).inserted
223+
}
224+
}
225+
#else
226+
func currentMediaState(axNodes: [TKAXNode]?) -> TKRuntimeMediaStateResponse {
227+
TKRuntimeMediaStateResponse(
228+
capturedAt: currentStateTimestamp(),
229+
surfaces: [],
230+
controls: TKRuntimeMediaControlCandidates(from: axNodes ?? []),
231+
unsupported: [
232+
TKRuntimeUnsupportedState(field: "media", reason: "UIKit is not available on this platform"),
233+
]
234+
)
235+
}
236+
#endif

Sources/TritonKit/TKRuntimeStateSnapshot.swift

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,13 +209,16 @@ func currentRuntimeSnapshot(_ request: TKRuntimeSnapshotRequest) -> TKRuntimeSna
209209
}
210210

211211
let ax: [TKAXNode]?
212-
if includes("ax") || includes("accessibility") {
212+
let axForMedia: [TKAXNode]?
213+
let shouldBuildAX = includes("ax") || includes("accessibility") || includes("media")
214+
if shouldBuildAX {
213215
let maxNodes = max(1, request.maxAXNodes ?? 800)
214216
var context = AXBuildContext(maxNodes: maxNodes)
215217
let nodes = keyWindows().map { window in
216218
buildAXWindowNode(for: window, context: &context)
217219
}
218-
ax = nodes
220+
axForMedia = nodes
221+
ax = (includes("ax") || includes("accessibility")) ? nodes : nil
219222
if context.remaining == 0 {
220223
truncation = TKRuntimeSnapshotTruncation(
221224
truncated: true,
@@ -224,12 +227,24 @@ func currentRuntimeSnapshot(_ request: TKRuntimeSnapshotRequest) -> TKRuntimeSna
224227
returnedCount: maxNodes
225228
)
226229
}
227-
artifact("ax")
230+
if includes("ax") || includes("accessibility") {
231+
artifact("ax")
232+
}
228233
} else {
229234
ax = nil
235+
axForMedia = nil
230236
skipped.append(TKRuntimeSnapshotSkipped(name: "ax", reason: "not requested"))
231237
}
232238

239+
let media: TKRuntimeMediaStateResponse?
240+
if includes("media") {
241+
media = currentMediaState(axNodes: axForMedia)
242+
artifact("media")
243+
} else {
244+
media = nil
245+
skipped.append(TKRuntimeSnapshotSkipped(name: "media", reason: "not requested"))
246+
}
247+
233248
let screenshot: TKRuntimeScreenshotMetadata?
234249
if includes("screenshot-metadata") || includes("screenshot") {
235250
if let window = keyWindows().first {
@@ -255,6 +270,7 @@ func currentRuntimeSnapshot(_ request: TKRuntimeSnapshotRequest) -> TKRuntimeSna
255270
scene: scene,
256271
route: route,
257272
responder: responder,
273+
media: media,
258274
geometry: geometry,
259275
ax: ax,
260276
screenshot: screenshot,

0 commit comments

Comments
 (0)