Files
rea/src/application/WebRuntimeService.test.ts
T

325 lines
10 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");
});
it.each(["observe", "inspect"] as const)(
"keeps %s cancellation primary when completed-shaped partial cannot be promoted within origin scope",
async (operation) => {
const partial =
operation === "observe" ? webExecutionFixture() : webListenersFixture();
const reason = { stop: "retained fact projection" };
const failure = new AnalysisCancelledError(
operation === "observe"
? "observe_web_execution"
: "inspect_web_event_listeners",
{ cause: reason, partialObservation: partial },
);
const service = new WebRuntimeService({
identity: runtimeIdentity,
observeExecution: async () => err(failure),
inspectEventListeners: async () => err(failure),
});
const input = {
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "selected-page",
allowed_origins: ["https://selected.example"],
};
const result =
operation === "observe"
? await service.observe({ ...input, observation_ms: 5 })
: await service.inspect({ ...input, selector: "#selected" });
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
cause: reason,
partialObservation: partial,
},
});
if (result.ok) throw new Error("Expected original cancellation");
expect(result.error).toBe(failure);
expect(result.error.partialObservation).not.toHaveProperty("evidence_id");
},
);