Skip to content

Commit d73703d

Browse files
committed
feat: split back navigation semantics
1 parent c393ffe commit d73703d

14 files changed

Lines changed: 219 additions & 14 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -511,11 +511,20 @@ extension RunnerTests {
511511
return Response(ok: true, data: DataPayload(message: "tmp/\(fileName)"))
512512
#endif
513513
case .back:
514-
if tapNavigationBack(app: activeApp) {
514+
if performDefaultBackAction(app: activeApp) {
515515
return Response(ok: true, data: DataPayload(message: "back"))
516516
}
517-
performBackGesture(app: activeApp)
518-
return Response(ok: true, data: DataPayload(message: "back"))
517+
return Response(ok: false, error: ErrorPayload(message: "back is not available"))
518+
case .backInApp:
519+
if tapInAppBackControl(app: activeApp) {
520+
return Response(ok: true, data: DataPayload(message: "backInApp"))
521+
}
522+
return Response(ok: false, error: ErrorPayload(message: "in-app back control is not available"))
523+
case .backSystem:
524+
if performSystemBackAction(app: activeApp) {
525+
return Response(ok: true, data: DataPayload(message: "backSystem"))
526+
}
527+
return Response(ok: false, error: ErrorPayload(message: "system back is not available"))
519528
case .home:
520529
pressHomeButton()
521530
return Response(ok: true, data: DataPayload(message: "home"))

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ extension RunnerTests {
2424

2525
// MARK: - Navigation Gestures
2626

27-
func tapNavigationBack(app: XCUIApplication) -> Bool {
27+
func tapInAppBackControl(app: XCUIApplication) -> Bool {
2828
#if os(macOS)
2929
if let back = macOSNavigationBackElement(app: app) {
3030
tapElementCenter(app: app, element: back)
@@ -37,7 +37,7 @@ extension RunnerTests {
3737
back.tap()
3838
return true
3939
}
40-
return pressTvRemoteMenuIfAvailable()
40+
return false
4141
#endif
4242
}
4343

@@ -51,6 +51,26 @@ extension RunnerTests {
5151
start.press(forDuration: 0.05, thenDragTo: end)
5252
}
5353

54+
func performDefaultBackAction(app: XCUIApplication) -> Bool {
55+
if tapInAppBackControl(app: app) {
56+
return true
57+
}
58+
performBackGesture(app: app)
59+
return true
60+
}
61+
62+
func performSystemBackAction(app: XCUIApplication) -> Bool {
63+
#if os(macOS)
64+
return tapInAppBackControl(app: app)
65+
#else
66+
if pressTvRemoteMenuIfAvailable() {
67+
return true
68+
}
69+
performBackGesture(app: app)
70+
return true
71+
#endif
72+
}
73+
5474
func performAppSwitcherGesture(app: XCUIApplication) {
5575
if performTvRemoteAppSwitcherIfAvailable() {
5676
return

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ extension RunnerTests {
170170

171171
func isInteractionCommand(_ command: CommandType) -> Bool {
172172
switch command {
173-
case .tap, .longPress, .drag, .type, .swipe, .back, .appSwitcher, .pinch:
173+
case .tap, .longPress, .drag, .type, .swipe, .back, .backInApp, .backSystem, .appSwitcher, .pinch:
174174
return true
175175
default:
176176
return false

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ enum CommandType: String, Codable {
1414
case snapshot
1515
case screenshot
1616
case back
17+
case backInApp
18+
case backSystem
1719
case home
1820
case appSwitcher
1921
case alert

skills/agent-device/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Use this skill as a router with mandatory defaults. Read this file first. For no
4848
- Use `get`, `is`, or `find` when they can answer the question without changing UI state.
4949
- Use `fill` to replace text.
5050
- Use `type` to append text.
51+
- When a task asks to "go back", prefer `back --in-app` for predictable app-owned navigation and reserve plain `back` or `back --system` for platform back gestures or button semantics.
5152
- If there is no simulator, no app install, or no open app session yet, switch to `bootstrap-install.md` instead of improvising setup steps.
5253
- Use the smallest unblock action first when transient UI blocks inspection, but do not navigate, search, or enter new text just to make the UI reveal data unless the user asked for that interaction.
5354
- Do not use external lookups to compensate for missing on-screen data unless the user asked for them.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { promises as fs } from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { dispatchCommand } from '../dispatch.ts';
7+
import type { DeviceInfo } from '../../utils/device.ts';
8+
9+
const ANDROID_DEVICE: DeviceInfo = {
10+
platform: 'android',
11+
id: 'emulator-5554',
12+
name: 'Pixel',
13+
kind: 'emulator',
14+
booted: true,
15+
};
16+
17+
async function withMockedAdb(
18+
tempPrefix: string,
19+
run: (argsLogPath: string) => Promise<void>,
20+
): Promise<void> {
21+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix));
22+
const adbPath = path.join(tmpDir, 'adb');
23+
const argsLogPath = path.join(tmpDir, 'args.log');
24+
await fs.writeFile(
25+
adbPath,
26+
'#!/bin/sh\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
27+
'utf8',
28+
);
29+
await fs.chmod(adbPath, 0o755);
30+
31+
const previousPath = process.env.PATH;
32+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
33+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
34+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
35+
36+
try {
37+
await run(argsLogPath);
38+
} finally {
39+
process.env.PATH = previousPath;
40+
if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
41+
else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
42+
await fs.rm(tmpDir, { recursive: true, force: true });
43+
}
44+
}
45+
46+
test('dispatch back --in-app falls back to Android system back keyevent', async () => {
47+
await withMockedAdb('agent-device-dispatch-back-in-app-', async (argsLogPath) => {
48+
const result = await dispatchCommand(ANDROID_DEVICE, 'back', [], undefined, {
49+
backMode: 'in-app',
50+
});
51+
52+
assert.equal(result?.action, 'back');
53+
assert.equal(result?.mode, 'in-app');
54+
const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean);
55+
assert.deepEqual(args, ['-s', 'emulator-5554', 'shell', 'input', 'keyevent', '4']);
56+
});
57+
});
58+
59+
test('dispatch back --system uses Android system back keyevent', async () => {
60+
await withMockedAdb('agent-device-dispatch-back-system-', async (argsLogPath) => {
61+
const result = await dispatchCommand(ANDROID_DEVICE, 'back', [], undefined, {
62+
backMode: 'system',
63+
});
64+
65+
assert.equal(result?.action, 'back');
66+
assert.equal(result?.mode, 'system');
67+
const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean);
68+
assert.deepEqual(args, ['-s', 'emulator-5554', 'shell', 'input', 'keyevent', '4']);
69+
});
70+
});

src/core/dispatch.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export async function dispatchCommand(
6767
jitterPx?: number;
6868
doubleTap?: boolean;
6969
clickButton?: 'primary' | 'secondary' | 'middle';
70+
backMode?: 'in-app' | 'system';
7071
pauseMs?: number;
7172
pattern?: 'one-way' | 'ping-pong';
7273
surface?: SessionSurface;
@@ -406,8 +407,8 @@ export async function dispatchCommand(
406407
return { path: screenshotPath };
407408
}
408409
case 'back': {
409-
await interactor.back();
410-
return { action: 'back' };
410+
await interactor.back(context?.backMode);
411+
return { action: 'back', mode: context?.backMode ?? 'default' };
411412
}
412413
case 'home': {
413414
await interactor.home();

src/platforms/ios/runner-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export type RunnerCommand = {
4040
| 'snapshot'
4141
| 'screenshot'
4242
| 'back'
43+
| 'backInApp'
44+
| 'backSystem'
4345
| 'home'
4446
| 'appSwitcher'
4547
| 'alert'

src/utils/__tests__/args.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ test('parseArgs recognizes command-specific flag combinations', async (t: TestCo
4242
assert.equal(parsed.flags.headless, true);
4343
},
4444
},
45+
{
46+
label: 'back --in-app',
47+
argv: ['back', '--in-app'],
48+
strictFlags: true,
49+
assertParsed: (parsed) => {
50+
assert.equal(parsed.command, 'back');
51+
assert.equal(parsed.flags.backMode, 'in-app');
52+
},
53+
},
54+
{
55+
label: 'back --system',
56+
argv: ['back', '--system'],
57+
strictFlags: true,
58+
assertParsed: (parsed) => {
59+
assert.equal(parsed.command, 'back');
60+
assert.equal(parsed.flags.backMode, 'system');
61+
},
62+
},
4563
{
4664
label: 'open --platform apple alias',
4765
argv: ['open', 'Settings', '--platform', 'apple', '--target', 'tv'],
@@ -534,6 +552,17 @@ test('parseArgs rejects invalid swipe pattern', () => {
534552
);
535553
});
536554

555+
test('parseArgs rejects conflicting back mode flags', () => {
556+
assert.throws(
557+
() => parseArgs(['back', '--in-app', '--system'], { strictFlags: true }),
558+
(error) =>
559+
error instanceof AppError &&
560+
error.code === 'INVALID_ARGS' &&
561+
error.message ===
562+
'back accepts only one explicit mode flag: use either --in-app or --system.',
563+
);
564+
});
565+
537566
test('usage includes concise top-level commands', () => {
538567
const usageText = usage();
539568
assert.match(usageText, /install-from-source <url>/);
@@ -790,6 +819,14 @@ test('command usage shows command and global flags separately', () => {
790819
assert.match(help, /--platform ios\|macos\|android\|apple/);
791820
});
792821

822+
test('back command usage documents explicit mode flags', () => {
823+
const help = usageForCommand('back');
824+
if (help === null) throw new Error('Expected command help text');
825+
assert.match(help, /agent-device back \[--in-app\|--system\]/);
826+
assert.match(help, /--in-app/);
827+
assert.match(help, /--system/);
828+
});
829+
793830
test('open command usage documents macOS desktop surface flags', () => {
794831
const help = usageForCommand('open');
795832
if (help === null) throw new Error('Expected command help text');
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { resolveAppleBackRunnerCommand } from '../interactors.ts';
4+
5+
test('resolveAppleBackRunnerCommand keeps default back behavior when no mode is provided', () => {
6+
assert.equal(resolveAppleBackRunnerCommand(), 'back');
7+
});
8+
9+
test('resolveAppleBackRunnerCommand maps explicit back modes to runner commands', () => {
10+
assert.equal(resolveAppleBackRunnerCommand('in-app'), 'backInApp');
11+
assert.equal(resolveAppleBackRunnerCommand('system'), 'backSystem');
12+
});

0 commit comments

Comments
 (0)