diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index 80d5bd99fd..9017317a5a 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -162,8 +162,9 @@ and during shutdown. APKs keep other names report the `unknown` role rather than guessing. - **Partial results keep facts.** Failed individual pulls return successful artifacts with `coverage: "partial"` and per-file `failures`. Listings - that contain unrecognized lines report them as `unparsed_lines` and mark - partial coverage instead of dropping rows. + and package searches that contain unrecognized lines report the first ten + as `unparsed_lines`, state the exact total in a limitation when more were + seen, and mark partial coverage instead of dropping rows. - **Mutations are explicit.** `push_adb_file`, `install_adb_package`, and `uninstall_adb_package` declare `mutatesTarget` and never run implicitly: pushes check for existing device files before transfer and refuse them diff --git a/src/adb/AdbDeviceOutput.test.ts b/src/adb/AdbDeviceOutput.test.ts index 17dec05c38..cd56b7bcf1 100644 --- a/src/adb/AdbDeviceOutput.test.ts +++ b/src/adb/AdbDeviceOutput.test.ts @@ -18,7 +18,7 @@ describe("parseAdbDevicesOutput", () => { ].join("\n"); const { devices, unparsedLines, daemonStarted } = parseAdbDevicesOutput(output); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); expect(daemonStarted).toBe(false); expect(devices).toEqual([ { @@ -88,7 +88,7 @@ describe("ADB daemon startup notices", () => { stderr, ); expect(daemonStarted).toBe(true); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); expect(devices).toHaveLength(1); }); @@ -98,7 +98,7 @@ describe("ADB daemon startup notices", () => { "* daemon started successfully *\r\n", ); expect(daemonStarted).toBe(true); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); }); it("does not treat a daemon startup error containing the success phrase as success", () => { @@ -107,7 +107,7 @@ describe("ADB daemon startup notices", () => { "* daemon not started successfully\r\n", ); expect(daemonStarted).toBe(false); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); }); it("ignores non-startup lines and device strings on stderr", () => { @@ -126,7 +126,7 @@ describe("ADB daemon startup notices", () => { stderr, ); expect(daemonStarted).toBe(false); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); expect(devices).toHaveLength(1); expect(devices[0]?.serial).toBe("emulator-5554"); }); @@ -158,11 +158,14 @@ describe("ADB device states", () => { expect(devices.map(({ serial, state }) => [serial, state])).toEqual([ ["serial-no-permission", "no permissions"], ]); - expect(unparsedLines).toEqual([ - "serial-bad-device deviceXYZ ", - "serial-bad-offline offline-ish", - "serial-bad-auth unauthorizedXYZ", - ]); + expect(unparsedLines).toEqual({ + sample: [ + "serial-bad-device deviceXYZ ", + "serial-bad-offline offline-ish", + "serial-bad-auth unauthorizedXYZ", + ], + total: 3, + }); }); it("returns unrecognized lines instead of dropping them", () => { @@ -172,7 +175,10 @@ describe("ADB device states", () => { "", ].join("\n"); const { unparsedLines } = parseAdbDevicesOutput(output); - expect(unparsedLines).toEqual(["this is not a device line"]); + expect(unparsedLines).toEqual({ + sample: ["this is not a device line"], + total: 1, + }); }); it("reports a non-numeric transport_id as unknown", () => { @@ -259,7 +265,7 @@ describe("parsePackageListOutput", () => { "", ].join("\n"); const { packages, unparsedLines } = parsePackageListOutput(output); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); expect(packages).toEqual([ { package_name: "com.example", @@ -278,7 +284,19 @@ describe("parsePackageListOutput", () => { "package:/data/app/x/base.apk=com.ok\n garbage line \n", ); expect(packages).toHaveLength(1); - expect(unparsedLines).toEqual([" garbage line "]); + expect(unparsedLines).toEqual({ sample: [" garbage line "], total: 1 }); + }); + + it("samples the first ten unrecognized lines and counts every one", () => { + const rows = Array.from( + { length: 11 }, + (_, index) => `row ${String(index + 1)}`, + ); + const { packages, unparsedLines } = parsePackageListOutput( + ["package:/data/app/x/base.apk=com.ok", ...rows].join("\n"), + ); + expect(packages).toHaveLength(1); + expect(unparsedLines).toEqual({ sample: rows.slice(0, 10), total: 11 }); }); }); @@ -291,7 +309,7 @@ describe("parsePmPathOutput", () => { "", ].join("\n"); const { paths, unparsedLines } = parsePmPathOutput(output); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); expect(paths).toEqual([ { device_path: "/data/app/~~a==/com.example.b==/base.apk", @@ -334,7 +352,7 @@ describe("parsePmPathOutput", () => { }, ]); expect(rejectedPaths).toEqual([]); - expect(unparsedLines).toEqual([]); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); }); it("rejects an empty final component without normalizing the source", () => { @@ -349,6 +367,6 @@ describe("parsePmPathOutput", () => { it("retains malformed lines including surrounding whitespace", () => { expect( parsePmPathOutput(" package:/data/app/pkg/base.apk \n").unparsedLines, - ).toEqual([" package:/data/app/pkg/base.apk "]); + ).toEqual({ sample: [" package:/data/app/pkg/base.apk "], total: 1 }); }); }); diff --git a/src/adb/AdbDeviceOutput.ts b/src/adb/AdbDeviceOutput.ts index 2691b57d1d..ec7be399cf 100644 --- a/src/adb/AdbDeviceOutput.ts +++ b/src/adb/AdbDeviceOutput.ts @@ -2,14 +2,18 @@ * Parsers for the exact adb and Package Manager output formats REA consumes. * * Each parser preserves what the tool actually printed: unparseable lines are - * retained rather than silently dropped, and derived classifications carry - * their evidence basis. + * sampled and counted rather than silently dropped, and derived + * classifications carry their evidence basis. */ import { adbHostFileName, type AdbHostFileNameReason, } from "./AdbHostFileName.js"; +import { + collectUnparsedLines, + type AdbUnparsedLines, +} from "./AdbUnparsedLines.js"; import { ADB_DEVICE_STATES, type AdbDeviceState, @@ -44,18 +48,18 @@ const metadataValue = (remainder: string, key: string): string | null => { /** * Parse `adb devices -l` device lines. The banner, blank lines, and the * daemon startup notices adb prints on first use are recognized separately; - * every other unrecognized line is returned for honest reporting. + * every other unrecognized line is sampled and counted for honest reporting. */ export const parseAdbDevicesOutput = ( stdout: string, stderr = "", ): { devices: readonly AdbDeviceEntry[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; daemonStarted: boolean; } => { const devices: AdbDeviceEntry[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); let daemonStarted = false; for (const rawLine of stdout.split(/\r?\n/u)) { const line = normalizeAdbOutputLine(rawLine); @@ -74,7 +78,7 @@ export const parseAdbDevicesOutput = ( return next === undefined || /\s/u.test(next); }); if (match === null || state === undefined) { - if (rawLine.trim() !== "") unparsedLines.push(rawLine); + if (rawLine.trim() !== "") unparsed.add(rawLine); continue; } const serial = match[1]!; @@ -122,7 +126,7 @@ export const parseAdbDevicesOutput = ( } } } - return { devices, unparsedLines, daemonStarted }; + return { devices, unparsedLines: unparsed.facts(), daemonStarted }; }; /** @@ -184,21 +188,21 @@ export interface AdbPackageEntry { const PACKAGE_LINE = /^package:(.+)=([^\s=]+)$/u; -/** Parse `pm list packages -f` lines, retaining unrecognized lines. */ +/** Parse `pm list packages -f` lines, sampling and counting unrecognized lines. */ export const parsePackageListOutput = ( stdout: string, ): { packages: readonly AdbPackageEntry[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; } => { const packages: AdbPackageEntry[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); for (const rawLine of stdout.split(/\r?\n/u)) { const line = rawLine.trim(); if (line === "") continue; const match = PACKAGE_LINE.exec(line); if (match === null) { - unparsedLines.push(rawLine); + unparsed.add(rawLine); continue; } packages.push({ @@ -206,7 +210,7 @@ export const parsePackageListOutput = ( base_apk_device_path: match[1]!, }); } - return { packages, unparsedLines }; + return { packages, unparsedLines: unparsed.facts() }; }; /** Quote raw device paths for prose without allowing bidi or invisible text. */ @@ -248,19 +252,19 @@ export const parsePmPathOutput = ( device_path: string; reason: AdbHostFileNameReason; }[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; } => { const paths: AdbPackagePathEntry[] = []; const rejectedPaths: { device_path: string; reason: AdbHostFileNameReason; }[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); for (const rawLine of stdout.split(/\r?\n/u)) { if (rawLine === "") continue; const match = /^package:(.*)$/u.exec(rawLine); if (match === null) { - unparsedLines.push(rawLine); + unparsed.add(rawLine); continue; } const devicePath = match[1]!; @@ -284,5 +288,5 @@ export const parsePmPathOutput = ( : "unknown", }); } - return { paths, rejectedPaths, unparsedLines }; + return { paths, rejectedPaths, unparsedLines: unparsed.facts() }; }; diff --git a/src/adb/AdbDirectoryListing.ts b/src/adb/AdbDirectoryListing.ts index a8c055ad6f..6499f199f6 100644 --- a/src/adb/AdbDirectoryListing.ts +++ b/src/adb/AdbDirectoryListing.ts @@ -1,5 +1,10 @@ import { isUtf8 } from "node:buffer"; +import { + collectUnparsedLines, + type AdbUnparsedLines, +} from "./AdbUnparsedLines.js"; + interface AdbDirectoryEntry { readonly name: string; readonly kind: "directory" | "file" | "symlink" | "other"; @@ -71,35 +76,35 @@ export const parseAdbDirectoryListing = ( stdout: Buffer, ): { entries: readonly AdbDirectoryEntry[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; } => { const entries: AdbDirectoryEntry[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); let offset = 0; while (offset < stdout.length) { const metadataEnd = stdout.indexOf(0, offset); const nameEnd = metadataEnd < 0 ? -1 : stdout.indexOf(0, metadataEnd + 1); const targetEnd = nameEnd < 0 ? -1 : stdout.indexOf(0, nameEnd + 1); if (targetEnd < 0) { - unparsedLines.push(describeUnparsedRecord(stdout.subarray(offset))); + unparsed.add(describeUnparsedRecord(stdout.subarray(offset))); break; } const rawRecord = stdout.subarray(offset, targetEnd + 1); if (!isUtf8(rawRecord)) { - unparsedLines.push(describeUnparsedRecord(rawRecord)); + unparsed.add(describeUnparsedRecord(rawRecord)); } else { const entry = parseEntry( stdout.toString("utf8", offset, metadataEnd), stdout.toString("utf8", metadataEnd + 1, nameEnd), stdout.toString("utf8", nameEnd + 1, targetEnd), ); - if (entry === null) unparsedLines.push(rawRecord.toString("utf8")); + if (entry === null) unparsed.add(rawRecord.toString("utf8")); else entries.push(entry); } offset = targetEnd + 1; } - return { entries, unparsedLines }; + return { entries, unparsedLines: unparsed.facts() }; }; const describeUnparsedRecord = (record: Buffer): string => diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 59085a5239..23586da063 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -1617,6 +1617,7 @@ it("resolves packages by case-insensitive substring", async () => { }, ]); expect(result.exact_match).toBe(false); + expect(result.unparsed_lines).toEqual([]); expect(result.coverage).toBe("complete"); }); diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 677aedf379..6a04dcf991 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -121,6 +121,7 @@ import { parseWmDensityOutput, parseWmSizeOutput, } from "./AdbShellOutput.js"; +import type { AdbUnparsedLines } from "./AdbUnparsedLines.js"; /** Stable identity for the ADB device-acquisition provider. */ export const ADB_PROVIDER_IDENTITY = { @@ -152,7 +153,6 @@ const SCREEN_TIMEOUT_MS = 60_000; const SCREEN_MAX_OUTPUT_BYTES = 32 * 1024 * 1024; const BUGREPORT_TIMEOUT_MS = 600_000; const BUGREPORT_MAX_OUTPUT_BYTES = 1024 * 1024; -const UNPARSED_LINES_REPORTED = 10; /** Carry the exact digest owner through caller-specific partial-result mapping. */ class AdbDigestFailure extends Error { @@ -381,23 +381,35 @@ const clientIdentityResult = ( type ClientIdentity = ReturnType; type ProviderIdentity = { id: string; name: string; version: string | null }; -const reported = ( - unparsedLines: readonly string[], - coverage: "complete" | "partial", -) => ({ - unparsed_lines: unparsedLines.slice(0, UNPARSED_LINES_REPORTED), - coverage: - coverage === "partial" || unparsedLines.length > 0 ? "partial" : "complete", -}); - -const unparsedOverflowLimitation = ( - unparsedLines: readonly string[], -): string[] => - unparsedLines.length > UNPARSED_LINES_REPORTED - ? [ - `unparsed_lines reports the first ${String(UNPARSED_LINES_REPORTED)} of ${String(unparsedLines.length)} unrecognized lines`, - ] - : []; +/** + * Build a parsed-listing execution whose `unparsed_lines`, coverage, and + * truncation limitation all derive from the parser's unrecognized-line facts, + * so the limitation can never describe a field the result does not carry. + */ +const parsedListingExecution = ( + result: Readonly>, + unparsed: AdbUnparsedLines, + provider: ProviderIdentity, + limitations: readonly string[] = [], +) => + createAnalysisExecution( + { + ...result, + unparsed_lines: unparsed.sample, + coverage: unparsed.total > 0 ? "partial" : "complete", + }, + provider, + { + limitations: [ + ...limitations, + ...(unparsed.total > unparsed.sample.length + ? [ + `unparsed_lines reports the first ${String(unparsed.sample.length)} of ${String(unparsed.total)} unrecognized lines`, + ] + : []), + ], + }, + ); /** Materialize the caller's output directory, refusing existing files inside. */ const prepareOutputDirectory = async ( @@ -1355,10 +1367,10 @@ export class AdbProvider { ran.value.stdout, ran.value.stderr, ); - if (unparsedLines.length > 0) + if (unparsedLines.total > 0) return err( new AnalysisProtocolError( - `adb devices reported an unrecognized line: ${unparsedLines[0]}`, + `adb devices reported an unrecognized line: ${unparsedLines.sample[0]!}`, ), ); return ok( @@ -1435,21 +1447,18 @@ export class AdbProvider { ran.value.stdout, ); return ok( - createAnalysisExecution( + parsedListingExecution( { client, serial: request.input.serial, scope: request.input.scope, packages, - ...reported(unparsedLines, "complete"), }, + unparsedLines, provider, - { - limitations: [ - "base_apk_device_path is a device filesystem path, not a host-local path", - ...unparsedOverflowLimitation(unparsedLines), - ], - }, + [ + "base_apk_device_path is a device filesystem path, not a host-local path", + ], ), ); } @@ -1603,16 +1612,10 @@ export class AdbProvider { ran.value.stdout, ); return ok( - createAnalysisExecution( - { - client, - serial: request.input.serial, - columns, - processes, - ...reported(unparsedLines, "complete"), - }, + parsedListingExecution( + { client, serial: request.input.serial, columns, processes }, + unparsedLines, provider, - { limitations: unparsedOverflowLimitation(unparsedLines) }, ), ); } @@ -1642,21 +1645,18 @@ export class AdbProvider { ran.value.stdout, ); return ok( - createAnalysisExecution( + parsedListingExecution( { client, serial: request.input.serial, device_path: request.input.device_path, entries, - ...reported(unparsedLines, "complete"), }, + unparsedLines, provider, - { - limitations: [ - "Entry kinds derive from stat permission mode; directory contents and link targets may change during capture", - ...unparsedOverflowLimitation(unparsedLines), - ], - }, + [ + "Entry kinds derive from stat permission mode; directory contents and link targets may change during capture", + ], ), ); } @@ -1670,15 +1670,10 @@ export class AdbProvider { ran.value.stdout, ); return ok( - createAnalysisExecution( - { - client, - serial: request.input.serial, - features, - ...reported(unparsedLines, "complete"), - }, + parsedListingExecution( + { client, serial: request.input.serial, features }, + unparsedLines, provider, - { limitations: unparsedOverflowLimitation(unparsedLines) }, ), ); } @@ -1692,15 +1687,10 @@ export class AdbProvider { ran.value.stdout, ); return ok( - createAnalysisExecution( - { - client, - serial: request.input.serial, - services, - ...reported(unparsedLines, "complete"), - }, + parsedListingExecution( + { client, serial: request.input.serial, services }, + unparsedLines, provider, - { limitations: unparsedOverflowLimitation(unparsedLines) }, ), ); } @@ -1830,7 +1820,7 @@ export class AdbProvider { entry.package_name.toLowerCase().includes(needle), ); return ok( - createAnalysisExecution( + parsedListingExecution( { client, serial: request.input.serial, @@ -1839,15 +1829,12 @@ export class AdbProvider { exact_match: matches.length === 1 && matches[0]!.package_name.toLowerCase() === needle, - coverage: unparsedLines.length > 0 ? "partial" : "complete", }, + unparsedLines, provider, - { - limitations: [ - "Matching is a case-insensitive substring over package names; application display labels are not part of pm output", - ...unparsedOverflowLimitation(unparsedLines), - ], - }, + [ + "Matching is a case-insensitive substring over package names; application display labels are not part of pm output", + ], ), ); } @@ -1952,7 +1939,7 @@ export class AdbProvider { }), ); } - if (parsed.paths.length === 0 && parsed.unparsedLines.length === 0) + if (parsed.paths.length === 0 && parsed.unparsedLines.total === 0) return err( new AnalysisUnsupportedTargetError( operation, @@ -1960,10 +1947,10 @@ export class AdbProvider { `Package Manager reported no APK paths for ${packageName}; it may not be installed for user 0`, ), ); - if (parsed.unparsedLines.length > 0) + if (parsed.unparsedLines.total > 0) return err( new AnalysisProtocolError( - `pm path reported an unrecognized line: ${parsed.unparsedLines[0]}`, + `pm path reported an unrecognized line: ${parsed.unparsedLines.sample[0]!}`, { capturedOutput }, ), ); diff --git a/src/adb/AdbShellOutput.ts b/src/adb/AdbShellOutput.ts index d5241c9686..8cbbf8095d 100644 --- a/src/adb/AdbShellOutput.ts +++ b/src/adb/AdbShellOutput.ts @@ -2,13 +2,17 @@ import type { AdbWindowFocusContext, AdbWindowFocusObservation, } from "../domain/adb/adbDeviceAnalysis.js"; +import { + collectUnparsedLines, + type AdbUnparsedLines, +} from "./AdbUnparsedLines.js"; /** * Parsers for fixed observation commands REA runs through `adb shell`. * * Formats follow toybox output on Android 8+ (verified against an Android 14 - * emulator). Unparseable lines are retained rather than dropped so callers - * can see the format drift instead of silently losing rows. + * emulator). Unparseable lines are sampled and counted rather than dropped so + * callers can see the format drift instead of silently losing rows. */ export interface AdbProcessEntry { @@ -29,10 +33,10 @@ export const parseProcessListOutput = ( ): { columns: string | null; processes: readonly AdbProcessEntry[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; } => { const processes: AdbProcessEntry[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); let columns: string | null = null; for (const rawLine of stdout.split(/\r?\n/u)) { const line = rawLine.trimEnd(); @@ -43,7 +47,7 @@ export const parseProcessListOutput = ( } const match = PROCESS_LINE.exec(line); if (match === null) { - unparsedLines.push(line); + unparsed.add(line); continue; } processes.push({ @@ -55,7 +59,7 @@ export const parseProcessListOutput = ( name: match[9]!.trim(), }); } - return { columns, processes, unparsedLines }; + return { columns, processes, unparsedLines: unparsed.facts() }; }; export interface AdbFeatureEntry { @@ -73,16 +77,16 @@ export const parseFeatureListOutput = ( stdout: string, ): { features: readonly AdbFeatureEntry[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; } => { const features: AdbFeatureEntry[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); for (const rawLine of stdout.split(/\r?\n/u)) { const line = rawLine.trim(); if (line === "") continue; const match = FEATURE_LINE.exec(line); if (match === null) { - unparsedLines.push(line); + unparsed.add(line); continue; } features.push({ @@ -90,7 +94,7 @@ export const parseFeatureListOutput = ( version: match[2] === undefined ? null : parseFeatureVersion(match[2]!), }); } - return { features, unparsedLines }; + return { features, unparsedLines: unparsed.facts() }; }; export interface AdbServiceEntry { @@ -105,17 +109,17 @@ export const parseServiceListOutput = ( stdout: string, ): { services: readonly AdbServiceEntry[]; - unparsedLines: readonly string[]; + unparsedLines: AdbUnparsedLines; } => { const services: AdbServiceEntry[] = []; - const unparsedLines: string[] = []; + const unparsed = collectUnparsedLines(); for (const rawLine of stdout.split(/\r?\n/u)) { const line = rawLine.trimEnd(); if (line.trim() === "" || /^Found \d+ services:/u.test(line.trim())) continue; const match = SERVICE_LINE.exec(line); if (match === null) { - if (line.trim() !== "") unparsedLines.push(line.trim()); + if (line.trim() !== "") unparsed.add(line.trim()); continue; } services.push({ @@ -123,7 +127,7 @@ export const parseServiceListOutput = ( interface: match[2] === "" ? null : match[2]!, }); } - return { services, unparsedLines }; + return { services, unparsedLines: unparsed.facts() }; }; const SIZE_PAIR = /^Physical size:\s+(\d+)x(\d+)$/u; diff --git a/src/adb/AdbUnparsedLines.ts b/src/adb/AdbUnparsedLines.ts new file mode 100644 index 0000000000..adaabf3d57 --- /dev/null +++ b/src/adb/AdbUnparsedLines.ts @@ -0,0 +1,27 @@ +/** Most unrecognized output lines a parser retains as source evidence. */ +const UNPARSED_LINE_SAMPLE_LIMIT = 10; + +/** + * Unrecognized output lines one parser observed: the first lines verbatim, up + * to {@link UNPARSED_LINE_SAMPLE_LIMIT}, and the exact number observed. + */ +export interface AdbUnparsedLines { + readonly sample: readonly string[]; + readonly total: number; +} + +/** Accumulate unrecognized lines without retaining more than the sample. */ +export const collectUnparsedLines = (): { + add: (line: string) => void; + facts: () => AdbUnparsedLines; +} => { + const sample: string[] = []; + let total = 0; + return { + add: (line) => { + total += 1; + if (sample.length < UNPARSED_LINE_SAMPLE_LIMIT) sample.push(line); + }, + facts: () => ({ sample, total }), + }; +}; diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index 07f7b863f5..ec773b7596 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -731,6 +731,7 @@ export const adbResultSchemas = { }), ), exact_match: z.boolean(), + unparsed_lines: z.array(z.string().min(1)), coverage: z.enum(["complete", "partial"]), }), install_adb_package: z.strictObject({ diff --git a/tests/acceptance/applications/adbPackageInventoryCoverage.test.ts b/tests/acceptance/applications/adbPackageInventoryCoverage.test.ts new file mode 100644 index 0000000000..59429c7e46 --- /dev/null +++ b/tests/acceptance/applications/adbPackageInventoryCoverage.test.ts @@ -0,0 +1,104 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const PACKAGE_LINE = "package:/data/app/example/base.apk=com.example.app"; +const unrecognized = (count: number) => + Array.from( + { length: count }, + (_, index) => `unrecognized package row ${String(index + 1)}`, + ); + +cliTest.skipIf(process.platform === "win32")( + "reports unrecognized package rows through package search and listing via CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-adb-package-coverage-"); + const binary = join(root, "adb"); + const inventory = join(root, "packages.txt"); + await writeFile( + binary, + `#!${process.execPath} +const args = process.argv.slice(2); +if (args[0] === 'version') { + console.log('Android Debug Bridge version 1.0.41\\nVersion 34.0.5-fixture\\nInstalled as ' + process.argv[1]); + process.exit(0); +} +if (args.join(' ') === '-s emulator-5554 shell pm list packages -f') { + process.stdout.write(require('node:fs').readFileSync(${JSON.stringify(inventory)})); + process.exit(0); +} +process.exit(91); +`, + { mode: 0o700 }, + ); + const environment = { + HOME: root, + USERPROFILE: root, + REA_ADB_PATH: binary, + REA_LOG_LEVEL: "silent", + }; + const { call } = await connectLocalToolsMcp(environment); + + for (const count of [0, 1, 11]) { + const rows = unrecognized(count); + await writeFile(inventory, [PACKAGE_LINE, ...rows, ""].join("\n")); + const expected = { + unparsed_lines: rows.slice(0, 10), + coverage: count === 0 ? "complete" : "partial", + }; + const truncation = + "unparsed_lines reports the first 10 of 11 unrecognized lines"; + + for (const [command, operation, tail] of [ + ["resolve-adb-packages", "resolve_adb_packages", ["example"]], + ["list-adb-packages", "list_adb_packages", []], + ] as const) { + const output = await cli.run({ + arguments: [command, "emulator-5554", ...tail, "--json"], + environment, + }); + expect(output.exitCode, output.stderr).toBe(0); + const evidence = parseEvidence(output.json); + expect( + evidence.normalized_result, + `${command} ${String(count)}`, + ).toMatchObject(expected); + if (count === 11) expect(evidence.limitations).toContain(truncation); + else + expect(evidence.limitations).not.toContain( + expect.stringContaining("unparsed_lines"), + ); + + if (operation !== "resolve_adb_packages") continue; + expect(evidence.normalized_result).toMatchObject({ + matches: [ + { + package_name: "com.example.app", + base_apk_device_path: "/data/app/example/base.apk", + }, + ], + exact_match: false, + }); + const reply = await call(operation, { + serial: "emulator-5554", + query: "example", + }); + expect(reply.isError).not.toBe(true); + const structured = toolContract(operation).outputSchema.parse( + reply.structuredContent, + ); + expect(structured.normalized_result).toEqual( + evidence.normalized_result, + ); + expect(structured.limitations).toEqual(evidence.limitations); + } + } + }, +); diff --git a/tests/boundary/mcp/adbMcp.test.ts b/tests/boundary/mcp/adbMcp.test.ts index bb3bb2330e..3ad6b69ede 100644 --- a/tests/boundary/mcp/adbMcp.test.ts +++ b/tests/boundary/mcp/adbMcp.test.ts @@ -242,6 +242,7 @@ const results: Readonly> = { }, ], exact_match: true, + unparsed_lines: [], coverage: "complete", }, install_adb_package: { diff --git a/tests/boundary/process/adbDirectoryListing.test.ts b/tests/boundary/process/adbDirectoryListing.test.ts index e060144dbe..8d95f689be 100644 --- a/tests/boundary/process/adbDirectoryListing.test.ts +++ b/tests/boundary/process/adbDirectoryListing.test.ts @@ -231,7 +231,7 @@ itPosix( }); expect(parseAdbDirectoryListing(output)).toEqual({ entries: [], - unparsedLines: [], + unparsedLines: { sample: [], total: 0 }, }); expect((await readFile(calls, "utf8")).trim()).toBe("3"); },