open-standards/wai/wai-sink-swift/Tests/WaiSinkTests/PlayerTests.swift
Transaction Science aa607a60a5 wai-sink-swift: TestHost, an application the tests run inside on a phone or tablet
A device runs no tool-hosted test bundle, so the package's tests run on one hosted in
TestHost/: a minimal application (an empty window) and an Xcode project whose unit-test
target compiles the same test files against this package by its path, with the corpus in
its resources and local networking allowed for the loopback origin and relay. The package
itself is unchanged for consumers. The tests import WaiSink plainly (they use only its
public interface) and read the corpus from Bundle.module in the package, from their own
bundle in the host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:59:34 -04:00

168 lines
9.9 KiB
Swift

// The platform's player through the sink (native-sink §4.4, §5, §6): a presentation made here
// with the platform's own encoder (H.264, fragmented MP4, each fragment named by its SHA-256),
// served from an origin on loopback, played to its end through the relay. Every object the player
// was handed is one the sink presented; the bundle verifies; a fragment changed at the origin is
// withheld from the player; and a session nobody can see ends as hidden, its player stopped.
import AVFoundation
import CryptoKit
import UniformTypeIdentifiers
import XCTest
import WaiSink
/// A short presentation in fragmented MP4 from the platform's encoder: its initialization
/// section and its media segments with their durations.
final class Fixture: NSObject, AVAssetWriterDelegate, @unchecked Sendable {
var initSection = Data()
var segments: [(Data, Double)] = []
func assetWriter(_ writer: AVAssetWriter, didOutputSegmentData data: Data, segmentType: AVAssetSegmentType, segmentReport: AVAssetSegmentReport?) {
switch segmentType {
case .initialization: initSection = data
case .separable: segments.append((data, segmentReport?.trackReports.first.map { $0.duration.seconds } ?? 1))
@unknown default: break
}
}
/// `seconds` of 30 frames a second, 160 by 96, a segment a second.
static func make(seconds: Int) throws -> Fixture {
let f = Fixture()
let w = AVAssetWriter(contentType: UTType.mpeg4Movie)
w.outputFileTypeProfile = .mpeg4AppleHLS
w.preferredOutputSegmentInterval = CMTime(seconds: 1, preferredTimescale: 1)
w.initialSegmentStartTime = .zero
w.delegate = f
let input = AVAssetWriterInput(mediaType: .video, outputSettings: [
AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: 160, AVVideoHeightKey: 96,
AVVideoCompressionPropertiesKey: [AVVideoMaxKeyFrameIntervalKey: 30, AVVideoAverageBitRateKey: 200_000],
])
input.expectsMediaDataInRealTime = false
let adaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: input, sourcePixelBufferAttributes: [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, kCVPixelBufferWidthKey as String: 160, kCVPixelBufferHeightKey as String: 96,
])
w.add(input)
guard w.startWriting() else { throw w.error ?? NSError(domain: "fixture", code: 1) }
w.startSession(atSourceTime: .zero)
for i in 0..<(seconds * 30) {
while !input.isReadyForMoreMediaData { usleep(1000) }
var pb: CVPixelBuffer?
CVPixelBufferCreate(nil, 160, 96, kCVPixelFormatType_32BGRA, nil, &pb)
CVPixelBufferLockBaseAddress(pb!, [])
let base = CVPixelBufferGetBaseAddress(pb!)!.assumingMemoryBound(to: UInt8.self)
for p in 0..<(CVPixelBufferGetBytesPerRow(pb!) * 96) { base[p] = UInt8((p + i * 7) & 0xff) }
CVPixelBufferUnlockBaseAddress(pb!, [])
adaptor.append(pb!, withPresentationTime: CMTime(value: CMTimeValue(i), timescale: 30))
}
input.markAsFinished()
let done = DispatchSemaphore(value: 0)
w.finishWriting { done.signal() }
done.wait()
guard w.status == .completed else { throw w.error ?? NSError(domain: "fixture", code: 2) }
return f
}
static func name(_ d: Data) -> String { SHA256.hash(data: d).map { String(format: "%02x", $0) }.joined() + ".m4s" }
/// The on-demand media playlist over the segments, each named by its SHA-256.
var playlist: String {
var s = "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:\(Int(segments.map(\.1).max()!.rounded(.up)))\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n#EXT-X-MAP:URI=\"init.mp4\"\n"
for (d, t) in segments { s += String(format: "#EXTINF:%.3f,\n", t) + Fixture.name(d) + "\n" }
return s + "#EXT-X-ENDLIST\n"
}
}
@MainActor
final class PlayerTests: XCTestCase {
static let owner = String(repeating: "ab", count: 32)
private func serve(_ f: Fixture, tamper: Int? = nil) throws -> TestOrigin {
let o = TestOrigin()
try o.start()
o.put("/p/v.m3u8", Data(f.playlist.utf8), type: "application/vnd.apple.mpegurl")
o.put("/p/init.mp4", f.initSection, type: "video/mp4")
for (i, (d, _)) in f.segments.enumerated() {
var b = d
if i == tamper { b[b.count / 2] ^= 0x40 }
o.put("/p/" + Fixture.name(d), b, type: "video/iso.segment")
}
return o
}
private func play(_ origin: TestOrigin, attention: WaiAttention, config: WaiSink.Config, timeout: TimeInterval = 30, stopAfter: TimeInterval? = nil) throws -> (WaiPlayerSession, Data, WaiSink.Ended) {
let s = try WaiPlayerSession(url: origin.url("/p/v.m3u8"), config: config, attention: attention)
s.player.isMuted = true
// No layer is on screen in a test: a video output is where the player presents.
s.prepareItem = { $0.add(AVPlayerItemVideoOutput(pixelBufferAttributes: nil)) }
let done = expectation(description: "the session ends")
var result: (Data, WaiSink.Ended)?
s.onEnd = { b, e in result = (b, e); done.fulfill() }
s.start()
if let t = stopAfter {
DispatchQueue.main.asyncAfter(deadline: .now() + t) { MainActor.assumeIsolated { s.stop() } }
}
wait(for: [done], timeout: timeout)
guard let r = result else {
let item = s.player.currentItem
let errs = item?.errorLog()?.events.map { "\($0.errorStatusCode) \($0.errorDomain) \($0.errorComment ?? "")" } ?? []
XCTFail("""
the session did not end within \(timeout) s: item status \(item?.status.rawValue ?? -1), error \(String(describing: item?.error)), \
time control \(s.player.timeControlStatus.rawValue), reason \(String(describing: s.player.reasonForWaitingToPlay)), \
current \(item?.currentTime().seconds ?? -1); error log \(errs); relay \(s.relay.events.map { "\($0.status) \($0.url.suffix(24)) \($0.refused ?? "")" }); \
origin \(origin.requests)
""")
throw NSError(domain: "WaiSinkTests", code: 1)
}
return (s, r.0, r.1)
}
func testThePlayerPlaysThroughTheSinkToItsEnd() throws {
let f = try Fixture.make(seconds: 3)
XCTAssertGreaterThanOrEqual(f.segments.count, 3)
let o = try serve(f)
defer { o.stop() }
let (s, bundle, ended) = try play(o, attention: WaiFixedAttention(), config: .init(channel: "film-library", owner: Self.owner, contentAddressed: true))
XCTAssertEqual(ended, .ended)
print("WAI-SINK end to end: \(s.relay.events.count) requests through the relay, ended \(ended.rawValue)")
// Every segment the player was handed was presented by the sink, held to its name.
let segs = s.relay.events.filter { $0.url.hasSuffix(".m4s") }
XCTAssertEqual(Set(segs.map(\.url)).count, f.segments.count)
XCTAssertTrue(segs.allSatisfy { $0.status == 200 && $0.bound == "sha256" && $0.refused == nil })
let out = try WaiSink.verifyBundle(bundle, owner: hexData(Self.owner), channels: ["film-library"])
XCTAssertEqual(out["outcome"] as? String, "accept", "\(out["reason"] ?? "") at \(out["at"] ?? "")")
guard out["outcome"] as? String == "accept" else { return }
XCTAssertEqual(out["ended"] as? String, "ended")
XCTAssertGreaterThan(out["presented_ms"] as? Int ?? 0, 1500, "\(out)")
XCTAssertNotNil(out["startup_ms"] as? Int)
print("WAI-SINK end to end: bundle \(out["outcome"] ?? ""), ended \(out["ended"] ?? ""), presented_ms \(out["presented_ms"] ?? ""), startup_ms \(out["startup_ms"] ?? "")")
let energy = out["energy"] as? [[String: Any]] ?? []
XCTAssertEqual(energy.map { $0["class"] as? String }, ["unmetered", "unmetered"], "the platform's decoder is outside every counter (§7)")
}
func testAFragmentChangedAtTheOriginIsWithheld() throws {
let f = try Fixture.make(seconds: 3)
let o = try serve(f, tamper: 1)
defer { o.stop() }
// The player asks again for a segment it was refused, and waits; the viewer stops it.
let (s, bundle, ended) = try play(o, attention: WaiFixedAttention(), config: .init(channel: "film-library", owner: Self.owner, contentAddressed: true), stopAfter: 6)
XCTAssertTrue(ended == .user || ended == .error, "\(ended)")
let bad = s.relay.events.filter { $0.refused == "object_digest_mismatch" }
XCTAssertFalse(bad.isEmpty, "the changed fragment was refused")
XCTAssertTrue(bad.allSatisfy { $0.status == 502 }, "and answered 502, never handed to the player")
print("WAI-SINK tampered fragment: \(bad.count) requests refused object_digest_mismatch, statuses \(Set(bad.map(\.status)).sorted())")
let out = try WaiSink.verifyBundle(bundle, owner: hexData(Self.owner), channels: ["film-library"])
XCTAssertEqual(out["outcome"] as? String, "accept", "\(out["reason"] ?? "") at \(out["at"] ?? "")")
}
func testASessionNobodyCanSeeEndsAsHidden() throws {
let f = try Fixture.make(seconds: 6)
let o = try serve(f)
defer { o.stop() }
let a = WaiFixedAttention(visibleBasisPoints: 0)
let (s, bundle, ended) = try play(o, attention: a, config: .init(channel: "film-library", owner: Self.owner, contentAddressed: true, unseenGraceMs: 1000))
XCTAssertEqual(ended, .hidden)
XCTAssertNil(s.player.currentItem, "the player's item is removed, so it requests nothing more")
let out = try WaiSink.verifyBundle(bundle, owner: hexData(Self.owner), channels: ["film-library"])
XCTAssertEqual(out["ended"] as? String, "hidden", "\(out)")
XCTAssertEqual(out["viewable_ms"] as? Int, 0)
print("WAI-SINK hidden: ended \(out["ended"] ?? ""), presented_ms \(out["presented_ms"] ?? ""), viewable_ms \(out["viewable_ms"] ?? "")")
}
}