mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
cd9252bc63
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.
296 lines
10 KiB
TypeScript
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));
|
|
}
|
|
},
|
|
);
|
|
}
|