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 @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- The tap family (`tap`, `long-press`, `ios tap`) declares its shared flags once via `TapTargetingOptions` / `TapTimingOptions` option groups instead of three hand-kept copies (#42). Flag names, defaults, validation messages, and `--json` envelopes are unchanged; the only user-visible deltas are in `--help` output, where per-verb wording ("Tap the center…" / "Long-press the center…") unifies to verb-neutral phrasing ("Target the center…") and the timing flags render as a contiguous block after `--duration`.
- `make e2e` now runs both the iOS and Android E2E suites in sequence (continuing past a platform failure and failing if either did); the iOS-only target is `make e2e-ios`, Android-only stays `make e2e-android`.
- `scripts/test-runner.sh` keeps running after a failed suite and prints a full pass/fail map at the end (a release gate needs the whole picture, not the first crash). The hardcoded suite list gained the missing `KeyboardStateTests` and the new suites, and the misnamed `StreamVideoDebugTests` entry — which silently matched zero tests — now points at the real `StreamVideoDebugTest` suite.

Expand Down
11 changes: 10 additions & 1 deletion Sources/SimUse/Commands/Button.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,21 @@ struct Button: SimUseExecutableCommand {
}

private func executeIOSSim() async throws -> ExecutionResult {
let sub = makeIOSSubcommand()
return try await sub.execute()
}

/// Construct the backend command and copy every parsed flag across.
/// A missed field stays in ArgumentParser's wrapper-definition state
/// and traps on first read (#42) — pinned by
/// `ForwarderInitializationGuardTests`.
func makeIOSSubcommand() -> IOSSimButtonCommand {
var sub = IOSSimButtonCommand()
sub.buttonType = buttonType
sub.duration = duration
sub.device = device
sub.json = json
return try await sub.execute()
return sub
}

private func executeAndroid() throws -> ExecutionResult {
Expand Down
11 changes: 10 additions & 1 deletion Sources/SimUse/Commands/DescribeUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ struct DescribeUI: SimUseExecutableCommand {
}

private func executeIOSSim() async throws -> ExecutionResult {
let sub = makeIOSSubcommand()
return try await sub.execute()
}

/// Construct the backend command and copy every parsed flag across.
/// A missed field stays in ArgumentParser's wrapper-definition state
/// and traps on first read (#42) — pinned by
/// `ForwarderInitializationGuardTests`.
func makeIOSSubcommand() -> IOSSimDescribeUICommand {
var sub = IOSSimDescribeUICommand()
sub.point = point
sub.maxProbes = maxProbes
Expand All @@ -115,7 +124,7 @@ struct DescribeUI: SimUseExecutableCommand {
sub.seedCellHeight = seedCellHeight
sub.device = device
sub.json = json
return try await sub.execute()
return sub
}

/// Android dispatch: routes through `AndroidDescribeUICommand.performDescribeUI`
Expand Down
11 changes: 10 additions & 1 deletion Sources/SimUse/Commands/Gesture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,15 @@ struct Gesture: SimUseExecutableCommand {
}

private func executeIOSSim() async throws -> ExecutionResult {
let sub = makeIOSSubcommand()
return try await sub.execute()
}

/// Construct the backend command and copy every parsed flag across.
/// A missed field stays in ArgumentParser's wrapper-definition state
/// and traps on first read (#42) — pinned by
/// `ForwarderInitializationGuardTests`.
func makeIOSSubcommand() -> IOSSimGestureCommand {
var sub = IOSSimGestureCommand()
sub.preset = preset
sub.screenWidth = screenWidth
Expand All @@ -149,7 +158,7 @@ struct Gesture: SimUseExecutableCommand {
sub.postDelay = postDelay
sub.device = device
sub.json = json
return try await sub.execute()
return sub
}

/// Android dispatch. Pre/post-delays use `Task.sleep` here (rather
Expand Down
15 changes: 12 additions & 3 deletions Sources/SimUse/Commands/KeyboardState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,22 @@ struct KeyboardState: SimUseExecutableCommand {
imePackage: state.imePackage
)
case .iOSSim, .none:
var sub = IOSSimKeyboardStateCommand()
sub.device = device
sub.json = json
let sub = makeIOSSubcommand()
return try await sub.execute()
}
}

/// Construct the backend command and copy every parsed flag across.
/// A missed field stays in ArgumentParser's wrapper-definition state
/// and traps on first read (#42) — pinned by
/// `ForwarderInitializationGuardTests`.
func makeIOSSubcommand() -> IOSSimKeyboardStateCommand {
var sub = IOSSimKeyboardStateCommand()
sub.device = device
sub.json = json
return sub
}

func format(_ result: ExecutionResult) -> CommandOutput {
.line(result.visible ? "soft" : "hidden")
}
Expand Down
137 changes: 35 additions & 102 deletions Sources/SimUse/Commands/LongPress.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,51 +57,7 @@ struct LongPress: SimUseExecutableCommand {
))
var alias: String?

@Option(name: [.customShort("x"), .customLong("x")], help: "The X coordinate of the long-press. Accepts -x or --x.")
var pointX: Double?

@Option(name: [.customShort("y"), .customLong("y")], help: "The Y coordinate of the long-press. Accepts -y or --y.")
var pointY: Double?

@Option(name: .customLong("point"), help: ArgumentHelp(
"The point to long-press as a coordinate pair — same semantics as -x/-y; specify only one form.",
valueName: "x,y"
))
var point: CoordinatePair?

@Option(name: [.customLong("id")], help: "Long-press the center of the element matching AXUniqueId/resource-id literally. For the N-th outline entry, use the positional `@N` alias instead — `--id 42` matches the identifier string '42', NOT outline alias @42. Ignored if explicit coordinates (-x/-y or --point) are provided.")
var elementID: String?

@Option(name: [.customLong("label")], help: "Long-press the center of the element matching AXLabel (accessibilityLabel). Ignored if explicit coordinates (-x/-y or --point) are provided.")
var elementLabel: String?

@Option(name: [.customLong("value")], help: "Long-press the center of the element matching AXValue (the current value of a control). Ignored if explicit coordinates (-x/-y or --point) are provided.")
var elementValue: String?

@Option(name: [.customLong("label-contains")], help: "Long-press the element whose AXLabel contains this case-sensitive substring. Useful when labels carry dynamic state (counters, timestamps). Mutually exclusive with --id/--label/--value/--label-regex.")
var labelContains: String?

@Option(name: [.customLong("label-regex")], help: "Long-press the element whose AXLabel matches this ICU regex. Anchor with ^/$ for exact match. Mutually exclusive with --id/--label/--value/--label-contains.")
var labelRegex: String?

@Option(name: [.customLong("element-type")], help: "Filter matches to elements of this accessibility type (e.g. Button, TextField). Narrows --id/--label/--value/--label-contains/--label-regex results when multiple elements match.")
var elementType: String?

@Option(
name: .customLong("frame"),
parsing: .singleValue,
help: ArgumentHelp(
"Geometric AND-filter on frame bounds. Repeatable. Each value is a comma-separated list of `key=value` pairs. Keys: minX, maxX, minY, maxY. Values are absolute pixels (e.g. 700) or 0..1 fractions of the screen with an `r` suffix (e.g. 0.6r). Combine with selectors to disambiguate when several elements share a label/pattern but live in different screen regions.",
valueName: "key=value[,key=value]"
)
)
var frameSpecs: [String] = []

@Option(name: .customLong("pre-delay"), help: "Delay before the long-press in seconds.")
var preDelay: Double?

@Option(name: .customLong("post-delay"), help: "Delay after the long-press in seconds.")
var postDelay: Double?
@OptionGroup var targeting: TapTargetingOptions

@Option(
name: .customLong("duration"),
Expand All @@ -111,11 +67,7 @@ struct LongPress: SimUseExecutableCommand {
)
var duration: Double = 0.8

@Option(name: .customLong("wait-timeout"), help: "Maximum seconds to poll for the element before failing (0 = no waiting, default). Only applies to --id/--label/--value/--label-contains/--label-regex targeting.")
var waitTimeout: Double = 0

@Option(name: .customLong("poll-interval"), help: "Seconds between accessibility tree polls when --wait-timeout is active (default: 0.25).")
var pollInterval: Double = 0.25
@OptionGroup var timing: TapTimingOptions

@OptionGroup var multiTouch: MultiTouchOptions

Expand All @@ -133,25 +85,16 @@ struct LongPress: SimUseExecutableCommand {

typealias ExecutionResult = IOSSimTapCommand.ExecutionResult

/// Same shared group validators as `Tap` / `IOSSimTapCommand` —
/// same selector / coordinate / delay constraints apply, and the
/// duration default (0.8) is in the [0, 10] range so the validator
/// accepts it. ArgumentParser does not auto-validate nested option
/// groups, so these explicit calls are load-bearing
/// (`TapValidationParityTests` pins that every surface makes them).
func validate() throws {
// Delegate to the shared tap rules — same selector / coordinate
// / delay constraints apply. The duration default (0.8) is in
// the [0, 10] range so the validator accepts it.
try IOSSimTapCommand.validateOptions(
alias: alias,
pointX: pointX, pointY: pointY, point: point,
elementID: elementID,
elementLabel: elementLabel,
elementValue: elementValue,
labelContains: labelContains,
labelRegex: labelRegex,
preDelay: preDelay,
postDelay: postDelay,
duration: duration,
waitTimeout: waitTimeout,
pollInterval: pollInterval,
frameSpecs: frameSpecs
)
try targeting.validate(alias: alias)
try timing.validate()
try TapTimingOptions.validateDuration(duration)
try multiTouch.validate()
}

Expand All @@ -168,32 +111,22 @@ struct LongPress: SimUseExecutableCommand {
.line("✓ Long-press at (\(result.x), \(result.y)) completed successfully")
}

/// iOS path: hand off to `IOSSimTapCommand` with `--duration`
/// carried through. That sub-command already splits the HID event
/// into down → sleep → up when duration > 0, which is exactly the
/// long-press recipe.
/// iOS path: hand off to the tap executor with `--duration` carried
/// through — it already splits the HID event into down → sleep → up
/// when duration > 0, which is exactly the long-press recipe. The
/// parsed groups are handed over as values, so no backend command
/// instance is hand-built and there is no per-field copy to
/// forget (#42).
private func executeIOSSim() async throws -> ExecutionResult {
var sub = IOSSimTapCommand()
sub.alias = alias
sub.pointX = pointX
sub.pointY = pointY
sub.point = point
sub.elementID = elementID
sub.elementLabel = elementLabel
sub.elementValue = elementValue
sub.labelContains = labelContains
sub.labelRegex = labelRegex
sub.elementType = elementType
sub.frameSpecs = frameSpecs
sub.preDelay = preDelay
sub.postDelay = postDelay
sub.duration = duration
sub.waitTimeout = waitTimeout
sub.pollInterval = pollInterval
sub.multiTouch = multiTouch
sub.device = device
sub.json = json
return try await sub.execute()
try await IOSSimTapCommand.performTap(
alias: alias,
targeting: targeting,
timing: timing,
duration: duration,
multiTouch: multiTouch,
device: device,
json: json
)
}

/// Android path: same selector resolution as `tap`, then a
Expand All @@ -204,21 +137,21 @@ struct LongPress: SimUseExecutableCommand {
/// across calls).
private func executeAndroid() throws -> ExecutionResult {
let frameFilter: SelectorFrameFilter? = {
guard !frameSpecs.isEmpty else { return nil }
return (try? SelectorFrameFilter(specs: frameSpecs))
guard !targeting.frameSpecs.isEmpty else { return nil }
return (try? SelectorFrameFilter(specs: targeting.frameSpecs))
}()
let selector = AndroidSelector(
id: elementID,
label: elementLabel,
labelContains: labelContains,
labelRegex: labelRegex,
value: elementValue,
id: targeting.elementID,
label: targeting.elementLabel,
labelContains: targeting.labelContains,
labelRegex: targeting.labelRegex,
value: targeting.elementValue,
valueContains: nil,
valueRegex: nil,
elementType: elementType,
elementType: targeting.elementType,
frame: frameFilter
)
let explicit = try TapCoordinateResolver.resolve(x: pointX, y: pointY, point: point)
let explicit = try TapCoordinateResolver.resolve(x: targeting.pointX, y: targeting.pointY, point: targeting.point)
let result = try AndroidTapCommand.performTap(
udid: device.resolved,
alias: alias,
Expand Down
11 changes: 10 additions & 1 deletion Sources/SimUse/Commands/MultiTouch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ struct MultiTouch: SimUseExecutableCommand {
}

private func executeIOSSim() async throws -> ExecutionResult {
let sub = makeIOSSubcommand()
return try await sub.execute()
}

/// Construct the backend command and copy every parsed flag across.
/// A missed field stays in ArgumentParser's wrapper-definition state
/// and traps on first read (#42) — pinned by
/// `ForwarderInitializationGuardTests`.
func makeIOSSubcommand() -> IOSSimMultiTouchCommand {
var sub = IOSSimMultiTouchCommand()
sub.x1 = x1
sub.y1 = y1
Expand All @@ -147,7 +156,7 @@ struct MultiTouch: SimUseExecutableCommand {
sub.postDelay = postDelay
sub.device = device
sub.json = json
return try await sub.execute()
return sub
}

private func executeAndroid() async throws -> ExecutionResult {
Expand Down
20 changes: 14 additions & 6 deletions Sources/SimUse/Commands/Paste.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,7 @@ struct Paste: SimUseExecutableCommand {
// iOS-only soft-keyboard probe — Android goes through
// ACTION_PASTE and has no equivalent HID-keyboard trap.
guard !PlatformRouter.looksLikeAndroid(device.resolved) else { return }
var sub = IOSSimPasteCommand()
sub.viaMenu = viaMenu
sub.device = device
sub.json = json
await sub.clientPreflight()
await makeIOSSubcommand().clientPreflight()
}

func execute() async throws -> ExecutionResult {
Expand All @@ -158,6 +154,18 @@ struct Paste: SimUseExecutableCommand {
}

private func executeIOSSim() async throws -> ExecutionResult {
let sub = makeIOSSubcommand()
return try await sub.execute()
}

/// Construct the backend command and copy every parsed flag across.
/// A missed field stays in ArgumentParser's wrapper-definition state
/// and traps on first read (#42) — pinned by
/// `ForwarderInitializationGuardTests`. Also feeds `clientPreflight`,
/// which previously copied only the three fields it read — a full
/// copy means the preflight can never trap if it starts reading
/// another field.
func makeIOSSubcommand() -> IOSSimPasteCommand {
var sub = IOSSimPasteCommand()
sub.text = text
sub.useStdin = useStdin
Expand All @@ -171,7 +179,7 @@ struct Paste: SimUseExecutableCommand {
sub.menuTimeout = menuTimeout
sub.device = device
sub.json = json
return try await sub.execute()
return sub
}

private func executeAndroid() throws -> ExecutionResult {
Expand Down
11 changes: 10 additions & 1 deletion Sources/SimUse/Commands/RecordVideo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,23 @@ struct RecordVideo: SimUseExecutableCommand {
}

private func executeIOSSim() async throws -> ExecutionResult {
let sub = makeIOSSubcommand()
return try await sub.execute()
}

/// Construct the backend command and copy every parsed flag across.
/// A missed field stays in ArgumentParser's wrapper-definition state
/// and traps on first read (#42) — pinned by
/// `ForwarderInitializationGuardTests`.
func makeIOSSubcommand() -> IOSSimRecordVideoCommand {
var sub = IOSSimRecordVideoCommand()
sub.fps = fps
sub.quality = quality
sub.scale = scale
sub.output = output
sub.device = device
sub.json = json
return try await sub.execute()
return sub
}

// MARK: - Android
Expand Down
11 changes: 10 additions & 1 deletion Sources/SimUse/Commands/Screenshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,20 @@ struct Screenshot: SimUseExecutableCommand {
}

private func executeIOSSim() async throws -> ExecutionResult {
let sub = makeIOSSubcommand()
return try await sub.execute()
}

/// Construct the backend command and copy every parsed flag across.
/// A missed field stays in ArgumentParser's wrapper-definition state
/// and traps on first read (#42) — pinned by
/// `ForwarderInitializationGuardTests`.
func makeIOSSubcommand() -> IOSSimScreenshotCommand {
var sub = IOSSimScreenshotCommand()
sub.output = output
sub.device = device
sub.json = json
return try await sub.execute()
return sub
}

private func executeAndroid() throws -> ExecutionResult {
Expand Down
Loading
Loading