* 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>
285 lines
8.9 KiB
TypeScript
285 lines
8.9 KiB
TypeScript
import { expect, it } from "vitest";
|
|
import { WebRuntimeService } from "./WebRuntimeService.js";
|
|
import {
|
|
recordingWebRuntimePort,
|
|
webExecutionFixture,
|
|
webListenersFixture,
|
|
} from "./WebRuntimeService.fixture.js";
|
|
import { err, ok } from "../domain/result.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisOutputError,
|
|
AnalysisTimeoutError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|
import {
|
|
expectDroppedPartial,
|
|
expectKeptPartial,
|
|
expectOwnerError,
|
|
} from "./ownerObservationExpectation.fixture.js";
|
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { parseEvidence } from "../domain/evidence.js";
|
|
|
|
it("rejects malformed input and prior cancellation without calling a provider", async () => {
|
|
const provider = recordingWebRuntimePort();
|
|
const service = new WebRuntimeService({
|
|
identity: () => provider.identity(),
|
|
observeExecution: () => {
|
|
throw new Error("Provider must not be called");
|
|
},
|
|
inspectEventListeners: () => {
|
|
throw new Error("Provider must not be called");
|
|
},
|
|
});
|
|
const malformed = await service.observe({
|
|
cdp_endpoint: "https://remote.example",
|
|
target_id: "page",
|
|
});
|
|
if (malformed.ok)
|
|
throw new Error("Invalid endpoint must fail before execution");
|
|
expect(malformed.error._tag).toBe("AnalysisInputError");
|
|
const reason = { requested: "stop before provider admission" };
|
|
const cancelled = await service.inspect(
|
|
{},
|
|
{ signal: AbortSignal.abort(reason) },
|
|
);
|
|
if (cancelled.ok)
|
|
throw new Error("Prior cancellation must fail before inspection");
|
|
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
|
|
expect(cancelled.error.cause).toBe(reason);
|
|
});
|
|
|
|
it("preserves actual provider failures and rejects changed target/selector identities", async () => {
|
|
const provider = recordingWebRuntimePort();
|
|
const args = {
|
|
cdp_endpoint: "http://127.0.0.1:9222",
|
|
target_id: "selected-page",
|
|
selector: "#selected",
|
|
};
|
|
const captured = webListenersFixture();
|
|
const timeout = new AnalysisTimeoutError("observe_web_execution", 20_000);
|
|
const service = new WebRuntimeService({
|
|
identity: () => provider.identity(),
|
|
observeExecution: async () => err(timeout),
|
|
inspectEventListeners: async () =>
|
|
ok({
|
|
...captured,
|
|
selected_node: { ...captured.selected_node, selector: "#different" },
|
|
}),
|
|
});
|
|
const failed = await service.observe({
|
|
cdp_endpoint: args.cdp_endpoint,
|
|
target_id: args.target_id,
|
|
observation_ms: 5,
|
|
});
|
|
expect(failed).toEqual(err(timeout));
|
|
const changed = await service.inspect(args);
|
|
if (changed.ok) throw new Error("Changed selection must be rejected");
|
|
expect(changed.error._tag).toBe("AnalysisOutputError");
|
|
});
|
|
|
|
it("converts raw execution retained by provider cleanup failure into partial Evidence", async () => {
|
|
const args = {
|
|
cdp_endpoint: "http://127.0.0.1:9222",
|
|
target_id: "selected-page",
|
|
observation_ms: 5,
|
|
};
|
|
const execution = webExecutionFixture(args.target_id, args.observation_ms);
|
|
const failure = new ProviderCleanupError(
|
|
"rea-cdp-browser",
|
|
["selected-page"],
|
|
{ reason: "target detach failed" },
|
|
{ operation: "observe_web_execution", partialObservation: execution },
|
|
);
|
|
const result = await new WebRuntimeService({
|
|
identity: runtimeIdentity,
|
|
observeExecution: async () => err(failure),
|
|
inspectEventListeners: async () => err(failure),
|
|
}).observe(args);
|
|
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_web_execution",
|
|
normalized_result: execution,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("retains completed execution Evidence on late abort and keeps invalid output primary", async () => {
|
|
const input = {
|
|
cdp_endpoint: "http://127.0.0.1:9222",
|
|
target_id: "selected-page",
|
|
observation_ms: 5,
|
|
};
|
|
const unused = new AnalysisTimeoutError(
|
|
"inspect_web_event_listeners",
|
|
20_000,
|
|
);
|
|
const controller = new AbortController();
|
|
const cancellationReason = new Error(
|
|
"caller cancelled the runtime observation",
|
|
);
|
|
const completed = await new WebRuntimeService({
|
|
identity: () => ({
|
|
id: "web-runtime",
|
|
name: "Runtime fixture",
|
|
version: "1",
|
|
}),
|
|
observeExecution: async () => {
|
|
controller.abort(cancellationReason);
|
|
return ok(webExecutionFixture(input.target_id, input.observation_ms));
|
|
},
|
|
inspectEventListeners: async () => err(unused),
|
|
}).observe(input, { signal: controller.signal });
|
|
if (completed.ok) throw new Error("Expected late cancellation");
|
|
expect(completed.error._tag).toBe("AnalysisCancelledError");
|
|
expect(completed.error.cause).toBe(cancellationReason);
|
|
expect(parseEvidence(completed.error.partialObservation)).toMatchObject({
|
|
operation: "observe_web_execution",
|
|
normalized_result: webExecutionFixture(
|
|
input.target_id,
|
|
input.observation_ms,
|
|
),
|
|
});
|
|
|
|
const late = new AbortController();
|
|
const invalid = await new WebRuntimeService({
|
|
identity: () => ({
|
|
id: "web-runtime",
|
|
name: "Runtime fixture",
|
|
version: "1",
|
|
}),
|
|
observeExecution: async () => {
|
|
late.abort();
|
|
return ok(webExecutionFixture(input.target_id, input.observation_ms));
|
|
},
|
|
inspectEventListeners: async () => err(unused),
|
|
}).observe(
|
|
{ ...input, allowed_origins: ["https://allowed.example"] },
|
|
{ signal: late.signal },
|
|
);
|
|
expect(invalid).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisOutputError" },
|
|
});
|
|
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
|
|
});
|
|
|
|
const runtimeIdentity = () => ({
|
|
id: "web-runtime",
|
|
name: "Runtime fixture",
|
|
version: "1",
|
|
});
|
|
|
|
const callRuntime = (
|
|
operation: "observe" | "inspect",
|
|
failure: AnalysisError,
|
|
) => {
|
|
const service = new WebRuntimeService({
|
|
identity: runtimeIdentity,
|
|
observeExecution: async () => err(failure),
|
|
inspectEventListeners: async () => err(failure),
|
|
});
|
|
return operation === "observe"
|
|
? service.observe({
|
|
cdp_endpoint: "http://127.0.0.1:9222",
|
|
target_id: "selected-page",
|
|
observation_ms: 5,
|
|
})
|
|
: service.inspect({
|
|
cdp_endpoint: "http://127.0.0.1:9222",
|
|
target_id: "selected-page",
|
|
selector: "#selected",
|
|
});
|
|
};
|
|
|
|
it.each([
|
|
{
|
|
operation: "observe" as const,
|
|
partial: {
|
|
instrumentation: { cleanup: "unconfirmed" },
|
|
window: { requested_ms: 5 },
|
|
},
|
|
},
|
|
{
|
|
operation: "inspect" as const,
|
|
partial: {
|
|
selected_node: { selector: "#selected" },
|
|
listeners: [],
|
|
},
|
|
},
|
|
])(
|
|
"keeps $operation cancellation when the retained observation is incomplete",
|
|
async ({ operation, partial }) => {
|
|
const reason = { requested: `stop incomplete ${operation}` };
|
|
const failure = new AnalysisCancelledError(
|
|
operation === "observe"
|
|
? "observe_web_execution"
|
|
: "inspect_web_event_listeners",
|
|
{ cause: reason, partialObservation: partial as never },
|
|
);
|
|
expectKeptPartial(
|
|
expectOwnerError(await callRuntime(operation, failure), failure, reason),
|
|
partial,
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
operation: "observe" as const,
|
|
partial: {
|
|
instrumentation: { cleanup: "unconfirmed" },
|
|
window: { requested_ms: Number.NaN },
|
|
},
|
|
reason: "Provider retained a non-JSON incomplete execution observation.",
|
|
},
|
|
{
|
|
operation: "inspect" as const,
|
|
partial: {
|
|
selected_node: { selector: "#selected" },
|
|
listeners: [undefined],
|
|
},
|
|
reason:
|
|
"Provider retained a non-JSON incomplete event-listener observation.",
|
|
},
|
|
])(
|
|
"drops a non-JSON $operation partial and keeps cancellation primary",
|
|
async ({ operation, partial, reason: outputReason }) => {
|
|
const reason = { requested: `stop unsafe ${operation}` };
|
|
const failure = new AnalysisCancelledError(
|
|
operation === "observe"
|
|
? "observe_web_execution"
|
|
: "inspect_web_event_listeners",
|
|
{ cause: reason, partialObservation: partial as never },
|
|
);
|
|
expectDroppedPartial(
|
|
expectOwnerError(await callRuntime(operation, failure), failure, reason),
|
|
outputReason,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("drops a non-JSON execution partial and keeps timeout primary", async () => {
|
|
const failure = new AnalysisTimeoutError("observe_web_execution", 5, {
|
|
partialObservation: {
|
|
instrumentation: { samples: [undefined] },
|
|
window: { requested_ms: 5 },
|
|
} as never,
|
|
});
|
|
const error = expectOwnerError(
|
|
await callRuntime("observe", failure),
|
|
failure,
|
|
undefined,
|
|
);
|
|
expectDroppedPartial(
|
|
error,
|
|
"Provider retained a non-JSON incomplete execution observation.",
|
|
"provider_timeout",
|
|
);
|
|
expect(projectAnalysisError(error).category).toBe("timeout");
|
|
});
|