import CoreVideo import Foundation import Testing @testable import Intercom /// Real browser against the real server: headless Chrome opens the phone page, connects and decodes. /// Opt-in: `MUSUBI_E2E=1 MUSUBI_E2E_INTERFACE=en0 swift test --filter BrowserEndToEnd`. @Suite(.enabled(if: ProcessInfo.processInfo.environment["MUSUBI_E2E"] == "1")) @MainActor struct BrowserEndToEndTests { static let chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" @Test(.timeLimit(.minutes(1))) func chromeReceivesTalkbackAndPreview() async throws { let interfaceName = ProcessInfo.processInfo.environment["MUSUBI_E2E_INTERFACE"] ?? "en0" let server = IntercomServer() await server.start(interfaceName: interfaceName) defer { server.stop() } let url = try #require(server.url, "Intercom did not start: \(server.error ?? "-")") let feeder = Feeder(input: server.input) feeder.start() defer { feeder.stop() } let profile = FileManager.default.temporaryDirectory.appendingPathComponent("musubi-chrome-\(UUID().uuidString)") let port = Int.random(in: 20_000...29_999) let chrome = Process() chrome.executableURL = URL(fileURLWithPath: Self.chrome) chrome.arguments = ["--headless=new", "--remote-debugging-port=\(port)", "--user-data-dir=\(profile.path)", "--autoplay-policy=no-user-gesture-required", "--no-first-run", "--no-default-browser-check", url.absoluteString] chrome.standardError = FileHandle.nullDevice try chrome.run() defer { chrome.terminate() try? FileManager.default.removeItem(at: profile) } let devtools = try await DevTools.connect(port: port) for _ in 0..<50 { if try await devtools.evaluate("!!document.querySelector('#join')") == "1" { break } try await Task.sleep(for: .milliseconds(200)) } _ = try await devtools.evaluate(""" document.querySelector('#name').value = 'Testgerät'; document.querySelector('#role').value = 'Kamera 1'; document.querySelector('#join').requestSubmit(); true """) var last = "" for _ in 0..<40 { try await Task.sleep(for: .milliseconds(500)) last = try await devtools.evaluate(""" (async () => { if (!pc) return 'none,0,0,' + location.href + ' ' + document.querySelector('#status')?.textContent; let video = 0, audio = 0; (await pc.getStats()).forEach(r => { if (r.type === 'inbound-rtp' && r.kind === 'video') video = r.framesDecoded || 0; if (r.type === 'inbound-rtp' && r.kind === 'audio') audio = r.packetsReceived || 0; }); return pc.connectionState + ',' + video + ',' + audio + ',' + document.querySelector('#status').textContent; })() """) let parts = last.split(separator: ",") if parts.count >= 3, parts[0] == "connected", Int(parts[1]) ?? 0 >= 20, Int(parts[2]) ?? 0 >= 50 { break } } let delay = try await devtools.evaluate(""" (async () => { let ms = -1; (await pc.getStats()).forEach(r => { if (r.type === 'inbound-rtp' && r.kind === 'video' && r.jitterBufferEmittedCount) ms = Math.round(1000 * r.jitterBufferDelay / r.jitterBufferEmittedCount); }); return ms; })() """) print("Browser: \(last) · video jitter buffer \(delay) ms") let parts = last.split(separator: ",").prefix(3) #expect(parts.first == "connected", "state: \(last)") #expect(Int(parts.dropFirst().first ?? "") ?? 0 >= 20, "video frames decoded: \(last)") #expect(Int(parts.last ?? "") ?? 0 >= 50, "audio packets received: \(last)") #expect(server.participants.first?.name == "Testgerät") #expect(server.participants.first?.connected == true) } } /// Pushes a moving test picture (25 fps) and a 440 Hz tone (20 ms blocks at 44.1 kHz to exercise resampling). private final class Feeder: @unchecked Sendable { private let input: IntercomInput private let queue = DispatchQueue(label: "e2e.feeder") private var timer: DispatchSourceTimer? private var tick = 0 private var phase = 0.0 init(input: IntercomInput) { self.input = input } func start() { let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now(), repeating: .milliseconds(20)) timer.setEventHandler { [weak self] in self?.step() } timer.resume() self.timer = timer } func stop() { timer?.cancel() } private func step() { tick += 1 var samples = [Float](repeating: 0, count: 882) for i in samples.indices { samples[i] = Float(sin(phase) * 0.2) phase += 2 * .pi * 440 / 44_100 } samples.withUnsafeBufferPointer { input.pushTalkback($0, sampleRate: 44_100) } guard tick.isMultiple(of: 2) else { return } var buffer: CVPixelBuffer? CVPixelBufferCreate(nil, 1280, 720, kCVPixelFormatType_32BGRA, [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &buffer) guard let buffer else { return } CVPixelBufferLockBaseAddress(buffer, []) let base = CVPixelBufferGetBaseAddress(buffer)!.assumingMemoryBound(to: UInt8.self) let stride = CVPixelBufferGetBytesPerRow(buffer) for y in 0..<720 { for x in 0..<1280 { let p = base + y * stride + x * 4 p[0] = UInt8((x + tick * 8) & 0xFF) p[1] = UInt8(y & 0xFF) p[2] = UInt8((tick * 4) & 0xFF) p[3] = 255 } } CVPixelBufferUnlockBaseAddress(buffer, []) input.pushVideo(buffer) } } /// Just enough of the Chrome DevTools Protocol: Runtime.evaluate on the first page. private final class DevTools: @unchecked Sendable { private let socket: URLSessionWebSocketTask private var nextID = 0 private init(socket: URLSessionWebSocketTask) { self.socket = socket } static func connect(port: Int) async throws -> DevTools { struct Target: Decodable { let type: String; let webSocketDebuggerUrl: String? } for _ in 0..<50 { if let (data, _) = try? await URLSession.shared.data(from: URL(string: "http://127.0.0.1:\(port)/json")!), let targets = try? JSONDecoder().decode([Target].self, from: data), let page = targets.first(where: { $0.type == "page" }), let ws = page.webSocketDebuggerUrl { let socket = URLSession.shared.webSocketTask(with: URL(string: ws)!) socket.resume() return DevTools(socket: socket) } try await Task.sleep(for: .milliseconds(200)) } throw URLError(.cannotConnectToHost) } func evaluate(_ expression: String) async throws -> String { nextID += 1 let id = nextID let message: [String: Any] = ["id": id, "method": "Runtime.evaluate", "params": ["expression": expression, "awaitPromise": true, "returnByValue": true]] let data = try JSONSerialization.data(withJSONObject: message) try await socket.send(.string(String(decoding: data, as: UTF8.self))) while true { guard case .string(let text) = try await socket.receive(), let json = try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any], json["id"] as? Int == id else { continue } let result = (json["result"] as? [String: Any])?["result"] as? [String: Any] return result?["value"].map { "\($0)" } ?? "" } } }