Skip to content
Open
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
5 changes: 3 additions & 2 deletions packages/android/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,9 @@ export class AndroidAgent extends PageAgent<AndroidDevice> {
*/
async runAdbShell(command: string, opt?: RunAdbShellOpt): Promise<string> {
if (opt?.timeout !== undefined) {
const adb = await this.interface.getAdb();
return await adb.shell(command, { timeout: opt.timeout });
return await this.interface.runShellCommandWithExitCode(command, {
timeout: opt.timeout,
});
}

const action =
Expand Down
59 changes: 57 additions & 2 deletions packages/android/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ const defaultNormalScrollDuration = 1000;

const IME_STRATEGY_ALWAYS_YADB = 'always-yadb' as const;
const IME_STRATEGY_YADB_FOR_NON_ASCII = 'yadb-for-non-ascii' as const;

// Marker used to capture the exit code of a command executed via `adb shell`.
// `adb.shell` only resolves with stdout, and on devices using the legacy adb
// shell protocol the remote exit code is not propagated to the adb process,
// so we echo it explicitly and parse it back from stdout.
const ADB_SHELL_EXIT_CODE_MARKER = '__MIDSCENE_ADB_EXIT_CODE__';
type ScrollDirection = 'up' | 'down' | 'left' | 'right';

const debugDevice = getDebug('android:device');
Expand Down Expand Up @@ -441,6 +447,56 @@ ${Object.keys(size)
});
}

/**
* Execute an `adb shell` command and surface the remote command's exit code.
*
* `adb.shell` only resolves with stdout, and on devices that use the legacy
* adb shell protocol the remote exit code is not propagated to the adb
* process. That means a failing command would resolve as if it succeeded and
* the caller could not tell it failed. To make failures observable across
* shell protocol versions, append an exit-code marker to the command, parse
* it back from stdout, and throw when the command exited non-zero.
*
* @returns stdout of the command (without the exit-code marker) on success.
* @throws if the command exits with a non-zero code.
*/
public async runShellCommandWithExitCode(
command: string,
opts: { timeout?: number } = {},
): Promise<string> {
const adb = await this.getAdb();
const wrappedCommand = `${command}\necho "${ADB_SHELL_EXIT_CODE_MARKER}$?"`;
const { stdout, stderr } = (await adb.shell(wrappedCommand, {
...opts,
outputFormat: adb.EXEC_OUTPUT_FORMAT.FULL,
})) as { stdout: string; stderr: string };

const markerMatch = stdout.match(
new RegExp(`${ADB_SHELL_EXIT_CODE_MARKER}(-?\\d+)\\s*$`),
);
if (!markerMatch || markerMatch.index === undefined) {
// The marker is missing (unexpected). Return the raw stdout untouched so
// we don't mask the command output, but log it for debugging.
debugDevice(
`runShellCommandWithExitCode: exit code marker not found for command: ${command}`,
);
return stdout;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Throw when the exit marker is missing

When the user command terminates the shell before the appended echo can run, such as agent.runAdbShell('exit 1') or exec false, no marker is emitted. On the legacy adb shell protocol this is the exact case where the remote exit status is not propagated to the host, so returning raw stdout here still reports a failing command as successful instead of surfacing the failure.

Useful? React with 👍 / 👎.

}

const exitCode = Number(markerMatch[1]);
const commandStdout = stdout.slice(0, markerMatch.index).replace(/\n$/, '');

if (exitCode !== 0) {
throw new Error(
`ADB shell command exited with code ${exitCode}: ${command}\n` +
`stdout: ${commandStdout || '<empty>'}\n` +
`stderr: ${stderr?.trim() || '<empty>'}`,
);
}

return commandStdout;
}

/**
* Get or create the scrcpy adapter (lazy initialization)
*/
Expand Down Expand Up @@ -2049,8 +2105,7 @@ const createPlatformActions = (
if (!param.command || param.command.trim() === '') {
throw new Error('RunAdbShell requires a non-empty command parameter');
}
const adb = await device.getAdb();
return await adb.shell(param.command);
return await device.runShellCommandWithExitCode(param.command);
},
}),
Launch: defineAction<typeof launchParamSchema, LaunchParam, void>({
Expand Down
13 changes: 9 additions & 4 deletions packages/android/tests/unit-test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,13 @@ describe('AndroidAgent', () => {
});

describe('runAdbShell', () => {
it('should pass timeout options to adb.shell without changing action schema', async () => {
it('should pass timeout options to the device shell helper without changing action schema', async () => {
const mockPage = new AndroidDevice('test-device');
const shell = vi.fn().mockResolvedValue('adb-result');
(mockPage as any).getAdb = vi.fn().mockResolvedValue({ shell });
const runShellCommandWithExitCode = vi
.fn()
.mockResolvedValue('adb-result');
(mockPage as any).runShellCommandWithExitCode =
runShellCommandWithExitCode;

const agent = new AndroidAgent(mockPage, {
modelConfig: mockedModelConfig,
Expand All @@ -168,7 +171,9 @@ describe('AndroidAgent', () => {
await expect(
agent.runAdbShell('sleep 2', { timeout: 2_000 }),
).resolves.toBe('adb-result');
expect(shell).toHaveBeenCalledWith('sleep 2', { timeout: 2_000 });
expect(runShellCommandWithExitCode).toHaveBeenCalledWith('sleep 2', {
timeout: 2_000,
});
});
});

Expand Down
63 changes: 63 additions & 0 deletions packages/android/tests/unit-test/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const createMockAdb = () => ({
hideKeyboard: vi.fn(),
push: vi.fn(),
isSoftKeyboardPresent: vi.fn().mockResolvedValue(false),
EXEC_OUTPUT_FORMAT: { STDOUT: 'stdout', FULL: 'full' },
});

let mockAdbInstance: ReturnType<typeof createMockAdb>;
Expand Down Expand Up @@ -2487,4 +2488,66 @@ describe('AndroidDevice', () => {
);
});
});

describe('runShellCommandWithExitCode', () => {
it('should append an exit-code marker and request full output', async () => {
mockAdb.shell.mockResolvedValueOnce({
stdout: 'hello\n__MIDSCENE_ADB_EXIT_CODE__0',
stderr: '',
} as any);

const result = await device.runShellCommandWithExitCode('echo hello');

expect(result).toBe('hello');
expect(mockAdb.shell).toHaveBeenCalledWith(
'echo hello\necho "__MIDSCENE_ADB_EXIT_CODE__$?"',
{ outputFormat: 'full' },
);
});

it('should forward the timeout option to adb.shell', async () => {
mockAdb.shell.mockResolvedValueOnce({
stdout: '__MIDSCENE_ADB_EXIT_CODE__0',
stderr: '',
} as any);

await device.runShellCommandWithExitCode('whoami', { timeout: 2_000 });

expect(mockAdb.shell).toHaveBeenCalledWith(
'whoami\necho "__MIDSCENE_ADB_EXIT_CODE__$?"',
{ timeout: 2_000, outputFormat: 'full' },
);
});

it('should throw with exit code, stdout and stderr when command fails', async () => {
mockAdb.shell.mockResolvedValueOnce({
stdout: 'partial output\n__MIDSCENE_ADB_EXIT_CODE__1',
stderr: 'No such file or directory',
} as any);

await expect(
device.runShellCommandWithExitCode('cat /missing'),
).rejects.toThrow(/exited with code 1/);

mockAdb.shell.mockResolvedValueOnce({
stdout: '__MIDSCENE_ADB_EXIT_CODE__2',
stderr: 'boom',
} as any);

await expect(device.runShellCommandWithExitCode('false')).rejects.toThrow(
/stderr: boom/,
);
});

it('should return raw stdout when the marker is missing', async () => {
mockAdb.shell.mockResolvedValueOnce({
stdout: 'unexpected output without marker',
stderr: '',
} as any);

const result = await device.runShellCommandWithExitCode('weird');

expect(result).toBe('unexpected output without marker');
});
});
});
Loading