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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ dist/
*.log
test/screenshots/*.png
test/artifacts/
*.gesture-telemetry.json
.build/
.swiftpm/
DerivedData/
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
"prepublishOnly": "pnpm build:all",
"prepack": "pnpm build:all",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test",
"test:unit": "node --test src/__tests__/*.test.ts src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts src/utils/**/__tests__/*.test.ts",
"test": "node --test && vitest run",
"test:unit": "node --test src/__tests__/*.test.ts src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts src/utils/**/__tests__/*.test.ts && vitest run",
"test:smoke": "node --test test/integration/smoke-*.test.ts",
"test:integration": "node --test test/integration/*.test.ts"
},
Expand Down Expand Up @@ -78,6 +78,7 @@
"@types/node": "^22.0.0",
"@types/pngjs": "^6.0.5",
"prettier": "^3.3.3",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.1.2"
}
}
620 changes: 620 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { test, expect, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
Expand All @@ -8,6 +7,48 @@ import { SessionStore } from '../session-store.ts';
import type { SessionState } from '../types.ts';
import { LeaseRegistry } from '../lease-registry.ts';

let snapshotCalls = 0;

vi.mock('../../platforms/android/index.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/android/index.ts')>();
return {
...actual,
snapshotAndroid: vi.fn(async () => {
snapshotCalls += 1;
if (snapshotCalls === 1) {
return {
nodes: [
{
index: 0,
type: 'android.widget.TextView',
label: 'Process system is not responding',
rect: { x: 50, y: 400, width: 500, height: 80 },
},
{
index: 1,
type: 'android.widget.Button',
label: 'Close app',
rect: { x: 100, y: 600, width: 220, height: 80 },
},
],
};
}
return { nodes: [] };
}),
openAndroidApp: vi.fn(async () => {}),
getAndroidAppState: vi.fn(async () => ({ package: 'com.android.settings' })),
};
});

const execCalls: string[][] = [];

vi.mock('../../utils/exec.ts', () => ({
runCmd: vi.fn(async (_cmd: string, args: string[]) => {
execCalls.push(args);
return { stdout: '', stderr: '', exitCode: 0 };
}),
}));

function makeStore(): SessionStore {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-router-android-modal-'));
return new SessionStore(path.join(tempRoot, 'sessions'));
Expand Down Expand Up @@ -40,12 +81,14 @@ function makeAndroidSession(name: string): SessionState {
}

test('generic Android gesture commands dismiss blocking system dialogs during recording', async () => {
snapshotCalls = 0;
execCalls.length = 0;

const sessionStore = makeStore();
sessionStore.set('default', makeAndroidSession('default'));
const dispatchCalls: string[][] = [];
const execCalls: string[][] = [];
const reopenedApps: string[] = [];
let snapshotCalls = 0;

const { openAndroidApp } = await import('../../platforms/android/index.ts');

const handler = createRequestHandler({
logPath: path.join(os.tmpdir(), 'daemon.log'),
Expand All @@ -57,36 +100,6 @@ test('generic Android gesture commands dismiss blocking system dialogs during re
dispatchCalls.push([command, ...positionals]);
return {};
},
snapshotAndroidUi: async () => {
snapshotCalls += 1;
if (snapshotCalls === 1) {
return {
nodes: [
{
index: 0,
type: 'android.widget.TextView',
label: 'Process system is not responding',
rect: { x: 50, y: 400, width: 500, height: 80 },
},
{
index: 1,
type: 'android.widget.Button',
label: 'Close app',
rect: { x: 100, y: 600, width: 220, height: 80 },
},
],
};
}
return { nodes: [] };
},
reopenAndroidApp: async (_device, app) => {
reopenedApps.push(app);
},
readAndroidAppState: async () => ({ package: 'com.android.settings' }),
execCommand: async (_cmd, args) => {
execCalls.push(args);
return { stdout: '', stderr: '', exitCode: 0 };
},
});

const response = await handler({
Expand All @@ -97,9 +110,12 @@ test('generic Android gesture commands dismiss blocking system dialogs during re
meta: { requestId: 'req-android-modal' },
});

assert.equal(response.ok, true);
assert.deepEqual(dispatchCalls, [['scroll', 'down', '0.55']]);
assert.deepEqual(execCalls, [['-s', 'emulator-5554', 'shell', 'input', 'tap', '210', '640']]);
assert.deepEqual(reopenedApps, ['com.android.settings']);
assert.equal(snapshotCalls, 2);
expect(response.ok).toBe(true);
expect(dispatchCalls).toEqual([['scroll', 'down', '0.55']]);
expect(execCalls).toEqual([['-s', 'emulator-5554', 'shell', 'input', 'tap', '210', '640']]);
expect(openAndroidApp).toHaveBeenCalledWith(
expect.objectContaining({ id: 'emulator-5554' }),
'com.android.settings',
);
expect(snapshotCalls).toBe(2);
});
47 changes: 13 additions & 34 deletions src/daemon/android-system-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,22 @@ export type AndroidBlockingDialogRecoveryResult = 'absent' | 'recovered' | 'fail

export async function recoverAndroidBlockingSystemDialog(params: {
session: SessionState;
snapshotAndroidUi?: typeof snapshotAndroid;
reopenAndroidApp?: typeof openAndroidApp;
readAndroidAppState?: typeof getAndroidAppState;
execCommand?: typeof runCmd;
}): Promise<AndroidBlockingDialogRecoveryResult> {
const {
session,
snapshotAndroidUi = snapshotAndroid,
reopenAndroidApp = openAndroidApp,
readAndroidAppState = getAndroidAppState,
execCommand = runCmd,
} = params;
const { session } = params;

if (session.device.platform !== 'android' || !session.recording) {
return 'absent';
}

try {
const nodes = await readAndroidSnapshotNodes(session, snapshotAndroidUi);
const nodes = await readAndroidSnapshotNodes(session);
const closeAppButton = findCloseAppButton(nodes);
if (!closeAppButton?.rect) {
return 'absent';
}

const { x, y } = centerOfRect(closeAppButton.rect);
const tapResult = await execCommand(
const tapResult = await runCmd(
'adb',
adbArgs(session.device, [
'shell',
Expand All @@ -66,7 +56,7 @@ export async function recoverAndroidBlockingSystemDialog(params: {
return 'failed';
}

const dismissed = await waitForBlockingDialogToDismiss(session, snapshotAndroidUi);
const dismissed = await waitForBlockingDialogToDismiss(session);
if (!dismissed) {
emitDiagnostic({
level: 'warn',
Expand All @@ -80,12 +70,8 @@ export async function recoverAndroidBlockingSystemDialog(params: {
}

if (session.appBundleId) {
await reopenAndroidApp(session.device, session.appBundleId);
const focused = await waitForFocusedAndroidApp(
session,
session.appBundleId,
readAndroidAppState,
);
await openAndroidApp(session.device, session.appBundleId);
const focused = await waitForFocusedAndroidApp(session, session.appBundleId);
if (!focused) {
emitDiagnostic({
level: 'warn',
Expand Down Expand Up @@ -126,11 +112,8 @@ export async function recoverAndroidBlockingSystemDialog(params: {
}
}

async function readAndroidSnapshotNodes(
session: SessionState,
snapshotAndroidUi: typeof snapshotAndroid,
): Promise<SnapshotNode[]> {
const rawSnapshot = await snapshotAndroidUi(session.device, {
async function readAndroidSnapshotNodes(session: SessionState): Promise<SnapshotNode[]> {
const rawSnapshot = await snapshotAndroid(session.device, {
interactiveOnly: false,
compact: false,
});
Expand All @@ -147,34 +130,30 @@ function findCloseAppButton(nodes: SnapshotNode[]): SnapshotNode | undefined {
});
}

async function waitForBlockingDialogToDismiss(
session: SessionState,
snapshotAndroidUi: typeof snapshotAndroid,
): Promise<boolean> {
async function waitForBlockingDialogToDismiss(session: SessionState): Promise<boolean> {
for (let attempt = 0; attempt < ANDROID_MODAL_POLL_ATTEMPTS; attempt += 1) {
const nodes = await readAndroidSnapshotNodes(session, snapshotAndroidUi);
const nodes = await readAndroidSnapshotNodes(session);
if (!containsBlockingDialog(nodes)) {
return true;
}
await sleep(ANDROID_MODAL_POLL_MS);
}
const nodes = await readAndroidSnapshotNodes(session, snapshotAndroidUi);
const nodes = await readAndroidSnapshotNodes(session);
return !containsBlockingDialog(nodes);
}

async function waitForFocusedAndroidApp(
session: SessionState,
appBundleId: string,
readAndroidAppState: typeof getAndroidAppState,
): Promise<boolean> {
for (let attempt = 0; attempt < ANDROID_MODAL_POLL_ATTEMPTS; attempt += 1) {
const state = await readAndroidAppState(session.device);
const state = await getAndroidAppState(session.device);
if (state.package === appBundleId) {
return true;
}
await sleep(ANDROID_MODAL_POLL_MS);
}
const state = await readAndroidAppState(session.device);
const state = await getAndroidAppState(session.device);
return state.package === appBundleId;
}

Expand Down
Loading
Loading