mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
1263 lines
43 KiB
TypeScript
1263 lines
43 KiB
TypeScript
import { SdkErrorCode } from "@modelcontextprotocol/client";
|
|
import { expect, it as test, onTestFinished, vi } from "vitest";
|
|
import { access, readFile, truncate, writeFile } from "node:fs/promises";
|
|
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 { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
const it = test.skipIf(process.platform === "win32");
|
|
|
|
// 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("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);
|
|
});
|