Files
openclaw/apps/ios/Tests/ScreenRecordServiceTests.swift
Peter Steinberger 6e792f08f8 fix(ios): serialize screen recording finalization (#101550)
* fix(ios): serialize screen recording finalization

* chore: keep release changelog out of PR
2026-07-07 11:13:46 +01:00

198 lines
6.8 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(set) var startCount = 0
private(set) var stopCount = 0
init(sampleBuffer: CMSampleBuffer? = nil) {
self.sampleBuffer = sampleBuffer
}
func recordStart(handler: ScreenRecordService.CaptureHandler? = nil) {
self.lock.lock()
self.startCount += 1
self.captureHandler = handler
self.lock.unlock()
}
func recordStop() {
self.lock.lock()
self.stopCount += 1
self.lock.unlock()
}
func counts() -> (start: Int, stop: Int) {
self.lock.lock()
defer { self.lock.unlock() }
return (self.startCount, self.stopCount)
}
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 `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)
}
}