Files
openclaw/apps/ios/Tests/ScreenRecordServiceTests.swift
NianJiu 067edbcb2d fix(ios): make Talk audio lifecycle-safe (#103072)
* fix(ios): cancel queued push-to-talk invokes

* fix(ios): make push-to-talk cancellation lifecycle-safe

* style(ios): use switch expression for PTT result

* fix(ios): bind audio capture to lifecycle owners

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* style(ios): format rebased lifecycle changes

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(ios): preserve routing hydration across reconnects

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(ios): cancel routing hydration on target switch

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* chore(ios): refresh native i18n inventory

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(i18n): preserve native localization IDs

* chore: leave changelog to release automation

* refactor(apple): split lifecycle and gateway helpers

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(ios): make lifecycle parsing nonisolated

* fix(ios): consume canonical Talk events

---------

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-10 15:03:40 +01:00

395 lines
14 KiB
Swift

import AVFoundation
import Foundation
import Testing
@testable import OpenClaw
private final class ScreenRecordServiceProbe: @unchecked Sendable {
private let lock = NSLock()
private let sampleBuffer: CMSampleBuffer?
private var captureHandler: ScreenRecordService.CaptureHandler?
private var startCompletion: ScreenRecordService.CaptureCompletion?
private var stopCompletion: ScreenRecordService.CaptureCompletion?
private var taskFinished = false
private(set) var startCount = 0
private(set) var stopCount = 0
init(sampleBuffer: CMSampleBuffer? = nil) {
self.sampleBuffer = sampleBuffer
}
func recordStart(
handler: ScreenRecordService.CaptureHandler? = nil,
completion: ScreenRecordService.CaptureCompletion? = nil)
{
self.lock.lock()
self.startCount += 1
self.captureHandler = handler
self.startCompletion = completion
self.lock.unlock()
}
func recordStop(completion: ScreenRecordService.CaptureCompletion? = nil) {
self.lock.lock()
self.stopCount += 1
self.stopCompletion = completion
self.lock.unlock()
}
func completeStart(error: Error? = nil) -> Bool {
self.lock.lock()
let completion = self.startCompletion
self.startCompletion = nil
self.lock.unlock()
completion?(error)
return completion != nil
}
func completeStop(error: Error? = nil) -> Bool {
self.lock.lock()
let completion = self.stopCompletion
self.stopCompletion = nil
self.lock.unlock()
completion?(error)
return completion != nil
}
func counts() -> (start: Int, stop: Int) {
self.lock.lock()
defer { self.lock.unlock() }
return (self.startCount, self.stopCount)
}
func markTaskFinished() {
self.lock.lock()
self.taskFinished = true
self.lock.unlock()
}
func hasTaskFinished() -> Bool {
self.lock.lock()
defer { self.lock.unlock() }
return self.taskFinished
}
func emitVideoSample() {
self.lock.lock()
let sampleBuffer = self.sampleBuffer
let captureHandler = self.captureHandler
self.lock.unlock()
if let sampleBuffer, let captureHandler {
captureHandler(sampleBuffer, .video, nil)
}
}
}
private func makeVideoSampleBuffer() throws -> CMSampleBuffer {
var pixelBuffer: CVPixelBuffer?
let pixelStatus = CVPixelBufferCreate(
kCFAllocatorDefault,
64,
64,
kCVPixelFormatType_32BGRA,
nil,
&pixelBuffer)
guard pixelStatus == kCVReturnSuccess, let pixelBuffer else {
throw ScreenRecordService.ScreenRecordError.captureFailed("Failed to create test pixel buffer")
}
var formatDescription: CMVideoFormatDescription?
let formatStatus = CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescriptionOut: &formatDescription)
guard formatStatus == noErr, let formatDescription else {
throw ScreenRecordService.ScreenRecordError.captureFailed("Failed to create test format")
}
var timing = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: 30),
presentationTimeStamp: .zero,
decodeTimeStamp: .invalid)
var sampleBuffer: CMSampleBuffer?
let sampleStatus = CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: formatDescription,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer)
guard sampleStatus == noErr, let sampleBuffer else {
throw ScreenRecordService.ScreenRecordError.captureFailed("Failed to create test sample")
}
return sampleBuffer
}
@Suite(.serialized) struct ScreenRecordServiceTests {
@Test func `clamp defaults and bounds`() {
#expect(ScreenRecordService._test_clampDurationMs(nil) == 10000)
#expect(ScreenRecordService._test_clampDurationMs(0) == 250)
#expect(ScreenRecordService._test_clampDurationMs(60001) == 60000)
#expect(ScreenRecordService._test_clampFps(nil) == 10)
#expect(ScreenRecordService._test_clampFps(0) == 1)
#expect(ScreenRecordService._test_clampFps(120) == 30)
#expect(ScreenRecordService._test_clampFps(.infinity) == 10)
}
@Test @MainActor func `record rejects invalid screen index`() async {
let recorder = ScreenRecordService()
do {
_ = try await recorder.record(
screenIndex: 1,
durationMs: 250,
fps: 5,
includeAudio: false,
outPath: nil)
Issue.record("Expected invalid screen index to throw")
} catch let error as ScreenRecordService.ScreenRecordError {
#expect(error.localizedDescription.contains("Invalid screen index") == true)
} catch {
Issue.record("Unexpected error type: \(error)")
}
}
@Test func `record stops capture when sleep is cancelled`() async {
let probe = ScreenRecordServiceProbe()
let started = AsyncStream<Void>.makeStream()
let recorder = ScreenRecordService(
startReplayKitCaptureAction: { _, _, completion in
probe.recordStart()
started.continuation.yield()
started.continuation.finish()
completion(nil)
},
stopReplayKitCaptureAction: { completion in
probe.recordStop()
completion(nil)
})
let recordingTask = Task {
try await recorder.record(
screenIndex: nil,
durationMs: 60000,
fps: 5,
includeAudio: false,
outPath: nil)
}
for await _ in started.stream {
break
}
recordingTask.cancel()
do {
_ = try await recordingTask.value
Issue.record("Expected cancellation to throw")
} catch is CancellationError {
// Expected; cleanup should stop ReplayKit before preserving cancellation.
} catch {
Issue.record("Unexpected error type: \(error)")
}
let counts = probe.counts()
#expect(counts.start == 1)
#expect(counts.stop == 1)
}
@Test func `cancelled pending start stops after late success`() async {
let probe = ScreenRecordServiceProbe()
let started = AsyncStream<Void>.makeStream()
let stopped = AsyncStream<Void>.makeStream()
let recorder = ScreenRecordService(
startReplayKitCaptureAction: { _, _, completion in
probe.recordStart(completion: completion)
started.continuation.yield()
started.continuation.finish()
},
stopReplayKitCaptureAction: { completion in
probe.recordStop(completion: completion)
stopped.continuation.yield()
stopped.continuation.finish()
})
let recordingTask = Task {
defer { probe.markTaskFinished() }
return try await recorder.record(
screenIndex: nil,
durationMs: 60000,
fps: 5,
includeAudio: true,
outPath: nil)
}
for await _ in started.stream {
break
}
recordingTask.cancel()
recordingTask.cancel()
#expect(probe.counts().stop == 0)
#expect(probe.completeStart())
for await _ in stopped.stream {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
#expect(!probe.hasTaskFinished())
#expect(probe.completeStop())
do {
_ = try await recordingTask.value
Issue.record("Expected cancellation to throw")
} catch is CancellationError {
// Startup ownership stays live until ReplayKit's one stop completes.
} catch {
Issue.record("Unexpected error type: \(error)")
}
let counts = probe.counts()
#expect(counts.start == 1)
#expect(counts.stop == 1)
}
@Test func `cancelled pending start failure needs no stop`() async {
let probe = ScreenRecordServiceProbe()
let started = AsyncStream<Void>.makeStream()
let recorder = ScreenRecordService(
startReplayKitCaptureAction: { _, _, completion in
probe.recordStart(completion: completion)
started.continuation.yield()
started.continuation.finish()
},
stopReplayKitCaptureAction: { completion in
probe.recordStop(completion: completion)
completion(nil)
})
let recordingTask = Task {
try await recorder.record(
screenIndex: nil,
durationMs: 60000,
fps: 5,
includeAudio: true,
outPath: nil)
}
for await _ in started.stream {
break
}
recordingTask.cancel()
#expect(probe.completeStart(error: CocoaError(.userCancelled)))
do {
_ = try await recordingTask.value
Issue.record("Expected cancellation to throw")
} catch is CancellationError {
// A stop callback cannot release ownership before startup resolves.
} catch {
Issue.record("Unexpected error type: \(error)")
}
let counts = probe.counts()
#expect(counts.start == 1)
#expect(counts.stop == 0)
}
@Test func `cancelled capture drains queued samples before deleting output`() async throws {
let probe = try ScreenRecordServiceProbe(sampleBuffer: makeVideoSampleBuffer())
let recordQueue = DispatchQueue(label: "ScreenRecordServiceTests.cancelledQueue")
let started = AsyncStream<Void>.makeStream()
let stopped = AsyncStream<Void>.makeStream()
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("screen-record-cancelled-\(UUID().uuidString).mp4")
var queueIsSuspended = true
recordQueue.suspend()
defer {
if queueIsSuspended {
recordQueue.resume()
}
try? FileManager.default.removeItem(at: outputURL)
}
let recorder = ScreenRecordService(
recordQueue: recordQueue,
startReplayKitCaptureAction: { _, handler, completion in
probe.recordStart(handler: handler)
completion(nil)
started.continuation.yield()
started.continuation.finish()
},
stopReplayKitCaptureAction: { completion in
probe.recordStop()
completion(nil)
stopped.continuation.yield()
stopped.continuation.finish()
})
let recordingTask = Task {
defer { probe.markTaskFinished() }
return try await recorder.record(
screenIndex: nil,
durationMs: 60000,
fps: 5,
includeAudio: false,
outPath: outputURL.path)
}
for await _ in started.stream {
break
}
probe.emitVideoSample()
recordingTask.cancel()
for await _ in stopped.stream {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
#expect(!probe.hasTaskFinished())
recordQueue.resume()
queueIsSuspended = false
do {
_ = try await recordingTask.value
Issue.record("Expected cancellation to throw")
} catch is CancellationError {
// Cleanup waits behind the queued writer work before returning.
} catch {
Issue.record("Unexpected error type: \(error)")
}
#expect(!FileManager.default.fileExists(atPath: outputURL.path))
#expect(probe.counts() == (start: 1, stop: 1))
}
@Test func `record drains final sample before finishing writer`() async throws {
let probe = try ScreenRecordServiceProbe(sampleBuffer: makeVideoSampleBuffer())
let recordQueue = DispatchQueue(label: "ScreenRecordServiceTests.recordQueue")
recordQueue.suspend()
let stopped = AsyncStream<Void>.makeStream()
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("screen-record-final-sample-\(UUID().uuidString).mp4")
defer { try? FileManager.default.removeItem(at: outputURL) }
let recorder = ScreenRecordService(
recordQueue: recordQueue,
startReplayKitCaptureAction: { _, handler, completion in
probe.recordStart(handler: handler)
completion(nil)
},
stopReplayKitCaptureAction: { completion in
probe.recordStop()
probe.emitVideoSample()
completion(nil)
stopped.continuation.yield()
stopped.continuation.finish()
})
let recordingTask = Task {
try await recorder.record(
screenIndex: nil,
durationMs: 250,
fps: 5,
includeAudio: false,
outPath: outputURL.path)
}
for await _ in stopped.stream {
break
}
recordQueue.resume()
let path = try await recordingTask.value
#expect(path == outputURL.path)
#expect(try (Data(contentsOf: outputURL)).isEmpty == false)
}
}