Files
rea/tests/boundary/providers/adb/adbCommandFinalization.test.ts
T
morlutoand星闌 8307ea3717 fix(adb): preserve command failure target identities
Keep the selected device, addressed file and actual host operand distinct
at the command owner. Parse known device and file diagnostics before
projection, including AOSP host write/read failures and exact snapshots.
Classify complete captured stderr before shortening display text, and
scan leading notices without expanding the capture into a line array.

Adapt Xinglan233's PR #4655 and extend its source-shaped host controls.
Add built CLI/MCP identity, long-path, recovery and input-preservation
coverage. Real platform-tools and Android devices remain unverified.

Fixes #4641
Fixes #4643
Fixes #5051

Co-authored-by: 星闌 <xinglan233@163.com>
2026-10-19 01:13:57 +08:00

334 lines
11 KiB
TypeScript

import { readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { beforeEach, expect, onTestFinished, test, vi } from "vitest";
import { runAdbCapture } from "../../../../src/adb/AdbCommand.js";
import { inspectAdbClient } from "../../../../src/adb/AdbConfiguration.js";
import { AdbDeviceAnalysisService } from "../../../../src/application/adb/AdbDeviceAnalysisService.js";
import {
execFileOutput,
execFileOutputFailure,
} from "../../../../src/process/ExecFileOutput.js";
import { AdbProvider } from "../../../../src/adb/AdbProvider.js";
import { parseEvidence } from "../../../../src/domain/evidence.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
const hooks = vi.hoisted(() => ({
afterCommand: undefined as (() => void) | undefined,
failure: undefined as unknown,
}));
vi.mock("../../../../src/process/ExecFileOutput.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../../../src/process/ExecFileOutput.js")
>();
return {
...actual,
execFileBytes: async (...args: Parameters<typeof actual.execFileBytes>) => {
try {
return await actual.execFileBytes(...args);
} catch (cause: unknown) {
hooks.failure = cause;
throw cause;
} finally {
hooks.afterCommand?.();
}
},
execFileOutput: async (
...args: Parameters<typeof actual.execFileOutput>
) => {
try {
return await actual.execFileOutput(...args);
} catch (cause: unknown) {
hooks.failure = cause;
throw cause;
} finally {
hooks.afterCommand?.();
}
},
};
});
beforeEach(() => {
hooks.afterCommand = undefined;
hooks.failure = undefined;
});
const capture = (
script: string,
signal?: AbortSignal,
limits = { timeoutMs: 1000, maxOutputBytes: 1024 },
) =>
runAdbCapture(
"inspect_adb_device",
{ device: "device" },
process.execPath,
["-e", script],
limits,
signal,
);
const failureOf = async (result: ReturnType<typeof capture>) => {
const completed = await result;
expect(completed.ok).toBe(false);
if (completed.ok) throw new Error("Expected command failure");
return completed.error;
};
test.each([
{
script: "process.stderr.write('remote failure'); process.exitCode=7",
tag: "AnalysisProtocolError",
},
{
script: "process.stderr.write('device unauthorized'); process.exitCode=1",
tag: "AnalysisUnsupportedTargetError",
},
{
script: "setInterval(()=>{},1000)",
tag: "AnalysisTimeoutError",
limits: { timeoutMs: 100, maxOutputBytes: 1024 },
},
{
script: "process.stdout.write('x'.repeat(4096))",
tag: "AnalysisResourceConstraintError",
limits: { timeoutMs: 1000, maxOutputBytes: 4 },
},
])(
"retains completed $tag when cancellation arrives at consumer handoff",
async ({ script, tag, limits }) => {
const controller = new AbortController();
hooks.afterCommand = () => controller.abort("later consumer cancellation");
const error = await failureOf(capture(script, controller.signal, limits));
expect(error._tag).toBe(tag);
expect(error.cause).toBe(hooks.failure);
},
);
test("hands completed bytes to the consuming provider after a late abort", async () => {
const controller = new AbortController();
hooks.afterCommand = () => controller.abort(null);
expect(
await capture("process.stdout.write('observed')", controller.signal),
).toEqual({
ok: true,
value: { stdout: Buffer.from("observed"), stderr: Buffer.alloc(0) },
});
});
test("retains the command owner's exact cancellation reason after active process exit", async () => {
const root = await createTestTempDirectory("rea-adb-active-command-");
const ready = join(root, "ready");
const controller = new AbortController();
const pending = capture(
`require('node:fs').writeFileSync(${JSON.stringify(ready)},'ready'); setInterval(()=>{},1000)`,
controller.signal,
{ timeoutMs: 5000, maxOutputBytes: 1024 },
);
try {
await vi.waitFor(async () =>
expect(await readFile(ready, "utf8")).toBe("ready"),
);
controller.abort(null);
const error = await failureOf(pending);
expect(error._tag).toBe("AnalysisCancelledError");
expect(error.cause).toBeNull();
expect(hooks.failure).toMatchObject({
_tag: "ExecFileOutputCancelledError",
cause: null,
});
} finally {
controller.abort(null);
await pending;
}
});
const clientScript = async (source: string) => {
const root = await createTestTempDirectory("rea-adb-client-command-");
const binary = join(root, "adb");
await writeFile(binary, `#!${process.execPath}\n${source}\n`, {
mode: 0o755,
});
return { binary, pathSource: "environment" as const };
};
const nativeClientTest = test.skipIf(process.platform === "win32");
test.each([null, "caller stopped", new Error("caller stopped")] as const)(
"keeps exact %s cancellation before version-probe admission",
async (reason) => {
const controller = new AbortController();
controller.abort(reason);
const error: unknown = await inspectAdbClient(
{ binary: "/must-not-start", pathSource: "environment" },
controller.signal,
).catch((cause: unknown) => cause);
expect(error).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
});
if (!(error instanceof Error)) throw new Error("Expected cancellation");
expect(error.cause).toBe(reason);
},
);
nativeClientTest(
"retains a version-probe capture overflow as a resource constraint",
async () => {
const selection = await clientScript(
"process.stdout.write('x'.repeat(128 * 1024))",
);
const provider = new AdbProvider({
environment: { REA_ADB_PATH: selection.binary },
});
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "inspect_adb_client",
input: {},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected capture overflow");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "resource_constraint",
});
expect(result.error).toMatchObject({
reportedLimits: { max_output_bytes: 65536 },
capturedOutput: { truncated: true, stdout_bytes: 65536 },
});
},
);
nativeClientTest(
"preserves missing-binary identity despite later cancellation",
async () => {
const root = await createTestTempDirectory("rea-adb-missing-command-");
const controller = new AbortController();
hooks.afterCommand = () => controller.abort(null);
const error: unknown = await inspectAdbClient(
{ binary: join(root, "missing"), pathSource: "environment" },
controller.signal,
).catch((cause: unknown) => cause);
expect(error).toMatchObject({
code: "binary_missing",
cause: hooks.failure,
});
},
);
nativeClientTest.each([
null,
"caller stopped",
new Error("caller stopped"),
] as const)(
"retains completed client Evidence and exact %s reason at application completion",
async (reason) => {
const selection = await clientScript(
"console.log('Android Debug Bridge version 1.0.41\\nVersion 34.0.5-test')",
);
const provider = new AdbProvider({
environment: { REA_ADB_PATH: selection.binary },
});
onTestFinished(() => provider.close());
const controller = new AbortController();
hooks.afterCommand = () => controller.abort(reason);
const result = await new AdbDeviceAnalysisService(provider).execute(
"inspect_adb_client",
{},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected completion cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "inspect_adb_client",
confidence: "observed",
normalized_result: {
client: { version: "34.0.5-test" },
contacted_adb_server: false,
},
});
},
);
nativeClientTest(
"keeps invalid client output primary over later cancellation",
async () => {
const selection = await clientScript(
"console.log('unrecognized version output')",
);
const controller = new AbortController();
hooks.afterCommand = () => controller.abort(null);
const error: unknown = await inspectAdbClient(
selection,
controller.signal,
).catch((cause: unknown) => cause);
expect(error).toMatchObject({ code: "version_unresolved" });
},
);
nativeClientTest.each([false, true])(
"projects the actual client probe deadline through provider admission (late abort: %s)",
async (lateAbort) => {
const selection = await clientScript("setInterval(()=>{},1000)");
const provider = new AdbProvider({
environment: { REA_ADB_PATH: selection.binary },
});
onTestFinished(() => provider.close());
const controller = new AbortController();
if (lateAbort)
hooks.afterCommand = () => controller.abort("after the timeout");
const result = await provider.execute(
{ operation: "inspect_adb_client", input: {} },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected version timeout");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "provider_timeout",
retryable: true,
details: { timeout_ms: 10000 },
});
expect(result.error.cause).toBe(hooks.failure);
},
20000,
);
nativeClientTest(
"projects an actual version-probe timeout through the public CLI",
async () => {
const selection = await clientScript("setInterval(()=>{},1000)");
const root = dirname(selection.binary);
const completed = await execFileOutput(
process.execPath,
["scripts/rea.mjs", "inspect-adb-client", "--json"],
{
env: {
PATH: process.env.PATH,
HOME: root,
USERPROFILE: root,
REA_ADB_PATH: selection.binary,
},
timeout: 20000,
stopSignal: "SIGTERM",
maxBuffer: 1024 * 1024,
},
).then(
(output) => ({ exitCode: 0, stdout: output.stdout }),
(cause: unknown) => {
const failure = execFileOutputFailure(cause);
if (failure === undefined) throw cause;
return { exitCode: failure.code, stdout: failure.stdout };
},
);
expect(completed.exitCode).toBe(1);
expect(JSON.parse(completed.stdout)).toMatchObject({
code: "provider_timeout",
retryable: true,
details: { timeout_ms: 10000 },
});
},
20000,
);