diff --git a/Packages/MusubiKit/Tests/IntercomTests/BrowserEndToEndTests.swift b/Packages/MusubiKit/Tests/IntercomTests/BrowserEndToEndTests.swift new file mode 100644 index 0000000..789e27f --- /dev/null +++ b/Packages/MusubiKit/Tests/IntercomTests/BrowserEndToEndTests.swift @@ -0,0 +1,172 @@ +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)" } ?? "" + } + } +}