mirror of
https://github.com/morluto/rea
synced 2026-10-26 22:28:27 -04:00
01788bac86
* 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>
278 lines
7.9 KiB
TypeScript
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 },
|
|
});
|
|
});
|