test: strengthen provider contracts and consolidate duplicate cases

This commit is contained in:
morluto committed 2026-11-10 16:02:45 -05:00
1 parent 6377a1831f
commit 93dc8d044d
10 files changed
+87 -178

No files matched your search

+45 -28
View File
@@ -779,20 +779,40 @@ if [ "$1" = "version" ]; then ${VERSION_BLOCK}; fi`,
});
it("reads a bounded logcat dump with the caller count and buffer", async () => {
const { binary } = await writeAdbStub(
shellStub({
logcat:
"08-10 13:00:00.000 I/Tag( 123): first\n08-10 13:00:01.000 I/Tag( 123): second\n",
}),
const { binary, root } = await writeAdbStub(
(root) => `printf '%s\\n' "$@" >> '${join(root, "argv.log")}'
${shellStub({
logcat:
"08-10 13:00:00.000 I/Tag( 123): first\n08-10 13:00:01.000 I/Tag( 123): second\n",
})(root)}`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "read_adb_logcat",
input: { serial: "emulator-5554", count: 500, buffer: "main" },
input: { serial: "emulator-5554", count: 1, buffer: "system", pid: 123 },
});
expect(result.lines).toEqual([
"08-10 13:00:00.000 I/Tag( 123): first",
"08-10 13:00:01.000 I/Tag( 123): second",
expect(
(await readFile(join(root, "argv.log"), "utf8")).trim().split("\n"),
).toEqual([
"version",
"-s",
"emulator-5554",
"shell",
"logcat",
"-d",
"-b",
"system",
"-t",
"1",
"--pid",
"123",
]);
expect(result).toMatchObject({
count: 1,
buffer: "system",
pid: 123,
coverage: "partial",
lines: ["08-10 13:00:01.000 I/Tag( 123): second"],
});
});
it("reports UTF-8 byte counts in total_bytes for non-ASCII logcat output", async () => {
@@ -1667,13 +1687,15 @@ exit 1`,
operation: "capture_adb_screen",
input: { serial: "emulator-5554", output_directory: output },
});
expect(result).toMatchObject({ width: 1, height: 1 });
expect(
await readFile(join(output, "screen.png")).then(
(bytes) => bytes.subarray(1, 4).toString("latin1") === "PNG",
() => false,
),
).toBe(true);
expect(result).toMatchObject({
width: 1,
height: 1,
bytes: SCREEN_PNG.byteLength,
sha256: createHash("sha256").update(SCREEN_PNG).digest("hex"),
});
expect((await readFile(join(output, "screen.png"))).equals(SCREEN_PNG)).toBe(
true,
);
});
it("stops a screen capture aborted after adb returns but before publication", async () => {
@@ -1889,11 +1911,12 @@ it("reads Android 16 focus declarations from displays when windows omits them",
"mCurrentFocus=Window{abc u0 com.example/.Main}\nmFocusedApp=ActivityRecord{def u0 com.example/.Main t1}\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
const execution = await providerOver(binary).execute({
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(result).toMatchObject({
if (!execution.ok) throw execution.error;
expect(execution.value.result).toMatchObject({
focused_window: "Window{abc u0 com.example/.Main}",
focused_app: "ActivityRecord{def u0 com.example/.Main t1}",
observed_lines: [
@@ -1901,11 +1924,6 @@ it("reads Android 16 focus declarations from displays when windows omits them",
"mFocusedApp=ActivityRecord{def u0 com.example/.Main t1}",
],
});
const execution = await providerOver(binary).execute({
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
if (!execution.ok) throw execution.error;
expect(execution.value.limitations).toContainEqual(
expect.stringContaining("dumpsys window displays"),
);
@@ -1918,16 +1936,15 @@ it("describes missing window focus as unknown after both sections omit declarati
"dumpsys:window:displays": "no focus\\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(result).toMatchObject({ focused_window: null, focused_app: null });
const execution = await providerOver(binary).execute({
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
if (!execution.ok) throw execution.error;
expect(execution.value.result).toMatchObject({
focused_window: null,
focused_app: null,
});
expect(execution.value.limitations).toContainEqual(
expect.stringContaining("focus is unknown"),
);
-10
View File
@@ -235,16 +235,6 @@ describe("parseWindowFocusOutput declaration presence", () => {
});
describe("parseDumpsysPackageOutput", () => {
it.each(["userId", "appId"])(
"retains the printed %s package identifier",
(key) => {
expect(
parseDumpsysPackageOutput(
`Packages:\n Package [com.example] (abcdef):\n ${key}=10234\n`,
).userId,
).toBe(10234);
},
);
it("reports unknown requested permissions when the package is null", () => {
expect(
parseDumpsysPackageOutput("Package [com.example] (abcdef):\n pkg=null\n")
+9 -4
View File
@@ -2,7 +2,6 @@ import { expect, it } from "vitest";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { captureJadxFailure } from "./JadxFailure.js";
import { jadxOperationTimeoutGuidance } from "./JadxOperationTimeout.js";
it("names REA_JADX_OPERATION_TIMEOUT_MS when the operation deadline elapses", () => {
const timeout = AbortSignal.abort(new Error("deadline"));
@@ -18,9 +17,15 @@ it("names REA_JADX_OPERATION_TIMEOUT_MS when the operation deadline elapses", ()
session: undefined,
});
const projected = projectAnalysisError(present());
const guidance = jadxOperationTimeoutGuidance(1500);
expect(projected.message).toBe(guidance.userMessage);
expect(projected.remediation.action).toBe(guidance.remediationAction);
expect(projected.message).toContain("1500 ms");
expect(projected.remediation.action).toContain(
"REA_JADX_OPERATION_TIMEOUT_MS",
);
expect(projected.remediation.action).toContain("update_analysis_settings");
expect(projected.remediation.action).toContain("jadx.operation_timeout_ms");
expect(projected.remediation.action).toContain(
"next operation without restarting REA",
);
expect(projected.details).toMatchObject({
limit: {
setting: "jadx.operation_timeout_ms",
+6 -29
View File
@@ -686,44 +686,20 @@ it("projects one locale's strings on request", async () => {
]);
});
it("notes a missing locale table instead of failing", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true, locale: "fr" },
});
expect(result.strings).toEqual([]);
});
it("notes a missing default strings table without strings", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ withStrings: false }),
);
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
const execution = await providerOver(command).execute({
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.strings).toEqual([]);
});
it("reports apktool's own refusal for undecodable targets", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ failDecode: true }),
if (!execution.ok) throw execution.error;
expect(execution.value.result).toMatchObject({ strings: [] });
expect(execution.value.limitations).toContain(
"No default res/values/strings.xml was found in the decoded workspace",
);
const apk = await apkFixture(root);
const projected = await refusedBy(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(projected).toMatchObject({
code: "unsupported_target",
details: { path: apk.path },
});
expect(projected.message).toContain(apk.path);
expect(projected.message).not.toContain(command);
expect(projected.message).toContain("Could not decode");
});
it("reports a missing launcher as a capability gap with recovery", async () => {
@@ -1525,6 +1501,7 @@ it.each(["first-selected.apk", "second-selected.apk"])(
details: { path, captured_output: { exit_code: 1 } },
});
expect(projected.message).toContain(path);
expect(projected.message).toContain("Could not decode");
expect(projected.message).not.toContain(command);
} finally {
await provider.close();
+8 -47
View File
@@ -23,7 +23,6 @@ import {
type AnalysisError,
} from "../domain/analysisErrorBase.js";
import type { Result } from "../domain/result.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok } from "../domain/result.js";
const provider = { id: "fixture", name: "Fixture", version: "1" };
@@ -137,52 +136,6 @@ describe("analysis context queries: navigation", () => {
);
});
it.each<JsonValue>([
null,
{},
{ found: false, procedure: null },
{ query_address: "0x401000", found: true, procedure: null },
])(
"keeps malformed containing-procedure output unknown: %j",
async (value) => {
const analysis: AnalysisOperationPort = {
execute: async (operation) =>
ok(
createAnalysisExecution(
operation === "resolve_containing_procedure"
? value
: operation === "current_document"
? "fixture"
: "0x401000",
provider,
),
),
};
expect(await getNavigationContext(analysis, {})).toMatchObject({
ok: false,
error: {
_tag: "AnalysisOutputError",
operation: "resolve_containing_procedure",
},
});
},
);
it("does not infer procedure absence from a provider error message", async () => {
const failure = new AnalysisProtocolError(
"not in a procedure: malformed reply",
);
const analysis: AnalysisOperationPort = {
execute: async (operation) =>
operation === "resolve_containing_procedure"
? err(failure)
: ok(createAnalysisExecution("0x401000", provider)),
};
expect(
await getNavigationContext(analysis, { document: "fixture" }),
).toEqual(err(failure));
});
it("uses an explicit document without passing it to current_document and preserves procedure failures", async () => {
const calls: string[] = [];
const failure = new AnalysisProtocolError("provider response malformed");
@@ -209,6 +162,14 @@ describe("navigation procedure admission", () => {
it.each([
{ condition: "missing result", value: null },
{ condition: "missing discriminator", value: {} },
{
condition: "missing absence reason",
value: { found: false, procedure: null },
},
{
condition: "found procedure is null",
value: { query_address: "0x401000", found: true, procedure: null },
},
{
condition: "missing query identity",
value: { found: false, procedure: null, reason: "not_in_procedure" },
@@ -40,7 +40,7 @@ it.each([
});
},
);
it("rejects source text changed across the artifact port while retaining the original digest", async () => {
it("rejects source text changed across the artifact port before acquiring the resolver", async () => {
const artifacts = {
...webModuleArtifactsFixture("import(name)"),
source: "import(other)",
@@ -304,22 +304,6 @@ it("honors cancellation both before acquisition and across decoder completion",
].map((path) => ({ kind: "artifact-path", path })),
});
const malformedDecoder: WebSourceMapPort = {
trace: async (input, options) => {
const decoded = await decoder.trace(input, options);
return decoded.ok
? ok({ ...decoded.value, source_map_sha256: "b".repeat(64) })
: decoded;
},
};
const malformed = await new WebSourceLocationService(
{ load: () => Promise.resolve(ok(webSourceLocationFixture())) },
malformedDecoder,
).trace(args);
expect(malformed.ok).toBe(false);
if (!malformed.ok)
expect(malformed.error).toBeInstanceOf(AnalysisOutputError);
const invalidController = new AbortController();
const malformedLate = await new WebSourceLocationService(
{ load: () => Promise.resolve(ok(webSourceLocationFixture())) },
-34
View File
@@ -725,40 +725,6 @@ describe("workflow residual question retention", () => {
);
});
it.each([
["spaces", " Which boundary? "],
["line feed and tab", "\nWhich boundary?\t"],
["byte order mark", "\uFEFFWhich boundary?\uFEFF"],
])(
"normalizes edge whitespace from %s without changing workflow Evidence",
(_, question) => {
const records = new InvestigationRecords();
const result = { residual_unknowns: [question] };
const evidence = createWorkflowEvidence({
target: undefined,
operation: "inspect_native_api",
parameters: {},
result,
upstreamProfile: undefined,
upstreamLimitations: [],
});
expect(records.recordEvidence(evidence).ok).toBe(true);
expect(
recordWorkflowUnknowns({
name: "inspect_native_api",
result,
evidenceId: evidence.evidence_id,
recordUnknown: (unknown) => records.recordUnknown(unknown),
}).ok,
).toBe(true);
expect(records.listUnknowns()).toMatchObject([
{ question: `Which boundary? (Evidence ${evidence.evidence_id})` },
]);
expect(evidence.normalized_result).toEqual(result);
},
);
it("rejects questions that are empty after whitespace normalization", () => {
const records = new InvestigationRecords();
const result = { residual_unknowns: [" \n\t "] };
@@ -36,7 +36,7 @@ import {
isSourceEvidenceRetainable,
} from "./AnalysisSnapshotCache.js";
import { EvidenceLedger } from "../investigation/EvidenceLedger.js";
import { enhancedOutputSchemas } from "../../contracts/toolOutputSchemaGroups.js";
import { nativeCallGraphSchema } from "../../domain/native/nativeCallGraph.js";
it.each(["provider", "workflow"] as const)(
"owns nested %s execution data before returning control to callers",
@@ -1270,12 +1270,13 @@ it.each([
evidenceProfile: revisedProfile,
}),
).toBeUndefined();
if (operation === "get_call_graph")
if (operation === "get_call_graph") {
expect(nativeCallGraphSchema.safeParse(oldResult).success).toBe(false);
expect(
enhancedOutputSchemas.get_call_graph.safeParse({
normalized_result: oldResult,
}).success,
).toBe(false);
nativeCallGraphSchema.safeParse({ graph: oldResult, limitations: [] })
.success,
).toBe(true);
}
},
);
@@ -18,6 +18,8 @@ const identity: ProviderIdentity = {
it("retains both child close failures when a resource owner rejects", async () => {
const attempts: string[] = [];
const artifactCause = new Error("artifact close rejected");
const nativeCause = new Error("native close rejected");
const artifact: AnalysisClient = {
execute: async () => ({
ok: true,
@@ -25,7 +27,7 @@ it("retains both child close failures when a resource owner rejects", async () =
}),
close: async () => {
attempts.push("artifact");
throw new Error("artifact close rejected");
throw artifactCause;
},
};
const native: AnalysisClient = {
@@ -35,14 +37,20 @@ it("retains both child close failures when a resource owner rejects", async () =
}),
close: async () => {
attempts.push("native");
throw new Error("native close rejected");
throw nativeCause;
},
};
const closed = await combineBundleResourceClients(artifact, native).close();
expect(attempts.sort()).toEqual(["artifact", "native"]);
expect(closed.ok).toBe(false);
if (!closed.ok) expect(closed.error.cleanupIncomplete).toBe(true);
if (closed.ok) throw new Error("Expected both child close failures");
expect(closed.error.cleanupIncomplete).toBe(true);
expect(closed.error.cause).toBeInstanceOf(AggregateError);
expect((closed.error.cause as AggregateError).errors).toEqual([
expect.objectContaining({ cause: artifactCause }),
expect.objectContaining({ cause: nativeCause }),
]);
});
it("reports the first mixed child failure before awaiting the other owner and retries both", async () => {