diff --git a/CHANGELOG.md b/CHANGELOG.md index 032adf48..8e40801e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- E2E coverage for the capture surfaces on both platforms: new `ScreenshotTests` (iOS) and `AndroidScreenshotTests` / `AndroidRecordVideoTests` suites — PNG-magic and AVAsset-loadable-MP4 assertions, top-level platform routing, `--output -` stdout mode, and a forced-segment-restart recording case — registered in the respective E2E runners. New unit suites pin `VideoOutputFile` path resolution, the `VideoFrameUtilities` frame processing (including the pass-through fast path), the shared flag validators, and the process-control primitives. - Android support for `stream-video`, and a top-level cross-platform `stream-video` verb (#78). `sim-use android stream-video --format h264` streams native `adb screenrecord` H.264 Annex B passthrough to stdout (variable frame rate, survives screenrecord's per-invocation time limit by restarting mid-stream — each new segment re-emits SPS/PPS, which ffplay/ffmpeg accept); `mjpeg` / `raw` / `ffmpeg` provide a screencap-based JPEG loop (~7–8 FPS ceiling) with byte-identical framing to the iOS formats. The new top-level `sim-use stream-video` routes by UDID shape: `bgra` stays iOS-only, `h264` is Android-only for now, and the platform-mismatch cases fail with a pointer to the right alternative. Streaming now also treats consumer hangup (ffplay quit, `head -c` done) as an orderly end-of-stream on Android instead of dying on SIGPIPE. `SIM_USE_SCREENRECORD_TIME_LIMIT=` (debug) forces short screenrecord segments so the restart path can be exercised on API ≥ 34 devices; it applies to Android `record-video` and `stream-video` alike. `--json` is rejected on every `stream-video` surface — stdout carries the raw video bytes and the envelope would corrupt the stream (the run summary goes to stderr). The Android segment loops only restart screenrecord after a clean exit 0 (time limit); a screenrecord that dies mid-run now aborts with its stderr instead of being blindly restarted — `record-video` still finalizes and keeps the partial MP4. - `sim-use android record-video`: the Android recording engine moved out of the top-level forwarder into `AndroidBackend`, giving `record-video` the same three-surface layout as every other cross-platform verb (top-level + `ios` + `android`). Flags and behavior are unchanged; `sim-use record-video` with an Android serial routes to the same implementation. - `make e2e-matrix` (`scripts/e2e-matrix.sh`): iOS E2E across the supported host environments — Xcode 26.x / Xcode 27.x × Device Hub closed at boot (the classic Simulator.app workflow, legacy `indigo` HID) / Device Hub open at boot (CoreDevice `dtuhid`). One leg runs the full suite (default `x27-hub`, the primary workflow once Xcode 27 ships; `--full |all|none` to change), the rest run the new smoke tier (`test-runner.sh --smoke`: describe-ui, tap, type, scroll); legs whose Xcode is not installed are skipped. The package builds once on the xcode-select toolchain (`build_products/` is toolchain-locked); each leg swaps only the *runtime* Xcode — `SIM_USE_TEST_DEVELOPER_DIR`, which the test harness injects as `DEVELOPER_DIR` into every process the suites spawn — and boots a device whose iOS runtime matches the leg's Xcode generation (runtimes are system-wide, so "newest available" would cross-contaminate). Every leg is double-gated against silently green-running the wrong combination: dtuhidd process-tree checks before and after the suites, plus a `SIM_USE_DEBUG` probe that must report the matching HID transport-selection predicate. Per-leg logs and a combination-evidence file land in `.build/e2e-matrix//`. `test-runner.sh` now also accepts arbitrary repeatable `swift test --filter` patterns (previously one suite from a fixed whitelist) and resolves its default simulator by exact device name on the newest *available* runtime (previously a substring match that could pick an unavailable device or an "… Pro Max" sibling). diff --git a/Tests/AndroidRecordVideoTests.swift b/Tests/AndroidRecordVideoTests.swift new file mode 100644 index 00000000..1300278c --- /dev/null +++ b/Tests/AndroidRecordVideoTests.swift @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import Testing +import AVFoundation +import CoreMedia + +@Suite("Android Record Video Tests", .serialized, .enabled(if: isAndroidE2EEnabled)) +struct AndroidRecordVideoTests { + @Test("android record-video produces a loadable MP4 with a video track") + func recordVideoSubcommand() async throws { + let result = try await recordForDuration(arguments: ["android", "record-video"], duration: 4.0) + defer { try? FileManager.default.removeItem(at: result.outputURL) } + + #expect(result.exitCode == 0, "unexpected exit \(result.exitCode); stderr: \(result.stderr)") + #expect(result.stderr.contains("Recording Android device")) + #expect(result.fileSize > 10_000, "recorded file should be non-empty, got \(result.fileSize)") + try await Self.expectValidMP4(at: result.outputURL) + } + + @Test("top-level record-video routes an adb serial to the same engine") + func recordVideoTopLevel() async throws { + let result = try await recordForDuration(arguments: ["record-video"], duration: 4.0) + defer { try? FileManager.default.removeItem(at: result.outputURL) } + + #expect(result.exitCode == 0, "unexpected exit \(result.exitCode); stderr: \(result.stderr)") + #expect(result.stderr.contains("Recording Android device")) + try await Self.expectValidMP4(at: result.outputURL) + } + + @Test("recording survives a screenrecord segment restart with one continuous MP4") + func recordAcrossSegmentRestart() async throws { + // 2-second forced segments over a ~6 s recording guarantee at + // least one restart. Proving the restart actually CONTRIBUTED + // frames needs care on two fronts: + // + // * screenrecord is VFR and a static screen can leave a whole + // segment frameless — drive screen activity for the entire + // recording so every segment has real frames to deliver. + // * `finish(stopHostTime:)` re-appends the last access unit at + // the stop time to keep the final image visible, so duration + // and isPlayable look healthy even if the muxer dropped + // everything after segment 1. The discriminating assertion is + // a real sample INSIDE the post-restart window (2.5–5.5 s): + // segment-1 frames sit below ~2.3 s and the trailing + // re-append lands at ~6 s, so only frames muxed from a later + // segment can appear there. + let serial = try AndroidE2E.requireSerial() + let adbPath = try AndroidE2E.adbPath() + let activityDriver = Task { + for _ in 0..<7 { + if Task.isCancelled { break } + let swipe = Process() + swipe.executableURL = URL(fileURLWithPath: adbPath) + swipe.arguments = ["-s", serial, "shell", "input", "swipe", "300", "800", "300", "400", "200"] + swipe.standardOutput = Pipe() + swipe.standardError = Pipe() + try? swipe.run() + swipe.waitUntilExit() + try? await Task.sleep(nanoseconds: 600_000_000) + } + } + defer { activityDriver.cancel() } + + let result = try await recordForDuration( + arguments: ["android", "record-video"], + duration: 6.0, + environment: ["SIM_USE_SCREENRECORD_TIME_LIMIT": "2"] + ) + activityDriver.cancel() + defer { try? FileManager.default.removeItem(at: result.outputURL) } + + #expect(result.exitCode == 0, "unexpected exit \(result.exitCode); stderr: \(result.stderr)") + #expect(result.stderr.contains("restarting"), "expected a forced segment restart, stderr: \(result.stderr)") + try await Self.expectValidMP4(at: result.outputURL) + + let asset = AVURLAsset(url: result.outputURL) + let playable = try await asset.load(.isPlayable) + #expect(playable, "restart-spanning mp4 should be playable") + let duration = try await asset.load(.duration).seconds + #expect(duration > 3.0, "expected media beyond one 2 s segment, got \(duration)s") + + let samplePTS = try await Self.videoSamplePTS(of: asset) + #expect(samplePTS == samplePTS.sorted(), "sample PTS must be non-decreasing across segments") + let postRestartSamples = samplePTS.filter { $0 > 2.5 && $0 < 5.5 } + #expect( + !postRestartSamples.isEmpty, + "no samples in the post-restart window — later segments were not muxed. PTS: \(samplePTS.map { String(format: "%.2f", $0) })" + ) + } + + // MARK: - Helpers + + /// Presentation timestamps of every video sample, read pass-through + /// (no decode) so assertions can see the real muxed sample layout + /// rather than AVAsset's summarized duration. + private static func videoSamplePTS(of asset: AVURLAsset) async throws -> [Double] { + let track = try #require(try await asset.loadTracks(withMediaType: .video).first, "mp4 has no video track") + let reader = try AVAssetReader(asset: asset) + let output = AVAssetReaderTrackOutput(track: track, outputSettings: nil) + reader.add(output) + reader.startReading() + var pts: [Double] = [] + while let sample = output.copyNextSampleBuffer() { + let stamp = CMSampleBufferGetPresentationTimeStamp(sample) + if stamp.isValid { + pts.append(stamp.seconds) + } + } + return pts + } + + private static func expectValidMP4(at url: URL) async throws { + let asset = AVURLAsset(url: url) + let tracks = try await asset.load(.tracks) + #expect(!tracks.isEmpty, "mp4 has no tracks (moov atom likely missing)") + #expect(tracks.contains { $0.mediaType == .video }, "mp4 has no video track") + } + + private func recordForDuration( + arguments: [String], + duration: TimeInterval, + environment: [String: String] = [:] + ) async throws -> (outputURL: URL, fileSize: Int, stderr: String, exitCode: Int32) { + let serial = try AndroidE2E.requireSerial() + let simUsePath = try TestHelpers.getSimUsePath() + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("sim-use-android-record-\(UUID().uuidString).mp4") + + let process = Process() + process.executableURL = URL(fileURLWithPath: simUsePath) + process.arguments = arguments + ["--device", serial, "--output", outputURL.path] + if !environment.isEmpty { + process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new } + } + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + try process.run() + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + process.terminate() + + try await TestHelpers.waitForProcessExit( + process, + timeout: 15.0, + description: "record-video did not exit after SIGTERM" + ) + + _ = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrText = String( + decoding: stderrPipe.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self + ) + let attributes = try? FileManager.default.attributesOfItem(atPath: outputURL.path) + let size = (attributes?[.size] as? Int) ?? 0 + return (outputURL: outputURL, fileSize: size, stderr: stderrText, exitCode: process.terminationStatus) + } +} diff --git a/Tests/AndroidScreenshotTests.swift b/Tests/AndroidScreenshotTests.swift new file mode 100644 index 00000000..98f0fe8b --- /dev/null +++ b/Tests/AndroidScreenshotTests.swift @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import Testing + +@Suite("Android Screenshot Tests", .serialized, .enabled(if: isAndroidE2EEnabled)) +struct AndroidScreenshotTests { + private static let pngMagic = Data([0x89, 0x50, 0x4E, 0x47]) + + @Test("android screenshot writes a real PNG to the given path") + func screenshotToFile() async throws { + let serial = try AndroidE2E.requireSerial() + let simUsePath = try TestHelpers.getSimUsePath() + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("sim-use-android-shot-\(UUID().uuidString).png") + defer { try? FileManager.default.removeItem(at: output) } + + _ = try await CommandRunner.run("\(simUsePath) android screenshot --device \(serial) --output \(output.path)") + + let data = try Data(contentsOf: output) + #expect(data.prefix(4) == Self.pngMagic) + #expect(data.count > 10_000, "screenshot should be a real image, got \(data.count) bytes") + } + + @Test("android screenshot --output - streams raw PNG bytes to stdout") + func screenshotToStdout() async throws { + let serial = try AndroidE2E.requireSerial() + let simUsePath = try TestHelpers.getSimUsePath() + + let process = Process() + process.executableURL = URL(fileURLWithPath: simUsePath) + process.arguments = ["android", "screenshot", "--device", serial, "--output", "-"] + + let stdoutPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = Pipe() + + let stdoutReadTask = Task { + try stdoutPipe.fileHandleForReading.readToEnd() ?? Data() + } + + try process.run() + try await TestHelpers.waitForProcessExit( + process, + timeout: 30.0, + description: "android screenshot --output - did not exit" + ) + + let data = (try? await stdoutReadTask.value) ?? Data() + #expect(process.terminationStatus == 0) + #expect(data.prefix(4) == Self.pngMagic, "stdout should carry raw PNG bytes") + #expect(data.count > 10_000) + } + + @Test("top-level screenshot routes an adb serial to the Android engine") + func screenshotTopLevel() async throws { + let serial = try AndroidE2E.requireSerial() + let simUsePath = try TestHelpers.getSimUsePath() + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("sim-use-android-top-shot-\(UUID().uuidString).png") + defer { try? FileManager.default.removeItem(at: output) } + + _ = try await CommandRunner.run("\(simUsePath) screenshot --udid \(serial) --output \(output.path)") + + let data = try Data(contentsOf: output) + #expect(data.prefix(4) == Self.pngMagic) + #expect(data.count > 10_000) + } +} diff --git a/Tests/ScreenshotTests.swift b/Tests/ScreenshotTests.swift new file mode 100644 index 00000000..2ba8f936 --- /dev/null +++ b/Tests/ScreenshotTests.swift @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import Testing + +@Suite("Screenshot Command Tests", .serialized, .enabled(if: isE2EEnabled)) +struct ScreenshotTests { + private static let pngMagic = Data([0x89, 0x50, 0x4E, 0x47]) + + @Test("Top-level screenshot writes a real PNG to the given path") + func screenshotToFile() async throws { + let udid = try TestHelpers.requireSimulatorUDID() + let simUsePath = try TestHelpers.getSimUsePath() + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("sim-use-screenshot-\(UUID().uuidString).png") + defer { try? FileManager.default.removeItem(at: output) } + + let (_, _) = try await CommandRunner.run("\(simUsePath) screenshot --udid \(udid) --output \(output.path)") + + let data = try Data(contentsOf: output) + #expect(data.prefix(4) == Self.pngMagic) + #expect(data.count > 10_000, "screenshot should be a real image, got \(data.count) bytes") + } + + @Test("screenshot --json returns an envelope whose path exists") + func screenshotJSONEnvelope() async throws { + let udid = try TestHelpers.requireSimulatorUDID() + let simUsePath = try TestHelpers.getSimUsePath() + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("sim-use-screenshot-json-\(UUID().uuidString).png") + defer { try? FileManager.default.removeItem(at: output) } + + let (stdout, _) = try await CommandRunner.run("\(simUsePath) screenshot --udid \(udid) --output \(output.path) --json") + + let json = try #require( + try JSONSerialization.jsonObject(with: Data(stdout.utf8)) as? [String: Any], + "expected a JSON envelope, got: \(stdout.prefix(200))" + ) + #expect(json["ok"] as? Bool == true) + let dataField = try #require(json["data"] as? [String: Any]) + let path = try #require(dataField["path"] as? String) + #expect(FileManager.default.fileExists(atPath: path)) + let bytes = try Data(contentsOf: URL(fileURLWithPath: path)) + #expect(bytes.prefix(4) == Self.pngMagic) + } + + @Test("screenshot into an existing directory stamps a filename inside it") + func screenshotToDirectory() async throws { + let udid = try TestHelpers.requireSimulatorUDID() + let simUsePath = try TestHelpers.getSimUsePath() + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("sim-use-screenshot-dir-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + _ = try await CommandRunner.run("\(simUsePath) screenshot --udid \(udid) --output '\(dir.path)'") + + let entries = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) + let pngs = entries.filter { $0.pathExtension == "png" } + #expect(pngs.count == 1, "expected exactly one stamped PNG, found \(entries.map(\.lastPathComponent))") + if let png = pngs.first { + let data = try Data(contentsOf: png) + #expect(data.prefix(4) == Self.pngMagic) + } + } +} diff --git a/Tests/SimUseCoreTests/ProcessControlTests.swift b/Tests/SimUseCoreTests/ProcessControlTests.swift new file mode 100644 index 00000000..e3d96f44 --- /dev/null +++ b/Tests/SimUseCoreTests/ProcessControlTests.swift @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +import Testing +import Foundation +import SimUseCore + +/// Pins the process-control primitives the streaming/recording commands +/// hang their stop paths on. +@Suite("ProcessControl primitives") +struct ProcessControlTests { + @Test("OnceFlag fires exactly once across concurrent setters") + func onceFlagSingleWinner() async { + let flag = OnceFlag() + let winners = await withTaskGroup(of: Bool.self, returning: Int.self) { group in + for _ in 0..<32 { + group.addTask { flag.trySet() } + } + var count = 0 + for await won in group where won { + count += 1 + } + return count + } + #expect(winners == 1) + #expect(flag.trySet() == false) + } + + @Test("cancellableSleep wakes early when the flag is cancelled") + func cancellableSleepEarlyWake() async throws { + let flag = CancellationFlag() + let start = ContinuousClock.now + + let sleeper = Task { + try await cancellableSleep(seconds: 30, flag: flag) + } + try await Task.sleep(nanoseconds: 100_000_000) // 100 ms + flag.cancel() + try await sleeper.value + + // A full 30 s sleep would dwarf this bound; the 5 ms polling + // chunks mean cancellation lands within tens of milliseconds. + let elapsed = ContinuousClock.now - start + #expect(elapsed < .seconds(5), "cancelled sleep took \(elapsed)") + } + + @Test("cancellableSleep returns immediately for non-positive durations") + func cancellableSleepZero() async throws { + let flag = CancellationFlag() + try await cancellableSleep(seconds: 0, flag: flag) + try await cancellableSleep(seconds: -1, flag: flag) + } + + @Test("CancellationFlag is sticky") + func cancellationFlagSticky() { + let flag = CancellationFlag() + #expect(!flag.isCancelled()) + flag.cancel() + flag.cancel() + #expect(flag.isCancelled()) + } +} diff --git a/Tests/VideoFrameProcessingTests.swift b/Tests/VideoFrameProcessingTests.swift new file mode 100644 index 00000000..da86667e --- /dev/null +++ b/Tests/VideoFrameProcessingTests.swift @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +import Testing +import Foundation +import AppKit +import CoreGraphics +import SimUseVideo + +/// Unit coverage for the frame-processing utilities behind the JPEG +/// streaming formats and the screencap-based recording fallbacks — +/// pure image plumbing, no device needed. +@Suite("VideoFrameUtilities — frame processing") +struct VideoFrameProcessingTests { + /// A solid-color PNG generated in-process, standing in for a + /// screenshot / `screencap -p` frame. + private func makePNG(width: Int, height: Int) throws -> Data { + let context = try #require(CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.setFillColor(CGColor(red: 0.2, green: 0.5, blue: 0.8, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + let image = try #require(context.makeImage()) + let rep = NSBitmapImageRep(cgImage: image) + return try #require(rep.representation(using: .png, properties: [:])) + } + + @Test("makeCGImage decodes PNG data and rejects garbage") + func makeCGImage() throws { + let png = try makePNG(width: 64, height: 48) + let image = try #require(VideoFrameUtilities.makeCGImage(from: png)) + #expect(image.width == 64) + #expect(image.height == 48) + + #expect(VideoFrameUtilities.makeCGImage(from: Data("not an image".utf8)) == nil) + } + + @Test("computeDimensions scales and rounds down to even") + func computeDimensions() throws { + let png = try makePNG(width: 101, height: 67) + let image = try #require(VideoFrameUtilities.makeCGImage(from: png)) + + let full = VideoFrameUtilities.computeDimensions(for: image, scale: 1.0) + #expect(full.width == 100) // 101 rounded down to even + #expect(full.height == 66) + + let half = VideoFrameUtilities.computeDimensions(for: image, scale: 0.5) + #expect(half.width == 50) + #expect(half.height == 32) // 33 rounded down to even + } + + @Test("computeDimensions never collapses below 2x2") + func computeDimensionsFloor() throws { + let png = try makePNG(width: 4, height: 4) + let image = try #require(VideoFrameUtilities.makeCGImage(from: png)) + let tiny = VideoFrameUtilities.computeDimensions(for: image, scale: 0.1) + #expect(tiny.width >= 2) + #expect(tiny.height >= 2) + } + + @Test("processJPEGData passes data through untouched at default settings") + func processPassthrough() async throws { + // Pins the intentional fast path shared with iOS streaming: at + // scale 1.0 / quality 80 the frame is forwarded byte-for-byte + // (no decode/re-encode), whatever its container format. + let png = try makePNG(width: 32, height: 32) + let out = try await VideoFrameUtilities.processJPEGData(png, scale: 1.0, quality: 80) + #expect(out == png) + } + + @Test("non-default quality re-encodes to JPEG") + func processReencodesQuality() async throws { + let png = try makePNG(width: 64, height: 64) + let out = try await VideoFrameUtilities.processJPEGData(png, scale: 1.0, quality: 50) + #expect(out != png) + // JPEG SOI marker. + #expect(out.prefix(2) == Data([0xFF, 0xD8])) + } + + @Test("scaling re-encodes to JPEG and preserves the aspect ratio") + func processScales() async throws { + let png = try makePNG(width: 100, height: 60) + let out = try await VideoFrameUtilities.processJPEGData(png, scale: 0.5, quality: 80) + #expect(out.prefix(2) == Data([0xFF, 0xD8])) + // The scale path draws through NSImage.lockFocus, which renders + // at the host's backing scale factor — absolute pixel dimensions + // are environment-dependent (1x headless vs 2x Retina), so pin + // the re-encode and the aspect ratio, not exact sizes. + let scaled = try #require(VideoFrameUtilities.makeCGImage(from: out)) + let aspect = Double(scaled.width) / Double(scaled.height) + #expect(abs(aspect - 100.0 / 60.0) < 0.15, "aspect drifted: \(scaled.width)x\(scaled.height)") + } + + @Test("estimateBitrate clamps to its floor and ceiling and grows with quality") + func estimateBitrate() { + // Tiny frame → floor. + #expect(H264StreamRecorder.estimateBitrate(width: 16, height: 16, fps: 1, quality: 1) == 1_000_000) + // Huge frame → ceiling. + #expect(H264StreamRecorder.estimateBitrate(width: 10_000, height: 10_000, fps: 60, quality: 100) == 50_000_000) + // Monotonic in quality between the clamps. + let low = H264StreamRecorder.estimateBitrate(width: 1080, height: 2400, fps: 30, quality: 30) + let high = H264StreamRecorder.estimateBitrate(width: 1080, height: 2400, fps: 30, quality: 90) + #expect(low < high) + } +} diff --git a/Tests/VideoOutputFileTests.swift b/Tests/VideoOutputFileTests.swift new file mode 100644 index 00000000..62f06a7f --- /dev/null +++ b/Tests/VideoOutputFileTests.swift @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +import Testing +import Foundation +import SimUseVideo + +/// Pins the `--output` path semantics every video-producing verb shares +/// (iOS + Android record-video, and the default filename shape agents +/// rely on). Pure filesystem logic — no device needed. +@Suite("VideoOutputFile — output path resolution") +struct VideoOutputFileTests { + private func makeTempDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("video-output-file-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + @Test("nil output defaults to a stamped mp4 in the current directory") + func defaultName() throws { + let url = try VideoOutputFile.prepareOutputURL(output: nil) + #expect(url.lastPathComponent.hasPrefix("sim-use-video-")) + #expect(url.pathExtension == "mp4") + #expect(url.deletingLastPathComponent().path == FileManager.default.currentDirectoryPath) + } + + @Test("empty / whitespace output falls back to the default name") + func whitespaceOutput() throws { + let url = try VideoOutputFile.prepareOutputURL(output: " ") + #expect(url.lastPathComponent.hasPrefix("sim-use-video-")) + } + + @Test("absolute file path is used as-is") + func absolutePath() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let target = dir.appendingPathComponent("clip.mp4") + let url = try VideoOutputFile.prepareOutputURL(output: target.path) + #expect(url.path == target.path) + } + + @Test("intermediate directories are created for a nested file path") + func createsIntermediateDirectories() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let target = dir.appendingPathComponent("a/b/clip.mp4") + let url = try VideoOutputFile.prepareOutputURL(output: target.path) + #expect(url.path == target.path) + var isDirectory: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: dir.appendingPathComponent("a/b").path, isDirectory: &isDirectory)) + #expect(isDirectory.boolValue) + } + + @Test("an existing directory receives a stamped file inside it") + func directoryTarget() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let url = try VideoOutputFile.prepareOutputURL(output: dir.path) + #expect(url.deletingLastPathComponent().path == dir.path) + #expect(url.lastPathComponent.hasPrefix("sim-use-video-")) + #expect(url.pathExtension == "mp4") + } + + @Test("an existing file at the target is replaced, not appended to") + func existingFileReplaced() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let target = dir.appendingPathComponent("clip.mp4") + try Data("stale".utf8).write(to: target) + + let url = try VideoOutputFile.prepareOutputURL(output: target.path) + #expect(url.path == target.path) + // prepareOutputURL removes the stale file so the recorder can + // create it fresh. + #expect(!FileManager.default.fileExists(atPath: target.path)) + } + + @Test("tilde in the output path expands to the home directory") + func tildeExpansion() throws { + let name = "sim-use-tilde-test-\(UUID().uuidString).mp4" + let url = try VideoOutputFile.prepareOutputURL(output: "~/\(name)") + defer { try? FileManager.default.removeItem(at: url) } + #expect(url.path == NSHomeDirectory() + "/" + name) + } +} diff --git a/Tests/VideoRecordingOptionsTests.swift b/Tests/VideoRecordingOptionsTests.swift new file mode 100644 index 00000000..5ac03845 --- /dev/null +++ b/Tests/VideoRecordingOptionsTests.swift @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +import Testing +import SimUseVideo + +/// Boundary pins for the shared record/stream flag validators — the one +/// place the cross-platform flag contract lives (#78). +@Suite("VideoRecordingOptions — flag validation") +struct VideoRecordingOptionsTests { + @Test("recording accepts the documented ranges") + func recordingAccepts() throws { + try VideoRecordingOptions.validate(fps: nil, quality: 80, scale: 1.0) + try VideoRecordingOptions.validate(fps: 1, quality: 1, scale: 0.1) + try VideoRecordingOptions.validate(fps: 60, quality: 100, scale: 1.0) + } + + @Test("recording rejects out-of-range values") + func recordingRejects() { + #expect(throws: (any Error).self) { try VideoRecordingOptions.validate(fps: 0, quality: 80, scale: 1.0) } + #expect(throws: (any Error).self) { try VideoRecordingOptions.validate(fps: 61, quality: 80, scale: 1.0) } + #expect(throws: (any Error).self) { try VideoRecordingOptions.validate(fps: nil, quality: 0, scale: 1.0) } + #expect(throws: (any Error).self) { try VideoRecordingOptions.validate(fps: nil, quality: 101, scale: 1.0) } + #expect(throws: (any Error).self) { try VideoRecordingOptions.validate(fps: nil, quality: 80, scale: 0.05) } + #expect(throws: (any Error).self) { try VideoRecordingOptions.validate(fps: nil, quality: 80, scale: 1.01) } + } + + @Test("streaming caps FPS at 30 where recording allows 60") + func streamingFPSCap() throws { + try VideoRecordingOptions.validateStreaming(fps: 30, quality: 80, scale: 1.0) + try VideoRecordingOptions.validateStreaming(fps: nil, quality: 80, scale: 1.0) + #expect(throws: (any Error).self) { try VideoRecordingOptions.validateStreaming(fps: 31, quality: 80, scale: 1.0) } + #expect(throws: (any Error).self) { try VideoRecordingOptions.validateStreaming(fps: 0, quality: 80, scale: 1.0) } + } +} diff --git a/scripts/test-runner-android.sh b/scripts/test-runner-android.sh index 6df9cdde..6d8fd375 100755 --- a/scripts/test-runner-android.sh +++ b/scripts/test-runner-android.sh @@ -30,6 +30,8 @@ PLAYGROUND_APK="Playgrounds/Android/app/build/outputs/apk/debug/app-debug.apk" ALL_SUITES=( "AndroidTapTests" "AndroidStreamVideoTests" + "AndroidRecordVideoTests" + "AndroidScreenshotTests" "AndroidSwipeScrollTests" "AndroidTypeTests" "AndroidKeyboardStateTests" diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index bd496650..c5e387b8 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -387,6 +387,7 @@ run_tests() { "PermissionAlertTests" "RecordVideoTests" "RemoteContentRecoveryTests" + "ScreenshotTests" "StreamVideoDebugTest" "StreamVideoTests" "SwipeTests"