Files
rea/src/apktool/ApktoolStringTables.test.ts
T
morluto 34a88d6c16 fix(apktool): retain completed facts after locale read failures
Return completed string projections with the exact failure cause in Result.
Force partial coverage and disclose the interrupted selection and remaining
unattempted selections without inventing table status or counts.

Assemble execution once from success or partial projection. Preserve the
original failure through existing ownership/cleanup classification, then
retain completed Evidence before snapshot fallback. Existing producer
observations retain precedence. Remove the throw-only projection contract.

Validation: four authored filesystem controls fail unchanged f1edf0da0;
99 focused checks across six files, check:fast and docs:check pass. Covers
late cancellation, denied workspace cleanup and retry, opaque cancellation
between tables, exact physical cause, and CLI/MCP contracts. Public CLI
failure retains completed facts; original failure framing is unchanged.
Two source reviews found no blocker.

Twelve small normal-path CLI tasks preserve exact replies. Measurements
show no speedup; failure reply grows to retain Evidence (cl100k estimate
189 to 696). No real decoder, whole-agent, or large-case memory claim.
Heavy tests and profiling remain stopped.

Fixes #4880
2026-10-18 04:29:41 +08:00

220 lines
6.7 KiB
TypeScript

import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
MAX_AGGREGATE_STRINGS,
MAX_STRINGS,
} from "../domain/apktool/apktoolResourceAnalysis.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { projectApktoolStringTables } from "./ApktoolStringTables.js";
const xml = (count: number) =>
`<resources>${Array.from({ length: count }, (_, index) => `<string name="s_${String(index)}">value</string>`).join("")}</resources>`;
const textRead = (text: string) => ({
text,
truncated: false,
sourceBytes: Buffer.byteLength(text, "utf8"),
});
describe("physical Apktool string facts", () => {
it("reads one physical table for two caller-selected aliases", async () => {
const paths: string[] = [];
const outcome = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
return textRead(xml(1));
},
},
"decoded",
{ include_strings: true, locale: "pt-BR", locales: ["pt-rBR"] },
);
if (!outcome.ok) throw outcome.error.cause;
const result = outcome.value;
expect(paths).toEqual([
join("decoded", "res", "values-pt-rBR", "strings.xml"),
]);
expect(result.tables.map((table) => table.locale)).toEqual([
"pt-BR",
"pt-rBR",
]);
expect(result.tables.map((table) => table.total_count)).toEqual([1, 1]);
});
it("counts complete source entries beyond the retained table prefix", async () => {
const outcome = await projectApktoolStringTables(
{
async readText() {
return textRead(
xml(MAX_STRINGS + 1).replace(
"</resources>",
'<string name="styled"><b>omitted</b></string></resources>',
),
);
},
},
"decoded",
{ include_strings: true },
);
if (!outcome.ok) throw outcome.error.cause;
const result = outcome.value;
expect(result.tables[0]).toMatchObject({
total_count: MAX_STRINGS + 1,
unparsed_count: 1,
coverage: "partial",
});
expect(result.strings).toHaveLength(MAX_STRINGS);
expect(result.limitations).toContain(
`strings reports the first ${String(MAX_STRINGS)} of ${String(MAX_STRINGS + 1)} entries`,
);
});
it("does not read unexamined tables after output exhaustion", async () => {
const paths: string[] = [];
const outcome = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
if (path.includes("values-fr"))
throw new Error("Unexamined table was read");
return textRead(xml(MAX_STRINGS));
},
},
"decoded",
{ include_strings: true, locales: ["de", "es", "it", "fr"] },
);
if (!outcome.ok) throw outcome.error.cause;
const result = outcome.value;
expect(paths).toHaveLength(4);
expect(result.tables.at(-1)).toEqual({
locale: "fr",
status: "skipped",
strings: [],
total_count: null,
unparsed_count: null,
coverage: "partial",
});
expect(
result.strings.length +
result.tables.reduce((count, table) => count + table.strings.length, 0),
).toBe(MAX_AGGREGATE_STRINGS);
});
it("keeps a known empty alias present after the output budget is spent", async () => {
const paths: string[] = [];
const outcome = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
return textRead(
xml(path.includes("values-pt-rBR") ? 0 : MAX_STRINGS),
);
},
},
"decoded",
{
include_strings: true,
locale: "pt-BR",
locales: ["de", "es", "it", "fr", "en", "pt-rBR"],
},
);
if (!outcome.ok) throw outcome.error.cause;
const result = outcome.value;
expect(paths.filter((path) => path.includes("values-pt-rBR"))).toHaveLength(
1,
);
expect(result.tables.at(-1)).toEqual({
locale: "pt-rBR",
status: "present",
strings: [],
total_count: 0,
unparsed_count: 0,
coverage: "complete",
});
expect(result.coverage).toBe("complete");
});
});
describe("Apktool string projection admission", () => {
it("retains completed tables when cancellation arrives before the next selection", async () => {
const controller = new AbortController();
const reason = { selected: "caller" };
const paths: string[] = [];
const result = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
controller.abort(reason);
return textRead(xml(1));
},
},
"decoded",
{ include_strings: true, locales: ["de", "fr"] },
controller.signal,
);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error.cause).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toHaveProperty("cause", reason);
expect(paths).toHaveLength(1);
expect(result.error.partial).toMatchObject({
coverage: "partial",
tables: [{ locale: null, total_count: 1 }],
});
expect(result.error.partial.limitations).toContain(
'String projection stopped at locale "de"; this selection and 1 remaining selections were not projected',
);
});
it("honors omitted strings without reading any selected table", async () => {
const outcome = await projectApktoolStringTables(
{
async readText() {
throw new Error("Unexpected read");
},
},
"decoded",
{ include_strings: false, locale: "de", locales: ["fr"] },
);
expect(outcome).toEqual({
ok: true,
value: { strings: [], tables: [], limitations: [], coverage: "complete" },
});
});
it("preserves the exact physical read failure", async () => {
const cause = new Error("Physical read and close failed");
const result = await projectApktoolStringTables(
{
async readText() {
throw cause;
},
},
"decoded",
{ include_strings: true },
);
if (result.ok) throw new Error("Expected read failure");
expect(result.error.cause).toBe(cause);
expect(result.error.partial.coverage).toBe("partial");
});
it("preserves opaque cancellation before any table read", async () => {
const reason = { selected: "caller" };
const pending = projectApktoolStringTables(
{
async readText() {
throw new Error("Unexpected read");
},
},
"decoded",
{ include_strings: true },
AbortSignal.abort(reason),
);
const result = await pending;
if (result.ok) throw new Error("Expected cancellation");
expect(result.error.cause).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toHaveProperty("cause", reason);
});
});