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.
173 lines
7.8 KiB
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)" } ?? ""
|
|
}
|
|
}
|
|
}
|