Skip to content

Commit bbe7c06

Browse files
authored
feat: auto-reverse Android localhost opens (#590)
* feat: auto-reverse Android localhost opens * test: cover Android localhost reverse edge cases * fix: reduce Android reverse error complexity * docs: simplify Android Metro reverse guidance * docs: prefer scripted SkillGym checks * test: clarify Android Metro SkillGym plan
1 parent 87f087c commit bbe7c06

8 files changed

Lines changed: 302 additions & 39 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,12 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o
191191
## Testing Matrix
192192
- Docs/skills only: no tests required unless a more specific rule below applies.
193193
- CLI help/guidance changes in `src/utils/command-schema.ts`: run `pnpm exec vitest run src/utils/__tests__/args.test.ts`.
194-
- 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.
194+
- 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.
195195
- Non-TS, no behavior impact: no tests unless requested.
196196
- Keep tests behavioral; do not assert shapes or cases TypeScript already proves.
197197
- Any TS change: `pnpm typecheck` or `pnpm check:quick`.
198+
- Fallow CI failures: reproduce with `pnpm check:fallow --base origin/main` instead of manually estimating complexity/dead-code impact.
199+
- Test-only DI seam CI failures: the workflow enforces this; do not add optional `typeof` DI params in production code.
198200
- Tooling/config change (`package.json`, `tsconfig*.json`, `.oxlintrc.json`, `.oxfmtrc.json`): `pnpm check:tooling`.
199201
- Daemon handler/shared module change: `pnpm check:unit`.
200202
- iOS runner/Swift change: `pnpm build:xcuitest`.
@@ -226,8 +228,8 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o
226228
- 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.
227229
- 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.
228230
- 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.
229-
- Build before SkillGym when local CLI help is needed: `pnpm build`, then `pnpm exec skillgym run ... --case <id>`.
230-
- Run SkillGym broad validation with `pnpm test:skillgym`; use v0.8 `--tag` filters for focused suite groups.
231+
- Use `pnpm test:skillgym:case <case-id>` for focused SkillGym validation; it runs the environment guard and builds local CLI help before `skillgym run`.
232+
- Run SkillGym broad validation with `pnpm test:skillgym`; append v0.8 filters such as `-- --tag fixture-smoke` for focused suite groups.
231233
- Preserve current high-value workflow guidance:
232234
- 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.
233235
- `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.

src/platforms/android/__tests__/index.test.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,224 @@ test('openAndroidApp rejects activity override for deep link URLs', async () =>
534534
);
535535
});
536536

537+
test('openAndroidApp ensures Android reverse before localhost deep link launch', async () => {
538+
const device: DeviceInfo = {
539+
platform: 'android',
540+
id: 'emulator-5554',
541+
name: 'Pixel',
542+
kind: 'emulator',
543+
booted: true,
544+
};
545+
const calls: Array<
546+
{ kind: 'exec'; args: string[] } | { kind: 'reverse'; local: string; remote: string }
547+
> = [];
548+
549+
await withAndroidAdbProvider(
550+
{
551+
exec: async (args) => {
552+
calls.push({ kind: 'exec', args });
553+
return { stdout: '', stderr: '', exitCode: 0 };
554+
},
555+
reverse: {
556+
ensure: async (mapping) => {
557+
calls.push({ kind: 'reverse', local: mapping.local, remote: mapping.remote });
558+
},
559+
remove: async () => {},
560+
removeAllOwned: async () => {},
561+
},
562+
},
563+
{ serial: 'emulator-5554' },
564+
async () => await openAndroidApp(device, 'exp://127.0.0.1:8083'),
565+
);
566+
567+
assert.deepEqual(calls, [
568+
{ kind: 'reverse', local: 'tcp:8083', remote: 'tcp:8083' },
569+
{
570+
kind: 'exec',
571+
args: [
572+
'shell',
573+
'am',
574+
'start',
575+
'-W',
576+
'-a',
577+
'android.intent.action.VIEW',
578+
'-d',
579+
'exp://127.0.0.1:8083',
580+
],
581+
},
582+
]);
583+
});
584+
585+
test('openAndroidApp ensures Android reverse before IPv6 localhost deep link launch', async () => {
586+
const device: DeviceInfo = {
587+
platform: 'android',
588+
id: 'emulator-5554',
589+
name: 'Pixel',
590+
kind: 'emulator',
591+
booted: true,
592+
};
593+
const calls: Array<
594+
{ kind: 'exec'; args: string[] } | { kind: 'reverse'; local: string; remote: string }
595+
> = [];
596+
597+
await withAndroidAdbProvider(
598+
{
599+
exec: async (args) => {
600+
calls.push({ kind: 'exec', args });
601+
return { stdout: '', stderr: '', exitCode: 0 };
602+
},
603+
reverse: {
604+
ensure: async (mapping) => {
605+
calls.push({ kind: 'reverse', local: mapping.local, remote: mapping.remote });
606+
},
607+
remove: async () => {},
608+
removeAllOwned: async () => {},
609+
},
610+
},
611+
{ serial: 'emulator-5554' },
612+
async () => await openAndroidApp(device, 'http://[::1]:8081/status'),
613+
);
614+
615+
assert.deepEqual(calls, [
616+
{ kind: 'reverse', local: 'tcp:8081', remote: 'tcp:8081' },
617+
{
618+
kind: 'exec',
619+
args: [
620+
'shell',
621+
'am',
622+
'start',
623+
'-W',
624+
'-a',
625+
'android.intent.action.VIEW',
626+
'-d',
627+
'http://[::1]:8081/status',
628+
],
629+
},
630+
]);
631+
});
632+
633+
test('openAndroidApp leaves localhost deep links without a port unchanged', async () => {
634+
const device: DeviceInfo = {
635+
platform: 'android',
636+
id: 'emulator-5554',
637+
name: 'Pixel',
638+
kind: 'emulator',
639+
booted: true,
640+
};
641+
const calls: string[][] = [];
642+
643+
await withAndroidAdbProvider(
644+
{
645+
exec: async (args) => {
646+
calls.push(args);
647+
return { stdout: '', stderr: '', exitCode: 0 };
648+
},
649+
reverse: {
650+
ensure: async () => {
651+
throw new Error('reverse should not run without a URL port');
652+
},
653+
remove: async () => {},
654+
removeAllOwned: async () => {},
655+
},
656+
},
657+
{ serial: 'emulator-5554' },
658+
async () => await openAndroidApp(device, 'http://localhost/path'),
659+
);
660+
661+
assert.deepEqual(calls, [
662+
[
663+
'shell',
664+
'am',
665+
'start',
666+
'-W',
667+
'-a',
668+
'android.intent.action.VIEW',
669+
'-d',
670+
'http://localhost/path',
671+
],
672+
]);
673+
});
674+
675+
test('openAndroidApp leaves non-localhost deep links unchanged', async () => {
676+
const device: DeviceInfo = {
677+
platform: 'android',
678+
id: 'emulator-5554',
679+
name: 'Pixel',
680+
kind: 'emulator',
681+
booted: true,
682+
};
683+
const calls: string[][] = [];
684+
685+
await withAndroidAdbProvider(
686+
{
687+
exec: async (args) => {
688+
calls.push(args);
689+
return { stdout: '', stderr: '', exitCode: 0 };
690+
},
691+
reverse: {
692+
ensure: async () => {
693+
throw new Error('reverse should not run for remote URLs');
694+
},
695+
remove: async () => {},
696+
removeAllOwned: async () => {},
697+
},
698+
},
699+
{ serial: 'emulator-5554' },
700+
async () => await openAndroidApp(device, 'https://example.com:8083/path'),
701+
);
702+
703+
assert.deepEqual(calls, [
704+
[
705+
'shell',
706+
'am',
707+
'start',
708+
'-W',
709+
'-a',
710+
'android.intent.action.VIEW',
711+
'-d',
712+
'https://example.com:8083/path',
713+
],
714+
]);
715+
});
716+
717+
test('openAndroidApp reports localhost reverse failures with port context', async () => {
718+
const device: DeviceInfo = {
719+
platform: 'android',
720+
id: 'emulator-5554',
721+
name: 'Pixel',
722+
kind: 'emulator',
723+
booted: true,
724+
};
725+
726+
await withAndroidAdbProvider(
727+
{
728+
exec: async (args) => {
729+
throw new Error(`unexpected adb exec: ${args.join(' ')}`);
730+
},
731+
reverse: {
732+
ensure: async () => {
733+
throw new Error('bridge unavailable');
734+
},
735+
remove: async () => {},
736+
removeAllOwned: async () => {},
737+
},
738+
},
739+
{ serial: 'emulator-5554' },
740+
async () => {
741+
await assert.rejects(
742+
() => openAndroidApp(device, 'http://localhost:8081'),
743+
(error: unknown) => {
744+
assert.equal(error instanceof AppError, true);
745+
assert.equal((error as AppError).code, 'COMMAND_FAILED');
746+
assert.match((error as Error).message, /tcp:8081/);
747+
assert.match((error as Error).message, /reverse/i);
748+
return true;
749+
},
750+
);
751+
},
752+
);
753+
});
754+
537755
test('setAndroidSetting appearance toggle flips current mode', async () => {
538756
await withMockedAdb(
539757
'agent-device-android-appearance-toggle-',

src/platforms/android/app-lifecycle.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import { isDeepLinkTarget } from '../../core/open-target.ts';
99
import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts';
1010
import { waitForAndroidBoot } from './devices.ts';
1111
import { runAndroidAdb } from './adb.ts';
12-
import { installAndroidAdbPackage, resolveAndroidAdbProvider } from './adb-executor.ts';
12+
import {
13+
createAndroidPortReverseManager,
14+
installAndroidAdbPackage,
15+
resolveAndroidAdbProvider,
16+
type AndroidPortReverseEndpoint,
17+
} from './adb-executor.ts';
1318
import { classifyAndroidAppTarget } from './open-target.ts';
1419
import { prepareAndroidInstallArtifact } from './install-artifact.ts';
1520
import {
@@ -36,6 +41,7 @@ const ANDROID_APPS_DISCOVERY_HINT =
3641
'Run agent-device apps --platform android to discover the installed package name, then retry open with that exact package.';
3742
const ANDROID_AMBIGUOUS_APP_HINT =
3843
'Run agent-device apps --platform android to see the exact installed package names before retrying open.';
44+
const ANDROID_LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
3945

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

@@ -217,6 +223,50 @@ async function readAndroidFocus(
217223
return null;
218224
}
219225

226+
function androidLocalhostReverseEndpoint(target: string): AndroidPortReverseEndpoint | null {
227+
let url: URL;
228+
try {
229+
url = new URL(target);
230+
} catch {
231+
return null;
232+
}
233+
234+
const hostname = url.hostname.toLowerCase();
235+
if (!ANDROID_LOCALHOST_HOSTNAMES.has(hostname)) return null;
236+
if (!url.port) return null;
237+
const port = Number(url.port);
238+
if (!Number.isInteger(port)) return null;
239+
return `tcp:${port}`;
240+
}
241+
242+
async function ensureAndroidLocalhostReverse(device: DeviceInfo, target: string): Promise<void> {
243+
const endpoint = androidLocalhostReverseEndpoint(target);
244+
if (!endpoint) return;
245+
246+
const reverse = createAndroidPortReverseManager(resolveAndroidAdbProvider(device));
247+
try {
248+
await reverse.ensure({ local: endpoint, remote: endpoint });
249+
} catch (error) {
250+
const details = {
251+
localPort: endpoint.replace('tcp:', ''),
252+
operation: `adb reverse ${endpoint} ${endpoint}`,
253+
};
254+
if (error instanceof AppError) {
255+
Object.assign(details, {
256+
hint: error.details?.hint,
257+
diagnosticId: error.details?.diagnosticId,
258+
logPath: error.details?.logPath,
259+
});
260+
}
261+
throw new AppError(
262+
'COMMAND_FAILED',
263+
`Failed to ensure Android port reverse ${endpoint} before opening localhost URL`,
264+
details,
265+
error,
266+
);
267+
}
268+
}
269+
220270
export async function openAndroidApp(
221271
device: DeviceInfo,
222272
app: string,
@@ -233,6 +283,7 @@ export async function openAndroidApp(
233283
'Activity override is not supported when opening a deep link URL',
234284
);
235285
}
286+
await ensureAndroidLocalhostReverse(device, deepLinkTarget);
236287
await runAndroidAdb(device, [
237288
'shell',
238289
'am',

src/utils/__tests__/args.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -861,7 +861,7 @@ test('usage includes agent workflows, config, environment, and examples footers'
861861
assert.match(usageText, /agent-facing, token-efficient view for planning and targeting actions/);
862862
assert.match(usageText, /Truncated text\/input preview: expand first with snapshot -s @e12/);
863863
assert.match(usageText, /React Native apps: read help react-native/);
864-
assert.match(usageText, /adb reverse tcp:<port> tcp:<port> is harmless/);
864+
assert.match(usageText, /localhost URL opens with a port auto-configure host reachability/);
865865
assert.match(usageText, /Expo Go\/dev clients: use the provided URL when given/);
866866
assert.match(usageText, /on iOS prefer open "Expo Go" <url>/);
867867
assert.match(usageText, /Install flows: install\/install-from-source first/);
@@ -975,7 +975,8 @@ test('usageForCommand resolves workflow help topic', () => {
975975
assert.match(help, /provider-native text injection when available/);
976976
assert.match(help, /Do not switch to raw adb, clipboard, or paste as an agent fallback/);
977977
assert.match(help, /if no URL is provided but a target\/app name is provided, open that target/);
978-
assert.match(help, /adb reverse tcp:<port> tcp:<port> before opening the app or URL/);
978+
assert.match(help, /localhost\/127\.0\.0\.1\/\[::1\] with a port auto-configure/);
979+
assert.match(help, /Manual adb reverse tcp:<port> tcp:<port> is only needed/);
979980
assert.match(help, /do not split clear\/restart/);
980981
assert.match(help, /do not write network log headers/);
981982
assert.match(help, /agent-device open exp:\/\/127\.0\.0\.1:8081 --platform ios/);
@@ -1044,7 +1045,7 @@ test('usageForCommand resolves dogfood help topic', () => {
10441045
assert.match(help, /Static\/on-load issues can use one screenshot/);
10451046
assert.match(help, /React Native warning\/error overlays can be real findings/);
10461047
assert.match(help, /Expo Go\/dev-client shells/);
1047-
assert.match(help, /adb reverse tcp:<port> tcp:<port> before opening the app or URL/);
1048+
assert.match(help, /direct Android localhost URL opens with a port auto-configure/);
10481049
assert.match(help, /Keep stateful commands serial within the same session/);
10491050
assert.match(help, /prefer agent-device open "Expo Go" <url>/);
10501051
assert.match(help, /dogfood-output\/report\.md/);

src/utils/command-schema.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ const AGENT_QUICKSTART_LINES = [
169169
'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.',
170170
'Truncated text/input preview: expand first with snapshot -s @e12, not get text.',
171171
'React Native apps: read help react-native for Metro, DevTools routing, and RN-specific blockers; use react-native dismiss-overlay for LogBox/RedBox overlays.',
172-
'Android RN/Expo Metro: adb reverse tcp:<port> tcp:<port> is harmless and helps the device reach any local Metro port.',
172+
'Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability.',
173173
'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.',
174174
'Install flows: install/install-from-source first, then open the installed id with --relaunch.',
175175
'Text: fill \'id="field-email"\' "qa@example.com" replaces; type appends after press.',
@@ -351,7 +351,7 @@ React Native dev loop:
351351
agent-device find "Home"
352352
Do not use agent-device reload. Use open --relaunch for native startup reset.
353353
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.
354-
Android RN/Expo Metro: run adb reverse tcp:<port> tcp:<port> before opening the app or URL; it is harmless even if already configured.
354+
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.
355355
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:
356356
agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios
357357
agent-device snapshot -i --platform ios
@@ -512,7 +512,7 @@ React Native dev loop:
512512
agent-device metro reload
513513
agent-device find "Home"
514514
Do not use agent-device reload. Use open --relaunch for native startup reset.
515-
Android RN/Expo Metro: run adb reverse tcp:<port> tcp:<port> before opening the app or URL; it is harmless even if already configured.
515+
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.
516516
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.
517517
518518
Overlays and busy RN UIs:
@@ -621,7 +621,7 @@ Coverage:
621621
Navigation, forms, empty/error/loading states, offline or retry behavior, permissions, settings, accessibility labels, orientation/keyboard, and obvious performance stalls.
622622
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.
623623
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.
624-
Android RN/Expo Metro: run adb reverse tcp:<port> tcp:<port> before opening the app or URL; it is harmless even if already configured.
624+
Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability.
625625
Categories: visual, functional, UX, content, performance, diagnostics, permissions, accessibility.
626626
Severity: critical blocks a core flow/data/crashes; high breaks a major feature; medium has friction or workaround; low is polish.
627627

0 commit comments

Comments
 (0)