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
47 changes: 47 additions & 0 deletions packages/platform-android/src/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,53 @@ async function runImeCheck(adb: AndroidAdbExecutor): Promise<DoctorCheck | undef
return checks.find((check) => check.id === 'android-test-ime');
}

function contextWithMetroProbe(
reverse: () => Promise<{ exitCode: number; stdout: string; stderr: string }>,
): HostDiagnosticsContext {
const metroAdb: AndroidAdbExecutor = async (args, options) => {
if (args[0] === 'reverse') {
assert.deepEqual(args, ['reverse', '--list']);
assert.equal(options?.allowFailure, true);
return await reverse();
}
return await fakeAdb(NORMAL_IME)(args, options);
};
return Object.freeze({ ...contextWith(metroAdb), shouldProbeMetro: true });
}

async function runReverseCheck(
reverse: () => Promise<{ exitCode: number; stdout: string; stderr: string }>,
): Promise<DoctorCheck | undefined> {
const checks = await androidDeviceChecks(ANDROID_EMULATOR, contextWithMetroProbe(reverse));
return checks.find((check) => check.id === 'android-reverse');
}

test('reports pass when the Metro reverse mapping already exists', async () => {
const check = await runReverseCheck(async () => ({
exitCode: 0,
stdout: 'tcp:8081 tcp:8081\n',
stderr: '',
}));
assert.equal(check?.status, 'pass');
assert.equal(check?.command, undefined);
assert.match(check?.summary ?? '', /exists for Metro port 8081/);
});

test('reports warn with the reverse command when the Metro mapping is missing', async () => {
const check = await runReverseCheck(async () => ({ exitCode: 0, stdout: '', stderr: '' }));
assert.equal(check?.status, 'warn');
assert.equal(check?.command, `adb -s ${ANDROID_EMULATOR.id} reverse tcp:8081 tcp:8081`);
});

test('reports warn instead of failing the doctor when the reverse probe errors', async () => {
const check = await runReverseCheck(async () => {
throw new Error('adb server died');
});
assert.equal(check?.status, 'warn');
assert.match(check?.summary ?? '', /Could not inspect Android adb reverse/);
assert.match(check?.hint ?? '', /adb server died/);
});

test('reports pass when the normal IME is active', async () => {
const check = await runImeCheck(fakeAdb(NORMAL_IME));
assert.equal(check?.status, 'pass');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ vi.mock('../sdk.ts', () => ({ ensureAndroidSdkPathConfigured: vi.fn(async () =>

import './test-utils/android-host-test-setup.ts';
import { runCmd, runCmdDetached, whichCmd } from '@agent-device/host-kit/command';
import { ensureAndroidEmulatorBooted } from '../emulator-lifecycle.ts';
import { AppError } from '@agent-device/kernel/errors';
import { ensureAndroidEmulatorBooted, waitForAndroidBoot } from '../emulator-lifecycle.ts';

const stopped: DeviceInfo = {
platform: 'android',
Expand Down Expand Up @@ -101,3 +102,92 @@ test('emulator boot excludes a running serial outside the resolved Android allow
assert.deepEqual(requests[0]?.androidSerialAllowlist, ['emulator-9999']);
assert.equal(mockRunCmdDetached.mock.calls.length, 0);
});

function bootPropFailure(stdout: string, stderr: string) {
return async () => {
await Promise.resolve();
return { stdout, stderr, exitCode: 1 };
};
}

/** A poll that keeps missing like a real booting device does, and actually burns wall-clock time. */
function slowBootPropFailure(delayMs: number) {
return async () => {
await new Promise((resolve) => setTimeout(resolve, delayMs));
return { stdout: '', stderr: '', exitCode: 1 };
};
}

async function expectBootFailure(
timeoutMs: number,
): Promise<{ code: string; message: string; details: Record<string, unknown> }> {
return await waitForAndroidBoot('emulator-5554', timeoutMs).then(
() => {
throw new Error('expected waitForAndroidBoot to reject');
},
(error: AppError) => ({
code: error.code,
message: error.message,
details: (error.details ?? {}) as Record<string, unknown>,
}),
);
}

test('a boot poll against an offline device fails as an adb transport loss without retrying', async () => {
mockRunCmd.mockImplementation(bootPropFailure('', 'error: device offline'));

const failure = await expectBootFailure(500);

assert.equal(failure.code, 'COMMAND_FAILED');
assert.equal(failure.details.reason, 'ADB_TRANSPORT_UNAVAILABLE');
assert.match(String(failure.details.hint), /adb devices -l/);
assert.equal(failure.details.serial, 'emulator-5554');
assert.equal(failure.details.stderr, 'error: device offline');
assert.equal(failure.details.exitCode, 1);
// shouldRetry refuses the transport loss, so exactly one poll ran.
assert.equal(mockRunCmd.mock.calls.length, 1);
});

test('a still-booting device past its deadline reports an Android boot timeout', async () => {
// 80 ms per poll with a 50 ms retry cadence crosses the 150 ms deadline inside the second
// poll, so the failure is a device that ran out of budget, not three fast poll misses.
mockRunCmd.mockImplementation(slowBootPropFailure(80));

const failure = await expectBootFailure(150);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

assert.equal(failure.code, 'COMMAND_FAILED');
assert.equal(failure.message, 'Android device did not finish booting in time');
assert.equal(failure.details.reason, 'ANDROID_BOOT_TIMEOUT');
assert.equal(failure.details.timeoutMs, 150);
assert.equal(Number(failure.details.elapsedMs) >= 150, true);
assert.equal(mockRunCmd.mock.calls.length, 2);
// The classifier sees BOOT_COMMAND_FAILED from the still-booting throw; the caller must
// normalize it to the timeout, not trust the raw classification.
assert.notEqual(failure.details.reason, 'BOOT_COMMAND_FAILED');
});

test('a TOOL_MISSING adb inside the boot loop keeps its error code and hint', async () => {
mockRunCmd.mockRejectedValue(new AppError('TOOL_MISSING', 'adb not found in PATH'));

const failure = await expectBootFailure(500);

assert.equal(failure.code, 'TOOL_MISSING');
assert.equal(failure.message, 'adb not found in PATH');
assert.equal(failure.details.reason, 'ADB_TRANSPORT_UNAVAILABLE');
});

test('boot failure details keep the original error details over the poll evidence', async () => {
mockRunCmd.mockRejectedValue(
new AppError('COMMAND_FAILED', 'error: device not found', {
reason: 'BOOT_COMMAND_FAILED',
diagnosticId: 'adb-x',
}),
);

const failure = await expectBootFailure(500);

assert.equal(failure.code, 'COMMAND_FAILED');
assert.equal(failure.message, 'error: device not found');
assert.equal(failure.details.reason, 'BOOT_COMMAND_FAILED');
assert.equal(failure.details.diagnosticId, 'adb-x');
});
99 changes: 98 additions & 1 deletion packages/platform-harmonyos/src/__tests__/hdc.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { beforeEach, test, vi } from 'vitest';

vi.mock('@agent-device/host-kit/command', async (importOriginal) => {
const actual = await importOriginal<typeof import('@agent-device/host-kit/command')>();
return { ...actual, runCmd: vi.fn() };
});
vi.mock('@agent-device/host-kit/process', async (importOriginal) => {
const actual = await importOriginal<typeof import('@agent-device/host-kit/process')>();
return { ...actual, hostEnvironment: vi.fn() };
});

import { runCmd } from '@agent-device/host-kit/command';
import { DEFAULT_HARMONY_HDC_TIMEOUT_MS, runHarmonyHdc, runHarmonyShell } from '../hdc.ts';
import { hostEnvironment } from '@agent-device/host-kit/process';
import {
DEFAULT_HARMONY_HDC_TIMEOUT_MS,
ensureHarmonyToolchainPathConfigured,
runHarmonyHdc,
runHarmonyShell,
} from '../hdc.ts';
import { mkdtempForTestSync } from './tmp-dir.ts';

const mockRunCmd = vi.mocked(runCmd);
const device = { id: '127.0.0.1:5555' };
Expand Down Expand Up @@ -53,3 +66,87 @@ test('runHarmonyHdc forwards a binding abort signal to an in-flight HDC command'
await assert.rejects(pending, (error: unknown) => error === aborted);
assert.equal(mockRunCmd.mock.calls[0]?.[2]?.signal, controller.signal);
});

/** HDC_SDK_PATH names the toolchains directory itself, so `HDC_SDK_PATH/hdc` must exist. */
async function seedDirectToolchain(root: string): Promise<string> {
await fs.mkdir(root, { recursive: true });
const hdc = path.join(root, 'hdc');
await fs.writeFile(hdc, '#!/bin/sh\nexit 0\n', 'utf8');
await fs.chmod(hdc, 0o755);
return root;
}

/** DevEco's home joins `default/openharmony/toolchains`; the command-line-tools root adds `sdk` first. */
async function seedNestedToolchain(root: string, ...segments: string[]): Promise<string> {
const toolchains = path.join(root, ...segments, 'default', 'openharmony', 'toolchains');
await fs.mkdir(toolchains, { recursive: true });
const hdc = path.join(toolchains, 'hdc');
await fs.writeFile(hdc, '#!/bin/sh\nexit 0\n', 'utf8');
await fs.chmod(hdc, 0o755);
return root;
}

test('ensureHarmonyToolchainPathConfigured prepends every root holding an hdc binary', async () => {
const hdcSdkRoot = await seedDirectToolchain(mkdtempForTestSync('agent-device-hdc-sdk-'));
const devecoRoot = await seedNestedToolchain(mkdtempForTestSync('agent-device-hdc-deveco-'));
const cmdlineRoot = await seedNestedToolchain(
mkdtempForTestSync('agent-device-hdc-cmdline-'),
'sdk',
);

const env: NodeJS.ProcessEnv = {
PATH: '/usr/bin',
HDC_SDK_PATH: hdcSdkRoot,
DEVECO_SDK_HOME: devecoRoot,
HARMONYOS_COMMAND_LINE_TOOLS: cmdlineRoot,
};

await ensureHarmonyToolchainPathConfigured(env);

assert.deepEqual((env.PATH ?? '').split(path.delimiter), [
hdcSdkRoot,
path.join(devecoRoot, 'default', 'openharmony', 'toolchains'),
path.join(cmdlineRoot, 'sdk', 'default', 'openharmony', 'toolchains'),
'/usr/bin',
]);
});

test('ensureHarmonyToolchainPathConfigured leaves PATH untouched when no root holds hdc', async () => {
const env: NodeJS.ProcessEnv = {
PATH: '/usr/bin',
HDC_SDK_PATH: ' ',
DEVECO_SDK_HOME: mkdtempForTestSync('agent-device-hdc-none-'),
HARMONYOS_COMMAND_LINE_TOOLS: mkdtempForTestSync('agent-device-hdc-none-'),
};

await ensureHarmonyToolchainPathConfigured(env);

assert.equal(env.PATH, '/usr/bin');
});

test('ensureHarmonyToolchainPathConfigured creates PATH and dedupes a root already on it', async () => {
const toolchains = await seedDirectToolchain(mkdtempForTestSync('agent-device-hdc-dup-'));

const env: NodeJS.ProcessEnv = {
PATH: `/usr/bin${path.delimiter}${toolchains}`,
HDC_SDK_PATH: toolchains,
};
await ensureHarmonyToolchainPathConfigured(env);

// The toolchain root moves to the front instead of appearing twice.
assert.equal(env.PATH, [toolchains, '/usr/bin'].join(path.delimiter));

const missingPath: NodeJS.ProcessEnv = { HDC_SDK_PATH: toolchains };
await ensureHarmonyToolchainPathConfigured(missingPath);
assert.equal(missingPath.PATH, toolchains);
});

test('ensureHarmonyToolchainPathConfigured defaults to the host environment', async () => {
const toolchains = await seedDirectToolchain(mkdtempForTestSync('agent-device-hdc-host-'));
const hostEnv: NodeJS.ProcessEnv = { PATH: '/usr/bin', HDC_SDK_PATH: toolchains };
vi.mocked(hostEnvironment).mockReturnValue(hostEnv);

await ensureHarmonyToolchainPathConfigured();

assert.equal(hostEnv.PATH, [toolchains, '/usr/bin'].join(path.delimiter));
});
Loading
Loading