From 3d028dbc74a465f6f8df1813a931fbb5362e7dc3 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:38:33 +0800 Subject: [PATCH] fix(adb): distinguish absent focus from missing declarations Parse window focus into declaration records, normalize explicit null references once, and preserve original named lines in Evidence. Keep fallback based on declaration presence and retain unknown focus when both sections omit fields. Validation: 86 focused tests including public CLI/MCP with advertised schemas, plus docs checks and five conformance tests. Fake ADB only; no physical device verified. Refs #4608 --- docs/adb-device-analysis.md | 6 ++ src/adb/AdbProvider.test.ts | 39 +++++++- src/adb/AdbProvider.ts | 8 +- src/adb/AdbShellOutput.test.ts | 35 ++++++- src/adb/AdbShellOutput.ts | 27 +++-- tests/boundary/cli/adbWindowFocus.test.ts | 114 ++++++++++++++++++++++ tests/boundary/mcp/adbMcp.test.ts | 2 +- 7 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 tests/boundary/cli/adbWindowFocus.test.ts diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index 56c4d225fe..3921cbe746 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -103,6 +103,12 @@ existing analysis families directly, for example | `start_adb_activity` | **mutates device** | Starts an intent (`am start -a` with optional URI, component, and up to 16 typed extras); preserves the activity manager's verdict | | `stop_adb_app` | **mutates device** | Force-stops every process of a package, ending a dynamic-analysis run | +For `inspect_adb_window`, an explicitly printed null focus reference becomes +JSON `null`. `observed_lines` retains the original focus declarations, including +their field names, so explicit absence can be distinguished from omitted +declarations. REA reads the displays section only when the windows section +declares neither focus field. If both sections omit them, focus remains unknown. + For `install_adb_package`, `replace_requested` records whether `-r` was sent. The `replaced` result is `null`: the successful install response does not say whether the package was installed fresh or replaced, and REA does not query the diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index e3bc096037..dfe81d510c 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -1316,8 +1316,8 @@ it("reads Android 16 focus declarations from displays when windows omits them", focused_window: "Window{abc u0 com.example/.Main}", focused_app: "ActivityRecord{def u0 com.example/.Main t1}", observed_lines: [ - "Window{abc u0 com.example/.Main}", - "ActivityRecord{def u0 com.example/.Main t1}", + "mCurrentFocus=Window{abc u0 com.example/.Main}", + "mFocusedApp=ActivityRecord{def u0 com.example/.Main t1}", ], }); const execution = await providerOver(binary).execute({ @@ -1354,6 +1354,41 @@ it("describes missing window focus as unknown after both sections omit declarati ); }); +it.each(["windows", "displays"])( + "preserves explicitly absent focus from %s without another fallback", + async (section) => { + const declarations = " mCurrentFocus=null\n mFocusedApp=null\n"; + const { binary } = await writeAdbStub( + shellStub({ + "dumpsys:window:windows": + section === "windows" ? declarations : "no focus\n", + ...(section === "displays" + ? { "dumpsys:window:displays": declarations } + : {}), + }), + ); + const execution = await providerOver(binary).execute({ + operation: "inspect_adb_window", + input: { serial: "emulator-5554" }, + }); + expect(execution.ok).toBe(true); + if (!execution.ok) throw execution.error; + expect(execution.value.result).toMatchObject({ + focused_window: null, + focused_app: null, + observed_lines: [" mCurrentFocus=null", " mFocusedApp=null"], + }); + expect(execution.value.limitations).toContain( + `Focus declarations were read from \`dumpsys window ${section}\``, + ); + expect( + execution.value.limitations.some((line) => + line.includes("focus is unknown"), + ), + ).toBe(false); + }, +); + it("reads one settings value and reports device-side nulls", async () => { const { binary } = await writeAdbStub(shellStub({ "settings:get": "1\n" })); const enabled = await evidenceResult(providerOver(binary), { diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 9fdca59419..59b5aba716 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -1762,11 +1762,11 @@ export class AdbProvider { { client, serial: request.input.serial, - focused_window: focusedWindow, - focused_app: focusedApp, + focused_window: focusedWindow?.value ?? null, + focused_app: focusedApp?.value ?? null, observed_lines: [ - ...(focusedWindow === null ? [] : [focusedWindow]), - ...(focusedApp === null ? [] : [focusedApp]), + ...(focusedWindow === null ? [] : [focusedWindow.rawLine]), + ...(focusedApp === null ? [] : [focusedApp.rawLine]), ], }, provider, diff --git a/src/adb/AdbShellOutput.test.ts b/src/adb/AdbShellOutput.test.ts index 20182153e7..5a04d1de7d 100644 --- a/src/adb/AdbShellOutput.test.ts +++ b/src/adb/AdbShellOutput.test.ts @@ -1,6 +1,39 @@ import { describe, expect, it } from "vitest"; -import { parseAmStartOutput, parseWmDensityOutput } from "./AdbShellOutput.js"; +import { + parseAmStartOutput, + parseWmDensityOutput, + parseWindowFocusOutput, +} from "./AdbShellOutput.js"; + +describe("parseWindowFocusOutput", () => { + it("separates missing declarations from explicit absence and retains source lines", () => { + expect(parseWindowFocusOutput("no declarations\n")).toEqual({ + focusedWindow: null, + focusedApp: null, + }); + expect( + parseWindowFocusOutput(" mCurrentFocus=null\nmFocusedApp=null\n"), + ).toEqual({ + focusedWindow: { value: null, rawLine: " mCurrentFocus=null" }, + focusedApp: { value: null, rawLine: "mFocusedApp=null" }, + }); + }); + + it("does not reinterpret later declarations after the first explicitly absent one", () => { + expect( + parseWindowFocusOutput( + "mCurrentFocus=null\nmCurrentFocus=Window{later}\nmFocusedApp=ActivityRecord{current}\n", + ), + ).toEqual({ + focusedWindow: { value: null, rawLine: "mCurrentFocus=null" }, + focusedApp: { + value: "ActivityRecord{current}", + rawLine: "mFocusedApp=ActivityRecord{current}", + }, + }); + }); +}); describe("parseWmDensityOutput", () => { it("reads labeled physical and override densities", () => { diff --git a/src/adb/AdbShellOutput.ts b/src/adb/AdbShellOutput.ts index 118dc05a29..51fd43d041 100644 --- a/src/adb/AdbShellOutput.ts +++ b/src/adb/AdbShellOutput.ts @@ -181,21 +181,30 @@ export const parseWmDensityOutput = ( return { physical: physical ?? bare, override }; }; -/** Parse focus declarations from `dumpsys window windows` or `displays`. */ +interface WindowFocusDeclaration { + readonly value: string | null; + readonly rawLine: string; +} + +/** A missing declaration differs from a declared null object reference. */ export const parseWindowFocusOutput = ( stdout: string, ): { - focusedWindow: string | null; - focusedApp: string | null; + focusedWindow: WindowFocusDeclaration | null; + focusedApp: WindowFocusDeclaration | null; } => { - let focusedWindow = null; - let focusedApp = null; + let focusedWindow: WindowFocusDeclaration | null = null; + let focusedApp: WindowFocusDeclaration | null = null; for (const line of stdout.split(/\r?\n/u)) { const trimmed = line.trim(); - if (focusedWindow === null && trimmed.startsWith("mCurrentFocus=")) - focusedWindow = trimmed.slice("mCurrentFocus=".length).trim(); - if (focusedApp === null && trimmed.startsWith("mFocusedApp=")) - focusedApp = trimmed.slice("mFocusedApp=".length).trim(); + if (focusedWindow === null && trimmed.startsWith("mCurrentFocus=")) { + const value = trimmed.slice("mCurrentFocus=".length).trim(); + focusedWindow = { value: value === "null" ? null : value, rawLine: line }; + } + if (focusedApp === null && trimmed.startsWith("mFocusedApp=")) { + const value = trimmed.slice("mFocusedApp=".length).trim(); + focusedApp = { value: value === "null" ? null : value, rawLine: line }; + } } return { focusedWindow, focusedApp }; }; diff --git a/tests/boundary/cli/adbWindowFocus.test.ts b/tests/boundary/cli/adbWindowFocus.test.ts new file mode 100644 index 0000000000..d54163ceb7 --- /dev/null +++ b/tests/boundary/cli/adbWindowFocus.test.ts @@ -0,0 +1,114 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, onTestFinished } from "vitest"; + +import { AdbProvider } from "../../../src/adb/AdbProvider.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +for (const { serial, section, focused, missing } of [ + { serial: "null-device", section: "windows", focused: false, missing: false }, + { + serial: "fallback-null-device", + section: "displays", + focused: false, + missing: false, + }, + { + serial: "focused-device", + section: "windows", + focused: true, + missing: false, + }, + { + serial: "missing-device", + section: "displays", + focused: false, + missing: true, + }, +]) { + cliTest.skipIf(process.platform === "win32")( + `preserves focus presence through CLI and MCP: ${serial}`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-adb-window-focus-"); + const binary = join(root, "adb"); + await writeFile( + binary, + `#!/bin/sh +if [ "$1" = version ]; then + printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-fixture\\nInstalled as %s\\n' "$0" + exit 0 +fi +if [ "$1" = -s ] && [ "$3" = shell ] && [ "$4" = dumpsys ] && [ "$5" = window ]; then + printf '%s\\n' "$6" >> "$0.calls" + if [ "$2" = missing-device ]; then printf 'no focus declarations\\n'; exit 0; fi + if [ "$2" = fallback-null-device ] && [ "$6" = windows ]; then printf 'no focus declarations\\n'; exit 0; fi + if [ "$2" = focused-device ]; then + printf ' mCurrentFocus=Window{current}\\n mFocusedApp=ActivityRecord{current}\\n' + else + printf ' mCurrentFocus=null\\n mFocusedApp=null\\n' + fi + exit 0 +fi +exit 1 +`, + { mode: 0o755 }, + ); + const output = await cli.run({ + arguments: ["inspect-adb-window", serial, "--json"], + environment: { REA_ADB_PATH: binary }, + }); + expect(output.exitCode).toBe(0); + const provider = new AdbProvider({ + environment: { REA_ADB_PATH: binary }, + }); + const harness = await createApplicationMcpHarness({ + serverOptions: { adbDeviceAnalysis: provider }, + }); + onTestFinished(() => harness.close()); + const schemas = await advertisedMcpToolSchemas( + harness.client, + "inspect_adb_window", + ); + const args = { serial }; + expect(schemas.ajv.validate(schemas.inputSchema, args)).toBe(true); + const response = await harness.client.callTool({ + name: schemas.name, + arguments: args, + }); + expect(response.isError).not.toBe(true); + expect( + schemas.ajv.validate(schemas.outputSchema, response.structuredContent), + ).toBe(true); + for (const raw of [output.json, response.structuredContent]) { + const evidence = parseEvidence( + toolContract("inspect_adb_window").outputSchema.parse(raw), + ); + expect(evidence.normalized_result).toMatchObject({ + focused_window: focused ? "Window{current}" : null, + focused_app: focused ? "ActivityRecord{current}" : null, + observed_lines: missing + ? [] + : [ + ` mCurrentFocus=${focused ? "Window{current}" : "null"}`, + ` mFocusedApp=${focused ? "ActivityRecord{current}" : "null"}`, + ], + }); + expect(evidence.limitations).toContainEqual( + missing + ? expect.stringContaining("focus is unknown, not absent") + : `Focus declarations were read from \`dumpsys window ${section}\``, + ); + } + const sections = + section === "windows" ? ["windows"] : ["windows", "displays"]; + expect( + (await readFile(`${binary}.calls`, "utf8")).trim().split("\n"), + ).toEqual([...sections, ...sections]); + }, + ); +} diff --git a/tests/boundary/mcp/adbMcp.test.ts b/tests/boundary/mcp/adbMcp.test.ts index 61af5378fe..b629287e6b 100644 --- a/tests/boundary/mcp/adbMcp.test.ts +++ b/tests/boundary/mcp/adbMcp.test.ts @@ -196,7 +196,7 @@ const results: Readonly> = { serial: "emulator-5554", focused_window: "Window{abcdef u0 com.example.app/Main}", focused_app: null, - observed_lines: ["Window{abcdef u0 com.example.app/Main}"], + observed_lines: ["mCurrentFocus=Window{abcdef u0 com.example.app/Main}"], }, read_adb_setting: { client: clientIdentity,