Files
reverse-engineering-anything/tests/boundary/process/jadxIntegration.test.ts
T
morluto 1d32eb3aa4 fix(providers): keep operation environments consistent through launch
Capture one environment snapshot per admitted WABT, JADX, firmware and Wakaru operation. Use it for tool selection, session identity and child launches while preserving configuration updates between operations.

Regressions reproduce changed JVM options under an old session key, mixed engine/limiter selection and environment drift between subprocesses. Keep selected-wrapper and cleanup coverage in a shared fixture with separate scenarios.

Validation: 115 distinct focused tests passed across the final five-file selection, including seven selected-command cases after refactoring. Fresh build, typecheck, lint and formatting passed. Tests use real subprocess fixtures; no real JADX/WABT/Binwalk/Wakaru engine or native Windows verification is claimed.
2026-10-27 00:25:43 +08:00

1392 lines
47 KiB
TypeScript

import { SdkErrorCode } from "@modelcontextprotocol/client";
import { expect, it as test, onTestFinished, vi } from "vitest";
import {
access,
chmod,
mkdir,
readFile,
symlink,
truncate,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js";
import { JadxProvider } from "../../../src/android/JadxProvider.js";
import { JadxSession } from "../../../src/android/JadxSession.js";
import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { snapshotAndroidEngine } from "../../../src/android/AndroidTargetSnapshot.js";
import { hashStableFile } from "../../../src/filesystem/StableFileHash.js";
import * as stableHash from "../../../src/filesystem/StableFileHash.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import { RegularFileCleanupFailure } from "../../../src/filesystem/OwnedRegularFile.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import {
createJadxProtocolFixture as setup,
verifyJadxFixtureCleanup as verifyCleanup,
} from "../../fixtures/android/jadx.js";
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const it = test.skipIf(process.platform === "win32");
it.each(["alias/../bin", ""])(
"keeps PATH=%j Java tied to each caller directory across private-workspace launches",
async (searchPath) => {
const root = await createTestTempDirectory("rea-jadx-relative-java-");
const apk = join(root, "fixture.apk"),
jar = join(root, "fixture.jar");
await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]);
await writeJadxJarInventory(jar);
const fixture = new URL(
"../../fixtures/android/jadx-mcp.mjs",
import.meta.url,
);
const command = [process.execPath, fileURLToPath(fixture)]
.map((value) => `'${value.replaceAll("'", `'"'"'`)}'`)
.join(" ");
const shim = join(root, "java-shim");
await writeFile(
shim,
`#!/bin/sh\nselected="\${0%/*}"\n[ -f "$selected/runtime-marker" ] || exit 19\npwd >> "$selected/java.log"\nif [ "$1" = "--list-modules" ]; then\n printf 'java.base@21.0.0\\njdk.compiler@21.0.0\\n'\nelse\n exec ${command}\nfi\n`,
);
await chmod(shim, 0o755);
const directories = [join(root, "first"), join(root, "second")];
for (const directory of directories) {
await mkdir(join(directory, "real/deep"), { recursive: true });
await mkdir(join(directory, "real/bin"));
await symlink(join(directory, "real/deep"), join(directory, "alias"));
await symlink(shim, join(directory, "real/bin/java"));
await writeFile(join(directory, "real/bin/runtime-marker"), directory);
await symlink(shim, join(directory, "java"));
await writeFile(join(directory, "runtime-marker"), directory);
}
const logPath = (directory: string) =>
join(directory, searchPath === "" ? "java.log" : "real/bin/java.log");
const provider = new JadxProvider({
...process.env,
JAVA_HOME: undefined,
PATH: searchPath,
REA_JADX_MCP_JAR: jar,
REA_JADX_OPERATION_TIMEOUT_MS: "5000",
REA_FAKE_JADX_CLASS_COUNT: "0",
});
const service = new AndroidAnalysisService(provider);
const previousCwd = process.cwd();
try {
process.chdir(directories[0]!);
const availability = await provider.inspectAvailability();
expect(availability, JSON.stringify(availability)).toMatchObject({
status: "available",
});
const first = await service.execute("inspect_android_package", {
path: apk,
});
if (!first.ok) throw first.error;
const firstStarts = (await readFile(logPath(directories[0]!), "utf8"))
.trim()
.split("\n");
expect(firstStarts).toHaveLength(3);
const firstWorkspace = firstStarts.at(-1)!;
expect(firstWorkspace).not.toBe(directories[0]);
process.chdir(directories[1]!);
const second = await service.execute("inspect_android_package", {
path: apk,
});
if (!second.ok) throw second.error;
expect(second.value.subject?.digest.sha256).toBe(
first.value.subject?.digest.sha256,
);
const secondStarts = (await readFile(logPath(directories[1]!), "utf8"))
.trim()
.split("\n");
expect(secondStarts).toHaveLength(2);
const secondWorkspace = secondStarts.at(-1)!;
expect(secondWorkspace).not.toBe(firstWorkspace);
await expect(access(firstWorkspace)).rejects.toMatchObject({
code: "ENOENT",
});
await provider.close();
await expect(access(secondWorkspace)).rejects.toMatchObject({
code: "ENOENT",
});
} finally {
process.chdir(previousCwd);
await provider.close();
}
},
);
// Select the provider's source hash after resolver and engine admission complete.
const failInputHashCloses = (path: string, failures: number) => {
const originalHash = stableHash.hashStableFile;
const originalClose = OwnedFileHandle.prototype.close;
let hashingInput = false;
const retained = new Set<OwnedFileHandle>();
const owners: OwnedFileHandle[] = [];
const close = vi.fn(async (owner: OwnedFileHandle) => {
owners.push(owner);
if (owners.length <= failures)
throw new Error("fixture input hash close unavailable");
return originalClose.call(owner);
});
const hash = vi
.spyOn(stableHash, "hashStableFile")
.mockImplementation(async (selected, options) => {
hashingInput = selected === path;
try {
return await originalHash(selected, options);
} finally {
hashingInput = false;
}
});
const closing = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
if (!hashingInput && !retained.has(this)) return originalClose.call(this);
retained.add(this);
return close(this);
});
return {
close,
owners,
restore: () => {
closing.mockRestore();
hash.mockRestore();
},
};
};
it("retains a failed input hash descriptor until provider shutdown retries it", async () => {
const { apk, provider, launches } = await setup();
const target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
const fault = failInputHashCloses(target.value.path, 2);
const { close, owners } = fault;
try {
const result = await provider.execute(target.value, {
operation: "inspect_android_package",
input: { path: apk },
});
expect(result).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
expect(close).toHaveBeenCalledTimes(2);
expect(owners).toHaveLength(2);
expect(owners[0]).toBe(owners[1]);
if (result.ok) throw new Error("Expected incomplete hash cleanup");
expect(result.error.cause).toBeInstanceOf(AggregateError);
await provider.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(close).toHaveBeenCalledTimes(3);
expect(owners[0]!.handle.fd).toBe(-1);
expect(launches).toHaveLength(0);
} finally {
fault.restore();
await provider.close();
await verifyCleanup(launches);
}
});
it("clears hash cleanup uncertainty when the provider immediately retries the same handle", async () => {
const { apk, provider, launches } = await setup();
const target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
const fault = failInputHashCloses(target.value.path, 1);
const { close } = fault;
try {
const result = await provider.execute(target.value, {
operation: "inspect_android_package",
input: { path: apk },
});
expect(result).toMatchObject({ ok: false });
if (result.ok) throw new Error("Expected the hash close failure");
expect(close).toHaveBeenCalledTimes(2);
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cause).toBeInstanceOf(AggregateError);
expect(launches).toHaveLength(0);
} finally {
fault.restore();
await provider.close();
await verifyCleanup(launches);
}
});
it("drains hash descriptors even when session shutdown fails and retains its workspace", async () => {
const { service, apk, provider, launches } = await setup();
const initial = await service.execute("inspect_android_package", {
path: apk,
});
if (!initial.ok) throw initial.error;
const workspace = launches[0]?.cwd;
if (workspace === undefined)
throw new Error("Expected an acquired workspace");
const target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
const fault = failInputHashCloses(target.value.path, 1);
const sessionClose = vi
.spyOn(JadxSession.prototype, "close")
.mockRejectedValueOnce(new Error("fixture session cleanup failure"));
try {
const outcome = await provider.execute(target.value, {
operation: "inspect_android_package",
input: { path: apk },
});
expect(outcome).toMatchObject({
ok: false,
error: {
cleanupIncomplete: true,
cleanupResources: [expect.any(String), workspace],
},
});
if (outcome.ok) throw new Error("Expected retained session cleanup");
expect(fault.close).toHaveBeenCalledTimes(2);
await expect(access(workspace)).resolves.toBeUndefined();
sessionClose.mockRestore();
fault.restore();
await provider.close();
expect(outcome.error.cleanupIncomplete).toBe(false);
await verifyCleanup(launches);
} finally {
sessionClose.mockRestore();
fault.restore();
await provider.close();
await verifyCleanup(launches);
}
});
it("cancels Android admission while hashing the selected APK", async () => {
const { apk, launches, provider } = await setup();
const target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
await writeFile(apk, Buffer.from([0x50, 0x4b, 0x03, 0x04]));
await truncate(apk, 1024 * 1024 * 1024);
const controller = new AbortController();
const abortCause = new Error("fixture caller cancelled JADX hashing");
const timer = setTimeout(() => controller.abort(abortCause), 100);
try {
const result = await provider.execute(
target.value,
{ operation: "inspect_android_package", input: { path: apk } },
{ signal: controller.signal },
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
if (result.ok) throw new Error("Expected Android admission cancellation");
expect(result.error.cause).toBe(abortCause);
expect(launches).toHaveLength(0);
} finally {
clearTimeout(timer);
await provider.close();
await verifyCleanup(launches);
}
});
it("preserves cancellation cause when hashing succeeded but descriptor close failed", async () => {
const { apk, launches, provider } = await setup();
const target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
const controller = new AbortController();
const abortCause = new Error("fixture caller cancelled after hash read");
const closeCause = new Error("fixture hash descriptor close failed");
const owners: OwnedFileHandle[] = [];
const originalClose = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async function (this: OwnedFileHandle) {
owners.push(this);
controller.abort(abortCause);
throw closeCause;
})
.mockImplementation(async function (this: OwnedFileHandle) {
owners.push(this);
return originalClose.call(this);
});
try {
const result = await provider.execute(
target.value,
{ operation: "inspect_android_package", input: { path: apk } },
{ signal: controller.signal },
);
expect(result).toMatchObject({
ok: false,
error: { cleanupIncomplete: false },
});
if (result.ok) throw new Error("Expected the descriptor close failure");
expect(close).toHaveBeenCalledTimes(2);
expect(result.error.cause).toBeInstanceOf(AggregateError);
const causes = (result.error.cause as AggregateError).errors;
expect(causes[0]).toMatchObject({ _tag: "AnalysisCancelledError" });
expect((causes[0] as Error).cause).toBe(abortCause);
const cleanupFailure = (causes[1] as Error).cause;
if (!(cleanupFailure instanceof RegularFileCleanupFailure))
throw new Error("Expected the retained hash cleanup failure");
expect(cleanupFailure.cleanupCause).toBe(closeCause);
expect(owners[1]).toBe(owners[0]);
expect(owners[0]?.handle.fd).toBe(-1);
expect(launches).toHaveLength(0);
} finally {
close.mockRestore();
await provider.close();
await verifyCleanup(launches);
}
});
it("rejects a copied JAR that differs from its admitted session identity", async () => {
const { jar } = await setup();
const hashOwners: OwnedFileHandle[] = [];
let admitted: string;
try {
admitted = (
await hashStableFile(jar, {
retainFailedClose: (owner) => hashOwners.push(owner),
})
).sha256;
} finally {
await Promise.all(hashOwners.map((owner) => owner.close()));
}
await writeFile(jar, "engine changed between hashing and copying");
const root = await PrivateRuntimeRoot.create({
prefix: "rea-jadx-admission-",
});
try {
await expect(
snapshotAndroidEngine(
jar,
admitted,
root.path,
"inspect_android_package",
),
).rejects.toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("bytes changed"),
});
} finally {
await root.close();
}
});
it("reuses immutable engine state while keeping each Evidence observation separate", async () => {
const { service, apk, launches, provider } = await setup();
const first = await service.execute("inspect_android_package", { path: apk });
if (!first.ok) throw first.error;
const preserved = JSON.stringify(first.value);
const second = await service.execute("inspect_android_class", {
path: apk,
class_name: "fixture.Target",
});
if (!second.ok) throw second.error;
expect(launches).toHaveLength(1);
expect(JSON.stringify(first.value)).toBe(preserved);
expect(second.value.raw_result).toMatchObject({
loaded: { class_count: 401 },
calls: [
expect.objectContaining({ operation: "rea_jvm_status" }),
expect.objectContaining({ operation: "get_class_summary" }),
],
});
expect(
androidResultSchemas.inspect_android_class.parse(
second.value.normalized_result,
).engine.heap_limit_mib,
).toBe(8192);
await provider.close();
await verifyCleanup(launches);
});
it("bounds output per operation without repeating prerequisite probes for a retained session", async () => {
const { service, apk, launches, provider, javaProbeCount } =
await setup("large-manifest");
const expectedManifest = "x".repeat(1024 * 1024);
for (let index = 0; index < 34; index += 1) {
const result = await service.execute("inspect_android_package", {
path: apk,
});
if (!result.ok) throw result.error;
expect(
androidResultSchemas.inspect_android_package.parse(
result.value.normalized_result,
).manifest.text,
).toBe(expectedManifest);
}
expect(launches).toHaveLength(1);
expect(await javaProbeCount()).toBe(1);
await provider.close();
await verifyCleanup(launches);
});
it("retire sessions when APK bytes, engine bytes or JVM options change", async () => {
const { service, apk, jar, launches, provider, environment, javaProbeCount } =
await setup();
const inspect = async () => {
const result = await service.execute("inspect_android_package", {
path: apk,
});
if (!result.ok) throw result.error;
return result.value;
};
const first = await inspect();
await writeFile(
apk,
Buffer.concat([await readFile(apk), Buffer.from("new artifact bytes")]),
);
const changed = await inspect();
expect(changed.subject?.digest.sha256).not.toBe(first.subject?.digest.sha256);
await verifyCleanup(launches.slice(0, 1));
await writeJadxJarInventory(jar, ["fixture/NewEngineMarker.class"]);
await inspect();
environment.JAVA_TOOL_OPTIONS = "-Xmx8g";
environment._JAVA_OPTIONS = "-XX:+UseG1GC";
await inspect();
expect(launches).toHaveLength(4);
expect(await javaProbeCount()).toBe(4);
expect(launches[3]).toMatchObject({
javaToolOptions: "-Xmx8g",
legacyJavaOptions: "-XX:+UseG1GC",
});
expect(launches[3]?.arguments).not.toContain("-Xmx512m");
expect(launches[3]?.arguments).not.toContain("-XX:ActiveProcessorCount=1");
await provider.close();
await verifyCleanup(launches);
});
it("keeps one operation's JVM environment aligned with its retained session identity", async () => {
const { service, apk, launches, provider, environment } = await setup();
environment.JAVA_TOOL_OPTIONS = "-Xmx512m";
const createRoot = PrivateRuntimeRoot.create.bind(PrivateRuntimeRoot);
const allocation = vi
.spyOn(PrivateRuntimeRoot, "create")
.mockImplementationOnce(async (options) => {
// Configuration and the reuse key are already admitted; launch follows
// this asynchronous workspace boundary.
environment.JAVA_TOOL_OPTIONS = "-Xmx8g";
return createRoot(options);
});
try {
const first = await service.execute("inspect_android_package", {
path: apk,
});
if (!first.ok) throw first.error;
expect(allocation).toHaveBeenCalledOnce();
environment.JAVA_TOOL_OPTIONS = "-Xmx512m";
const second = await service.execute("inspect_android_package", {
path: apk,
});
if (!second.ok) throw second.error;
expect(launches).toHaveLength(1);
expect(launches[0]?.javaToolOptions).toBe("-Xmx512m");
} finally {
allocation.mockRestore();
await provider.close();
await verifyCleanup(launches);
}
});
it("retires an idle engine and starts a fresh one for a later request", async () => {
const { service, apk, launches, provider } = await setup();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const first = await service.execute("inspect_android_package", {
path: apk,
});
if (!first.ok) throw first.error;
vi.advanceTimersByTime(60_000);
} finally {
vi.useRealTimers();
}
await expect
.poll(async () => {
try {
await verifyCleanup(launches);
return true;
} catch {
return false;
}
})
.toBe(true);
const later = await service.execute("inspect_android_package", { path: apk });
if (!later.ok) throw later.error;
expect(launches).toHaveLength(2);
await provider.close();
await verifyCleanup(launches);
});
it("completes large class inventories with exact case-sensitive search semantics", async () => {
const { service, apk, provider } = await setup("large-inventory");
const full = await service.execute("search_android_classes", {
path: apk,
query: "",
});
if (!full.ok) throw full.error;
const inventory = androidResultSchemas.search_android_classes.parse(
full.value.normalized_result,
);
expect(inventory.matches).toHaveLength(9001);
expect(inventory.matches.at(-1)).toBe("fixture.Class8999");
const mixedCase = await service.execute("search_android_classes", {
path: apk,
query: "target",
});
if (!mixedCase.ok) throw mixedCase.error;
expect(
androidResultSchemas.search_android_classes.parse(
mixedCase.value.normalized_result,
).matches,
).toEqual([]);
await provider.close();
});
it("close cancels active and queued work and prevents later acquisition", async () => {
const { service, apk, launches, provider } = await setup("stall");
const active = service.execute("inspect_android_package", { path: apk });
await expect.poll(() => launches.length).toBe(1);
const queued = service.execute("inspect_android_package", { path: apk });
await provider.close();
for (const result of [
await active,
await queued,
await service.execute("inspect_android_package", { path: apk }),
])
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(launches).toHaveLength(1);
await verifyCleanup(launches);
});
it("does not carry a factory instance's cleanup failure into another instance", async () => {
const failed = await setup("cleanup-failure");
const healthy = await setup();
const failure = await failed.service.execute("inspect_android_package", {
path: failed.apk,
});
expect(failure).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
const result = await healthy.service.execute("inspect_android_package", {
path: healthy.apk,
});
expect(result.ok).toBe(true);
await healthy.provider.close();
await verifyCleanup(healthy.launches);
});
it("retains APK identity, original input, raw producer data and normalized observations", async () => {
const { service, apk, launches, provider } = await setup();
const outcome = await service.execute("inspect_android_package", {
path: apk,
});
if (!outcome.ok) throw outcome.error;
const result = androidResultSchemas.inspect_android_package.parse(
outcome.value.normalized_result,
);
expect(result.package_name).toBe("fixture");
expect(result.engine.source_revision).toBeNull();
expect(outcome.value.subject?.local_path).toBe(apk);
expect(outcome.value.parameters).toEqual({ path: apk });
expect(outcome.value.raw_result).toMatchObject({
calls: expect.arrayContaining([
expect.objectContaining({ operation: "get_android_manifest" }),
]),
});
await provider.close();
await verifyCleanup(launches);
});
it("pages matching classes independently of the total APK class count", async () => {
const { service, apk, launches, provider } = await setup("large-inventory");
const outcome = await service.execute("search_android_classes", {
path: apk,
query: "Class",
});
if (!outcome.ok) throw outcome.error;
const result = androidResultSchemas.search_android_classes.parse(
outcome.value.normalized_result,
);
expect(result.total_classes).toBe(9001);
expect(result.matches).toHaveLength(9000);
expect(result.matches[0]).toBe("fixture.Class0");
expect(result.matches[4096]).toBe("fixture.Class4096");
expect(result.matches.at(-1)).toBe("fixture.Class8999");
await provider.close();
await verifyCleanup(launches);
});
it("preserves duplicate class-name rows and occurrence counts", async () => {
const { service, apk, launches, provider } = await setup(
"duplicate-class-name",
);
const outcome = await service.execute("search_android_classes", {
path: apk,
query: "fixture.Target",
});
if (!outcome.ok) throw outcome.error;
const result = androidResultSchemas.search_android_classes.parse(
outcome.value.normalized_result,
);
expect(result.total_classes).toBe(402);
expect(result.matches).toEqual(["fixture.Target", "fixture.Target"]);
expect(result.duplicate_names).toEqual([
{ name: "fixture.Target", occurrences: 2 },
]);
const full = await service.execute("search_android_classes", {
path: apk,
query: "",
});
if (!full.ok) throw full.error;
const fullResult = androidResultSchemas.search_android_classes.parse(
full.value.normalized_result,
);
expect(fullResult.total_classes).toBe(402);
expect(fullResult.matches).toHaveLength(402);
expect(fullResult.duplicate_names).toEqual([
{ name: "fixture.Target", occurrences: 2 },
]);
await provider.close();
await verifyCleanup(launches);
});
it("exposes overload candidates and executes only an explicitly selected ambiguous method", async () => {
const { service, apk, launches, provider } = await setup();
const input = {
path: apk,
class_name: "fixture.Target",
method_name: "choose",
};
const ambiguous = await service.execute("inspect_android_method", input);
expect(ambiguous).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
issues: [
{
path: ["overload_index"],
message: expect.stringContaining("1: choose(int)"),
},
],
},
});
const selected = await service.execute("inspect_android_method", {
...input,
overload_index: 1,
});
if (!selected.ok) throw selected.error;
const result = androidResultSchemas.inspect_android_method.parse(
selected.value.normalized_result,
);
expect(result.method).toMatchObject({
overload_index: 1,
reported_signature: "choose(int): void",
dex_descriptor: "(I)V",
});
expect(result.source.text).toContain("overload 1");
const references = await service.execute("trace_android_references", input);
expect(references).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
issues: [expect.objectContaining({ path: ["overload_index"] })],
},
});
const selectedReferences = await service.execute("trace_android_references", {
...input,
overload_index: 1,
});
if (!selectedReferences.ok) throw selectedReferences.error;
expect(
androidResultSchemas.trace_android_references.parse(
selectedReferences.value.normalized_result,
),
).toMatchObject({
overload_index: 1,
reported_signature: "choose(int): void",
});
await provider.close();
await verifyCleanup(launches);
});
it.each([
"wrong-apk",
"empty-page",
"summary-error",
"wrong-method",
"wrong-descriptor",
"partial-xrefs",
])(
"rejects producer failure %s rather than claiming complete analysis",
async (mode) => {
const { service, apk, launches, provider } = await setup(mode);
const operation =
mode === "wrong-apk"
? "inspect_android_package"
: mode === "empty-page"
? "search_android_classes"
: mode === "partial-xrefs"
? "trace_android_references"
: "inspect_android_method";
const outcome = await service.execute(operation, {
path: apk,
...(operation === "search_android_classes"
? { query: "" }
: operation === "inspect_android_package"
? {}
: { class_name: "fixture.Target", method_name: "onCreate" }),
});
expect(outcome).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
await provider.close();
await verifyCleanup(launches);
},
);
it("rejects fuzzy class resolution and unsupported upstream versions", async () => {
for (const mode of ["wrong-class", "version", "engine-version"]) {
const { service, apk, launches, provider } = await setup(mode);
const outcome = await service.execute("inspect_android_class", {
path: apk,
class_name: "fixture.Target",
});
expect(outcome).toMatchObject({
ok: false,
error: {
_tag:
mode === "version" || mode === "engine-version"
? "AnalysisCapabilityUnavailableError"
: "AnalysisInputError",
},
});
await provider.close();
await verifyCleanup(launches);
}
});
it.each(["truncated", "smali"])(
"retains %s source coverage instead of presenting it as successful complete Java",
async (mode) => {
const { service, apk, launches, provider } = await setup(mode);
const outcome = await service.execute("inspect_android_method", {
path: apk,
class_name: "fixture.Target",
method_name: "onCreate",
});
if (!outcome.ok) throw outcome.error;
const result = androidResultSchemas.inspect_android_method.parse(
outcome.value.normalized_result,
);
expect(result.source.status).toBe(
mode === "truncated" ? "partial" : "complete",
);
expect(result.representation).toBe(mode === "smali" ? "smali" : "java");
if (mode === "truncated")
expect(result.source.reported_total_bytes).toBe(100);
else expect(result.fell_back).toBe(true);
await provider.close();
await verifyCleanup(launches);
},
);
it("cancels active and queued requests, cleans ownership and never launches a queued cancelled request", async () => {
const { service, apk, launches, provider } = await setup("stall");
const activeController = new AbortController();
const active = service.execute(
"inspect_android_package",
{ path: apk },
{ signal: activeController.signal },
);
await expect.poll(() => launches.length).toBe(1);
const queuedController = new AbortController();
const queued = service.execute(
"inspect_android_package",
{ path: apk },
{ signal: queuedController.signal },
);
queuedController.abort();
expect(await queued).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(launches).toHaveLength(1);
activeController.abort();
expect(await active).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
await provider.close();
await verifyCleanup(launches);
});
it("ends each queued stalled operation at the caller-selected deadline and cleans ownership", async () => {
const { service, apk, launches, provider } = await setup(
"stall",
undefined,
undefined,
{ REA_JADX_OPERATION_TIMEOUT_MS: "1500" },
);
const started = performance.now();
// The second request waits behind the first; its deadline starts at the
// queue front, so it gets its own engine launch and runtime reply.
const outcomes = await Promise.all([
service.execute("inspect_android_package", { path: apk }),
service.execute("inspect_android_package", { path: apk }),
]);
expect(performance.now() - started).toBeLessThan(20_000);
for (const outcome of outcomes) {
expect(outcome).toMatchObject({
ok: false,
error: {
_tag: "AnalysisTimeoutError",
timeoutMs: 1500,
partialObservation: {
calls: [expect.objectContaining({ operation: "rea_jvm_status" })],
},
},
});
if (outcome.ok) throw new Error("Expected the selected deadline");
expect(projectAnalysisError(outcome.error)).toMatchObject({
code: "provider_timeout",
details: { timeout_ms: 1500 },
});
}
expect(launches).toHaveLength(2);
// The deadline path itself releases ownership before provider shutdown.
await verifyCleanup(launches);
await provider.close();
});
it("completes an operation under the largest accepted deadline", async () => {
const { service, apk, provider, launches } = await setup(
"normal",
undefined,
undefined,
{ REA_JADX_OPERATION_TIMEOUT_MS: "2147483647" },
);
const outcome = await service.execute("inspect_android_package", {
path: apk,
});
expect(outcome.ok).toBe(true);
await provider.close();
await verifyCleanup(launches);
});
it.each(["", "2147483648"])(
"rejects REA_JADX_OPERATION_TIMEOUT_MS=%j before probing or launching Java",
async (value) => {
const { service, apk, launches, javaProbeCount } = await setup(
"normal",
undefined,
undefined,
{ REA_JADX_OPERATION_TIMEOUT_MS: value },
);
const outcome = await service.execute("inspect_android_package", {
path: apk,
});
expect(outcome).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("REA_JADX_OPERATION_TIMEOUT_MS"),
},
});
expect(launches).toHaveLength(0);
// The fake JDK records each module-listing probe; none may have run.
await expect(javaProbeCount()).rejects.toMatchObject({ code: "ENOENT" });
},
);
it("bounds each upstream call by the selected deadline instead of a fixed limit", async () => {
// Provider workflows cannot reach the former fixed 120-second call limit in
// a test, so exercise the session boundary that owns each upstream call.
const { apk } = await setup();
const target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
const workspace = await createTestTempDirectory("rea-jadx-call-deadline-");
const pids: number[] = [];
const session = new JadxSession(
{
command: process.execPath,
arguments: [],
cwd: workspace,
hostEnvironment: process.env,
signal: new AbortController().signal,
},
async (options) => {
const spawned = await spawnOwnedProviderProcess({
...options,
command: process.execPath,
arguments: [
fileURLToPath(
new URL("../../fixtures/android/jadx-mcp.mjs", import.meta.url),
),
],
expectedCommand: process.execPath,
env: { ...options.env, REA_FAKE_JADX_MODE: "stall" },
});
if (spawned.process.pid !== undefined) pids.push(spawned.process.pid);
return spawned;
},
);
onTestFinished(() => session.close());
try {
const started = performance.now();
await expect(
session.execute({
request: { operation: "inspect_android_package", input: { path: apk } },
target: target.value,
snapshot: apk,
jarHash: "0".repeat(64),
bridgeHash: "0".repeat(64),
signal: new AbortController().signal,
timeoutMs: 300,
}),
).rejects.toMatchObject({
code: SdkErrorCode.RequestTimeout,
data: { timeout: 300 },
});
expect(performance.now() - started).toBeLessThan(10_000);
expect(session.partialObservation()?.calls).toEqual([
expect.objectContaining({ operation: "rea_jvm_status" }),
]);
} finally {
await session.close();
}
expect(pids).toHaveLength(1);
for (const pid of pids)
expect(() => process.kill(-pid, 0)).toThrowError(
expect.objectContaining({ code: "ESRCH" }),
);
});
it("reports missing explicit tools without trying to install or launch them", async () => {
const { apk } = await setup();
const provider = new JadxProvider({}, () => {
throw new Error("must not launch");
});
const outcome = await new AndroidAnalysisService(provider).execute(
"inspect_android_package",
{ path: apk },
);
expect(outcome).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
},
});
});
it.each(["tool-error", "frame-overflow", "body-error", "heap-decoy"])(
"preserves the actual failed provider constraint for %s",
async (mode) => {
const { service, apk, launches, provider } = await setup(mode);
const outcome = await service.execute(
mode === "body-error"
? "inspect_android_method"
: "inspect_android_package",
{
path: apk,
...(mode === "body-error"
? { class_name: "fixture.Target", method_name: "onCreate" }
: {}),
},
);
if (outcome.ok) throw new Error("Expected a provider failure");
if (mode !== "frame-overflow")
expect(outcome.error).toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: {
upstream_operation:
mode === "body-error"
? "get_method_body"
: mode === "heap-decoy"
? "load_apk"
: "get_android_manifest",
reason:
mode === "body-error"
? "// ERROR: decompile timed out or failed"
: mode === "heap-decoy"
? "load failed: cannot read /inputs/Java heap space.apk"
: "manifest decoder rejected malformed binary XML",
},
});
else
expect(outcome.error).toMatchObject({
_tag: "AnalysisOutputError",
reason: expect.stringContaining("MCP frame exceeds"),
});
await provider.close();
await verifyCleanup(launches);
},
);
it.each(["heap-failure", "other-memory-failure"])(
"projects typed JVM memory failure and cleans its process for %s",
async (mode) => {
const { service, apk, launches, provider } = await setup(mode);
const outcome = await service.execute("inspect_android_package", {
path: apk,
});
if (outcome.ok) throw new Error("Expected memory allocation failure");
expect(outcome.error).toMatchObject({
_tag: "AnalysisResourceConstraintError",
resource: "memory",
retryable: false,
reportedLimits: {
observed_max_heap_bytes: 67108864,
memory_area: mode === "heap-failure" ? "heap" : "unknown",
},
diagnostics: { upstream_operation: "load_apk", target_path: apk },
});
const projected = projectAnalysisError(outcome.error);
expect(projected).toMatchObject({
code: "resource_constraint",
retryable: false,
});
expect(projected.remediation.action).toContain(
mode === "heap-failure"
? "REA_JADX_HEAP_MIB"
: "increasing heap alone may not help",
);
await provider.close();
await verifyCleanup(launches);
},
);
it("retains typed memory failure through failed cleanup and restores it after retry", async () => {
let fails = true;
const { service, apk, launches, provider } = await setup(
"heap-failure",
() => fails,
);
const originalRootClose = PrivateRuntimeRoot.prototype.close;
const workspaceCloseCause = new Error("fixture workspace close failure");
let workspaceCloseFails = true;
let workspaceCloseAttempts = 0;
const rootClose = vi
.spyOn(PrivateRuntimeRoot.prototype, "close")
.mockImplementation(function (this: PrivateRuntimeRoot) {
// Independent native helpers own their own roots. Keep the acquired
// Android workspace's failure active until the test explicitly clears it.
if (this.path === launches[0]?.cwd) {
workspaceCloseAttempts += 1;
if (workspaceCloseFails) return Promise.reject(workspaceCloseCause);
}
return originalRootClose.call(this);
});
try {
const result = await service.execute("inspect_android_package", {
path: apk,
});
if (result.ok) throw new Error("Expected failed memory cleanup");
const failure = result.error;
expect(failure.cleanupIncomplete).toBe(true);
expect(projectAnalysisError(failure)).toMatchObject({
code: "cleanup_incomplete",
details: {
diagnostics: { primary_error: { code: "resource_constraint" } },
},
});
const workspace = launches[0]?.cwd;
expect(failure.cleanupResources).toContain(workspace);
expect(workspaceCloseAttempts).toBe(0);
fails = false;
await expect(provider.close()).rejects.toMatchObject({
cleanupIncomplete: true,
});
expect(failure.cleanupResources).toEqual([workspace]);
expect(workspaceCloseAttempts).toBeGreaterThan(0);
workspaceCloseFails = false;
await provider.close();
expect(failure.cleanupIncomplete).toBe(false);
expect(failure.cleanupResources).toEqual([]);
expect(projectAnalysisError(failure)).toMatchObject({
code: "resource_constraint",
retryable: false,
remediation: { action: expect.stringContaining("REA_JADX_HEAP_MIB") },
details: {
resource: "memory",
reported_limits: { observed_max_heap_bytes: 67108864 },
},
});
await verifyCleanup(launches);
} finally {
rootClose.mockRestore();
workspaceCloseFails = false;
fails = false;
await provider.close();
}
});
it("rejects bytes changed after target admission without launching an engine", async () => {
const { provider, apk, launches } = await setup();
const target = await parseBinaryTarget(apk);
if (!target.ok) throw target.error;
await writeFile(
apk,
Buffer.concat([
await readFile(apk),
Buffer.from("changed after admission"),
]),
);
const outcome = await provider.execute(target.value, {
operation: "inspect_android_package",
input: { path: apk },
});
expect(outcome).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
issues: [{ message: expect.stringContaining("bytes changed") }],
},
});
expect(launches).toHaveLength(0);
});
it("retains an uncertain workspace and blocks later launches after cleanup cannot be verified", async () => {
const { service, apk, launches } = await setup("cleanup-failure");
const outcome = await service.execute("inspect_android_package", {
path: apk,
});
expect(outcome).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
const workspace = launches[0]?.cwd;
if (workspace === undefined) throw new Error("Expected acquired workspace");
await expect(access(workspace)).resolves.toBeUndefined();
expect(
await service.execute("inspect_android_package", { path: apk }),
).toMatchObject({ ok: false, error: { cleanupIncomplete: true } });
expect(launches).toHaveLength(1);
});
it("resolves every reported shutdown cleanup failure after ownership recovers", async () => {
let cleanupFails = true;
const { service, apk, launches, provider } = await setup(
"normal",
() => cleanupFails,
);
const closeAfterRecovery = async () => {
cleanupFails = false;
// Closing the transport can leave the child between live and exited
// snapshots. Retry retained ownership until cleanup is actually verified.
await expect
.poll(() => provider.close(), { timeout: 5_000 })
.toBeUndefined();
};
try {
const outcome = await service.execute("inspect_android_package", {
path: apk,
});
if (!outcome.ok) throw outcome.error;
const firstClose = provider.close();
expect(provider.close()).toBe(firstClose);
const firstFailure = await firstClose.then(
() => {
throw new Error("Expected shutdown cleanup to remain incomplete");
},
(cause: unknown) => cause,
);
expect(firstFailure).toMatchObject({ cleanupIncomplete: true });
expect(firstFailure).toHaveProperty("cause", expect.any(AggregateError));
const secondFailure = await provider.close().then(
() => {
throw new Error(
"Expected the second cleanup retry to remain incomplete",
);
},
(cause: unknown) => cause,
);
expect(secondFailure).toMatchObject({ cleanupIncomplete: true });
expect(secondFailure).toHaveProperty("cause", expect.any(AggregateError));
const workspace = launches[0]?.cwd;
if (workspace === undefined) throw new Error("Expected acquired workspace");
await expect(access(workspace)).resolves.toBeUndefined();
await closeAfterRecovery();
expect(firstFailure).toMatchObject({ cleanupIncomplete: false });
expect(secondFailure).toMatchObject({ cleanupIncomplete: false });
await verifyCleanup(launches);
expect(launches).toHaveLength(1);
} finally {
await closeAfterRecovery();
}
});
it.each(["idle", "target-change"])(
"retries %s retirement before acquiring another engine",
async (retirement) => {
let cleanupFails = true;
let cleanupAttempts = 0;
const { service, apk, jar, launches, provider } = await setup(
"normal",
() => {
cleanupAttempts += 1;
return cleanupFails;
},
);
try {
if (retirement === "idle")
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const first = await service.execute("inspect_android_package", {
path: apk,
});
if (!first.ok) throw first.error;
if (retirement === "idle") {
vi.advanceTimersByTime(60_000);
vi.useRealTimers();
await expect.poll(() => cleanupAttempts).toBeGreaterThan(0);
} else {
await writeJadxJarInventory(jar, ["fixture/NewEngineMarker.class"]);
expect(
await service.execute("inspect_android_package", { path: apk }),
).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
}
const workspace = launches[0]?.cwd;
if (workspace === undefined)
throw new Error("Expected acquired workspace");
await expect(access(workspace)).resolves.toBeUndefined();
expect(launches).toHaveLength(1);
cleanupFails = false;
const retried = await service.execute("inspect_android_package", {
path: apk,
});
if (!retried.ok) throw retried.error;
expect(launches).toHaveLength(2);
await provider.close();
await verifyCleanup(launches);
} finally {
vi.useRealTimers();
cleanupFails = false;
await provider.close();
}
},
);
it("retains the execution failure while cleanup is pending and resumes after cleanup recovers", async () => {
let cleanupFails = true;
const { service, apk, launches, provider } = await setup(
"tool-error",
() => cleanupFails,
);
try {
const outcome = await service.execute("inspect_android_package", {
path: apk,
});
expect(outcome).toMatchObject({
ok: false,
error: {
cleanupIncomplete: true,
diagnostics: {
primary_error: {
details: {
diagnostics: {
reason: "manifest decoder rejected malformed binary XML",
},
},
},
},
},
});
if (outcome.ok) throw new Error("Expected cleanup failure");
expect(outcome.error.cause).toBeInstanceOf(AggregateError);
const causes = (outcome.error.cause as AggregateError).errors;
expect(causes[0]).toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: {
reason: "manifest decoder rejected malformed binary XML",
},
});
expect(
await service.execute("inspect_android_package", { path: apk }),
).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
expect(launches).toHaveLength(1);
cleanupFails = false;
const retried = await service.execute("inspect_android_package", {
path: apk,
});
expect(retried).toMatchObject({
ok: false,
error: {
cleanupIncomplete: false,
diagnostics: {
reason: "manifest decoder rejected malformed binary XML",
},
},
});
expect(launches).toHaveLength(2);
expect(outcome.error.cleanupIncomplete).toBe(false);
expect(outcome.error.cleanupResources).toEqual([]);
expect(projectAnalysisError(outcome.error)).toMatchObject({
code: "execution_failure",
details: {
diagnostics: {
reason: "manifest decoder rejected malformed binary XML",
},
},
});
await verifyCleanup(launches);
} finally {
cleanupFails = false;
await provider.close();
}
});
it("accepts exact selected-overload smali from the bridge", async () => {
const { service, apk, launches, provider } = await setup(
"selected-overload-smali",
);
const outcome = await service.execute("inspect_android_method", {
path: apk,
class_name: "fixture.Target",
method_name: "choose",
overload_index: 1,
});
if (!outcome.ok) throw outcome.error;
const result = androidResultSchemas.inspect_android_method.parse(
outcome.value.normalized_result,
);
expect(result).toMatchObject({
method: { overload_index: 1, dex_descriptor: "(I)V" },
representation: "smali",
body_status: "available",
});
await provider.close();
await verifyCleanup(launches);
});
it("retains parsed native body absence without claiming an empty implementation", async () => {
const { service, apk, launches, provider } = await setup("native-method");
const outcome = await service.execute("inspect_android_method", {
path: apk,
class_name: "fixture.Target",
method_name: "onCreate",
});
if (!outcome.ok) throw outcome.error;
const result = androidResultSchemas.inspect_android_method.parse(
outcome.value.normalized_result,
);
expect(result.body_status).toBe("not_available");
expect(result.source.text).toBe("");
expect(result.markers).toEqual(["native_method"]);
await provider.close();
await verifyCleanup(launches);
});