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
105 changes: 105 additions & 0 deletions src/compat/maestro/__tests__/runtime-flow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { CommandFlags } from '../../../core/dispatch.ts';
import type { DaemonRequest, DaemonResponse, SessionAction } from '../../../daemon/types.ts';
import { invokeMaestroRunFlowWhen } from '../runtime-flow.ts';

test('invokeMaestroRunFlowWhen waits briefly for visible conditions', async () => {
let snapshots = 0;
const invokedActions: SessionAction[] = [];
const batchSteps: CommandFlags['batchSteps'] = [
{ command: 'click', positionals: ['label="Dismiss"'] },
];

const response = await invokeMaestroRunFlowWhen({
baseReq: {
token: 't',
session: 's',
flags: { platform: 'android' },
},
positionals: ['visible', 'label="Dismiss" || text="Dismiss" || id="Dismiss"'],
batchSteps,
line: 12,
step: 4,
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
assert.equal(req.command, 'snapshot');
snapshots += 1;
return {
ok: true,
data: {
createdAt: Date.now(),
nodes:
snapshots === 1
? []
: [
{
index: 1,
ref: 'e1',
type: 'android.widget.TextView',
label: 'Dismiss',
rect: { x: 201, y: 2180, width: 138, height: 55 },
depth: 20,
},
],
},
};
},
invokeReplayAction: async ({ action }): Promise<DaemonResponse> => {
invokedActions.push(action);
return { ok: true, data: { clicked: true } };
},
});

assert.equal(response.ok, true);
assert.equal(snapshots, 2);
assert.deepEqual(
invokedActions.map((action) => [action.command, action.positionals]),
[['click', ['label="Dismiss"']]],
);
if (response.ok) {
assert.equal(response.data?.ran, 1);
}
});

test('invokeMaestroRunFlowWhen keeps notVisible conditions immediate', async () => {
let snapshots = 0;
const response = await invokeMaestroRunFlowWhen({
baseReq: {
token: 't',
session: 's',
flags: { platform: 'android' },
},
positionals: ['notVisible', 'label="Loading" || text="Loading" || id="Loading"'],
batchSteps: [{ command: 'click', positionals: ['label="Continue"'] }],
line: 14,
step: 7,
invoke: async (): Promise<DaemonResponse> => {
snapshots += 1;
return {
ok: true,
data: {
createdAt: Date.now(),
nodes: [
{
index: 1,
ref: 'e1',
type: 'android.widget.TextView',
label: 'Loading',
rect: { x: 120, y: 420, width: 160, height: 48 },
depth: 8,
},
],
},
};
},
invokeReplayAction: async (): Promise<DaemonResponse> => {
throw new Error('notVisible should skip while the selector is visible');
},
});

assert.equal(response.ok, true);
assert.equal(snapshots, 1);
if (response.ok) {
assert.equal(response.data?.skipped, true);
}
});
4 changes: 2 additions & 2 deletions src/compat/maestro/__tests__/runtime-interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot',
expect(clicks).toEqual([['86', '89']]);
});

test('invokeMaestroSwipeScreen uses a conservative Android content-lane directional swipe', async () => {
test('invokeMaestroSwipeScreen uses an Android content-lane directional swipe', async () => {
const swipes: string[][] = [];
const response = await invokeMaestroSwipeScreen({
baseReq: {
Expand All @@ -56,7 +56,7 @@ test('invokeMaestroSwipeScreen uses a conservative Android content-lane directio
});

expect(response.ok).toBe(true);
expect(swipes).toEqual([['756', '1521', '324', '1521', '300']]);
expect(swipes).toEqual([['864', '1521', '216', '1521', '300']]);
});

function currentBreadcrumbSnapshot(): SnapshotState {
Expand Down
61 changes: 61 additions & 0 deletions src/compat/maestro/__tests__/runtime-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,67 @@ test('resolveMaestroNodeFromSnapshot preserves read order for duplicate matches
});
});

test('resolveMaestroNodeFromSnapshot prefers duplicate text on foreground overlapping screen', () => {
const snapshot: SnapshotState = {
createdAt: Date.now(),
nodes: [
{
index: 1,
ref: 'e1',
type: 'android.widget.ScrollView',
label: 'Article, Go back, Show Dialog',
rect: { x: 0, y: 120, width: 1080, height: 1800 },
depth: 6,
},
{
index: 2,
ref: 'e2',
type: 'android.widget.Button',
label: 'Show Dialog',
rect: { x: 720, y: 980, width: 280, height: 88 },
enabled: true,
hittable: true,
depth: 14,
parentIndex: 1,
},
{
index: 30,
ref: 'e30',
type: 'android.widget.ScrollView',
label: 'NewsFeed, Push NewsFeed, Show Dialog',
rect: { x: 0, y: 120, width: 1080, height: 1800 },
depth: 6,
},
{
index: 31,
ref: 'e31',
type: 'android.widget.Button',
label: 'Show Dialog',
rect: { x: 720, y: 1320, width: 280, height: 88 },
enabled: true,
hittable: true,
depth: 14,
parentIndex: 30,
},
],
};

const target = resolveMaestroNodeFromSnapshot(
snapshot,
'label="Show Dialog" || text="Show Dialog" || id="Show Dialog"',
{},
'android',
{ referenceWidth: 1080, referenceHeight: 2340 },
{ promoteTapTarget: true },
);

expect(target).toMatchObject({
ok: true,
node: expect.objectContaining({ index: 31 }),
rect: { x: 720, y: 1320, width: 280, height: 88 },
});
});

test('resolveVisibleMaestroNodeFromSnapshot requires visible text matches to be on screen', () => {
const snapshot: SnapshotState = {
createdAt: Date.now(),
Expand Down
60 changes: 55 additions & 5 deletions src/compat/maestro/runtime-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ import {
readMaestroSelectorPlatform,
resolveVisibleMaestroNodeFromSnapshot,
} from './runtime-targets.ts';
import { sleep } from '../../utils/timeouts.ts';

const MAESTRO_RUN_FLOW_WHEN_POLICY = {
visibleTimeoutMs: 3000,
visiblePollMs: 250,
} as const;

type MaestroRunFlowWhenCondition =
| { ok: true; mode: string; predicate: string; selector: string }
| { ok: true; mode: string; selector: string }
| { ok: false; response: DaemonResponse };

export async function invokeMaestroRunFlowWhen(params: {
Expand Down Expand Up @@ -80,7 +86,6 @@ function readMaestroRunFlowWhenCondition(positionals: string[]): MaestroRunFlowW
return {
ok: true,
mode,
predicate: mode === 'visible' ? 'visible' : 'hidden',
selector,
};
}
Expand All @@ -92,22 +97,67 @@ async function evaluateMaestroRunFlowWhenCondition(
},
condition: Extract<MaestroRunFlowWhenCondition, { ok: true }>,
): Promise<{ ok: true; matched: boolean } | { ok: false; response: DaemonResponse }> {
if (condition.mode === 'visible') {
return await waitForMaestroRunFlowVisibleCondition(params, condition);
}

const response = await captureMaestroRawSnapshot(params);
if (!response.ok) return { ok: false, response };
const result = readMaestroRunFlowVisibleCondition(params, condition.selector, response);
if (!result.ok) {
return {
ok: false,
response: result.response,
};
}
return { ok: true, matched: !result.matched };
}

async function waitForMaestroRunFlowVisibleCondition(
params: {
baseReq: ReplayBaseRequest;
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
},
condition: Extract<MaestroRunFlowWhenCondition, { ok: true }>,
): Promise<{ ok: true; matched: boolean } | { ok: false; response: DaemonResponse }> {
// Maestro conditionals commonly guard UI that appears immediately after the
// previous command. Keep this bounded and only for visible; notVisible stays
// a point-in-time condition so optional cleanup blocks do not become waits.
const startedAt = Date.now();
while (true) {
const response = await captureMaestroRawSnapshot(params);
if (!response.ok) return { ok: false, response };
const result = readMaestroRunFlowVisibleCondition(params, condition.selector, response);
if (!result.ok) return { ok: false, response: result.response };
if (result.matched) return { ok: true, matched: true };
if (Date.now() - startedAt >= MAESTRO_RUN_FLOW_WHEN_POLICY.visibleTimeoutMs) {
return { ok: true, matched: false };
}
await sleep(MAESTRO_RUN_FLOW_WHEN_POLICY.visiblePollMs);
}
}

function readMaestroRunFlowVisibleCondition(
params: {
baseReq: ReplayBaseRequest;
},
selector: string,
response: Extract<DaemonResponse, { ok: true }>,
): { ok: true; matched: boolean } | { ok: false; response: DaemonResponse } {
const snapshot = readSnapshotState(response.data);
if (!snapshot) {
return {
ok: false,
response: errorResponse('COMMAND_FAILED', 'Unable to read snapshot data for runFlow.when.'),
};
}
const visible = resolveVisibleMaestroNodeFromSnapshot(
const matched = resolveVisibleMaestroNodeFromSnapshot(
snapshot,
condition.selector,
selector,
readMaestroSelectorPlatform(params.baseReq.flags),
getSnapshotReferenceFrame(snapshot),
).ok;
return { ok: true, matched: condition.mode === 'visible' ? visible : !visible };
return { ok: true, matched };
}

async function invokeMaestroRunFlowWhenSteps(
Expand Down
2 changes: 1 addition & 1 deletion src/compat/maestro/runtime-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ function androidHorizontalDirectionalSwipeX(
endX: number,
): [number, number] {
if (platform !== 'android') return [startX, endX];
return startX < endX ? [30, 70] : [70, 30];
return startX < endX ? [20, 80] : [80, 20];
}

function resolvePercentScreenSwipe(
Expand Down
Loading
Loading