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
8 changes: 4 additions & 4 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1298,10 +1298,10 @@ export const RAW_COMMAND_DESCRIPTORS = [
frameworkTier: 'extended',
timeoutPolicy: {
...SETTLE_FLAG_PRESERVE_DAEMON_TIMEOUT_POLICY,
// Android's cold path may inspect/install the helper, hand off a running
// snapshot helper, hold for 120 seconds, then use 15 seconds of helper
// completion overhead. Keep that complete route inside the envelope.
envelopeMs: 210_000,
// Android cold-helper worst case at the maximum hold plus the daemon-result margin, applied
// to every platform. Proven by the ledger test:
// test/integration/provider-scenarios/android-longpress-ledger.test.ts.
envelopeMs: 320_000,
},
postActionObservation: postActionObservation('longpress'),
batchable: true,
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/gesture-plan-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type GestureContactCount = 1 | 2 | 3 | 4 | 5;

export const GESTURE_DURATION_MIN_MS = 16;
export const GESTURE_DURATION_MAX_MS = 10_000;
export const LONG_PRESS_DURATION_MAX_MS = 120_000;
export const DEFAULT_DRAG_SOURCE_HOLD_MS = 800;
export const DEFAULT_DRAG_MOVE_MS = 500;
export const DEFAULT_DRAG_DESTINATION_HOLD_MS = 0;
Expand Down
8 changes: 5 additions & 3 deletions src/__tests__/command-descriptor-timeout-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ test('request envelopes deviating from the default are bounded, reviewed sets',
reinstall: 180_000,
uninstall: 180_000,
install_source: 180_000,
longpress: 210_000,
// longpress: proven by the worst-case ledger test in
// test/integration/provider-scenarios/android-longpress-ledger.test.ts.
longpress: 320_000,
// fold: proven by the worst-case ledger test in
// test/integration/provider-scenarios/ios-fold.test.ts.
fold: 255_000,
Expand Down Expand Up @@ -260,7 +262,7 @@ test('interaction --settle budgets add post-action settle time on top of the nor
positionals: ['300', '500', '120000'],
flags: {},
}),
210_000,
320_000,
);
// Bare --settle adds its default budget after the longpress-specific base,
// so a maximum hold still leaves room for post-action observation.
Expand All @@ -269,7 +271,7 @@ test('interaction --settle budgets add post-action settle time on top of the nor
...base,
flags: { settle: true },
}),
250_000,
360_000,
);
// Bare timeoutMs without --settle remains wire-compatible with older touch
// command clients: it is ignored instead of opting into settle semantics.
Expand Down
7 changes: 5 additions & 2 deletions src/commands/interaction/runtime/gestures.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { DragGestureInput } from '@agent-device/contracts/gesture-plan-types';
import {
LONG_PRESS_DURATION_MAX_MS,
type DragGestureInput,
} from '@agent-device/contracts/gesture-plan-types';
import type {
HoverCommandResult,
LongPressCommandResult,
Expand Down Expand Up @@ -149,7 +152,7 @@ export const longPressCommand: RuntimeCommand<
const durationMs =
options.durationMs === undefined
? undefined
: requireIntInRange(options.durationMs, 'durationMs', 0, 120_000);
: requireIntInRange(options.durationMs, 'durationMs', 0, LONG_PRESS_DURATION_MAX_MS);
const point = requireResolvedPoint(resolved);
const backendResult = await runtime.backend.longPress(toBackendContext(runtime, options), point, {
durationMs,
Expand Down
222 changes: 222 additions & 0 deletions test/integration/provider-scenarios/android-longpress-ledger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry';
import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy';
import { LONG_PRESS_DURATION_MAX_MS } from '@agent-device/contracts/gesture-plan-types';
import type {
AndroidAdbExecutorOptions,
AndroidAdbExecutorResult,
AndroidAdbProvider,
} from '@agent-device/platform-android/mechanics';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { test, vi } from 'vitest';
import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../../src/__tests__/test-utils/android-snapshot-helper.ts';
import { respondToAndroidSettingsAdbCommand } from './android-world.ts';
import { assertRpcOk } from './assertions.ts';
import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts';
import { createProviderScenarioHarness } from './harness.ts';

// The longpress envelope must outlast the Android cold-helper route's worst case plus the margin
// that lets the daemon's own typed result arrive first; otherwise the client gives up on a request
// the device is still finishing. This test proves that bound against the real route: it imports no
// platform-android step figure, so it stays true however the route's steps change. The only
// imported figure is the hold cap the validator reads.
//
// One step stays outside this ledger: replacing the helper stops a running snapshot helper session
// first, and that hand-off waits on host-side timers in
// packages/platform-android/src/snapshot-helper-session-lifecycle.ts (stop request, graceful exit,
// process exit) rather than on a provider call this fake can charge. Its device-side stop does go
// through the provider and is charged when it runs.
type LongPressLedgerCall = Readonly<{
tool: 'exec' | 'install' | 'pull';
args: readonly string[];
timeoutMs: number;
}>;

const HELPER = ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT.manifest;
const HELPER_REMOTE_APK = `/data/app/~~ledger/${HELPER.packageName}/base.apk`;
const ok: AndroidAdbExecutorResult = { stdout: '', stderr: '', exitCode: 0 };

function gestureHelperOutput(): string {
return [
`INSTRUMENTATION_RESULT: agentDeviceProtocol=${HELPER.statusProtocol}`,
'INSTRUMENTATION_RESULT: helperApiVersion=1',
'INSTRUMENTATION_RESULT: ok=true',
'INSTRUMENTATION_RESULT: kind=swipe',
'INSTRUMENTATION_CODE: 0',
].join('\n');
}

/**
* The slowest cold-helper branch the route allows: the installed helper reports the bundled
* versionCode, so identity falls through to the APK checksum (`pm path` + pull); the pulled APK
* mismatches, so the helper is replaced; the first replace is rejected as
* INSTALL_FAILED_UPDATE_INCOMPATIBLE, so the route uninstalls and installs again; then the hold runs
* one-shot because no live helper session exists.
*/
function createLongPressLedgerAdbProvider(params: {
isTracking: () => boolean;
onCall: (call: LongPressLedgerCall) => void;
}): { provider: AndroidAdbProvider; ledger: LongPressLedgerCall[] } {
const ledger: LongPressLedgerCall[] = [];
let installAttempts = 0;
// A call without a bounded timeoutMs is not a worst case the envelope assertion can see, so it
// fails the test rather than costing 0 virtual ms.
const record = (
tool: LongPressLedgerCall['tool'],
args: readonly string[],
options?: AndroidAdbExecutorOptions,
): void => {
if (!params.isTracking()) return;
const timeoutMs = options?.timeoutMs;
assert.ok(
timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0,
`longpress ledger call has no bounded timeoutMs: ${tool} ${args.join(' ')}`,
);
const call: LongPressLedgerCall = { tool, args: [...args], timeoutMs };
ledger.push(call);
params.onCall(call);
};
const provider: AndroidAdbProvider = {
snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT,
exec: async (args, options) => {
record('exec', args, options);
// A dump without a focus line sends the dialog and foreground observations through every
// variant they may read, so each one spends its whole budget.
if (params.isTracking() && isObservationDump(args)) return ok;
if (args.includes('--show-versioncode') && args.includes(HELPER.packageName)) {
return {
...ok,
stdout: `package:${HELPER.packageName} versionCode:${HELPER.versionCode}\n`,
};
}
if (args[0] === 'shell' && args[1] === 'pm' && args[2] === 'path') {
return { ...ok, stdout: `package:${HELPER_REMOTE_APK}\n` };
}
if (args[0] === 'uninstall' && args[1] === HELPER.packageName) {
return { ...ok, stdout: 'Success\n' };
}
if (args.includes('instrument') && args.includes('gesture')) {
return { ...ok, stdout: gestureHelperOutput() };
}
if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'start') {
return { ...ok, stdout: 'Starting: Intent' };
}
return respondToAndroidSettingsAdbCommand([...args], '', '', {});
},
pull: async (remotePath, localPath, options) => {
record('pull', [remotePath, localPath], options);
fs.writeFileSync(localPath, 'a previously installed helper build');
return ok;
},
install: async (apkPath, options) => {
record('install', [apkPath], options);
installAttempts += 1;
if (installAttempts === 1) {
return {
stdout: '',
stderr: 'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: signatures do not match]',
exitCode: 1,
};
}
return { ...ok, stdout: 'Success\n' };
},
};
return { provider, ledger };
}

function isObservationDump(args: readonly string[]): boolean {
return (
args[0] === 'shell' && args[1] === 'dumpsys' && (args[2] === 'window' || args[2] === 'activity')
);
}

function gestureDurationMs(call: LongPressLedgerCall): number | undefined {
const payloadIndex = call.args.indexOf('payloadBase64');
if (payloadIndex < 0) return undefined;
const payload = JSON.parse(
Buffer.from(call.args[payloadIndex + 1]!, 'base64').toString('utf8'),
) as { durationMs?: unknown };
return typeof payload.durationMs === 'number' ? payload.durationMs : undefined;
}

function assertLongPressLedgerPhaseCoverage(ledger: readonly LongPressLedgerCall[]): void {
const printed = `\nLedger:\n${JSON.stringify(ledger, null, 2)}`;
const isVersionRead = (call: LongPressLedgerCall) =>
call.tool === 'exec' && call.args.includes('--show-versioncode');
const isApkPathRead = (call: LongPressLedgerCall) =>
call.tool === 'exec' && call.args.includes('pm') && call.args.includes('path');
const isGesture = (call: LongPressLedgerCall) =>
call.tool === 'exec' && call.args.includes('instrument') && call.args.includes('gesture');

assert.ok(ledger.some(isVersionRead), `ledger is missing the helper version read${printed}`);
assert.ok(ledger.some(isApkPathRead), `ledger is missing the helper APK path read${printed}`);
assert.ok(
ledger.some((call) => call.tool === 'pull'),
`ledger is missing the helper APK pull${printed}`,
);
assert.equal(
ledger.filter((call) => call.tool === 'install').length,
2,
`ledger must record the rejected helper install and its retry${printed}`,
);
assert.ok(
ledger.some((call) => call.args[0] === 'uninstall'),
`ledger is missing the incompatible-helper uninstall${printed}`,
);
assert.ok(
ledger.some((call) => call.tool === 'exec' && isObservationDump(call.args)),
`ledger is missing the dialog and foreground observation dumps${printed}`,
);
const gestures = ledger.filter(isGesture);
assert.equal(gestures.length, 1, `ledger must record exactly one gesture dispatch${printed}`);
assert.equal(
gestureDurationMs(gestures[0]!),
LONG_PRESS_DURATION_MAX_MS,
`the gesture dispatch must carry the maximum hold${printed}`,
);
}

test('longpress cold-helper worst-case ledger covers the client envelope with the daemon-result margin', async () => {
// Matches REQUEST_TIMEOUT_BUDGET_MARGIN_MS, packages/command-registry/src/timeout-policy.ts.
// Not imported: it is not exported (fallow would flag an export used only by a test).
const REQUIRED_DAEMON_RESULT_MARGIN_MS = 30_000;

let virtualElapsedMs = 0;
let tracking = false;
const { provider, ledger } = createLongPressLedgerAdbProvider({
isTracking: () => tracking,
onCall: (call) => {
virtualElapsedMs += Math.max(0, call.timeoutMs - 1);
},
});
const dateSpy = vi.spyOn(Date, 'now');
const daemon = await createProviderScenarioHarness({
deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID],
androidAdbProvider: () => provider,
});
const selection = { platform: 'android', serial: PROVIDER_SCENARIO_ANDROID.id } as const;
const positionals = ['195', '320', String(LONG_PRESS_DURATION_MAX_MS)];
try {
await daemon.client().apps.open({ app: 'com.android.settings', ...selection, testIme: false });
tracking = true;
const originNowMs = Date.now();
dateSpy.mockImplementation(() => originNowMs + virtualElapsedMs);
assertRpcOk(await daemon.callCommand('longpress', positionals, selection));
} finally {
dateSpy.mockRestore();
await daemon.close();
}

assertLongPressLedgerPhaseCoverage(ledger);

const envelopeMs = resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('longpress'), {
positionals,
flags: {},
});
assert.ok(envelopeMs !== undefined, 'longpress must declare a bounded envelope');
assert.ok(
virtualElapsedMs + REQUIRED_DAEMON_RESULT_MARGIN_MS <= envelopeMs,
`longpress ledger worst case (${virtualElapsedMs}ms) + ${REQUIRED_DAEMON_RESULT_MARGIN_MS}ms ` +
`margin exceeds the ${envelopeMs}ms envelope.\nLedger:\n${JSON.stringify(ledger, null, 2)}`,
);
}, 20_000);
Loading