// 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"] ?? "")") } }