fix(adb): report unrecognized package rows from resolve-adb-packages (#5874)

resolve_adb_packages marked coverage partial when pm list packages printed
unrecognized rows, but dropped the rows themselves and could still emit a
limitation describing an unparsed_lines field the result never carried.

ADB parsers now own unrecognized-line facts as a bounded sample (first ten)
plus the exact total, and a single provider projection derives
unparsed_lines, coverage, and the truncation limitation from those facts for
every parsed listing and the package search. The resolver result schema now
carries unparsed_lines, matching list_adb_packages through CLI and MCP.

Fixes #5843

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-21 05:06:11 -07:00
1 parent ce1f4b1bba
commit 5b014a739e
12 files changed
+280 -127

No files matched your search

+3 -2
View File
@@ -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
+34 -16
View File
@@ -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 });
});
});
+20 -16
View File
@@ -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() };
};
+11 -6
View File
@@ -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 =>
+1
View File
@@ -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");
});
+59 -72
View File
@@ -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<typeof clientIdentityResult>;
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<Record<string, unknown>>,
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 },
),
);
+18 -14
View File
@@ -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;
+27
View File
@@ -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 }),
};
};
+1
View File
@@ -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({
@@ -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);
}
}
},
);
+1
View File
@@ -242,6 +242,7 @@ const results: Readonly<Record<string, unknown>> = {
},
],
exact_match: true,
unparsed_lines: [],
coverage: "complete",
},
install_adb_package: {
@@ -231,7 +231,7 @@ itPosix(
});
expect(parseAdbDirectoryListing(output)).toEqual({
entries: [],
unparsedLines: [],
unparsedLines: { sample: [], total: 0 },
});
expect((await readFile(calls, "utf8")).trim()).toBe("3");
},