Skip to content
Open
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
4 changes: 2 additions & 2 deletions android-multitouch-helper/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Android MultiTouch Helper

Small instrumentation APK used to inject Android two-pointer gestures through
Small instrumentation APK used to inject Android touch gestures through
`UiAutomation.injectInputEvent`. The helper accepts a compact base64 JSON payload so local ADB,
remote ADB tunnels, and remote providers that allow `adb install -t` plus `am instrument` can use
the same contract.
Expand Down Expand Up @@ -34,7 +34,7 @@ Successful results include:

- `ok=true`
- `helperApiVersion=1`
- `kind` (`pinch`, `rotate`, or `transform`)
- `kind` (`swipe`, `pinch`, `rotate`, or `transform`)
- `injectedEvents`
- `elapsedMs`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,18 @@ private GestureSpec readSpec(Bundle arguments) throws Exception {
throw new IllegalArgumentException("Unsupported protocol: " + protocol);
}
String kind = payload.getString("kind");
if (!"pinch".equals(kind) && !"rotate".equals(kind) && !"transform".equals(kind)) {
if (!"swipe".equals(kind)
&& !"pinch".equals(kind)
&& !"rotate".equals(kind)
&& !"transform".equals(kind)) {
throw new IllegalArgumentException("Unsupported kind: " + kind);
}
int x = payload.getInt("x");
int y = payload.getInt("y");
int x = "swipe".equals(kind) ? payload.getInt("x1") : payload.getInt("x");
int y = "swipe".equals(kind) ? payload.getInt("y1") : payload.getInt("y");
int dx = payload.optInt("dx", 0);
int dy = payload.optInt("dy", 0);
int x2 = payload.optInt("x2", x + dx);
int y2 = payload.optInt("y2", y + dy);
int durationMs = clamp(payload.optInt("durationMs", 300), MIN_DURATION_MS, MAX_DURATION_MS);
int radius = clamp(payload.optInt("radius", DEFAULT_RADIUS), MIN_RADIUS, MAX_RADIUS);
double scale = payload.optDouble("scale", 1.0d);
Expand All @@ -83,10 +88,13 @@ private GestureSpec readSpec(Bundle arguments) throws Exception {
if (("rotate".equals(kind) || "transform".equals(kind)) && !isFinite(degrees)) {
throw new IllegalArgumentException("Degrees must be finite");
}
return new GestureSpec(kind, x, y, dx, dy, durationMs, scale, degrees, radius);
return new GestureSpec(kind, x, y, dx, dy, x2, y2, durationMs, scale, degrees, radius);
}

private int injectGesture(GestureSpec spec) {
if ("swipe".equals(spec.kind)) {
return injectSinglePointerGesture(spec);
}
UiAutomation automation = getUiAutomation();
long downTime = SystemClock.uptimeMillis();
long eventTime = downTime;
Expand Down Expand Up @@ -149,6 +157,47 @@ private int injectGesture(GestureSpec spec) {
}
}

private int injectSinglePointerGesture(GestureSpec spec) {
UiAutomation automation = getUiAutomation();
long downTime = SystemClock.uptimeMillis();
long eventTime = downTime;
PointerPair activePointer = pointerPairAt(spec, 0);
int count = 0;

try {
inject(
automation,
motionEvent(downTime, eventTime, MotionEvent.ACTION_DOWN, activePointer),
true);
count += 1;

int frameCount =
Math.max(3, Math.round(spec.durationMs / (float) MOVE_FRAME_INTERVAL_MS));
for (int index = 1; index < frameCount; index += 1) {
double t = (double) index / (double) frameCount;
PointerPair frame = pointerPairAt(spec, t);
eventTime = downTime + Math.round(spec.durationMs * t);
inject(automation, motionEvent(downTime, eventTime, MotionEvent.ACTION_MOVE, frame), false);
count += 1;
activePointer = frame;
}

eventTime = downTime + spec.durationMs;
activePointer = pointerPairAt(spec, 1);
inject(
automation,
motionEvent(downTime, eventTime, MotionEvent.ACTION_UP, activePointer),
true);
count += 1;
return count;
} catch (RuntimeException error) {
if (count > 0) {
injectCancel(automation, downTime, eventTime + 16, activePointer);
}
throw error;
}
}

private static void inject(UiAutomation automation, MotionEvent event, boolean waitForDispatch) {
try {
if (!automation.injectInputEvent(event, waitForDispatch)) {
Expand Down Expand Up @@ -203,6 +252,11 @@ private static MotionEvent motionEvent(long downTime, long eventTime, int action
}

private static PointerPair pointerPairAt(GestureSpec spec, double t) {
if ("swipe".equals(spec.kind)) {
return new PointerPair(
new float[] {(float) (spec.x + (spec.x2 - spec.x) * t)},
new float[] {(float) (spec.y + (spec.y2 - spec.y) * t)});
}
if ("pinch".equals(spec.kind)) {
double startRadius = spec.radius / Math.max(spec.scale, 1.0d);
double endRadius = spec.radius;
Expand Down Expand Up @@ -255,6 +309,8 @@ private static final class GestureSpec {
final int y;
final int dx;
final int dy;
final int x2;
final int y2;
final int durationMs;
final double scale;
final double degrees;
Expand All @@ -266,6 +322,8 @@ private static final class GestureSpec {
int y,
int dx,
int dy,
int x2,
int y2,
int durationMs,
double scale,
double degrees,
Expand All @@ -275,6 +333,8 @@ private static final class GestureSpec {
this.y = y;
this.dx = dx;
this.dy = dy;
this.x2 = x2;
this.y2 = y2;
this.durationMs = durationMs;
this.scale = scale;
this.degrees = degrees;
Expand Down
12 changes: 6 additions & 6 deletions src/__tests__/runtime-interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,12 +609,12 @@ test('runtime gesture swipe presets use stable viewport lanes', async () => {
session: 'default',
});

assert.deepEqual(pageSwipe.from, { x: 85, y: 65 });
assert.deepEqual(pageSwipe.to, { x: 15, y: 65 });
assert.deepEqual(pageSwipe.from, { x: 85, y: 50 });
assert.deepEqual(pageSwipe.to, { x: 15, y: 50 });
assert.deepEqual(edgeSwipe.from, { x: 8, y: 50 });
assert.deepEqual(edgeSwipe.to, { x: 85, y: 50 });
assert.deepEqual(calls, [
{ from: { x: 85, y: 65 }, to: { x: 15, y: 65 }, durationMs: 300 },
{ from: { x: 85, y: 50 }, to: { x: 15, y: 50 }, durationMs: 300 },
{ from: { x: 8, y: 50 }, to: { x: 85, y: 50 }, durationMs: 350 },
]);
});
Expand All @@ -634,9 +634,9 @@ test('runtime iOS in-page swipe presets avoid edge-navigation lanes', async () =
session: 'default',
});

assert.deepEqual(pageSwipe.from, { x: 15, y: 65 });
assert.deepEqual(pageSwipe.to, { x: 85, y: 65 });
assert.deepEqual(calls, [{ from: { x: 15, y: 65 }, to: { x: 85, y: 65 }, durationMs: 300 }]);
assert.deepEqual(pageSwipe.from, { x: 15, y: 50 });
assert.deepEqual(pageSwipe.to, { x: 85, y: 50 });
assert.deepEqual(calls, [{ from: { x: 15, y: 50 }, to: { x: 85, y: 50 }, durationMs: 300 }]);
});

test('runtime viewport gestures reject inspect-only macOS surfaces', async () => {
Expand Down
43 changes: 43 additions & 0 deletions src/compat/maestro/__tests__/runtime-assertions.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, test, vi } from 'vitest';
import {
invokeMaestroAssertNotVisible,
Expand Down Expand Up @@ -230,6 +233,46 @@ test('invokeMaestroAssertVisible does not use Android raw fallback for generated
assert.equal(snapshotFlags.some((flags) => flags?.snapshotRaw === true), false);
});

test('invokeMaestroAssertVisible writes terminal snapshot artifacts for failed attempts', async () => {
const artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'maestro-assert-artifacts-'));
try {
const response = await invokeMaestroAssertVisible({
baseReq: {
token: 't',
session: 's',
flags: { platform: 'android', artifactsDir },
},
positionals: ['id="album-0"', '0'],
invoke: async (): Promise<DaemonResponse> => ({
ok: true,
data: snapshot([
node('Chat', { identifier: 'chat-tab', type: 'android.widget.Button' }),
node('Contacts', { identifier: 'contacts-tab', type: 'android.widget.Button' }),
]),
}),
});

assert.equal(response.ok, false);
if (!response.ok) {
const artifactPaths = response.error.details?.artifactPaths;
assert.deepEqual(artifactPaths, [
path.join(artifactsDir, 'failure-snapshot.json'),
path.join(artifactsDir, 'failure-snapshot.txt'),
]);
}
assert.match(
fs.readFileSync(path.join(artifactsDir, 'failure-snapshot.txt'), 'utf8'),
/@e1 \[button\] "Chat"/,
);
assert.match(
fs.readFileSync(path.join(artifactsDir, 'failure-snapshot.json'), 'utf8'),
/"identifier": "chat-tab"/,
);
} finally {
fs.rmSync(artifactsDir, { recursive: true, force: true });
}
});

test('invokeMaestroAssertVisible treats an elapsed ellipsis loading gate as already past loading', async () => {
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(250);

Expand Down
17 changes: 15 additions & 2 deletions src/compat/maestro/__tests__/runtime-interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ test('invokeMaestroTapOn resolves mutating taps from the current snapshot', asyn
const selector =
'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"';

const { response, clicks, snapshots } = await runTapOn(selector, () =>
const { response, clicks, clickFlags, snapshots } = await runTapOn(selector, () =>
currentBreadcrumbSnapshot(),
);

expect(response.ok).toBe(true);
expect(snapshots).toBe(1);
expect(clicks).toEqual([['86', '89']]);
expect(clickFlags[0]?.postGestureStabilization).toBe(true);
});

test('invokeMaestroTapOn uses optimized interactive snapshots by default', async () => {
Expand Down Expand Up @@ -125,6 +126,7 @@ test('invokeMaestroTapOn clicks explicit React Native overlay controls directly'

test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gesture presets', async () => {
const gestures: string[][] = [];
const gestureFlags: Array<DaemonRequest['flags']> = [];
const response = await invokeMaestroSwipeScreen({
baseReq: {
token: 'test',
Expand All @@ -135,6 +137,7 @@ test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gest
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
if (req.command === 'gesture') {
gestures.push(req.positionals ?? []);
gestureFlags.push(req.flags);
return { ok: true, data: {} };
}
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
Expand All @@ -143,6 +146,7 @@ test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gest

expect(response.ok).toBe(true);
expect(gestures).toEqual([['swipe', 'left', '300']]);
expect(gestureFlags[0]?.postGestureStabilization).toBeUndefined();
});

test('invokeMaestroSwipeScreen mirrors horizontal directional swipe presets', async () => {
Expand All @@ -169,6 +173,7 @@ test('invokeMaestroSwipeScreen mirrors horizontal directional swipe presets', as

test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async () => {
const swipes: string[][] = [];
const swipeFlags: Array<DaemonRequest['flags']> = [];
const response = await invokeMaestroSwipeScreen({
baseReq: {
token: 'test',
Expand All @@ -182,6 +187,7 @@ test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async (
}
if (req.command === 'swipe') {
swipes.push(req.positionals ?? []);
swipeFlags.push(req.flags);
return { ok: true, data: {} };
}
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
Expand All @@ -190,6 +196,7 @@ test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async (

expect(response.ok).toBe(true);
expect(swipes).toEqual([['200', '600', '200', '280', '300']]);
expect(swipeFlags[0]?.postGestureStabilization).toBeUndefined();
});

test('invokeMaestroSwipeScreen keeps Android horizontal percentage swipes on the content lane', async () => {
Expand Down Expand Up @@ -219,6 +226,7 @@ test('invokeMaestroSwipeScreen keeps Android horizontal percentage swipes on the

test('invokeMaestroTapPointPercent shares percentage point geometry without clamping', async () => {
const clicks: string[][] = [];
const clickFlags: Array<DaemonRequest['flags']> = [];
const response = await invokeMaestroTapPointPercent({
baseReq: {
token: 'test',
Expand All @@ -232,6 +240,7 @@ test('invokeMaestroTapPointPercent shares percentage point geometry without clam
}
if (req.command === 'click') {
clicks.push(req.positionals ?? []);
clickFlags.push(req.flags);
return { ok: true, data: {} };
}
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
Expand All @@ -240,6 +249,7 @@ test('invokeMaestroTapPointPercent shares percentage point geometry without clam

expect(response.ok).toBe(true);
expect(clicks).toEqual([['500', '-80']]);
expect(clickFlags[0]?.postGestureStabilization).toBe(true);
});

function currentBreadcrumbSnapshot(): SnapshotState {
Expand Down Expand Up @@ -277,10 +287,12 @@ async function runTapOn(
response: DaemonResponse;
commands: string[];
clicks: string[][];
clickFlags: Array<DaemonRequest['flags']>;
snapshots: number;
}> {
const commands: string[] = [];
const clicks: string[][] = [];
const clickFlags: Array<DaemonRequest['flags']> = [];
let snapshots = 0;
const response = await invokeMaestroTapOn({
baseReq: {
Expand All @@ -297,12 +309,13 @@ async function runTapOn(
}
if (req.command === 'click') {
clicks.push(req.positionals ?? []);
clickFlags.push(req.flags);
return { ok: true, data: {} };
}
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
},
});
return { response, commands, clicks, snapshots };
return { response, commands, clicks, clickFlags, snapshots };
}

function fullScreenSnapshot(width: number, height: number): SnapshotState {
Expand Down
Loading
Loading