Files
rea/src/application/javascript/JavaScriptRuntimeObservationService.test.ts
T
morluto de4579e993 fix(observation): keep incomplete cancellation observations (#5644)
* fix(observation): keep incomplete cancellation observations

Service projection treated a retained observation that failed the completed
schema as a malformed completed result. Cancellation became unreadable_output
and the partial was dropped. Incomplete observations now stay on the owner
error, including the exact abort reason.

* fix(observation): drop non-JSON incomplete observations

Keep a retained partial only when projectAnalysisError can serialize it.
Clear a non-JSON partial and record an AnalysisOutputError as a secondary
failure so cancellation and timeout stay the owner error.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(observation): validate incomplete partials without copying

jsonValueSchema.safeParse cloned a JSON-safe partial that projection
copies again. Use jsonValueValidationIssue so the gate only checks.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(ida): expect non-retryable unresolved procedure lookups

Main stops treating invalid_request as retryable. An unchanged missing
procedure stays invalid_request and is not marked retryable.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(observation): share incomplete-observation assertions

The new non-JSON cases repeated the same owner-error checks and pushed
duplication over the 3.5% limit. One expectation helper keeps those
checks without a second copy.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(observation): keep the assertion helper out of the package

The helper imports Vitest. Rename it to a fixture so the production
build excludes it and dist does not import a dev dependency.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(observation): clear non-JSON partials on cleanup primaries

Projection can return the cleanup primary after retry. Clearing only
the wrapper left that primary's partial in place, so projectAnalysisError
threw. The gate now clears every non-JSON partial in the chain.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(observation): freeze retained incomplete observations

A JSON partial stayed a live provider object, so a later mutation could
make projection throw. The gate now keeps one frozen snapshot and still
drops a non-JSON partial without copying it.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* refactor(observation): share web runtime partial retention

Observe and inspect used the same promote-or-keep path. One helper
owns that sequence so the two operations cannot drift.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(observation): snapshot a shared partial once

A cleanup wrapper and its primary can hold the same partial object.
The gate now reuses the first frozen snapshot for that object.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(observation): parse a kept partial once

jsonValueSchema.safeParse validates and copies only a JSON partial.
The gate freezes that copy and no longer walks the same value first.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(observation): share repeated observation fixtures

The incomplete-observation tests repeated the same provider setup and
an empty Electron coverage tail, which pushed duplication over 3.5%.
One coverage fixture and shared assertions bring the tree back under
the threshold.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-21 04:17:46 +08:00

353 lines
10 KiB
TypeScript

import { expect, it } from "vitest";
import { BrowserObservationError } from "../../domain/browserObservationError.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { err, ok } from "../../domain/result.js";
import { parseEvidence } from "../../domain/evidence.js";
import type { JavaScriptRuntimeTargetList } from "../../domain/javascript/javascriptRuntimeObservation.js";
import { javascriptRuntimeObservationSchema } from "../../domain/javascript/javascriptRuntimeObservation.js";
import type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js";
import {
listJavaScriptRuntimeTargets,
observeJavaScriptRuntime,
} from "./JavaScriptRuntimeObservationService.js";
import {
expectDroppedPartial,
expectKeptPartial,
expectOutputError,
expectOwnerError,
expectPreemptedCancellation,
} from "../ownerObservationExpectation.fixture.js";
const observeCancelled = async (partial: unknown) => {
const reason = { requested: "stop before runtime observation completed" };
const failure = new AnalysisCancelledError("observe_javascript_runtime", {
cause: reason,
partialObservation: partial as never,
});
const result = await observeJavaScriptRuntime(
{
identity: () => ({ id: "test", name: "Test", version: "1" }),
listTargets: async () =>
err(new AnalysisCancelledError("list_javascript_runtime_targets")),
observe: async () => err(failure),
},
{
inspector_endpoint: "http://127.0.0.1:9229",
target_id: "target",
observation_ms: 0,
},
);
return { result, failure, reason };
};
it("preserves failed Inspector cleanup when cancellation occurs during observation", async () => {
const controller = new AbortController();
const failure = new BrowserObservationError(
"observe_javascript_runtime",
"cleanup_failed",
{
cause: new AnalysisCancelledError("observe_javascript_runtime"),
},
);
const provider: JavaScriptRuntimeObservationPort = {
identity: () => ({
id: "test-inspector",
name: "Test Inspector",
version: "1",
}),
listTargets: async () => err(failure),
observe: async () => {
controller.abort();
return err(failure);
},
};
const result = await observeJavaScriptRuntime(
provider,
{
inspector_endpoint: "http://127.0.0.1:9229",
target_id: "target",
observation_ms: 0,
},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed cleanup");
expect(result.error).toBe(failure);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual(["browser_transport"]);
expect(result.error.userCategory).toBe("cancelled");
});
it("retains target Evidence when cancellation arrives during provider cleanup", async () => {
const controller = new AbortController();
const provider: JavaScriptRuntimeObservationPort = {
identity: () => ({
id: "test-inspector",
name: "Test Inspector",
version: "1",
}),
listTargets: async () => {
controller.abort();
return ok({
runtime: {
product: "node.js/v24",
protocol_version: "1.3",
v8_version: "13",
},
targets: [],
excluded: { unsupported_location: 0, unconnectable: 0 },
limitations: [],
});
},
observe: async () =>
err(new AnalysisCancelledError("observe_javascript_runtime")),
};
const result = await listJavaScriptRuntimeTargets(
provider,
{ inspector_endpoint: "http://127.0.0.1:9229" },
{ signal: controller.signal },
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "list_javascript_runtime_targets",
partialObservation: {
normalized_result: {
runtime: { product: "node.js/v24" },
targets: [],
},
},
},
});
if (!result.ok) {
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "list_javascript_runtime_targets",
normalized_result: { targets: [] },
});
}
});
it("does not let late cancellation hide an invalid completed Inspector report", async () => {
const malformed = {
runtime: {
product: "Node.js",
protocol_version: "1.3",
v8_version: "12.0",
},
targets: [],
excluded: { unsupported_location: -1, unconnectable: 0 },
limitations: [],
} as unknown as JavaScriptRuntimeTargetList;
const output = await listJavaScriptRuntimeTargets(
{
identity: () => ({ id: "test", name: "Test", version: "1" }),
listTargets: async () => ok(malformed),
observe: async () => {
throw new Error("not used");
},
},
{ inspector_endpoint: "http://127.0.0.1:9229" },
);
expectOutputError(output);
const controller = new AbortController();
const late = await listJavaScriptRuntimeTargets(
{
identity: () => ({ id: "test", name: "Test", version: "1" }),
listTargets: async () => {
controller.abort();
return ok(malformed);
},
observe: async () => {
throw new Error("not used");
},
},
{ inspector_endpoint: "http://127.0.0.1:9229" },
{ signal: controller.signal },
);
expectOutputError(late);
});
it("converts a raw runtime observation retained by cleanup failure", async () => {
const observation = javascriptRuntimeObservationSchema.parse({
runtime: {
product: "node.js/v24",
protocol_version: "1.3",
v8_version: "13",
},
target: {
target_id: "target",
protocol_type: "node",
attached: false,
location: { kind: "file", file_path: "/fixture.js" },
runtime_kind: "node",
runtime_kind_authority: "caller-declared-unverified",
},
capture: {
observation_ms: 0,
events_observed: 0,
events_retained: 0,
events_dropped: 0,
metadata_bytes_retained: 0,
truncated: false,
truncation_reasons: [],
},
scripts: {
items: [],
observed_total: 0,
excluded: { unsupported_location: 0, invalid_protocol_value: 0 },
},
execution_contexts: [],
directly_observed: ["Inspector target attached and queried."],
unavailable_without_instrumentation: [],
unknowns: [],
limitations: [],
});
const failure = new BrowserObservationError(
"observe_javascript_runtime",
"cleanup_failed",
{
cleanup: {
reason: "Inspector close failed",
resources: ["browser_transport"],
},
partialObservation: observation,
},
);
const result = await observeJavaScriptRuntime(
{
identity: () => ({ id: "test", name: "Test", version: "1" }),
listTargets: async () =>
ok({
runtime: {
product: "node.js/v24",
protocol_version: "1.3",
v8_version: "13",
},
targets: [],
excluded: { unsupported_location: 0, unconnectable: 0 },
limitations: [],
}),
observe: async () => err(failure),
},
{
inspector_endpoint: "http://127.0.0.1:9229",
target_id: "target",
observation_ms: 0,
},
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe(failure);
expect(result.error.cleanupIncomplete).toBe(true);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "observe_javascript_runtime",
normalized_result: observation,
});
}
});
it("reports non-JSON Inspector output before late cancellation", async () => {
const controller = new AbortController();
const value = {
runtime: {
product: "node.js/v24",
protocol_version: "1.3",
v8_version: "13",
},
targets: [],
excluded: { unsupported_location: 0, unconnectable: 0 },
limitations: [] as string[],
};
Reflect.set(value, "limitations", [undefined]);
const provider: JavaScriptRuntimeObservationPort = {
identity: () => ({
id: "test-inspector",
name: "Test Inspector",
version: "1",
}),
listTargets: async () => {
controller.abort();
return ok(value);
},
observe: async () =>
err(new AnalysisCancelledError("observe_javascript_runtime")),
};
await expect(
listJavaScriptRuntimeTargets(
provider,
{ inspector_endpoint: "http://127.0.0.1:9229" },
{ signal: controller.signal },
),
).resolves.toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError", partialObservation: undefined },
});
});
it.each(["list", "observe"] as const)(
"preserves a pre-abort reason before Inspector %s provider work",
async (kind) => {
const reason = new Error(`stop Inspector ${kind}`);
const controller = new AbortController();
controller.abort(reason);
let providerCalls = 0;
const provider: JavaScriptRuntimeObservationPort = {
identity: () => ({ id: "test", name: "Test", version: "1" }),
listTargets: async () => {
providerCalls += 1;
return err(
new AnalysisCancelledError("list_javascript_runtime_targets"),
);
},
observe: async () => {
providerCalls += 1;
return err(new AnalysisCancelledError("observe_javascript_runtime"));
},
};
const result =
kind === "list"
? await listJavaScriptRuntimeTargets(
provider,
{ inspector_endpoint: "http://127.0.0.1:9229" },
{ signal: controller.signal },
)
: await observeJavaScriptRuntime(
provider,
{
inspector_endpoint: "http://127.0.0.1:9229",
target_id: "target",
observation_ms: 0,
},
{ signal: controller.signal },
);
expectPreemptedCancellation(result, reason, providerCalls);
},
);
it("keeps cancellation when the retained runtime observation is incomplete", async () => {
const partial = { runtime: "partial", scripts: [] };
const { result, failure, reason } = await observeCancelled(partial);
expectKeptPartial(expectOwnerError(result, failure, reason), partial);
});
it.each([{ scripts: [undefined] }, { elapsed_ms: Number.NaN }])(
"drops a non-JSON runtime partial and keeps cancellation primary",
async (partial) => {
const { result, failure, reason } = await observeCancelled(partial);
expectDroppedPartial(
expectOwnerError(result, failure, reason),
"Inspector retained a non-JSON incomplete observation.",
);
},
);