fix(providers): keep operation environments consistent through launch

Capture one environment snapshot per admitted WABT, JADX, firmware and Wakaru operation. Use it for tool selection, session identity and child launches while preserving configuration updates between operations.

Regressions reproduce changed JVM options under an old session key, mixed engine/limiter selection and environment drift between subprocesses. Keep selected-wrapper and cleanup coverage in a shared fixture with separate scenarios.

Validation: 115 distinct focused tests passed across the final five-file selection, including seven selected-command cases after refactoring. Fresh build, typecheck, lint and formatting passed. Tests use real subprocess fixtures; no real JADX/WABT/Binwalk/Wakaru engine or native Windows verification is claimed.
This commit is contained in:
morluto committed 2026-10-27 00:25:43 +08:00
1 parent 2cdc8cb92e
commit 1d32eb3aa4
7 files changed
+394 -119

No files matched your search

+13 -10
View File
@@ -1,3 +1,4 @@
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import type { AnalysisSettingsReader } from "../domain/analysisSettings.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
@@ -295,7 +296,8 @@ export class JadxProvider implements AndroidAnalysisPort {
/** Report actual local prerequisites without starting JADX or opening a target. */
inspectAvailability(signal?: AbortSignal) {
const pending = inspectJadxAvailability(this.environment, {
const environment = snapshotEnvironment(this.environment);
const pending = inspectJadxAvailability(environment, {
operationTimeoutMs:
this.settings?.().overrides["jadx.operation_timeout_ms"],
resourceOwner: this.#prerequisiteOwner,
@@ -442,10 +444,11 @@ export class JadxProvider implements AndroidAnalysisPort {
request: AndroidRequest,
options?: ExecutionOptions,
): Promise<Outcome> {
const environment = snapshotEnvironment(this.environment);
// Resolve the accepted operation's override before acquiring any resource.
// An unusable launch value is refused without starting the engine.
const timeoutMs = resolveJadxOperationTimeout(
this.environment,
environment,
request.operation,
this.settings?.().overrides["jadx.operation_timeout_ms"],
);
@@ -471,7 +474,7 @@ export class JadxProvider implements AndroidAnalysisPort {
`Target ${target.path} is ${target.format}; provide one standalone APK.`,
);
const configuration = await resolveJadxConfigurationInputs(
this.environment,
environment,
request.operation,
signal,
);
@@ -513,11 +516,11 @@ export class JadxProvider implements AndroidAnalysisPort {
java: configuration.java,
arguments: configuration.jvmArguments,
options: [
this.environment.JAVA_TOOL_OPTIONS,
this.environment._JAVA_OPTIONS,
this.environment.JDK_JAVA_OPTIONS,
this.environment.PATH,
this.environment.JAVA_HOME,
environment.JAVA_TOOL_OPTIONS,
environment._JAVA_OPTIONS,
environment.JDK_JAVA_OPTIONS,
environment.PATH,
environment.JAVA_HOME,
],
});
if (this.#retained !== undefined && this.#retained.key !== key) {
@@ -528,7 +531,7 @@ export class JadxProvider implements AndroidAnalysisPort {
if (retained === undefined) {
await validateJadxConfiguration(
configuration,
this.environment,
environment,
request.operation,
{
resourceOwner: this.#prerequisiteOwner,
@@ -589,7 +592,7 @@ export class JadxProvider implements AndroidAnalysisPort {
bridge,
],
cwd: root.path,
hostEnvironment: this.environment,
hostEnvironment: environment,
signal: workspaceSignal,
},
this.launcher,
+5 -2
View File
@@ -1,3 +1,4 @@
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js";
import { join } from "node:path";
import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnalysisPort.js";
@@ -327,6 +328,8 @@ export class FirmwareProvider implements FirmwareAnalysisPort {
request: FirmwareRequest,
options?: ExecutionOptions,
): Promise<Outcome> {
// Capture configuration after queue admission; later operations may select updates.
const environment = snapshotEnvironment(this.environment);
let root: PrivateRuntimeRoot | undefined;
let rootLease: AnalysisCleanupLease | undefined;
const engineName =
@@ -358,7 +361,7 @@ export class FirmwareProvider implements FirmwareAnalysisPort {
);
});
const command = await resolveFirmwareCommand(
this.environment,
environment,
engineName,
request.operation,
this.#files,
@@ -385,7 +388,7 @@ export class FirmwareProvider implements FirmwareAnalysisPort {
limiter: command.limiter.invocationPath,
args,
cwd: root?.path ?? "",
environment: this.environment,
environment,
signal,
deadline,
retainCleanup: (supervisor) => {
+5 -2
View File
@@ -1,3 +1,4 @@
import { snapshotEnvironment } from "../../process/snapshotEnvironment.js";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -109,6 +110,8 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
): Promise<Result<AnalysisExecution, AnalysisError>> {
const pending = await this.#retire();
if (pending !== undefined) return err(pending);
// Capture configuration after queue/cleanup admission; later requests may select updates.
const environment = snapshotEnvironment(this.environment);
let root: string | undefined;
let tree: SafeOutputTree | undefined;
let failed: { readonly cause: unknown } | undefined;
@@ -117,7 +120,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
try {
checkRecoveryCancellation(options?.signal);
const engine = await resolveWakaruCommand(
this.environment,
environment,
this.#resources,
options?.signal,
);
@@ -149,7 +152,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
input,
sources,
engine,
environment: this.environment,
environment,
launcher: this.launcher,
options,
deadline,
+8 -3
View File
@@ -45,6 +45,7 @@ import {
} from "../domain/wasm/wasmArtifact.js";
import { err, ok, type Result } from "../domain/result.js";
import { runOwnedCommand } from "../process/OwnedCommand.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import { artifactCommandOutput } from "../process/ArtifactAnalysisResources.js";
import {
@@ -105,8 +106,11 @@ export class WabtArtifactProvider implements WasmArtifactPort {
input: InspectWasmArtifactInput,
options?: WasmArtifactExecutionOptions,
): Promise<Result<WasmArtifactExecution, AnalysisError>> {
const environment = snapshotEnvironment(this.environment);
try {
return await this.#resources.run(() => this.#inspect(input, options));
return await this.#resources.run(() =>
this.#inspect(input, environment, options),
);
} catch (cause: unknown) {
if (cause instanceof ArtifactResourceRetryFailure)
return err(resourceCleanupFailure(cause));
@@ -130,6 +134,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
async #inspect(
input: InspectWasmArtifactInput,
environment: Readonly<NodeJS.ProcessEnv>,
options?: WasmArtifactExecutionOptions,
): Promise<Result<WasmArtifactExecution, AnalysisError>> {
let runtime: WabtRuntime | undefined;
@@ -208,7 +213,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
throw new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
});
const directory = this.environment.REA_WABT_BIN_DIRECTORY ?? "";
const directory = environment.REA_WABT_BIN_DIRECTORY ?? "";
if (!isAbsolute(directory))
throw new Error(
"REA_WABT_BIN_DIRECTORY must be an absolute tool directory.",
@@ -296,7 +301,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
// and process-group identity own cleanup after their exec handoff.
expectedCommand: null,
runId: `rea-wabt-${randomUUID()}`,
hostEnvironment: this.environment,
hostEnvironment: environment,
},
{
timeoutMs: commandTimeoutMs,
@@ -0,0 +1,87 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { WabtArtifactProvider } from "../../../src/wasm/WabtArtifactProvider.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
// Real subprocesses with WABT-shaped replies establish environment ownership,
// not the real WABT release's interpretation of these bytes.
it.skipIf(process.platform === "win32")(
"keeps one WABT inspection coherent while accepting environment changes on the next call",
async () => {
const directory = await createTestTempDirectory("rea-wabt-environment-");
const path = join(directory, "selected.wasm");
const log = join(directory, "children.jsonl");
await writeFile(path, Buffer.alloc(76, 1));
const dump = await readFile(
new URL("../../fixtures/wasm/objdump.txt", import.meta.url),
"utf8",
);
for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"]) {
const output =
tool === "wasm-objdump"
? dump
: tool === "wasm2wat"
? '(module (@custom "test" "payload"))\n'
: "";
await writeFile(
join(directory, tool),
`#!${process.execPath}
const fs = require("node:fs");
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({
marker: process.env.REA_TEST_MARKER,
empty: process.env.REA_TEST_EMPTY,
absent: Object.hasOwn(process.env, "REA_TEST_ABSENT")
}) + "\\n");
process.stdout.write(process.argv[2] === "--version" ? "1.0.42\\n" : ${JSON.stringify(output)});
`,
{ mode: 0o700 },
);
}
const environment: NodeJS.ProcessEnv = {
REA_WABT_BIN_DIRECTORY: directory,
REA_TEST_MARKER: "selected-environment",
REA_TEST_EMPTY: "",
};
let runtimeCreations = 0;
const provider = new WabtArtifactProvider(environment, undefined, () => {
runtimeCreations += 1;
// Selection already succeeded, but version probes have not launched.
if (runtimeCreations === 2) {
environment.REA_WABT_BIN_DIRECTORY = join(directory, "unselected");
environment.REA_TEST_MARKER = "changed-environment";
environment.REA_TEST_EMPTY = "changed";
environment.REA_TEST_ABSENT = "added";
}
return PrivateRuntimeRoot.create({ prefix: "rea-wabt-environment-" });
});
onTestFinished(() => provider.close());
const input = { path, glue_paths: [], candidate_paths: [] };
const first = await provider.inspect(input);
if (!first.ok) throw first.error;
const second = await provider.inspect(input);
if (!second.ok) throw second.error;
expect(second.value.result).toEqual(first.value.result);
const children = (await readFile(log, "utf8"))
.trimEnd()
.split("\n")
.map((line) => JSON.parse(line) as unknown);
expect(children).toEqual(
Array.from({ length: 12 }, () => ({
marker: "selected-environment",
empty: "",
absent: false,
})),
);
expect(JSON.stringify(second.value)).not.toContain("selected-environment");
// Later calls intentionally observe reconfiguration, not a lifetime freeze.
const third = await provider.inspect(input);
expect(third).toMatchObject({
ok: false,
error: { _tag: "AnalysisCapabilityUnavailableError" },
});
expect(runtimeCreations).toBe(2);
},
);
@@ -153,103 +153,132 @@ const selectedEngineLauncher = (kind: "firmware" | "wakaru"): string => {
return `#!/bin/sh\nIFS= read -r role < "$0.role" || exit 19\nif [ -f "$0.retarget" ]; then\n IFS= read -r target < "$0.retarget"\n /bin/ln -sf "$target" "$0"\nfi\nif [ "$role" = limiter ]; then\n while [ "$1" != -- ]; do shift; done\n shift\n exec "$@"\nfi\nexec ${invocation} "$@"\n`;
};
// Exercise shared CLI/MCP workflows with real children and filesystem. Only
// Linux/x64 admission and /proc limits are simulated; the launcher restores the
// actual host before entering real process supervision.
// Own real wrapper processes and their workspaces. Linux/x64 admission and
// /proc limits are simulated; supervision always runs on the actual host.
const createSelectedEngineFixture = async (
kind: "firmware" | "wakaru",
afterLaunch?: () => void,
) => {
const directory = await createTestTempDirectory("rea-selected-command-");
const bin = join(directory, "bin"),
libexec = join(directory, "libexec");
await mkdir(bin);
await mkdir(libexec);
const command = join(bin, "engine"),
limiter = join(bin, "prlimit");
const commandSource = join(libexec, "engine"),
limiterSource = join(libexec, "limiter");
const replacement = join(libexec, "replacement");
const script = selectedEngineLauncher(kind);
for (const path of [commandSource, limiterSource, replacement])
await writeFile(path, script, { mode: 0o755 });
await symlink(commandSource, command);
await symlink(limiterSource, limiter);
await writeFile(`${command}.role`, `${kind}\n`);
await writeFile(`${limiter}.role`, "limiter\n");
const input = join(directory, kind === "firmware" ? "input.bin" : "input.js");
await writeFile(
input,
kind === "firmware" ? "fixture-input-data" : "module.exports = 42;",
);
const platform = Object.getOwnPropertyDescriptor(process, "platform");
const arch = Object.getOwnPropertyDescriptor(process, "arch");
if (platform === undefined || arch === undefined)
throw new Error("Missing host descriptors");
const restoreHost = () => {
Object.defineProperty(process, "platform", platform);
Object.defineProperty(process, "arch", arch);
};
const launches: {
cwd?: string;
pid?: number;
command: string;
environmentMarker: string | undefined;
}[] = [];
const environment = {
...process.env,
REA_BINWALK_COMMAND: command,
REA_FIRMWARE_PRLIMIT_COMMAND: limiter,
REA_WAKARU_COMMAND: command,
REA_JAVASCRIPT_PRLIMIT_COMMAND: limiter,
REA_SELECTED_ENVIRONMENT: "initial",
};
const launcher: typeof spawnOwnedProviderProcess = async (options) => {
restoreHost();
const child = await spawnOwnedProviderProcess(options);
launches.push({
command: options.command,
environmentMarker: options.hostEnvironment?.REA_SELECTED_ENVIRONMENT,
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
...(child.process.pid === undefined ? {} : { pid: child.process.pid }),
});
afterLaunch?.();
return child;
};
const fileSizeCeiling = vi
.spyOn(FirmwareFileAccess.prototype, "fileSizeCeiling")
.mockResolvedValue(Number.MAX_SAFE_INTEGER);
onTestFinished(() => fileSizeCeiling.mockRestore());
const service =
kind === "firmware"
? new FirmwareAnalysisService(new FirmwareProvider(environment, launcher))
: new JavaScriptRecoveryService(
new WakaruProvider(environment, launcher),
);
onTestFinished(async () => {
restoreHost();
await service.close();
for (const launch of launches) {
if (launch.pid !== undefined)
expect(await observeProcessStartIdentity(launch.pid)).toBeUndefined();
if (launch.cwd !== undefined)
await expect(readdir(launch.cwd)).rejects.toMatchObject({
code: "ENOENT",
});
}
});
let sequence = 0;
const execute = async () => {
Object.defineProperty(process, "platform", {
...platform,
value: "linux",
});
Object.defineProperty(process, "arch", { ...arch, value: "x64" });
try {
return service instanceof FirmwareAnalysisService
? await service.execute("inspect_firmware_regions", { path: input })
: await service.recover({
path: input,
output_directory: join(directory, `output-${sequence++}`),
});
} finally {
restoreHost();
}
};
return {
bin,
command,
limiter,
commandSource,
limiterSource,
replacement,
environment,
launches,
execute,
};
};
it.skipIf(process.platform === "win32").each(["firmware", "wakaru"] as const)(
"executes selected %s wrappers and retains completed output when either alias changes",
async (kind) => {
const directory = await createTestTempDirectory("rea-selected-command-");
const bin = join(directory, "bin"),
libexec = join(directory, "libexec");
await mkdir(bin);
await mkdir(libexec);
const command = join(bin, "engine"),
limiter = join(bin, "prlimit");
const commandSource = join(libexec, "engine"),
limiterSource = join(libexec, "limiter");
const replacement = join(libexec, "replacement");
const script = selectedEngineLauncher(kind);
for (const path of [commandSource, limiterSource, replacement])
await writeFile(path, script, { mode: 0o755 });
await symlink(commandSource, command);
await symlink(limiterSource, limiter);
await writeFile(`${command}.role`, `${kind}\n`);
await writeFile(`${limiter}.role`, "limiter\n");
const input = join(
directory,
kind === "firmware" ? "input.bin" : "input.js",
);
await writeFile(
input,
kind === "firmware" ? "fixture-input-data" : "module.exports = 42;",
);
const platform = Object.getOwnPropertyDescriptor(process, "platform");
const arch = Object.getOwnPropertyDescriptor(process, "arch");
if (platform === undefined || arch === undefined)
throw new Error("Missing host descriptors");
const restoreHost = () => {
Object.defineProperty(process, "platform", platform);
Object.defineProperty(process, "arch", arch);
};
const launches: { cwd?: string; pid?: number }[] = [];
const launcher: typeof spawnOwnedProviderProcess = async (options) => {
restoreHost();
const child = await spawnOwnedProviderProcess(options);
launches.push({
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
...(child.process.pid === undefined ? {} : { pid: child.process.pid }),
});
return child;
};
const fileSizeCeiling = vi
.spyOn(FirmwareFileAccess.prototype, "fileSizeCeiling")
.mockResolvedValue(Number.MAX_SAFE_INTEGER);
onTestFinished(() => fileSizeCeiling.mockRestore());
const service =
kind === "firmware"
? new FirmwareAnalysisService(
new FirmwareProvider(
{
...process.env,
REA_BINWALK_COMMAND: command,
REA_FIRMWARE_PRLIMIT_COMMAND: limiter,
},
launcher,
),
)
: new JavaScriptRecoveryService(
new WakaruProvider(
{
...process.env,
REA_WAKARU_COMMAND: command,
REA_JAVASCRIPT_PRLIMIT_COMMAND: limiter,
},
launcher,
),
);
onTestFinished(async () => {
restoreHost();
await service.close();
});
let sequence = 0;
const execute = async () => {
Object.defineProperty(process, "platform", {
...platform,
value: "linux",
});
Object.defineProperty(process, "arch", { ...arch, value: "x64" });
try {
return service instanceof FirmwareAnalysisService
? await service.execute("inspect_firmware_regions", { path: input })
: await service.recover({
path: input,
output_directory: join(directory, `output-${sequence++}`),
});
} finally {
restoreHost();
}
};
const {
command,
limiter,
commandSource,
limiterSource,
replacement,
execute,
} = await createSelectedEngineFixture(kind);
const first = await execute();
if (!first.ok) throw first.error;
expect(first.value.normalized_result).toMatchObject({
@@ -287,14 +316,127 @@ it.skipIf(process.platform === "win32").each(["firmware", "wakaru"] as const)(
selectedPath,
);
}
for (const launch of launches) {
if (launch.pid !== undefined)
expect(await observeProcessStartIdentity(launch.pid)).toBeUndefined();
if (launch.cwd !== undefined)
await expect(readdir(launch.cwd)).rejects.toMatchObject({
code: "ENOENT",
});
}
},
);
it.skipIf(process.platform === "win32")(
"keeps firmware configuration consistent within an operation and accepts later updates",
async () => {
const {
bin,
command,
limiter,
commandSource,
limiterSource,
environment,
launches,
execute,
} = await createSelectedEngineFixture("firmware");
const nextCommand = join(bin, "next-engine");
const nextLimiter = join(bin, "next-prlimit");
await symlink(commandSource, nextCommand);
await symlink(limiterSource, nextLimiter);
await writeFile(`${nextCommand}.role`, "firmware\n");
await writeFile(`${nextLimiter}.role`, "limiter\n");
const originalHash = FirmwareFileAccess.prototype.hash;
const hash = vi.spyOn(FirmwareFileAccess.prototype, "hash");
onTestFinished(() => hash.mockRestore());
hash.mockImplementationOnce(async function (
this: FirmwareFileAccess,
path,
signal,
) {
const digest = await originalHash.call(this, path, signal);
environment.REA_BINWALK_COMMAND = nextCommand;
environment.REA_FIRMWARE_PRLIMIT_COMMAND = nextLimiter;
environment.REA_SELECTED_ENVIRONMENT = "updated";
return digest;
});
const first = await execute();
if (!first.ok) throw first.error;
expect(first.value.raw_result).toMatchObject({
version_execution: { command, resource_limiter: limiter },
});
expect(launches).toEqual(
expect.arrayContaining([
expect.objectContaining({
command: limiter,
environmentMarker: "initial",
}),
]),
);
expect(
launches.every((launch) => launch.environmentMarker === "initial"),
).toBe(true);
const next = await execute();
if (!next.ok) throw next.error;
expect(next.value.raw_result).toMatchObject({
version_execution: {
command: nextCommand,
resource_limiter: nextLimiter,
},
});
expect(launches.at(-1)).toMatchObject({
command: nextLimiter,
environmentMarker: "updated",
});
},
);
it.skipIf(process.platform === "win32")(
"keeps Wakaru configuration consistent across version and recovery launches",
async () => {
let mutateOnce = true;
const fixture = await createSelectedEngineFixture("wakaru", () => {
if (!mutateOnce) return;
mutateOnce = false;
fixture.environment.REA_WAKARU_COMMAND = nextCommand;
fixture.environment.REA_JAVASCRIPT_PRLIMIT_COMMAND = nextLimiter;
fixture.environment.REA_SELECTED_ENVIRONMENT = "updated";
});
const {
bin,
command,
limiter,
commandSource,
limiterSource,
launches,
execute,
} = fixture;
const nextCommand = join(bin, "next-engine");
const nextLimiter = join(bin, "next-prlimit");
await symlink(commandSource, nextCommand);
await symlink(limiterSource, nextLimiter);
await writeFile(`${nextCommand}.role`, "wakaru\n");
await writeFile(`${nextLimiter}.role`, "limiter\n");
const first = await execute();
if (!first.ok) throw first.error;
expect(first.value.raw_result).toMatchObject({
version: { command, resource_limiter: limiter },
});
expect(
launches.map(({ command, environmentMarker }) => ({
command,
environmentMarker,
})),
).toEqual([
{ command: limiter, environmentMarker: "initial" },
{ command: limiter, environmentMarker: "initial" },
]);
const next = await execute();
if (!next.ok) throw next.error;
expect(next.value.raw_result).toMatchObject({
version: { command: nextCommand, resource_limiter: nextLimiter },
});
expect(
launches.slice(2).map(({ command, environmentMarker }) => ({
command,
environmentMarker,
})),
).toEqual([
{ command: nextLimiter, environmentMarker: "updated" },
{ command: nextLimiter, environmentMarker: "updated" },
]);
},
);
@@ -459,6 +459,38 @@ it("retire sessions when APK bytes, engine bytes or JVM options change", async (
await verifyCleanup(launches);
});
it("keeps one operation's JVM environment aligned with its retained session identity", async () => {
const { service, apk, launches, provider, environment } = await setup();
environment.JAVA_TOOL_OPTIONS = "-Xmx512m";
const createRoot = PrivateRuntimeRoot.create.bind(PrivateRuntimeRoot);
const allocation = vi
.spyOn(PrivateRuntimeRoot, "create")
.mockImplementationOnce(async (options) => {
// Configuration and the reuse key are already admitted; launch follows
// this asynchronous workspace boundary.
environment.JAVA_TOOL_OPTIONS = "-Xmx8g";
return createRoot(options);
});
try {
const first = await service.execute("inspect_android_package", {
path: apk,
});
if (!first.ok) throw first.error;
expect(allocation).toHaveBeenCalledOnce();
environment.JAVA_TOOL_OPTIONS = "-Xmx512m";
const second = await service.execute("inspect_android_package", {
path: apk,
});
if (!second.ok) throw second.error;
expect(launches).toHaveLength(1);
expect(launches[0]?.javaToolOptions).toBe("-Xmx512m");
} finally {
allocation.mockRestore();
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"] });