mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
de4579e993
* 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>
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import { expect, it } from "vitest";
|
|
import type { BrowserObservationPort } from "./BrowserObservationPort.js";
|
|
import { listBrowserTargets } from "./BrowserObservationService.js";
|
|
import type { BrowserTargetList } from "../domain/browserObservationSchemas.js";
|
|
import { parseEvidence } from "../domain/evidence.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisOutputError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import {
|
|
expectDroppedPartial,
|
|
expectKeptPartial,
|
|
expectOutputError,
|
|
expectOwnerError,
|
|
} from "./ownerObservationExpectation.fixture.js";
|
|
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
|
import { err, ok } from "../domain/result.js";
|
|
import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js";
|
|
import { inspectWebPage } from "./BrowserObservationService.js";
|
|
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
|
|
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
|
|
import { analyzeWebBundle } from "./BrowserObservationService.js";
|
|
|
|
const input = {
|
|
cdp_endpoint: {
|
|
transport: "http_discovery" as const,
|
|
url: "http://127.0.0.1:9222",
|
|
recorded: "http://127.0.0.1:9222",
|
|
},
|
|
allowed_origins: [],
|
|
};
|
|
|
|
const report: BrowserTargetList = {
|
|
browser: {
|
|
product: "Chrome",
|
|
protocol_version: "1.3",
|
|
revision: "1",
|
|
user_agent: "Chrome fixture",
|
|
js_version: "V8 fixture",
|
|
},
|
|
targets: [],
|
|
excluded: { disallowed_origin: 0, unsupported_url: 0, non_page: 0 },
|
|
limitations: [],
|
|
};
|
|
|
|
const provider = (
|
|
listTargets: BrowserObservationPort["listTargets"],
|
|
): BrowserObservationPort =>
|
|
({
|
|
identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }),
|
|
listTargets,
|
|
}) as unknown as BrowserObservationPort;
|
|
|
|
it("retains completed browser inventory Evidence when the provider completes after abort", async () => {
|
|
const controller = new AbortController();
|
|
const result = await listBrowserTargets(
|
|
provider(async () => {
|
|
controller.abort();
|
|
return ok(report);
|
|
}),
|
|
input,
|
|
{ signal: controller.signal },
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
|
operation: "list_browser_targets",
|
|
normalized_result: report,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("does not let late cancellation hide invalid completed browser output", async () => {
|
|
const malformed = {
|
|
...report,
|
|
limitations: [() => undefined],
|
|
} as unknown as BrowserTargetList;
|
|
const output = await listBrowserTargets(
|
|
provider(async () => ok(malformed)),
|
|
input,
|
|
);
|
|
expectOutputError(output);
|
|
|
|
const controller = new AbortController();
|
|
const late = await listBrowserTargets(
|
|
provider(async () => {
|
|
controller.abort();
|
|
return ok(malformed);
|
|
}),
|
|
input,
|
|
{ signal: controller.signal },
|
|
);
|
|
expectOutputError(late);
|
|
});
|
|
|
|
it("converts a raw page inspection retained after browser cleanup failure", async () => {
|
|
const inspection = passiveNetworkCapture();
|
|
const failure = new BrowserObservationError(
|
|
"inspect_web_page",
|
|
"cleanup_failed",
|
|
{
|
|
cleanup: {
|
|
reason: "target detach failed",
|
|
resources: ["browser target"],
|
|
},
|
|
partialObservation: inspection,
|
|
},
|
|
);
|
|
const browser: BrowserObservationPort = {
|
|
identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }),
|
|
listTargets: async () => ok(report),
|
|
inspectPage: async () => ({ ok: false, error: failure }),
|
|
} as unknown as BrowserObservationPort;
|
|
const pageInput = inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: input.cdp_endpoint.recorded,
|
|
allowed_origins: [],
|
|
target_id: "page",
|
|
observation_ms: 0,
|
|
include_script_sources: false,
|
|
});
|
|
const result = await inspectWebPage(browser, pageInput);
|
|
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: "inspect_web_page",
|
|
normalized_result: inspection,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("retains captured page Evidence when bundle analysis fails after capture", async () => {
|
|
const inspection = passiveNetworkCapture();
|
|
const failure = new BrowserObservationError(
|
|
"analyze_web_bundle",
|
|
"cancelled",
|
|
{ partialObservation: inspection },
|
|
);
|
|
const browser: BrowserObservationPort = {
|
|
...provider(async () => ok(report)),
|
|
analyzeBundle: async () => ({ ok: false as const, error: failure }),
|
|
} as unknown as BrowserObservationPort;
|
|
const bundleInput = analyzeWebBundleInputSchema.parse({
|
|
...input,
|
|
cdp_endpoint: input.cdp_endpoint.recorded,
|
|
target_id: "page",
|
|
observation_ms: 0,
|
|
include_script_sources: true,
|
|
});
|
|
const result = await analyzeWebBundle(browser, bundleInput);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toBe(failure);
|
|
expect(failure.operation).toBe("analyze_web_bundle");
|
|
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
|
operation: "inspect_web_page",
|
|
normalized_result: inspection,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("retains completed bundle facts as bundle Evidence after cancellation", async () => {
|
|
const { analyzeCapturedWebBundle } =
|
|
await import("../domain/webBundleAnalyzer.js");
|
|
const { AnalysisCancelledError } =
|
|
await import("../domain/analysisErrorCore.js");
|
|
const bundle = analyzeCapturedWebBundle(passiveNetworkCapture());
|
|
const reason = { requested: "stop after completed script" };
|
|
const failure = new AnalysisCancelledError("analyze_web_bundle", {
|
|
cause: reason,
|
|
partialObservation: bundle,
|
|
});
|
|
const browser: BrowserObservationPort = {
|
|
...provider(async () => ok(report)),
|
|
analyzeBundle: async () => ({ ok: false as const, error: failure }),
|
|
};
|
|
const selected = analyzeWebBundleInputSchema.parse({
|
|
cdp_endpoint: input.cdp_endpoint.recorded,
|
|
target_id: "page",
|
|
max_heap_mb: 256,
|
|
analysis_timeout_ms: 100,
|
|
total_analysis_timeout_ms: 1000,
|
|
});
|
|
const result = await analyzeWebBundle(browser, selected);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected cancellation");
|
|
expect(result.error).toBe(failure);
|
|
expect(result.error.cause).toBe(reason);
|
|
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
|
operation: "analyze_web_bundle",
|
|
parameters: expect.objectContaining({
|
|
max_heap_mb: 256,
|
|
analysis_timeout_ms: 100,
|
|
total_analysis_timeout_ms: 1000,
|
|
}),
|
|
normalized_result: bundle,
|
|
});
|
|
});
|
|
|
|
it("preserves a prior caller reason without admitting browser work", async () => {
|
|
const reason = new AnalysisOutputError(
|
|
"inspect_web_page",
|
|
"caller supplied reason",
|
|
);
|
|
const result = await listBrowserTargets(
|
|
provider(async () => {
|
|
throw new Error("Cancelled inventory must not start");
|
|
}),
|
|
input,
|
|
{ signal: AbortSignal.abort(reason) },
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected cancelled inventory");
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(result.error.cause).toBe(reason);
|
|
});
|
|
|
|
it("keeps page cancellation when the retained inspection is incomplete", async () => {
|
|
const reason = { requested: "stop during page inspection" };
|
|
const partial = {
|
|
target: { target_id: "page" },
|
|
network: {},
|
|
capture_window: {},
|
|
};
|
|
const failure = new AnalysisCancelledError("inspect_web_page", {
|
|
cause: reason,
|
|
partialObservation: partial as never,
|
|
});
|
|
const browser = {
|
|
...provider(async () => ok(report)),
|
|
inspectPage: async () => err(failure),
|
|
} as unknown as BrowserObservationPort;
|
|
const pageInput = inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: input.cdp_endpoint.recorded,
|
|
allowed_origins: [],
|
|
target_id: "page",
|
|
observation_ms: 0,
|
|
include_script_sources: false,
|
|
});
|
|
const result = await inspectWebPage(browser, pageInput);
|
|
expectKeptPartial(expectOwnerError(result, failure, reason), partial);
|
|
});
|
|
|
|
it("keeps bundle cancellation and an earlier failure when retained facts are incomplete", async () => {
|
|
const reason = { requested: "stop during bundle analysis" };
|
|
const partial = { observations: [], capture: { status: "partial" } };
|
|
const cancelled = new AnalysisCancelledError("analyze_web_bundle", {
|
|
cause: reason,
|
|
partialObservation: partial as never,
|
|
});
|
|
const timedOut = new BrowserObservationError(
|
|
"analyze_web_bundle",
|
|
"timeout",
|
|
{ partialObservation: partial as never },
|
|
);
|
|
const selected = analyzeWebBundleInputSchema.parse({
|
|
cdp_endpoint: input.cdp_endpoint.recorded,
|
|
target_id: "page",
|
|
observation_ms: 0,
|
|
});
|
|
for (const failure of [cancelled, timedOut]) {
|
|
const browser = {
|
|
...provider(async () => ok(report)),
|
|
analyzeBundle: async () => err(failure),
|
|
} as unknown as BrowserObservationPort;
|
|
const result = await analyzeWebBundle(browser, selected);
|
|
expectKeptPartial(
|
|
expectOwnerError(result, failure, failure.cause),
|
|
partial,
|
|
failure === timedOut ? "provider_timeout" : "cancelled",
|
|
);
|
|
}
|
|
expect(projectAnalysisError(cancelled).code).toBe("cancelled");
|
|
});
|
|
|
|
const pageInput = () =>
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: input.cdp_endpoint.recorded,
|
|
allowed_origins: [],
|
|
target_id: "page",
|
|
observation_ms: 0,
|
|
include_script_sources: false,
|
|
});
|
|
|
|
const bundleInput = () =>
|
|
analyzeWebBundleInputSchema.parse({
|
|
cdp_endpoint: input.cdp_endpoint.recorded,
|
|
target_id: "page",
|
|
observation_ms: 0,
|
|
});
|
|
|
|
const browserReturning = (
|
|
failure: BrowserObservationError | AnalysisCancelledError,
|
|
) =>
|
|
({
|
|
...provider(async () => ok(report)),
|
|
inspectPage: async () => err(failure),
|
|
analyzeBundle: async () => err(failure),
|
|
}) as unknown as BrowserObservationPort;
|
|
|
|
it.each([
|
|
{
|
|
name: "bundle bytes",
|
|
operation: "analyze_web_bundle" as const,
|
|
partial: { observations: [], capture: { bytes: Number.NaN } },
|
|
},
|
|
{
|
|
name: "bundle observations",
|
|
operation: "analyze_web_bundle" as const,
|
|
partial: { observations: [undefined], capture: {} },
|
|
},
|
|
{
|
|
name: "page requests",
|
|
operation: "inspect_web_page" as const,
|
|
partial: {
|
|
target: { target_id: "page" },
|
|
network: { requests: Number.NaN },
|
|
capture_window: {},
|
|
},
|
|
},
|
|
])(
|
|
"drops a non-JSON $name partial and keeps cancellation primary",
|
|
async ({ operation, partial }) => {
|
|
const reason = { requested: `stop ${operation}` };
|
|
const failure = new AnalysisCancelledError(operation, {
|
|
cause: reason,
|
|
partialObservation: partial as never,
|
|
});
|
|
const result =
|
|
operation === "inspect_web_page"
|
|
? await inspectWebPage(browserReturning(failure), pageInput())
|
|
: await analyzeWebBundle(browserReturning(failure), bundleInput());
|
|
const error = expectOwnerError(result, failure, reason);
|
|
expectDroppedPartial(
|
|
error,
|
|
"Browser provider retained a non-JSON incomplete observation.",
|
|
);
|
|
expect(projectAnalysisError(error).details).not.toHaveProperty(
|
|
"partial_observation",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
name: "schema-shaped page",
|
|
partial: {
|
|
target: { target_id: "page" },
|
|
network: { requests: Number.NaN },
|
|
capture_window: {},
|
|
},
|
|
},
|
|
{
|
|
name: "shapeless page",
|
|
partial: { requests: Number.NaN },
|
|
},
|
|
])(
|
|
"drops a non-JSON $name partial and keeps timeout primary",
|
|
async ({ partial }) => {
|
|
const failure = new BrowserObservationError("inspect_web_page", "timeout", {
|
|
partialObservation: partial as never,
|
|
});
|
|
const error = expectOwnerError(
|
|
await inspectWebPage(browserReturning(failure), pageInput()),
|
|
failure,
|
|
undefined,
|
|
);
|
|
expectDroppedPartial(
|
|
error,
|
|
"Browser provider retained a non-JSON incomplete observation.",
|
|
"provider_timeout",
|
|
);
|
|
expect(projectAnalysisError(error).category).toBe("timeout");
|
|
},
|
|
);
|