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: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,12 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o
## Testing Matrix
- Docs/skills only: no tests required unless a more specific rule below applies.
- CLI help/guidance changes in `src/utils/command-schema.ts`: run `pnpm exec vitest run src/utils/__tests__/args.test.ts`.
- SkillGym prompt/assertion changes: run the touched `--case` checks. For broad validation, use `pnpm test:skillgym`; use `--tag fixture-smoke` or `--tag skill-guidance` when validating one suite group.
- SkillGym prompt/assertion changes: run `pnpm test:skillgym:case <case-id>`; the script builds local CLI help first. For broad validation, use `pnpm test:skillgym`; append `-- --tag fixture-smoke` or `-- --tag skill-guidance` when validating one suite group.
- Non-TS, no behavior impact: no tests unless requested.
- Keep tests behavioral; do not assert shapes or cases TypeScript already proves.
- Any TS change: `pnpm typecheck` or `pnpm check:quick`.
- Fallow CI failures: reproduce with `pnpm check:fallow --base origin/main` instead of manually estimating complexity/dead-code impact.
- Test-only DI seam CI failures: the workflow enforces this; do not add optional `typeof` DI params in production code.
- Tooling/config change (`package.json`, `tsconfig*.json`, `.oxlintrc.json`, `.oxfmtrc.json`): `pnpm check:tooling`.
- Daemon handler/shared module change: `pnpm check:unit`.
- iOS runner/Swift change: `pnpm build:xcuitest`.
Expand Down Expand Up @@ -226,8 +228,8 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o
- For behavior/CLI surface changes and command-planning guidance changes, write or update a SkillGym case in `test/skillgym/suites/agent-device-smoke-suite.ts` that captures the expected agent command plan.
- Do not update `skills/**/SKILL.md` for command behavior or workflow guidance unless the user explicitly asks; skills must route to versioned CLI help instead of carrying behavior details.
- Keep SkillGym cases behavioral and command-planning oriented. Prefer prompts that assert the user-visible contract and expected command family over brittle exact output, but forbid known bad patterns.
- Build before SkillGym when local CLI help is needed: `pnpm build`, then `pnpm exec skillgym run ... --case <id>`.
- Run SkillGym broad validation with `pnpm test:skillgym`; use v0.8 `--tag` filters for focused suite groups.
- Use `pnpm test:skillgym:case <case-id>` for focused SkillGym validation; it runs the environment guard and builds local CLI help before `skillgym run`.
- Run SkillGym broad validation with `pnpm test:skillgym`; append v0.8 filters such as `-- --tag fixture-smoke` for focused suite groups.
- Preserve current high-value workflow guidance:
- iOS Expo Go dogfood: prefer `agent-device open "Expo Go" <url> --platform ios` when the shell is known, then `snapshot -i` to confirm the project UI rather than the runner splash.
- `keyboard dismiss` is the preferred iOS keyboard-dismissal path before manually pressing visible keyboard controls such as `Done`; it remains best-effort and can report unsupported layouts explicitly.
Expand Down
218 changes: 218 additions & 0 deletions src/platforms/android/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,224 @@ test('openAndroidApp rejects activity override for deep link URLs', async () =>
);
});

test('openAndroidApp ensures Android reverse before localhost deep link launch', async () => {
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};
const calls: Array<
{ kind: 'exec'; args: string[] } | { kind: 'reverse'; local: string; remote: string }
> = [];

await withAndroidAdbProvider(
{
exec: async (args) => {
calls.push({ kind: 'exec', args });
return { stdout: '', stderr: '', exitCode: 0 };
},
reverse: {
ensure: async (mapping) => {
calls.push({ kind: 'reverse', local: mapping.local, remote: mapping.remote });
},
remove: async () => {},
removeAllOwned: async () => {},
},
},
{ serial: 'emulator-5554' },
async () => await openAndroidApp(device, 'exp://127.0.0.1:8083'),
);

assert.deepEqual(calls, [
{ kind: 'reverse', local: 'tcp:8083', remote: 'tcp:8083' },
{
kind: 'exec',
args: [
'shell',
'am',
'start',
'-W',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://127.0.0.1:8083',
],
},
]);
});

test('openAndroidApp ensures Android reverse before IPv6 localhost deep link launch', async () => {
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};
const calls: Array<
{ kind: 'exec'; args: string[] } | { kind: 'reverse'; local: string; remote: string }
> = [];

await withAndroidAdbProvider(
{
exec: async (args) => {
calls.push({ kind: 'exec', args });
return { stdout: '', stderr: '', exitCode: 0 };
},
reverse: {
ensure: async (mapping) => {
calls.push({ kind: 'reverse', local: mapping.local, remote: mapping.remote });
},
remove: async () => {},
removeAllOwned: async () => {},
},
},
{ serial: 'emulator-5554' },
async () => await openAndroidApp(device, 'http://[::1]:8081/status'),
);

assert.deepEqual(calls, [
{ kind: 'reverse', local: 'tcp:8081', remote: 'tcp:8081' },
{
kind: 'exec',
args: [
'shell',
'am',
'start',
'-W',
'-a',
'android.intent.action.VIEW',
'-d',
'http://[::1]:8081/status',
],
},
]);
});

test('openAndroidApp leaves localhost deep links without a port unchanged', async () => {
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};
const calls: string[][] = [];

await withAndroidAdbProvider(
{
exec: async (args) => {
calls.push(args);
return { stdout: '', stderr: '', exitCode: 0 };
},
reverse: {
ensure: async () => {
throw new Error('reverse should not run without a URL port');
},
remove: async () => {},
removeAllOwned: async () => {},
},
},
{ serial: 'emulator-5554' },
async () => await openAndroidApp(device, 'http://localhost/path'),
);

assert.deepEqual(calls, [
[
'shell',
'am',
'start',
'-W',
'-a',
'android.intent.action.VIEW',
'-d',
'http://localhost/path',
],
]);
});

test('openAndroidApp leaves non-localhost deep links unchanged', async () => {
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};
const calls: string[][] = [];

await withAndroidAdbProvider(
{
exec: async (args) => {
calls.push(args);
return { stdout: '', stderr: '', exitCode: 0 };
},
reverse: {
ensure: async () => {
throw new Error('reverse should not run for remote URLs');
},
remove: async () => {},
removeAllOwned: async () => {},
},
},
{ serial: 'emulator-5554' },
async () => await openAndroidApp(device, 'https://example.com:8083/path'),
);

assert.deepEqual(calls, [
[
'shell',
'am',
'start',
'-W',
'-a',
'android.intent.action.VIEW',
'-d',
'https://example.com:8083/path',
],
]);
});

test('openAndroidApp reports localhost reverse failures with port context', async () => {
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};

await withAndroidAdbProvider(
{
exec: async (args) => {
throw new Error(`unexpected adb exec: ${args.join(' ')}`);
},
reverse: {
ensure: async () => {
throw new Error('bridge unavailable');
},
remove: async () => {},
removeAllOwned: async () => {},
},
},
{ serial: 'emulator-5554' },
async () => {
await assert.rejects(
() => openAndroidApp(device, 'http://localhost:8081'),
(error: unknown) => {
assert.equal(error instanceof AppError, true);
assert.equal((error as AppError).code, 'COMMAND_FAILED');
assert.match((error as Error).message, /tcp:8081/);
assert.match((error as Error).message, /reverse/i);
return true;
},
);
},
);
});

test('setAndroidSetting appearance toggle flips current mode', async () => {
await withMockedAdb(
'agent-device-android-appearance-toggle-',
Expand Down
53 changes: 52 additions & 1 deletion src/platforms/android/app-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import { isDeepLinkTarget } from '../../core/open-target.ts';
import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts';
import { waitForAndroidBoot } from './devices.ts';
import { runAndroidAdb } from './adb.ts';
import { installAndroidAdbPackage, resolveAndroidAdbProvider } from './adb-executor.ts';
import {
createAndroidPortReverseManager,
installAndroidAdbPackage,
resolveAndroidAdbProvider,
type AndroidPortReverseEndpoint,
} from './adb-executor.ts';
import { classifyAndroidAppTarget } from './open-target.ts';
import { prepareAndroidInstallArtifact } from './install-artifact.ts';
import {
Expand All @@ -36,6 +41,7 @@ const ANDROID_APPS_DISCOVERY_HINT =
'Run agent-device apps --platform android to discover the installed package name, then retry open with that exact package.';
const ANDROID_AMBIGUOUS_APP_HINT =
'Run agent-device apps --platform android to see the exact installed package names before retrying open.';
const ANDROID_LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);

type AndroidAppResolution = { type: 'intent' | 'package'; value: string };

Expand Down Expand Up @@ -217,6 +223,50 @@ async function readAndroidFocus(
return null;
}

function androidLocalhostReverseEndpoint(target: string): AndroidPortReverseEndpoint | null {
let url: URL;
try {
url = new URL(target);
} catch {
return null;
}

const hostname = url.hostname.toLowerCase();
if (!ANDROID_LOCALHOST_HOSTNAMES.has(hostname)) return null;
if (!url.port) return null;
const port = Number(url.port);
if (!Number.isInteger(port)) return null;
return `tcp:${port}`;
}

async function ensureAndroidLocalhostReverse(device: DeviceInfo, target: string): Promise<void> {
const endpoint = androidLocalhostReverseEndpoint(target);
if (!endpoint) return;

const reverse = createAndroidPortReverseManager(resolveAndroidAdbProvider(device));
try {
await reverse.ensure({ local: endpoint, remote: endpoint });
} catch (error) {
const details = {
localPort: endpoint.replace('tcp:', ''),
operation: `adb reverse ${endpoint} ${endpoint}`,
};
if (error instanceof AppError) {
Object.assign(details, {
hint: error.details?.hint,
diagnosticId: error.details?.diagnosticId,
logPath: error.details?.logPath,
});
}
throw new AppError(
'COMMAND_FAILED',
`Failed to ensure Android port reverse ${endpoint} before opening localhost URL`,
details,
error,
);
}
}

export async function openAndroidApp(
device: DeviceInfo,
app: string,
Expand All @@ -233,6 +283,7 @@ export async function openAndroidApp(
'Activity override is not supported when opening a deep link URL',
);
}
await ensureAndroidLocalhostReverse(device, deepLinkTarget);
await runAndroidAdb(device, [
'shell',
'am',
Expand Down
7 changes: 4 additions & 3 deletions src/utils/__tests__/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,7 +861,7 @@ test('usage includes agent workflows, config, environment, and examples footers'
assert.match(usageText, /agent-facing, token-efficient view for planning and targeting actions/);
assert.match(usageText, /Truncated text\/input preview: expand first with snapshot -s @e12/);
assert.match(usageText, /React Native apps: read help react-native/);
assert.match(usageText, /adb reverse tcp:<port> tcp:<port> is harmless/);
assert.match(usageText, /localhost URL opens with a port auto-configure host reachability/);
assert.match(usageText, /Expo Go\/dev clients: use the provided URL when given/);
assert.match(usageText, /on iOS prefer open "Expo Go" <url>/);
assert.match(usageText, /Install flows: install\/install-from-source first/);
Expand Down Expand Up @@ -968,7 +968,8 @@ test('usageForCommand resolves workflow help topic', () => {
assert.match(help, /provider-native text injection when available/);
assert.match(help, /Do not switch to raw adb, clipboard, or paste as an agent fallback/);
assert.match(help, /if no URL is provided but a target\/app name is provided, open that target/);
assert.match(help, /adb reverse tcp:<port> tcp:<port> before opening the app or URL/);
assert.match(help, /localhost\/127\.0\.0\.1\/\[::1\] with a port auto-configure/);
assert.match(help, /Manual adb reverse tcp:<port> tcp:<port> is only needed/);
assert.match(help, /do not split clear\/restart/);
assert.match(help, /do not write network log headers/);
assert.match(help, /agent-device open exp:\/\/127\.0\.0\.1:8081 --platform ios/);
Expand Down Expand Up @@ -1037,7 +1038,7 @@ test('usageForCommand resolves dogfood help topic', () => {
assert.match(help, /Static\/on-load issues can use one screenshot/);
assert.match(help, /React Native warning\/error overlays can be real findings/);
assert.match(help, /Expo Go\/dev-client shells/);
assert.match(help, /adb reverse tcp:<port> tcp:<port> before opening the app or URL/);
assert.match(help, /direct Android localhost URL opens with a port auto-configure/);
assert.match(help, /Keep stateful commands serial within the same session/);
assert.match(help, /prefer agent-device open "Expo Go" <url>/);
assert.match(help, /dogfood-output\/report\.md/);
Expand Down
8 changes: 4 additions & 4 deletions src/utils/command-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ const AGENT_QUICKSTART_LINES = [
'Anti-pattern: snapshot -i followed by snapshot -i | grep ...; prior refs stay valid until app state changes, and --force-full is the explicit full re-read.',
'Truncated text/input preview: expand first with snapshot -s @e12, not get text.',
'React Native apps: read help react-native for Metro, DevTools routing, and RN-specific blockers; use react-native dismiss-overlay for LogBox/RedBox overlays.',
'Android RN/Expo Metro: adb reverse tcp:<port> tcp:<port> is harmless and helps the device reach any local Metro port.',
'Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability.',
'Expo Go/dev clients: use the provided URL when given; on iOS prefer open "Expo Go" <url>; Android URL opens infer the foreground package for logs/perf when possible.',
'Install flows: install/install-from-source first, then open the installed id with --relaunch.',
'Text: fill \'id="field-email"\' "qa@example.com" replaces; type appends after press.',
Expand Down Expand Up @@ -349,7 +349,7 @@ React Native dev loop:
agent-device find "Home"
Do not use agent-device reload. Use open --relaunch for native startup reset.
React Native apps: use help react-native for Metro/Fast Refresh, DevTools routing, and RN-specific blockers; use react-native dismiss-overlay for LogBox/RedBox overlays.
Android RN/Expo Metro: run adb reverse tcp:<port> tcp:<port> before opening the app or URL; it is harmless even if already configured.
Android RN/Expo Metro: direct Android URL opens to localhost/127.0.0.1/[::1] with a port auto-configure host reachability. Manual adb reverse tcp:<port> tcp:<port> is only needed for app/package launches or unsupported flows where the app cannot reach local Metro.
Expo Go is a host shell. Use a provided project URL instead of inventing a bundle id; if no URL is provided but a target/app name is provided, open that target and do not inspect project files to find one. On iOS, prefer host + URL when the host shell is known because direct URL open can report success while leaving the runner/shell focused; verify with snapshot -i after opening:
agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios
agent-device snapshot -i --platform ios
Expand Down Expand Up @@ -510,7 +510,7 @@ React Native dev loop:
agent-device metro reload
agent-device find "Home"
Do not use agent-device reload. Use open --relaunch for native startup reset.
Android RN/Expo Metro: run adb reverse tcp:<port> tcp:<port> before opening the app or URL; it is harmless even if already configured.
Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, use help react-native if the app cannot reach local Metro.
Expo Go/dev clients are host shells. Use provided project URLs, verify with snapshot -i after opening, and ask instead of inventing app ids or URLs. Help workflow owns the full Expo URL command shapes.

Overlays and busy RN UIs:
Expand Down Expand Up @@ -619,7 +619,7 @@ Coverage:
Navigation, forms, empty/error/loading states, offline or retry behavior, permissions, settings, accessibility labels, orientation/keyboard, and obvious performance stalls.
React Native warning/error overlays can be real findings or test blockers. Capture them, use react-native dismiss-overlay if unrelated, re-snapshot, and report them.
Expo Go/dev-client shells: use the provided exp:// or dev-client URL and record whether the shell, project load, or app UI is being tested. On iOS dogfood, prefer agent-device open "Expo Go" <url> when Expo Go is the known shell, then snapshot -i to confirm the project UI rather than the runner splash.
Android RN/Expo Metro: run adb reverse tcp:<port> tcp:<port> before opening the app or URL; it is harmless even if already configured.
Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability.
Categories: visual, functional, UX, content, performance, diagnostics, permissions, accessibility.
Severity: critical blocks a core flow/data/crashes; high breaks a major feature; medium has friction or workaround; low is polish.

Expand Down
Loading
Loading