You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Musubi/Packages/MusubiKit/Tests/IntercomTests/BrowserEndToEndTests.swift

173 lines
7.8 KiB

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

Powered by TurnKey Linux.