Files
rea/tests/boundary/providers/adb/adbHashCleanupConsumers.test.ts
T
morluto cd9252bc63 test(filesystem): exercise digest cancellation and admission ownership
Cover ADB pull, package and bugreport physical failures and completed cancellation across successful and failed closes. Verify artifact retention callback timing and Pcap failed-admission owner recovery.

Refs #4598. Controlled real host files and simulated ADB output; native close faults and Android device behavior remain unverified.
2026-10-17 05:47:35 +08:00

296 lines
10 KiB
TypeScript

import { readFile, writeFile, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, onTestFinished, test, vi } from "vitest";
import { AdbProvider } from "../../../../src/adb/AdbProvider.js";
import * as regularFile from "../../../../src/filesystem/RegularFile.js";
import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js";
import {
execFileBytes,
execFileOutput,
} from "../../../../src/process/ExecFileOutput.js";
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
vi.mock(
"../../../../src/process/ExecFileOutput.js",
async (importOriginal) => ({
...(await importOriginal<
typeof import("../../../../src/process/ExecFileOutput.js")
>()),
execFileBytes: vi.fn(),
execFileOutput: vi.fn(),
}),
);
afterEach(() => vi.restoreAllMocks());
const fixture = async () => {
const root = await createTestTempDirectory("rea-adb-hash-consumer-");
const binary = join(root, "adb");
vi.mocked(execFileOutput).mockImplementation(async () => {
const stdout = `Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\nInstalled as ${binary}\n`;
return {
stdout,
stderr: "",
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: 0,
};
});
vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => {
const last = args.at(-1)!;
if (args.includes("pull")) {
await writeFile(last, "pulled bytes");
return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
}
if (args.includes("path") && args.includes("pm"))
return {
stdout: Buffer.from("package:/data/app/example/base.apk\n"),
stderr: Buffer.alloc(0),
};
if (args.includes("bugreport")) {
const staging = last;
const archive = join(staging, "report.zip");
await writeFile(archive, "pulled bytes");
return {
stdout: Buffer.from(`Bug report copied to ${archive}\n`),
stderr: Buffer.alloc(0),
};
}
return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
});
const provider = new AdbProvider({ environment: { REA_ADB_PATH: binary } });
onTestFinished(() => provider.close());
const failFirstClose = () => {
const originalClose = OwnedFileHandle.prototype.close;
let failed = false;
vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementation(
async function (this: OwnedFileHandle) {
if (!failed) {
failed = true;
throw new Error("hash descriptor close denied");
}
return originalClose.call(this);
},
);
};
return { root, provider, failFirstClose };
};
const digestCases = [
{
label: "single-file pull",
retainedPath: (root: string) => join(root, "payload.bin"),
request: (root: string) => ({
operation: "pull_adb_file" as const,
input: {
serial: "device",
device_path: "/sdcard/payload.bin",
output_directory: root,
},
}),
expected: {
bytes: 12,
device_path: "/sdcard/payload.bin",
sha256: expect.any(String),
},
},
{
label: "package pull",
retainedPath: (root: string) => join(root, "example", "base.apk"),
request: (root: string) => ({
operation: "pull_adb_package" as const,
input: {
serial: "device",
package: "example",
output_directory: root,
},
}),
expected: {
package_name: "example",
coverage: "partial",
failures: [
{ device_path: "/data/app/example/base.apk", stage: "digest" },
],
},
},
{
label: "bugreport collection",
retainedPath: (root: string) => join(root, "report.zip"),
request: (root: string) => ({
operation: "collect_adb_bugreport" as const,
input: { serial: "device", output_directory: root },
}),
expected: { bytes: 12, serial: "device", sha256: expect.any(String) },
},
] as const;
test.each(digestCases)(
"preserves the linked hash cleanup error through $label",
async ({ request, expected, retainedPath }) => {
const f = await fixture();
f.failFirstClose();
const result = await f.provider.execute(request(f.root));
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected hash descriptor cleanup failure");
const returnedError = result.error;
const originalCause = returnedError.cause;
expect(returnedError.cleanupIncomplete).toBe(true);
expect(returnedError.diagnostics).toMatchObject({
primary_error: {
details: { partial_observation: { result: expect.any(Object) } },
},
});
expect(
projectAnalysisError(returnedError).details?.partial_observation,
).toMatchObject({ result: expected });
await f.provider.close();
expect(returnedError.cleanupIncomplete).toBe(false);
expect(returnedError.cause).toBe(originalCause);
await expect(readFile(retainedPath(f.root))).resolves.toEqual(
Buffer.from("pulled bytes"),
);
},
);
const errorChainSome = (
error: unknown,
predicate: (error: unknown) => boolean,
): boolean => {
if (predicate(error)) return true;
if (!(error instanceof Error)) return false;
return (
errorChainSome(error.cause, predicate) ||
(error instanceof AggregateError &&
error.errors.some((child: unknown) => errorChainSome(child, predicate)))
);
};
for (const closeFails of [false, true]) {
test.each(digestCases)(
`preserves a read failure through $label when close aborts (close fails: ${String(closeFails)})`,
async ({ request, retainedPath }) => {
const f = await fixture();
const path = retainedPath(f.root);
const controller = new AbortController();
const primary = Object.assign(
new Error("injected physical digest read EIO"),
{ code: "EIO" },
);
const nativeOpen = regularFile.openRegularFile;
let opened: FileHandle | undefined;
vi.spyOn(regularFile, "openRegularFile").mockImplementation(
async (selected, options) => {
const file = await nativeOpen(selected, options);
if (selected === path) {
opened = file;
vi.spyOn(file, "read").mockRejectedValueOnce(primary);
}
return file;
},
);
const nativeClose = OwnedFileHandle.prototype.close;
let allowClose = !closeFails;
let owner: OwnedFileHandle | undefined;
vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementation(
async function (this: OwnedFileHandle) {
if (this.handle === opened) {
owner = this;
controller.abort("cancelled after the physical failure");
if (!allowClose) throw new Error("digest close remains blocked");
}
return nativeClose.call(this);
},
);
try {
const result = await f.provider.execute(request(f.root), {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected digest read failure");
expect(projectAnalysisError(result.error).code).not.toBe("cancelled");
expect(
errorChainSome(
result.error,
(cause) => cause instanceof AnalysisCancelledError,
),
).toBe(false);
expect(errorChainSome(result.error, (cause) => cause === primary)).toBe(
true,
);
if (closeFails) expect(result.error.cleanupIncomplete).toBe(true);
allowClose = true;
await f.provider.close();
expect(owner?.handle.fd).toBe(-1);
expect(result.error.cleanupIncomplete).toBe(false);
} finally {
allowClose = true;
await f.provider.close();
if (owner !== undefined) await nativeClose.call(owner);
}
},
);
}
for (const closeFails of [false, true]) {
test.each(digestCases)(
`retains exact cancellation and completed digest through $label (close fails: ${String(closeFails)})`,
async ({ request, retainedPath }) => {
const f = await fixture();
const path = retainedPath(f.root);
const controller = new AbortController();
const reason = null;
const nativeOpen = regularFile.openRegularFile;
let opened: FileHandle | undefined;
vi.spyOn(regularFile, "openRegularFile").mockImplementation(
async (selected, options) => {
const handle = await nativeOpen(selected, options);
if (selected === path) opened = handle;
return handle;
},
);
const nativeClose = OwnedFileHandle.prototype.close;
let allowClose = !closeFails;
vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementation(
async function (this: OwnedFileHandle) {
if (this.handle === opened) {
controller.abort(reason);
if (!allowClose)
throw new Error("digest close remains unavailable");
}
await nativeClose.call(this);
},
);
try {
const result = await f.provider.execute(request(f.root), {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected completion cancellation");
expect(projectAnalysisError(result.error).code).toBe(
closeFails ? "cleanup_incomplete" : "cancelled",
);
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(
JSON.stringify(
projectAnalysisError(result.error).details?.partial_observation,
),
).toContain('"sha256"');
expect(result.error.cleanupIncomplete).toBe(closeFails);
allowClose = true;
await f.provider.close();
expect(opened?.fd).toBe(-1);
expect(result.error.cleanupIncomplete).toBe(false);
} finally {
allowClose = true;
await f.provider.close();
if (opened !== undefined && opened.fd >= 0)
await nativeClose.call(new OwnedFileHandle(opened));
}
},
);
}