Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<seconds>` (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 <leg>|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/<timestamp>/`. `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).
Expand Down
160 changes: 160 additions & 0 deletions Tests/AndroidRecordVideoTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
68 changes: 68 additions & 0 deletions Tests/AndroidScreenshotTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
65 changes: 65 additions & 0 deletions Tests/ScreenshotTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
60 changes: 60 additions & 0 deletions Tests/SimUseCoreTests/ProcessControlTests.swift
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading
Loading