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
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,84 @@ extension RunnerTests {
synthesized: false,
message: "scrolled"
)
case .desktopScroll:
guard let direction = command.direction,
direction == "up" || direction == "down" || direction == "left" || direction == "right"
else {
return Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "desktopScroll requires direction up|down|left|right"
)
)
}
let appFrame = activeApp.frame
let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: appFrame)
guard frame.width > 0, frame.height > 0 else {
return Response(
ok: false,
error: ErrorPayload(message: "desktopScroll could not resolve a usable interaction frame")
)
}
guard let plan = runnerScrollGesturePlan(
direction: direction,
amount: command.amount,
pixels: command.pixels,
referenceWidth: frame.width,
referenceHeight: frame.height
) else {
return Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "desktopScroll could not compute a wheel plan"
)
)
}
let x = frame.midX
let y = frame.midY
let localX = x - (appFrame.isEmpty ? frame.minX : appFrame.minX)
let localY = y - (appFrame.isEmpty ? frame.minY : appFrame.minY)
if let durationMs = command.durationMs,
durationMs.isFinite == false || durationMs < 0 || durationMs > 10000
{
return Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "desktopScroll durationMs must be between 0 and 10000"
)
)
}
let touchFrame = resolvedTouchVisualizationFrame(
app: activeApp,
x: localX,
y: localY
)
do {
var scrollError: Error?
let timing = measureGesture {
do {
try desktopScrollAt(
app: activeApp,
x: x,
y: y,
direction: direction,
pixels: plan.travelPixels,
durationMs: command.durationMs
)
} catch {
scrollError = error
}
}
if let scrollError {
throw scrollError
}
return gestureResponse(message: "scrolled", timing: timing, frame: .touch(touchFrame))
} catch {
return Response(ok: false, error: ErrorPayload(message: error.localizedDescription))
}
case .remotePress:
guard let button = tvRemoteButton(from: command.remoteButton) else {
return Response(ok: false, error: ErrorPayload(message: "remotePress requires remoteButton"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ final class RunnerCommandJournal {
case .snapshot, .screenshot:
return false
case .tap, .mouseClick, .longPress, .drag,
.remotePress, .type, .swipe, .scroll, .findText, .querySelector, .readText, .back,
.remotePress, .type, .swipe, .scroll, .desktopScroll, .findText, .querySelector, .readText, .back,
.backInApp, .backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn,
.alert, .pinch, .sequence, .rotateGesture, .transformGesture, .recordStart, .recordStop,
.status, .uptime, .shutdown:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import XCTest

#if os(macOS)
import CoreGraphics
#endif

private enum RunnerInterfaceOrientation {
static let unknown = 0
static let portrait = 1
Expand Down Expand Up @@ -605,6 +609,110 @@ extension RunnerTests {
#endif
}

func desktopScrollAt(
app: XCUIApplication,
x: Double,
y: Double,
direction: String,
pixels: Double,
durationMs: Double?
) throws {
#if os(macOS)
guard let events = desktopScrollWheelDeltaEvents(
direction: direction,
pixels: pixels,
durationMs: durationMs
) else {
throw NSError(
domain: "AgentDeviceRunner",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "unsupported desktop scroll direction: \(direction)"]
)
}

let coordinate = interactionCoordinate(app: app, x: x, y: y)
let interval = desktopScrollEventIntervalSeconds(durationMs: durationMs, eventCount: events.count)
for (index, deltas) in events.enumerated() {
// Keep desktop scrolling on XCTest's coordinate API so macOS owns wheel synthesis, natural
// scrolling preference handling, and cursor placement instead of posting raw CGEvents.
coordinate.scroll(
byDeltaX: CGFloat(deltas.horizontal),
deltaY: CGFloat(deltas.vertical)
)
if interval > 0 && index < events.count - 1 {
Thread.sleep(forTimeInterval: interval)
}
}
#elseif os(tvOS)
throw NSError(
domain: "AgentDeviceRunner",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "desktopScroll is not supported on tvOS"]
)
#else
throw NSError(
domain: "AgentDeviceRunner",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "desktopScroll is only supported on macOS"]
)
#endif
}

func desktopScrollWheelDeltas(direction: String, pixels: Double) -> (vertical: Int32, horizontal: Int32)? {
let magnitude = Int32(max(1, min(Double(Int32.max), pixels.rounded())))
switch direction {
case "up":
return (vertical: magnitude, horizontal: 0)
case "down":
return (vertical: -magnitude, horizontal: 0)
case "left":
return (vertical: 0, horizontal: magnitude)
case "right":
return (vertical: 0, horizontal: -magnitude)
default:
return nil
}
}

func desktopScrollWheelDeltaEvents(
direction: String,
pixels: Double,
durationMs: Double?
) -> [(vertical: Int32, horizontal: Int32)]? {
guard let totalDeltas = desktopScrollWheelDeltas(direction: direction, pixels: pixels) else {
return nil
}
let magnitude = max(abs(Int(totalDeltas.vertical)), abs(Int(totalDeltas.horizontal)))
let duration = max(0, durationMs ?? 0)
let requestedEventCount = duration > 0 ? Int(ceil(duration / 16.0)) : 1
let eventCount = max(1, min(magnitude, requestedEventCount))
guard eventCount > 1 else {
return [totalDeltas]
}

if totalDeltas.vertical != 0 {
return distributeDesktopScrollDelta(totalDeltas.vertical, eventCount: eventCount)
.map { (vertical: $0, horizontal: 0) }
}
return distributeDesktopScrollDelta(totalDeltas.horizontal, eventCount: eventCount)
.map { (vertical: 0, horizontal: $0) }
}

func desktopScrollEventIntervalSeconds(durationMs: Double?, eventCount: Int) -> TimeInterval {
guard let durationMs, durationMs > 0, eventCount > 1 else { return 0 }
return (durationMs / 1000.0) / Double(eventCount - 1)
}

private func distributeDesktopScrollDelta(_ delta: Int32, eventCount: Int) -> [Int32] {
let sign: Int32 = delta < 0 ? -1 : 1
let magnitude = abs(Int(delta))
let base = magnitude / eventCount
let remainder = magnitude % eventCount
return (0..<eventCount).map { index in
sign * Int32(base + (index < remainder ? 1 : 0))
}
}

func doubleTapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
if let outcome = selectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), action: "double tap") {
guard case .performed = outcome else { return outcome }
Expand Down Expand Up @@ -1199,4 +1307,26 @@ extension RunnerTests {
XCTAssertEqual(vector.dy, expected.dy, "dy interfaceOrientation \(orientation)")
}
}

func testDesktopScrollWheelDeltasMapDirections() throws {
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "up", pixels: 120)).vertical, 120)
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "down", pixels: 120)).vertical, -120)
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "left", pixels: 120)).horizontal, 120)
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "right", pixels: 120)).horizontal, -120)
XCTAssertNil(desktopScrollWheelDeltas(direction: "diagonal", pixels: 120))
}

func testDesktopScrollWheelDeltaEventsHonorDurationAndPreservePixels() throws {
let events = try XCTUnwrap(desktopScrollWheelDeltaEvents(direction: "down", pixels: 200, durationMs: 50))
XCTAssertEqual(events.count, 4)
XCTAssertEqual(events.map(\.vertical).reduce(0, +), -200)
XCTAssertEqual(events.map(\.horizontal).reduce(0, +), 0)
XCTAssertEqual(desktopScrollEventIntervalSeconds(durationMs: 50, eventCount: events.count), 0.05 / 3.0)
}

func testDesktopScrollWheelDeltaEventsKeepInstantScrollSingleEvent() throws {
let events = try XCTUnwrap(desktopScrollWheelDeltaEvents(direction: "down", pixels: 200, durationMs: 0))
XCTAssertEqual(events.count, 1)
XCTAssertEqual(events.first?.vertical, -200)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ enum CommandType: String, Codable {
case type
case swipe
case scroll
case desktopScroll
case findText
case querySelector
case readText
Expand Down Expand Up @@ -69,8 +70,9 @@ extension CommandType {
// Interaction commands: require the foreground-guard + stabilization preflight.
// keyboardReturn is the sibling of keyboardDismiss (missing from the historical switch —
// drift the table now prevents). .scroll is the fused frame-resolve + drag scroll; same
// classification as .drag. .sequence is the fused multi-step gesture batch.
case .tap, .longPress, .drag, .remotePress, .type, .swipe, .scroll,
// classification as .drag. .desktopScroll is the macOS frame-resolve + wheel event sibling.
// .sequence is the fused multi-step gesture batch.
case .tap, .longPress, .drag, .remotePress, .type, .swipe, .scroll, .desktopScroll,
.back, .backInApp, .backSystem, .rotate, .appSwitcher,
.keyboardDismiss, .keyboardReturn, .pinch, .sequence, .rotateGesture, .transformGesture:
return CommandTraits(isInteraction: true, readOnly: .never, isLifecycle: false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,24 @@ extension RunnerTests {
XCTAssertEqual(plan.travelPixels, 720)
}

func testRunnerScrollGesturePlanClampsExplicitPixelsVertically() throws {
// 400x800, down, pixels 1000 clamps travel to the safe band (720): (200,760)->(200,40).
let plan = try XCTUnwrap(
runnerScrollGesturePlan(
direction: "down",
amount: nil,
pixels: 1000,
referenceWidth: 400,
referenceHeight: 800
)
)
XCTAssertEqual(plan.x1, 200)
XCTAssertEqual(plan.y1, 760)
XCTAssertEqual(plan.x2, 200)
XCTAssertEqual(plan.y2, 40)
XCTAssertEqual(plan.travelPixels, 720)
}

func testRunnerScrollGesturePlanFloorsTinyFrames() throws {
// 2x2, down, pixels 10 engages every max(1, ...) floor and the .5 rounding cases the two
// ports must agree on (halfTravel 0.5 -> 1, center 1 from 2/2): (1,2)->(1,0), travel 1.
Expand Down
34 changes: 25 additions & 9 deletions scripts/integration-progress-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export function buildIntegrationProgressModel({ root = process.cwd() } = {}) {
const handlerTests = listFiles(handlerTestDir, (file) => file.endsWith('.test.ts'));
const providerScenarioTests = listFiles(providerScenarioDir, (file) => file.endsWith('.test.ts'));
const providerScenarioSources = listFiles(providerScenarioDir, (file) => file.endsWith('.ts'));
const providerScenarioSupportSources = providerScenarioSources.filter((file) => !file.endsWith('.test.ts'));
const providerScenarioSupportSources = providerScenarioSources.filter(
(file) => !file.endsWith('.test.ts'),
);
const handlerStats = summarizeFiles(handlerTests);
const providerScenarioStats = summarizeFiles(providerScenarioTests);
const providerScenarioSupportStats = summarizeFiles(providerScenarioSupportSources);
Expand All @@ -28,7 +30,10 @@ export function buildIntegrationProgressModel({ root = process.cwd() } = {}) {
);
const mockHeavyHandlerRows = summarizeMockHeavyHandlerFiles(root, mockHeavyHandlerFiles);
const providerPressureRows = summarizeProviderPressure(providerScenarioSources);
const publicCommandRows = summarizePublicCommandCoverage(providerScenarioTests, clientCommandMethods);
const publicCommandRows = summarizePublicCommandCoverage(
providerScenarioTests,
clientCommandMethods,
);
const missingPublicCommands = publicCommandRows.filter((command) => command.references === 0);
const flagCoverageRows = summarizeProviderScenarioFlagCoverage(providerScenarioTests);
const missingFlagRows = flagCoverageRows.filter((flag) => flag.references === 0);
Expand Down Expand Up @@ -57,12 +62,18 @@ export function buildIntegrationProgressModel({ root = process.cwd() } = {}) {
'Public commands covered by provider-backed integration',
`${publicCommandRows.length - missingPublicCommands.length}/${publicCommandRows.length}`,
],
['Public commands missing provider-backed integration coverage', String(missingPublicCommands.length)],
[
'Public commands missing provider-backed integration coverage',
String(missingPublicCommands.length),
],
[
'Device-observable workflow flags covered by provider-backed integration',
`${flagCoverageRows.length - missingFlagRows.length}/${flagCoverageRows.length}`,
],
['Device-observable workflow flags missing provider-backed integration coverage', String(missingFlagRows.length)],
[
'Device-observable workflow flags missing provider-backed integration coverage',
String(missingFlagRows.length),
],
[
'Public CLI flags intentionally outside provider-backed integration',
String(excludedFlagRows.reduce((sum, group) => sum + group.keys.length, 0)),
Expand Down Expand Up @@ -140,6 +151,7 @@ function summarizeProviderScenarioFlagCoverage(files) {
['hideTouches', 'recording without touch overlays'],
['intervalMs', 'repeated press interval'],
['delayMs', 'typing/fill delay'],
['durationMs', 'scroll and gesture duration'],
['holdMs', 'press hold duration'],
['jitterPx', 'press jitter'],
['pixels', 'scroll distance'],
Expand Down Expand Up @@ -338,16 +350,16 @@ function summarizeProviderPressure(files) {
const surfaces = [
{
name: 'Android ADB provider',
pattern: /\bAndroidAdbProvider\b|\bandroidAdbProvider\b|\badbProvider\b|\badb\.(?:exec|installer|puller|portReverse)\b/g,
pattern:
/\bAndroidAdbProvider\b|\bandroidAdbProvider\b|\badbProvider\b|\badb\.(?:exec|installer|puller|portReverse)\b/g,
},
{
name: 'Apple runner provider',
pattern: /\bAppleRunnerProvider\b|\bappleRunnerProvider\b|\b(?:ios|macos|tvos)\.runner\b/g,
},
{
name: 'Apple simctl/devicectl provider',
pattern:
/\bsimctl\b|\bdevicectl\b|\brunXcrun\b|\bsimctl\s*:|\bdevicectl\s*:/g,
pattern: /\bsimctl\b|\bdevicectl\b|\brunXcrun\b|\bsimctl\s*:|\bdevicectl\s*:/g,
},
{
name: 'Apple macOS helper provider',
Expand Down Expand Up @@ -451,7 +463,9 @@ function readClientCommandMethods(commandContractFiles) {
for (const file of commandContractFiles) {
const text = fs.readFileSync(file, 'utf8');
for (const block of readCommandContractBlocks(text)) {
for (const method of block.source.matchAll(/\bclient\.([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\s*\(/g)) {
for (const method of block.source.matchAll(
/\bclient\.([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\s*\(/g,
)) {
commands.set(`${method[1]}.${method[2]}`, block.name);
}
}
Expand Down Expand Up @@ -513,7 +527,9 @@ function extractProviderScenarioCommandReferences(text, clientCommandMethods) {

function extractLiteralCommandReferences(text) {
const commands = [];
for (const match of text.matchAll(/\bcommand:\s*['"]([^'"]+)['"]|\.callCommand\(\s*['"]([^'"]+)['"]/g)) {
for (const match of text.matchAll(
/\bcommand:\s*['"]([^'"]+)['"]|\.callCommand\(\s*['"]([^'"]+)['"]/g,
)) {
commands.push(match[1] ?? match[2]);
}
return commands;
Expand Down
1 change: 1 addition & 0 deletions src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export type BackendScrollOptions = {
direction: ScrollDirection;
amount?: number;
pixels?: number;
durationMs?: number;
};

export type BackendPinchOptions = {
Expand Down
1 change: 1 addition & 0 deletions src/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags {
hideTouches: options.hideTouches,
intervalMs: options.intervalMs,
delayMs: options.delayMs,
durationMs: options.durationMs,
holdMs: options.holdMs,
jitterPx: options.jitterPx,
pixels: options.pixels,
Expand Down
Loading
Loading