diff --git a/packages/platform-android/src/__tests__/doctor.test.ts b/packages/platform-android/src/__tests__/doctor.test.ts index 22aea044ac..46066a1e2d 100644 --- a/packages/platform-android/src/__tests__/doctor.test.ts +++ b/packages/platform-android/src/__tests__/doctor.test.ts @@ -74,6 +74,53 @@ async function runImeCheck(adb: AndroidAdbExecutor): Promise 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 { + 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'); diff --git a/packages/platform-android/src/__tests__/emulator-lifecycle.test.ts b/packages/platform-android/src/__tests__/emulator-lifecycle.test.ts index 2b1ed3052c..8185ed2db5 100644 --- a/packages/platform-android/src/__tests__/emulator-lifecycle.test.ts +++ b/packages/platform-android/src/__tests__/emulator-lifecycle.test.ts @@ -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', @@ -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 }> { + 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, + }), + ); +} + +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); + + 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'); +}); diff --git a/packages/platform-harmonyos/src/__tests__/hdc.test.ts b/packages/platform-harmonyos/src/__tests__/hdc.test.ts index 8de56a5782..06a396aacd 100644 --- a/packages/platform-harmonyos/src/__tests__/hdc.test.ts +++ b/packages/platform-harmonyos/src/__tests__/hdc.test.ts @@ -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(); return { ...actual, runCmd: vi.fn() }; }); +vi.mock('@agent-device/host-kit/process', async (importOriginal) => { + const actual = await importOriginal(); + 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' }; @@ -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 { + 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 { + 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)); +}); diff --git a/packages/platform-linux/src/__tests__/snapshot.test.ts b/packages/platform-linux/src/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..9c1d22b5b3 --- /dev/null +++ b/packages/platform-linux/src/__tests__/snapshot.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; + +const { captureAccessibilityTree } = vi.hoisted(() => ({ + captureAccessibilityTree: vi.fn(), +})); + +// snapshot.ts consumes the AT-SPI2 bridge seam; fake it rather than a generic exec mock. +vi.mock('../atspi-bridge.ts', () => ({ captureAccessibilityTree })); + +import { readLinuxTextAtPoint, snapshotLinux } from '../snapshot.ts'; + +const mockCapture = vi.mocked(captureAccessibilityTree); + +function node(partial: Partial & { index: number }): RawSnapshotNode { + return { type: 'text', ...partial }; +} + +function captureNodes(nodes: RawSnapshotNode[]): void { + mockCapture.mockResolvedValue({ nodes, truncated: false, surface: 'desktop' }); +} + +beforeEach(() => { + mockCapture.mockReset(); +}); + +test('snapshotLinux maps the app surface to the frontmost app and forwards the signal', async () => { + const signal = new AbortController().signal; + captureNodes([]); + + await snapshotLinux('app', signal); + + assert.deepEqual(mockCapture.mock.calls[0], ['frontmost-app', { signal }]); +}); + +test('snapshotLinux falls back to desktop for the unsupported menubar surface', async () => { + captureNodes([]); + + await snapshotLinux('menubar'); + + assert.equal(mockCapture.mock.calls[0]?.[0], 'desktop'); +}); + +test('readLinuxTextAtPoint prefers the deepest match and the smaller area at equal depth', async () => { + captureNodes([ + node({ + index: 0, + depth: 0, + label: 'window', + rect: { x: 0, y: 0, width: 200, height: 200 }, + }), + node({ + index: 1, + depth: 1, + label: 'outer-group', + rect: { x: 10, y: 10, width: 100, height: 100 }, + }), + node({ + index: 2, + depth: 1, + label: 'button', + rect: { x: 20, y: 20, width: 40, height: 20 }, + }), + node({ + index: 3, + depth: 1, + label: 'elsewhere', + rect: { x: 150, y: 150, width: 30, height: 30 }, + }), + node({ index: 4, depth: 2, label: 'no-rect' }), + ]); + + const text = await readLinuxTextAtPoint(30, 25, 'desktop'); + + // Deepest first among equal depths, the narrower rect wins: the 40x20 button, not the 100x100 group. + assert.equal(text, 'button'); +}); + +test('readLinuxTextAtPoint skips matches without readable text and reads value before label', async () => { + captureNodes([ + node({ + index: 0, + depth: 1, + label: '', + value: '', + rect: { x: 0, y: 0, width: 10, height: 10 }, + }), + node({ + index: 1, + depth: 0, + value: 'entry-value', + label: 'ignored-label', + rect: { x: 0, y: 0, width: 40, height: 40 }, + }), + ]); + + assert.equal(await readLinuxTextAtPoint(5, 5, 'desktop'), 'entry-value'); +}); + +test('readLinuxTextAtPoint reads the identifier when value and label are absent', async () => { + captureNodes([ + node({ + index: 0, + depth: 0, + identifier: 'accessibility-id', + rect: { x: 0, y: 0, width: 10, height: 10 }, + }), + ]); + + assert.equal(await readLinuxTextAtPoint(5, 5, 'desktop'), 'accessibility-id'); +}); + +test('readLinuxTextAtPoint returns an empty string when no rect covers the point', async () => { + captureNodes([ + node({ + index: 0, + depth: 0, + label: 'far', + rect: { x: 100, y: 100, width: 10, height: 10 }, + }), + ]); + + assert.equal(await readLinuxTextAtPoint(0, 0, 'desktop'), ''); +}); + +test('readLinuxTextAtPoint ties depth 0 through rect area when rects are degenerate', async () => { + // Zero-area rects keep the sort stable on rect area (0 * 0 === 0 * 0) and still resolve by tree order. + captureNodes([ + node({ index: 0, depth: 0, label: 'first', rect: { x: 0, y: 0, width: 0, height: 0 } }), + node({ index: 1, depth: 0, label: 'second', rect: { x: 0, y: 0, width: 0, height: 10 } }), + ]); + + // Vitest/JS sort is stable, so equal depth and equal area resolve in tree order. + assert.equal(await readLinuxTextAtPoint(0, 0, 'desktop'), 'first'); +}); + +test('readLinuxTextAtPoint defaults a missing depth to 0 when ordering matches', async () => { + captureNodes([ + node({ index: 0, label: 'no-depth', rect: { x: 0, y: 0, width: 100, height: 100 } }), + node({ index: 1, depth: 0, label: 'root', rect: { x: 0, y: 0, width: 100, height: 100 } }), + ]); + + assert.equal(await readLinuxTextAtPoint(50, 50, 'desktop'), 'no-depth'); +}); diff --git a/packages/provision-kit/src/boot-diagnostics.test.ts b/packages/provision-kit/src/boot-diagnostics.test.ts index abcd449414..9cd404750f 100644 --- a/packages/provision-kit/src/boot-diagnostics.test.ts +++ b/packages/provision-kit/src/boot-diagnostics.test.ts @@ -1,6 +1,10 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { bootFailureHint, classifyBootFailure } from './boot-diagnostics.ts'; +import { + bootFailureHint, + classifyBootFailure, + type BootFailureReason, +} from './boot-diagnostics.ts'; import { isInfrastructureBootFailureReason } from '@agent-device/contracts/boot-failure'; import { AppError } from '@agent-device/kernel/errors'; @@ -51,6 +55,20 @@ test('bootFailureHint returns actionable guidance', () => { assert.equal(hint.includes('xcodebuild logs'), true); }); +const HINT_CASES = [ + ['IOS_BOOT_TIMEOUT', /simctl bootstatus/], + ['ANDROID_BOOT_TIMEOUT', /sys\.boot_completed/], + ['ADB_TRANSPORT_UNAVAILABLE', /adb devices -l/], + ['CI_RESOURCE_STARVATION_SUSPECTED', /resource constrained/], + ['IOS_TOOL_MISSING', /xcode-select --install/], + ['BOOT_COMMAND_FAILED', /failing boot phase/], + ['UNKNOWN', /Retry once/], +] satisfies ReadonlyArray; + +test.each(HINT_CASES)('bootFailureHint gives %s its own remediation text', (reason, expected) => { + assert.match(bootFailureHint(reason), expected); +}); + test('connect phase does not classify non-timeout errors as connect timeout', () => { const reason = classifyBootFailure({ message: 'Runner returned malformed JSON payload',