mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 06:15:59 +00:00
* fix(frida): drain admitted operations and honor queued cancellation * fix(frida): settle cancelled loads before queued resume * test(frida): share lifecycle fixtures without duplicating assertions * fix(frida): preserve operation admission before session close * test(apple): bound cold archive fixture compilation * fix(frida): retain cancelled script creation evidence * test(release): scope preflight schema to selected jobs * fix(process): report no remaining time after a provider deadline fires Node timers can fire while performance.now() is still a fraction of a millisecond short of the monotonic deadline. ProviderStartupDeadline then reported interruption "timeout" and an aborted signal while remainingMs() returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0 (Hopper and Ghidra startup) could run past an elapsed deadline, and the real-timer test "aborts a bounded wait when its real monotonic deadline expires" failed intermittently in CI (expected 1 to be 0). Once the deadline's own timer has classified a timeout, report zero remaining time. A deterministic regression test fires the timer with the monotonic clock 0.4 ms short of the deadline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(comparison): recheck historical calls-only changes (#2223) * fix(comparison): preserve incomplete function call evidence Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty. Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls. * fix(comparison): recheck historical calls-only changes --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> (cherry picked from commit688defca0f) * fix(ci): isolate Linux Hopper process fixture inventory (cherry picked from commit8cac51e121) * fix(android): make DEX descriptor pattern portable (cherry picked from commit1335bcddc5) * fix: repair the static-check failures on the default branch (#2895) * fix: repair the static-check failures on the default branch The default branch fails its own required `Static checks` job three ways, so every pull request reports that job as failed regardless of its own change. - `npm run format:check` reports `src/domain/functionComparison.test.ts` and `src/ida/IdaOperationRunner.ts`. - `npm run lint` reports a duplicate `import { join } from "node:path"` in `tests/boundary/mcp/ghidraInventoryMcp.test.ts` (lines 4 and 17). - `npm run typecheck` reports `format: "devin"` missing from the client format union in `src/application/SupportedClients.ts`, and an unchecked index in `tests/boundary/cli/clientProfileLifecycle.test.ts`, whose pair array is inferred as `string[][]`. Apply the pinned formatter, drop the duplicate import, list the `"devin"` format that its own definition already uses, and mark the pair array `as const`. Validation: `npm run check:fast` and `npm run format:check` pass. * fix(setup): keep Devin on the JSON configuration dialect --------- Co-authored-by: 吴佳浩 <wujiahao@supremind.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> (cherry picked from commitdf3d96da80) --------- Co-authored-by: morluto <morluto@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Lucien <164857476+dakjdakd@users.noreply.github.com> Co-authored-by: 吴佳浩Alben <35291372+XiaoBinGan@users.noreply.github.com> Co-authored-by: 吴佳浩 <wujiahao@supremind.com> Co-authored-by: OpenAI Codex <codex@openai.com>
644 lines
18 KiB
TypeScript
644 lines
18 KiB
TypeScript
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { expect, it } from "vitest";
|
|
|
|
import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js";
|
|
import type { FridaInstrumentationPort } from "./FridaInstrumentationPort.js";
|
|
import { FridaInstrumentationService } from "./FridaInstrumentationService.js";
|
|
import {
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisInputError,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
|
|
const createSession = () => ({
|
|
sessionId: "00000000-0000-4000-8000-000000000001",
|
|
deviceId: "local",
|
|
target: "fixture",
|
|
pid: 10,
|
|
mode: "spawn" as const,
|
|
state: "paused" as const,
|
|
});
|
|
|
|
const createScriptObservation = () => ({
|
|
scriptId: "00000000-0000-4000-8000-000000000002",
|
|
sourceKind: "inline" as const,
|
|
sourcePath: null,
|
|
sourceSha256: "a".repeat(64),
|
|
messages: [],
|
|
messagesTruncated: false,
|
|
});
|
|
|
|
const createProvider = (
|
|
overrides: Partial<FridaInstrumentationPort> = {},
|
|
): FridaInstrumentationPort => ({
|
|
async listDevices() {
|
|
return { ok: true, value: { devices: [], cleanupError: null } };
|
|
},
|
|
async listProcesses() {
|
|
return {
|
|
ok: true,
|
|
value: { deviceId: "local", processes: [], cleanupError: null },
|
|
};
|
|
},
|
|
async startSession() {
|
|
return { ok: true, value: createSession() };
|
|
},
|
|
async loadScript() {
|
|
return { ok: true, value: createScriptObservation() };
|
|
},
|
|
async resumeSession() {
|
|
return { ok: true, value: null };
|
|
},
|
|
async unloadScript() {
|
|
return { ok: true, value: null };
|
|
},
|
|
status() {
|
|
return undefined;
|
|
},
|
|
async closeSession() {
|
|
return { ok: true, value: { targetResumeObserved: false } };
|
|
},
|
|
async closeAll() {},
|
|
...overrides,
|
|
});
|
|
|
|
it.each(["startSession", "instrument"] as const)(
|
|
"does not start Frida when %s receives an already-cancelled signal",
|
|
async (method) => {
|
|
let starts = 0;
|
|
let loads = 0;
|
|
const provider = createProvider({
|
|
async startSession() {
|
|
starts += 1;
|
|
return { ok: true, value: createSession() };
|
|
},
|
|
async loadScript() {
|
|
loads += 1;
|
|
return { ok: true, value: createScriptObservation() };
|
|
},
|
|
});
|
|
const service = new FridaInstrumentationService(provider);
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("cancelled before call"));
|
|
|
|
const result =
|
|
method === "startSession"
|
|
? await service.startSession(
|
|
{ mode: "spawn", deviceId: "local", program: "/tmp/fixture" },
|
|
controller.signal,
|
|
)
|
|
: await service.instrument(
|
|
{
|
|
mode: "spawn",
|
|
deviceId: "local",
|
|
program: "/tmp/fixture",
|
|
source: { sourceKind: "inline", source: "send('ready');" },
|
|
durationMs: 1,
|
|
},
|
|
controller.signal,
|
|
);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(starts).toBe(0);
|
|
expect(loads).toBe(0);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
[join(tmpdir(), "rea-missing-frida-script.js"), "no file exists there"],
|
|
[tmpdir(), "it is not a regular file"],
|
|
])(
|
|
"rejects the unreadable script file %s before starting Frida",
|
|
async (path, reason) => {
|
|
let starts = 0;
|
|
const provider = createProvider({
|
|
async startSession() {
|
|
starts += 1;
|
|
return { ok: true, value: createSession() };
|
|
},
|
|
});
|
|
const service = new FridaInstrumentationService(provider);
|
|
|
|
const result = await service.instrument({
|
|
mode: "spawn",
|
|
deviceId: "local",
|
|
program: "/tmp/fixture",
|
|
source: { sourceKind: "file", path },
|
|
durationMs: 1,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.error).toBeInstanceOf(AnalysisInputError);
|
|
expect((result.error as AnalysisInputError).issues).toEqual([
|
|
{
|
|
path: ["path"],
|
|
reason: "invalid_value",
|
|
message: `Cannot read Frida script file ${path}: ${reason}.`,
|
|
},
|
|
]);
|
|
expect(starts).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("reports cancellation before checking a file script source", async () => {
|
|
const service = new FridaInstrumentationService(createProvider());
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("cancelled before call"));
|
|
|
|
const result = await service.instrument(
|
|
{
|
|
mode: "spawn",
|
|
deviceId: "local",
|
|
program: "/tmp/fixture",
|
|
source: {
|
|
sourceKind: "file",
|
|
path: join(tmpdir(), "rea-missing-frida-script.js"),
|
|
},
|
|
durationMs: 1,
|
|
},
|
|
controller.signal,
|
|
);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
});
|
|
|
|
it("does not resume a spawned target when cancellation arrives during script loading", async () => {
|
|
const controller = new AbortController();
|
|
let resumes = 0;
|
|
let closes = 0;
|
|
const service = new FridaInstrumentationService(
|
|
createProvider({
|
|
async loadScript() {
|
|
controller.abort(new Error("cancelled while loading"));
|
|
return { ok: true, value: createScriptObservation() };
|
|
},
|
|
async resumeSession() {
|
|
resumes += 1;
|
|
return { ok: true, value: null };
|
|
},
|
|
async closeSession() {
|
|
closes += 1;
|
|
return { ok: true, value: { targetResumeObserved: true } };
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await service.instrument(
|
|
{
|
|
mode: "spawn",
|
|
deviceId: "local",
|
|
program: "/tmp/fixture",
|
|
source: { sourceKind: "inline", source: "send('ready');" },
|
|
durationMs: 1,
|
|
},
|
|
controller.signal,
|
|
);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
normalized_result: {
|
|
cleanup_error: null,
|
|
target_state_before_cleanup: "paused",
|
|
target_resume_observed: true,
|
|
instrumentation_cleanup: "released",
|
|
target_liveness_after_cleanup: "unknown",
|
|
},
|
|
parameters: {
|
|
mode: "spawn",
|
|
program: "/tmp/fixture",
|
|
},
|
|
});
|
|
}
|
|
expect(resumes).toBe(0);
|
|
expect(closes).toBe(1);
|
|
});
|
|
|
|
it("reports the spawned session when cancellation arrives during resume", async () => {
|
|
const controller = new AbortController();
|
|
let closes = 0;
|
|
const service = new FridaInstrumentationService(
|
|
createProvider({
|
|
async resumeSession() {
|
|
controller.abort(new Error("cancelled while resuming"));
|
|
return { ok: true, value: null };
|
|
},
|
|
status() {
|
|
return {
|
|
...createSession(),
|
|
state: "running",
|
|
targetResumeObserved: true,
|
|
scripts: [],
|
|
messages: [],
|
|
messagesTruncated: false,
|
|
};
|
|
},
|
|
async closeSession() {
|
|
closes += 1;
|
|
return { ok: true, value: { targetResumeObserved: false } };
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await service.instrument(
|
|
{
|
|
mode: "spawn",
|
|
deviceId: "local",
|
|
program: "/tmp/fixture",
|
|
argv: ["--owned-fixture"],
|
|
source: { sourceKind: "inline", source: "send('ready');" },
|
|
durationMs: 60_000,
|
|
},
|
|
controller.signal,
|
|
);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
normalized_result: {
|
|
target_state_before_cleanup: "running",
|
|
target_resume_observed: true,
|
|
instrumentation_cleanup: "released",
|
|
target_liveness_after_cleanup: "unknown",
|
|
},
|
|
parameters: {
|
|
argv: ["--owned-fixture"],
|
|
},
|
|
});
|
|
}
|
|
expect(closes).toBe(1);
|
|
});
|
|
|
|
it("does not infer a Frida resume for a running attached target on cancellation", async () => {
|
|
const controller = new AbortController();
|
|
const service = new FridaInstrumentationService(
|
|
createProvider({
|
|
async loadScript() {
|
|
controller.abort(new Error("cancelled while loading"));
|
|
return { ok: true, value: createScriptObservation() };
|
|
},
|
|
status() {
|
|
return {
|
|
...createSession(),
|
|
mode: "attach",
|
|
state: "running",
|
|
targetResumeObserved: false,
|
|
scripts: [],
|
|
messages: [],
|
|
messagesTruncated: false,
|
|
};
|
|
},
|
|
async closeSession() {
|
|
return { ok: true, value: { targetResumeObserved: false } };
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await service.instrument(
|
|
{
|
|
mode: "attach",
|
|
deviceId: "local",
|
|
pid: 512,
|
|
source: { sourceKind: "inline", source: "send('ready');" },
|
|
durationMs: 0,
|
|
},
|
|
controller.signal,
|
|
);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
normalized_result: {
|
|
target_state_before_cleanup: "running",
|
|
target_resume_observed: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("retains an observed spawn resume after the target detaches before cancellation", async () => {
|
|
const controller = new AbortController();
|
|
const service = new FridaInstrumentationService(
|
|
createProvider({
|
|
async loadScript() {
|
|
controller.abort(new Error("cancelled after target exit"));
|
|
return { ok: true, value: createScriptObservation() };
|
|
},
|
|
status() {
|
|
return {
|
|
...createSession(),
|
|
state: "detached",
|
|
targetResumeObserved: true,
|
|
scripts: [],
|
|
messages: [],
|
|
messagesTruncated: false,
|
|
};
|
|
},
|
|
async closeSession() {
|
|
return { ok: true, value: { targetResumeObserved: false } };
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await service.instrument(
|
|
{
|
|
mode: "spawn",
|
|
deviceId: "local",
|
|
program: "/tmp/fixture",
|
|
source: { sourceKind: "inline", source: "send('ready');" },
|
|
durationMs: 0,
|
|
},
|
|
controller.signal,
|
|
);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
normalized_result: {
|
|
target_state_before_cleanup: "detached",
|
|
target_resume_observed: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ cleanupState: "running" as const, resumeObserved: true },
|
|
{ cleanupState: "paused" as const, resumeObserved: false },
|
|
])(
|
|
"uses a spawned target state transition after failed cleanup ($cleanupState)",
|
|
async ({ cleanupState, resumeObserved }) => {
|
|
const controller = new AbortController();
|
|
let statusCalls = 0;
|
|
const service = new FridaInstrumentationService(
|
|
createProvider({
|
|
async loadScript() {
|
|
controller.abort(new Error("cancelled while loading"));
|
|
return { ok: true, value: createScriptObservation() };
|
|
},
|
|
status() {
|
|
statusCalls += 1;
|
|
return {
|
|
...createSession(),
|
|
state: statusCalls === 1 ? "paused" : cleanupState,
|
|
targetResumeObserved: false,
|
|
scripts: [],
|
|
messages: [],
|
|
messagesTruncated: false,
|
|
};
|
|
},
|
|
async closeSession() {
|
|
return {
|
|
ok: false,
|
|
error: new AnalysisCapabilityUnavailableError(
|
|
"frida",
|
|
"close_frida_session",
|
|
"session cleanup failed",
|
|
),
|
|
};
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await service.instrument(
|
|
{
|
|
mode: "spawn",
|
|
deviceId: "local",
|
|
program: "/tmp/fixture",
|
|
source: { sourceKind: "inline", source: "send('ready');" },
|
|
durationMs: 0,
|
|
},
|
|
controller.signal,
|
|
);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
normalized_result: {
|
|
instrumentation_cleanup: "incomplete_or_unknown",
|
|
target_state_before_cleanup: "paused",
|
|
target_resume_observed: resumeObserved,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
it("records target selection in Evidence without remote authentication values", async () => {
|
|
const token = "synthetic-frida-token";
|
|
const certificate = "synthetic-frida-certificate";
|
|
const service = new FridaInstrumentationService(createProvider());
|
|
|
|
const result = await service.instrument({
|
|
mode: "spawn",
|
|
remote: {
|
|
address: "127.0.0.1:27042",
|
|
token,
|
|
certificate,
|
|
origin: "rea-test",
|
|
keepaliveInterval: 1_000,
|
|
},
|
|
program: "/tmp/fixture",
|
|
argv: ["--marker", "selected-argument"],
|
|
source: { sourceKind: "inline", source: "send('ready');" },
|
|
durationMs: 1,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.value.evidence.parameters).toMatchObject({
|
|
session_id: createSession().sessionId,
|
|
remote: {
|
|
address: "127.0.0.1:27042",
|
|
origin: "rea-test",
|
|
keepalive_interval: 1_000,
|
|
},
|
|
mode: "spawn",
|
|
program: "/tmp/fixture",
|
|
argv: ["--marker", "selected-argument"],
|
|
});
|
|
const parameters = JSON.stringify(result.value.evidence.parameters);
|
|
expect(parameters).not.toContain(token);
|
|
expect(parameters).not.toContain(certificate);
|
|
});
|
|
|
|
it("reports completed cleanup when closing the parent session releases a failed script", async () => {
|
|
const service = new FridaInstrumentationService(
|
|
createProvider({
|
|
async loadScript() {
|
|
return {
|
|
ok: false,
|
|
error: new AnalysisCapabilityUnavailableError(
|
|
"frida",
|
|
"load_frida_script",
|
|
"script load failed",
|
|
{
|
|
cleanup: {
|
|
reason: "script unload failed",
|
|
resources: ["script-1"],
|
|
},
|
|
},
|
|
),
|
|
};
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await service.instrument({
|
|
mode: "spawn",
|
|
deviceId: "local",
|
|
program: "/tmp/fixture",
|
|
source: { sourceKind: "inline", source: "send('ready');" },
|
|
durationMs: 1,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error.cleanupIncomplete).toBe(false);
|
|
});
|
|
|
|
it("cleans up a session and returns typed cancellation after a pending start", async () => {
|
|
let finishStart: (() => void) | undefined;
|
|
let markStartCalled: (() => void) | undefined;
|
|
const startGate = new Promise<void>((resolve) => {
|
|
finishStart = resolve;
|
|
});
|
|
const startCalled = new Promise<void>((resolve) => {
|
|
markStartCalled = resolve;
|
|
});
|
|
const closed: string[] = [];
|
|
const provider: FridaInstrumentationPort = {
|
|
async listDevices() {
|
|
return { ok: true, value: { devices: [], cleanupError: null } };
|
|
},
|
|
async listProcesses() {
|
|
return {
|
|
ok: true,
|
|
value: { deviceId: "local", processes: [], cleanupError: null },
|
|
};
|
|
},
|
|
async startSession() {
|
|
markStartCalled?.();
|
|
await startGate;
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
sessionId: "00000000-0000-4000-8000-000000000001",
|
|
deviceId: "local",
|
|
target: "fixture",
|
|
pid: 10,
|
|
mode: "attach",
|
|
state: "running",
|
|
},
|
|
};
|
|
},
|
|
async loadScript() {
|
|
return {
|
|
ok: false,
|
|
error: new AnalysisInputError("load_frida_script"),
|
|
};
|
|
},
|
|
async resumeSession() {
|
|
return { ok: true, value: null };
|
|
},
|
|
async unloadScript() {
|
|
return { ok: true, value: null };
|
|
},
|
|
status() {
|
|
return undefined;
|
|
},
|
|
async closeSession(sessionId) {
|
|
closed.push(sessionId);
|
|
return { ok: true, value: { targetResumeObserved: false } };
|
|
},
|
|
async closeAll() {},
|
|
};
|
|
const service = new FridaInstrumentationService(provider);
|
|
const controller = new AbortController();
|
|
const pending = service.startSession(
|
|
{ mode: "attach", deviceId: "local", pid: 10 },
|
|
controller.signal,
|
|
);
|
|
await startCalled;
|
|
controller.abort(new Error("caller cancelled"));
|
|
finishStart?.();
|
|
|
|
const result = await pending;
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(closed).toEqual(["00000000-0000-4000-8000-000000000001"]);
|
|
});
|
|
|
|
it("retains a cancelled provider load's source and messages in one-shot Evidence", async () => {
|
|
const controller = new AbortController();
|
|
const script = {
|
|
...createScriptObservation(),
|
|
messages: [{ type: "send", payload: "cancelled-load" }],
|
|
};
|
|
const service = new FridaInstrumentationService(
|
|
createProvider({
|
|
async loadScript() {
|
|
controller.abort(new Error("cancel during native load"));
|
|
return {
|
|
ok: false,
|
|
error: new FridaScriptLoadCancelledError(script, {
|
|
cause: controller.signal.reason,
|
|
}),
|
|
};
|
|
},
|
|
}),
|
|
);
|
|
const result = await service.instrument(
|
|
{
|
|
mode: "spawn",
|
|
program: "/fixture",
|
|
source: { sourceKind: "inline", source: "send('cancelled-load')" },
|
|
durationMs: 0,
|
|
},
|
|
controller.signal,
|
|
);
|
|
if (result.ok) throw new Error("Cancelled instrumentation succeeded");
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
normalized_result: {
|
|
source_kind: "inline",
|
|
source_sha256: script.sourceSha256,
|
|
messages: script.messages,
|
|
instrumentation_cleanup: "released",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("preserves a provider-committed load when cancellation arrives during result delivery", async () => {
|
|
const controller = new AbortController();
|
|
let unloads = 0;
|
|
const script = createScriptObservation();
|
|
const service = new FridaInstrumentationService(
|
|
createProvider({
|
|
loadScript() {
|
|
const completed = Promise.resolve({ ok: true as const, value: script });
|
|
void completed.then(() =>
|
|
controller.abort(new Error("late cancellation")),
|
|
);
|
|
return completed;
|
|
},
|
|
async unloadScript() {
|
|
unloads += 1;
|
|
return { ok: true, value: null };
|
|
},
|
|
}),
|
|
);
|
|
const loaded = await service.loadScript(
|
|
createSession().sessionId,
|
|
{ sourceKind: "inline", source: "send('ready')" },
|
|
controller.signal,
|
|
);
|
|
expect(controller.signal.aborted).toBe(true);
|
|
if (!loaded.ok) throw loaded.error;
|
|
expect(loaded.value.normalized_result).toMatchObject({
|
|
script_id: script.scriptId,
|
|
});
|
|
expect(unloads).toBe(0);
|
|
});
|