Files
rea/src/adb/AdbCommand.test.ts
T
morluto 01788bac86 fix(android): name the JADX deadline and recover apktool decode (#6024)
* fix(android): name the JADX deadline and recover apktool decode

JADX operation timeouts now name REA_JADX_OPERATION_TIMEOUT_MS, the
effective milliseconds, the 1..2147483647 range, and that a change applies
on the next process start. Apktool decode accepts decode_timeout_ms,
omitted means 600000, and a timeout names that key. ADB deadlines and
apktool and Java version probes stay fixed and say there is no runtime
override.

Refs #5910

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(android): derive capture recovery from the fixed allowance

Main now stores a fixed allowance instead of a prebuilt remediation
string. Apktool and adb capture refusals keep the no-runtime-override
sentence in that allowance name, including that decode_timeout_ms does
not raise the apktool output cap.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(apktool): observe a real decode deadline and process cleanup

A hanging interpreter launcher and its pipe-holding child stay alive
until decode_timeout_ms elapses. The provider then reports
provider_timeout with completed cleanup, and both processes are gone.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-22 00:31:43 +08:00

278 lines
7.9 KiB
TypeScript

import { expect, it, vi } from "vitest";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
ExecFileOutputCancelledError,
execFileBytes,
} from "../process/ExecFileOutput.js";
import { probeAdbPathExistence, runAdbCapture } from "./AdbCommand.js";
vi.mock("../process/ExecFileOutput.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../process/ExecFileOutput.js")>()),
execFileBytes: vi.fn(),
}));
const probe = (signal?: AbortSignal) =>
probeAdbPathExistence(
"push_adb_file",
"device",
"/device/path",
"/host/adb",
{
timeoutMs: 100,
maxOutputBytes: 1024,
},
signal,
);
it("classifies the complete diagnostic before shortening its displayed reason", async () => {
const localPath = `/host/${"directory/".repeat(60)}payload.bin`;
const stderr = `adb: error: cannot create '${localPath}': Permission denied`;
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("local destination refused"), {
code: 1,
stdout: "",
stderr,
signal: null,
killed: false,
}),
);
await expect(
runAdbCapture(
"pull_adb_file",
{ device: "device", path: "/remote/file", localPath },
"/host/adb",
[],
{ timeoutMs: 100, maxOutputBytes: 2048 },
undefined,
),
).resolves.toMatchObject({
ok: false,
error: {
_tag: "AnalysisUnsupportedTargetError",
path: localPath,
reason: stderr.slice(0, 512),
},
});
});
it("recognizes only the completed empty false result as absence", async () => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("test false"), {
code: 1,
stdout: "",
stderr: "",
signal: null,
killed: false,
}),
);
await expect(probe()).resolves.toEqual({ ok: true, value: false });
});
it.each([
{ code: 2 },
{ code: "ENOENT" },
{ signal: "SIGTERM" },
{ collectionFailure: true },
{ stdout: "unexpected" },
{ stderr: " " },
])("keeps nonstandard false completion unknown: %j", async (extra) => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("probe failed"), {
code: 1,
stdout: "",
stderr: "",
signal: null,
killed: false,
...extra,
}),
);
const result = await probe();
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisProtocolError",
diagnostics: { destination_existence: "unknown" },
},
});
});
it.each([
[{ killed: true }, "AnalysisTimeoutError"],
[{ outputTruncated: true }, "AnalysisResourceConstraintError"],
] as const)(
"preserves a stopped or incomplete probe: %j",
async (extra, tag) => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("probe failed"), {
code: 1,
stdout: "",
stderr: "",
signal: null,
killed: false,
...extra,
}),
);
await expect(probe()).resolves.toMatchObject({
ok: false,
error: { _tag: tag },
});
},
);
it("recognizes an empty successful probe as existence", async () => {
vi.mocked(execFileBytes).mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
});
await expect(probe()).resolves.toEqual({ ok: true, value: true });
});
it.each(["stdout", "stderr"] as const)(
"retains unexpected successful %s as unknown",
async (stream) => {
vi.mocked(execFileBytes).mockResolvedValueOnce({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
[stream]: Buffer.from("unexpected"),
});
await expect(probe()).resolves.toMatchObject({
ok: false,
error: {
_tag: "AnalysisProtocolError",
capturedOutput: { [stream]: "unexpected", exit_code: 0 },
},
});
},
);
it.each([null, "caller stopped", new Error("caller stopped")] as const)(
"uses the command owner's exact %s cancellation reason",
async (reason) => {
const cancelled = Object.assign(new ExecFileOutputCancelledError(reason), {
stdout: "partial",
stderr: "diagnostic",
stdoutBytes: 7,
stderrBytes: 10,
});
vi.mocked(execFileBytes).mockRejectedValueOnce(cancelled);
const result = await probe();
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
if (result.ok) throw new Error("Expected owned cancellation");
expect(result.error.cause).toBe(reason);
expect(result.error.capturedOutput).toMatchObject({
stdout: "partial",
stderr: "diagnostic",
stdout_bytes: 7,
stderr_bytes: 10,
});
},
);
it("does not infer cancellation from a foreign ABORT_ERR code", async () => {
const failure = Object.assign(new Error("foreign code"), {
code: "ABORT_ERR",
stdout: "",
stderr: "",
});
vi.mocked(execFileBytes).mockRejectedValueOnce(failure);
expect(await probe()).toMatchObject({
ok: false,
error: { _tag: "AnalysisProtocolError" },
});
});
it.each([true, false])(
"projects a truncated adb capture as retryable only for volatile output (%s)",
async (volatileOutput) => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("too much"), {
code: 1,
stdout: "",
stderr: "",
signal: null,
killed: false,
outputTruncated: true,
}),
);
const result = await runAdbCapture(
volatileOutput ? "read_adb_logcat" : "install_adb_package",
{ device: "device" },
"/host/adb",
volatileOutput ? ["logcat", "-d"] : ["install", "app.apk"],
{ timeoutMs: 100, maxOutputBytes: 1024, volatileOutput },
undefined,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a capture allowance refusal");
const projected = projectAnalysisError(result.error);
expect(projected.retryable).toBe(volatileOutput);
expect(projected.remediation.action).toContain(
"The adb output capture allowance",
);
expect(projected.remediation.action).toContain(
volatileOutput
? "live output may change between requests"
: "Repeating the unchanged request fails the same way.",
);
expect(projected.remediation.action).toContain("1024 bytes");
expect(projected.remediation.action).toContain("no runtime override");
},
);
it("names the effective adb deadline and says it has no runtime override", async () => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("deadline"), {
code: "ETIMEDOUT",
stdout: "",
stderr: "",
signal: null,
killed: true,
}),
);
const result = await runAdbCapture(
"pull_adb_file",
{ device: "device" },
"/host/adb",
["pull", "/remote"],
{ timeoutMs: 600_000, maxOutputBytes: 1024 },
undefined,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected an adb deadline");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "provider_timeout",
details: { timeout_ms: 600_000 },
});
expect(projected.message).toContain("600000 ms");
expect(projected.message).toContain("no runtime override");
expect(projected.remediation.action).toContain("no runtime override");
expect(projected.remediation.action).not.toContain(
"REA_JADX_OPERATION_TIMEOUT_MS",
);
});
it("keeps a physical failure even when the later abort reason is that same error", async () => {
const controller = new AbortController();
const failure = new Error("physical command failure");
vi.mocked(execFileBytes).mockImplementationOnce(async () => {
controller.abort(failure);
throw failure;
});
const result = await runAdbCapture(
"inspect_adb_device",
{ device: "device" },
"/host/adb",
[],
{ timeoutMs: 100, maxOutputBytes: 1024 },
controller.signal,
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisProtocolError", cause: failure },
});
});