mirror of
https://github.com/morluto/rea.git
synced 2026-10-31 20:06:14 +00:00
Retain exact empty and computed literal keys in JavaScript observations. Bind CDP response bodies to request occurrences, account for late responses, and validate the target after final progress callbacks.
527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
|
import {
|
|
inspectWebPageInputSchema,
|
|
listBrowserTargetsInputSchema,
|
|
} from "../../../src/domain/browserObservation.js";
|
|
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
|
|
import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js";
|
|
import {
|
|
startFakeCdpBrowser,
|
|
type FakeCdpBrowser,
|
|
} from "../../fixtures/fakeCdpBrowser.js";
|
|
import { trackBrowser } from "./cdpBrowserProvider.support.js";
|
|
|
|
describe("CdpBrowserProvider: cancellation and session events", () => {
|
|
it("honors cancellation during the final progress callback before publishing inspection", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
trackBrowser(browser);
|
|
const controller = new AbortController();
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
{
|
|
signal: controller.signal,
|
|
progress: {
|
|
report(update) {
|
|
if (update.completed === 3) controller.abort();
|
|
return Promise.resolve();
|
|
},
|
|
},
|
|
},
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "inspect_web_page",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("projects direct-session cancellation onto the requested operation", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
pageScopedVersionWebSocket: true,
|
|
omitTargetWebSocket: true,
|
|
});
|
|
trackBrowser(browser);
|
|
const controller = new AbortController();
|
|
const result = await new CdpBrowserProvider().observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 1_000,
|
|
}),
|
|
{
|
|
signal: controller.signal,
|
|
progress: {
|
|
report(update) {
|
|
if (update.completed === 1) controller.abort();
|
|
return Promise.resolve();
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "observe_web_session",
|
|
},
|
|
});
|
|
const methods = browser.commands.map(({ method }) => method);
|
|
expect(methods).not.toContain("Target.attachToTarget");
|
|
expect(methods).not.toContain("Target.detachFromTarget");
|
|
expect(methods).not.toContain("Target.closeTarget");
|
|
expect(methods).not.toContain("Browser.close");
|
|
});
|
|
|
|
it("projects capture-window cancellation onto bundle analysis", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
pageScopedVersionWebSocket: true,
|
|
omitTargetWebSocket: true,
|
|
});
|
|
trackBrowser(browser);
|
|
const controller = new AbortController();
|
|
const result = await new CdpBrowserProvider().analyzeBundle(
|
|
analyzeWebBundleInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 1_000,
|
|
}),
|
|
{
|
|
signal: controller.signal,
|
|
progress: {
|
|
report(update) {
|
|
if (update.completed === 2) controller.abort();
|
|
return Promise.resolve();
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "analyze_web_bundle",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("observes user-driven reload, SPA, redirect, failure, and lifecycle events", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
sessionTimeline: "same_origin",
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 20,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.window.end_reason).toBe("window_elapsed");
|
|
expect(result.value.timeline.map(({ type }) => type)).toEqual([
|
|
"navigation_requested",
|
|
"same_origin_reload",
|
|
"same_document_navigation",
|
|
"redirect",
|
|
"load_failed",
|
|
"lifecycle",
|
|
]);
|
|
expect(result.value.timeline).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: "same_document_navigation",
|
|
url: `${browser.allowedOrigin}/reloaded?token=session-secret#spa-secret`,
|
|
}),
|
|
expect.objectContaining({
|
|
type: "load_failed",
|
|
detail: "net::ERR_CONNECTION_REFUSED",
|
|
}),
|
|
]),
|
|
);
|
|
expect(result.value.completeness.status).toBe("attach_limited");
|
|
expect(JSON.stringify(result.value)).toContain("session-secret#spa-secret");
|
|
expect(JSON.stringify(result.value)).toContain("redirect-secret");
|
|
expect(browser.commands.map(({ method }) => method)).toContain(
|
|
"Target.detachFromTarget",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("CdpBrowserProvider: session termination and scope changes", () => {
|
|
it("ends a browser session when its flat target session detaches", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
sessionTimeline: "target_detached",
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 1_000,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.window.end_reason).toBe("target_terminated");
|
|
expect(result.value.timeline.at(-1)).toMatchObject({
|
|
type: "target_terminated",
|
|
detail: "target_terminated",
|
|
});
|
|
});
|
|
|
|
it("ends a direct page session when its transport disconnects", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
pageScopedVersionWebSocket: true,
|
|
omitTargetWebSocket: true,
|
|
closeAfterMethod: "Page.setLifecycleEventsEnabled",
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 1_000,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.window.end_reason).toBe("target_terminated");
|
|
expect(result.value.timeline.at(-1)).toMatchObject({
|
|
type: "target_terminated",
|
|
detail: "target_terminated",
|
|
});
|
|
const methods = browser.commands.map(({ method }) => method);
|
|
expect(methods).not.toContain("Target.attachToTarget");
|
|
expect(methods).not.toContain("Target.detachFromTarget");
|
|
expect(methods).not.toContain("Target.closeTarget");
|
|
});
|
|
|
|
it("ends immediately when a redirect leaves policy without exposing its URL", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
sessionTimeline: "outside_policy",
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 1_000,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.window.end_reason).toBe("target_left_scope");
|
|
expect(result.value.timeline.at(-1)).toMatchObject({
|
|
type: "redirect",
|
|
url: null,
|
|
destination_scope: "outside_policy",
|
|
});
|
|
expect(result.value.completeness).toMatchObject({
|
|
status: "policy_filtered",
|
|
policy_filtered_sections: ["timeline"],
|
|
excluded: [
|
|
{
|
|
section: "timeline",
|
|
reason: "out_of_target_scope",
|
|
count: 1,
|
|
},
|
|
],
|
|
});
|
|
expect(JSON.stringify(result.value)).not.toContain("private.example.test");
|
|
});
|
|
|
|
it("fails closed when the final session frame leaves policy without an event", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
frameUrlAfterFirstRead: "https://private.example.test/final",
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 1,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.window.end_reason).toBe("target_left_scope");
|
|
expect(result.value.target.final_url).toBeNull();
|
|
expect(result.value.completeness).toMatchObject({
|
|
status: "policy_filtered",
|
|
policy_filtered_sections: ["timeline"],
|
|
});
|
|
expect(JSON.stringify(result.value)).not.toContain("private.example.test");
|
|
});
|
|
});
|
|
|
|
describe("CdpBrowserProvider: protocol failures and cancellation cleanup", () => {
|
|
it("degrades optional domains but propagates protocol and payload failures", async () => {
|
|
const optional = await startFakeCdpBrowser({
|
|
unsupportedMethods: ["Accessibility.getFullAXTree"],
|
|
});
|
|
trackBrowser(optional);
|
|
const degraded = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: optional.endpoint,
|
|
allowed_origins: [optional.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (!degraded.ok) throw degraded.error;
|
|
expect(degraded.value.accessibility.nodes).toEqual([]);
|
|
expect(degraded.value.accessibility.text_capture.status).toBe(
|
|
"unavailable",
|
|
);
|
|
expect(degraded.value.completeness.unavailable_sections).toContain(
|
|
"accessibility",
|
|
);
|
|
expect(degraded.value.limitations).toContain(
|
|
"Accessibility.getFullAXTree was unavailable from this browser target.",
|
|
);
|
|
|
|
const invalidAccessibilityParams = await startFakeCdpBrowser({
|
|
commandError: (command) =>
|
|
command.method === "Accessibility.getFullAXTree"
|
|
? { code: -32_602, message: "Invalid params" }
|
|
: undefined,
|
|
});
|
|
trackBrowser(invalidAccessibilityParams);
|
|
const invalidParamsResult = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: invalidAccessibilityParams.endpoint,
|
|
allowed_origins: [invalidAccessibilityParams.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
expect(invalidParamsResult).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BrowserObservationError",
|
|
reason: "protocol_error",
|
|
command: "Accessibility.getFullAXTree",
|
|
code: -32_602,
|
|
reportedMessage: "Invalid params",
|
|
},
|
|
});
|
|
|
|
for (const [options, reason] of [
|
|
[{ oversizedDiscovery: true }, "invalid_endpoint_response"],
|
|
[{ invalidBrowserWebSocket: true }, "invalid_endpoint_response"],
|
|
] as const) {
|
|
const failed = await startFakeCdpBrowser(options);
|
|
trackBrowser(failed);
|
|
const result = await new CdpBrowserProvider().listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: failed.endpoint,
|
|
allowed_origins: [failed.allowedOrigin],
|
|
}),
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.error).toMatchObject({
|
|
_tag: "BrowserObservationError",
|
|
reason,
|
|
});
|
|
}
|
|
|
|
const malformed = await startFakeCdpBrowser({
|
|
malformedMessageOnMethod: "Page.getFrameTree",
|
|
});
|
|
trackBrowser(malformed);
|
|
const protocol = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: malformed.endpoint,
|
|
allowed_origins: [malformed.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
expect(protocol).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
|
});
|
|
|
|
const malformedEvent = await startFakeCdpBrowser({
|
|
malformedEventOnMethod: "Debugger.enable",
|
|
});
|
|
trackBrowser(malformedEvent);
|
|
const eventProtocol = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: malformedEvent.endpoint,
|
|
allowed_origins: [malformedEvent.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
expect(eventProtocol).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
|
});
|
|
|
|
const malformedEventShape = await startFakeCdpBrowser({
|
|
malformedEventShapeOnMethod: "Debugger.enable",
|
|
});
|
|
trackBrowser(malformedEventShape);
|
|
const eventShapeProtocol = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: malformedEventShape.endpoint,
|
|
allowed_origins: [malformedEventShape.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
expect(eventShapeProtocol).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
|
});
|
|
});
|
|
|
|
it("cancels observation and still detaches without closing the page", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
trackBrowser(browser);
|
|
const controller = new AbortController();
|
|
const pending = new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 1_000,
|
|
}),
|
|
{ signal: controller.signal },
|
|
);
|
|
// Wait until the attach response has been consumed and capture has begun.
|
|
// Seeing the attach request alone races its response under worker load.
|
|
await waitForCommand(browser, "Page.getFrameTree");
|
|
controller.abort();
|
|
const result = await pending;
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "inspect_web_page",
|
|
},
|
|
});
|
|
const methods = browser.commands.map(({ method }) => method);
|
|
expect(methods).toContain("Target.detachFromTarget");
|
|
expect(methods).not.toContain("Target.closeTarget");
|
|
expect(methods).not.toContain("Browser.close");
|
|
});
|
|
});
|
|
|
|
describe("CdpBrowserProvider: navigation commit and target identity", () => {
|
|
it("waits for navigation commit and rechecks the attached main-frame origin", async () => {
|
|
const transitioning = await startFakeCdpBrowser({
|
|
transitionalFrameReads: 2,
|
|
});
|
|
trackBrowser(transitioning);
|
|
const input = (browser: FakeCdpBrowser) =>
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
});
|
|
const committed = await new CdpBrowserProvider().inspectPage(
|
|
input(transitioning),
|
|
);
|
|
if (!committed.ok) throw committed.error;
|
|
expect(committed.value.target.origin).toBe(transitioning.allowedOrigin);
|
|
expect(committed.value.frames).toContainEqual(
|
|
expect.objectContaining({
|
|
frame_id: "frame-main",
|
|
origin: transitioning.allowedOrigin,
|
|
}),
|
|
);
|
|
|
|
const navigated = await startFakeCdpBrowser({
|
|
attachedFrameUrl: "https://unapproved.example.test/after-attach",
|
|
});
|
|
trackBrowser(navigated);
|
|
const denied = await new CdpBrowserProvider().inspectPage(input(navigated));
|
|
expect(denied).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "target_not_allowed" },
|
|
});
|
|
const methods = navigated.commands.map(({ method }) => method);
|
|
expect(methods).toContain("Target.detachFromTarget");
|
|
expect(methods).not.toContain("DOMSnapshot.captureSnapshot");
|
|
expect(methods).not.toContain("Storage.getUsageAndQuota");
|
|
|
|
const duringObservation = await startFakeCdpBrowser({
|
|
navigateDuringObservationUrl:
|
|
"https://unapproved.example.test/during-observation",
|
|
});
|
|
trackBrowser(duringObservation);
|
|
const interrupted = await new CdpBrowserProvider().inspectPage(
|
|
input(duringObservation),
|
|
);
|
|
expect(interrupted).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "target_not_allowed" },
|
|
});
|
|
expect(
|
|
duringObservation.commands.map(({ method }) => method),
|
|
).not.toContain("Accessibility.getFullAXTree");
|
|
|
|
const captureOptions: { navigateDuringCaptureUrl?: string } = {};
|
|
const duringCapture = await startFakeCdpBrowser(captureOptions);
|
|
captureOptions.navigateDuringCaptureUrl = `${duringCapture.allowedOrigin}/changed`;
|
|
trackBrowser(duringCapture);
|
|
const discarded = await new CdpBrowserProvider().inspectPage(
|
|
input(duringCapture),
|
|
);
|
|
expect(discarded).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "target_changed" },
|
|
});
|
|
expect(duringCapture.commands.map(({ method }) => method)).toContain(
|
|
"Target.detachFromTarget",
|
|
);
|
|
|
|
const crossOriginCapture = await startFakeCdpBrowser({
|
|
navigateDuringCaptureUrl:
|
|
"https://unapproved.example.test/during-capture",
|
|
});
|
|
trackBrowser(crossOriginCapture);
|
|
const rejectedCapture = await new CdpBrowserProvider().inspectPage(
|
|
input(crossOriginCapture),
|
|
);
|
|
expect(rejectedCapture).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "target_not_allowed" },
|
|
});
|
|
});
|
|
});
|
|
|
|
const waitForCommand = async (
|
|
browser: FakeCdpBrowser,
|
|
method: string,
|
|
): Promise<void> => {
|
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
if (browser.commands.some((command) => command.method === method)) return;
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error(`Timed out waiting for fake CDP command ${method}`);
|
|
};
|