Preserve uncertainty in offline passive network comparisons when request identity, metadata, or per-kind inventory coverage is incomplete. Retain proven HTTP/WebSocket changes without using missing information to infer membership, duplicate counts, or equality. Compare redirect resource types only where both observations retain them. Keep metadata completeness with its owner, stream HTTP content digests, and require full coverage for multiplicity and WebSocket stream inventories. Preserve independently known metadata changes and full-inventory counts. Keep the original JavaScript configuration-escape, JSON, cancellation, SAZ and schema-documentation fixes compatible with current main. Split public CLI comparisons into isolated test cases without loosening timeouts. Validate actual Chromium/HTTP captures through CLI and real stdio SDK, with explicit limits and unchanged source bytes. Co-authored-by: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
210 lines
7.8 KiB
TypeScript
210 lines
7.8 KiB
TypeScript
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import {
|
|
McpServer,
|
|
STDIO_DEFAULT_MAX_BUFFER_SIZE,
|
|
} from "@modelcontextprotocol/server";
|
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { compareWebCaptureEvidence } from "../../../src/application/BrowserObservationService.js";
|
|
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
|
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
|
import {
|
|
browserCaptureComparisonInputSchema,
|
|
compareBrowserCaptures,
|
|
} from "../../../src/domain/browserCaptureComparison.js";
|
|
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
|
|
import { browserScenarioCaptureSchema } from "../../../src/domain/browserScenarioCapture.js";
|
|
import { ToolResultDelivery } from "../../../src/server/toolResult.js";
|
|
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
|
|
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
|
|
|
const delivery = new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE);
|
|
|
|
const contract = toolContract("compare_web_captures");
|
|
|
|
async function capturePassivePair(
|
|
provider: CdpBrowserProvider,
|
|
browser: Awaited<ReturnType<typeof startFakeCdpBrowser>>,
|
|
) {
|
|
const produced = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
target_id: "allowed-page",
|
|
allowed_origins: [browser.allowedOrigin, "https://private.example.test"],
|
|
observation_ms: 0,
|
|
include_json_body_shapes: true,
|
|
include_websocket_shapes: true,
|
|
}),
|
|
);
|
|
if (!produced.ok) throw produced.error;
|
|
// Capture order must not turn identical frame identities into a DOM change.
|
|
produced.value.frames = [null, browser.allowedOrigin, ""].map(
|
|
(origin, index) => ({
|
|
frame_id: String(index),
|
|
parent_frame_id: null,
|
|
url: "about:blank",
|
|
origin,
|
|
}),
|
|
);
|
|
const reordered = structuredClone(produced.value);
|
|
reordered.frames.reverse();
|
|
return {
|
|
before: { inspection: produced.value },
|
|
after: { inspection: reordered },
|
|
};
|
|
}
|
|
|
|
describe("browser comparison input boundary", () => {
|
|
it("advertises complete producer captures and agrees with SDK validation on both comparison families", async () => {
|
|
const browser = await startFakeCdpBrowser({ sensitiveShapes: true });
|
|
const server = new McpServer({ name: "capture-input", version: "0" });
|
|
const client = new Client({ name: "capture-input", version: "0" });
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
let calls = 0;
|
|
const provider = new CdpBrowserProvider();
|
|
server.registerTool(
|
|
contract.name,
|
|
toolRegistrationOptions(contract),
|
|
async (input) => {
|
|
calls += 1;
|
|
const compared = await compareWebCaptureEvidence(provider, input);
|
|
return compared.ok
|
|
? delivery.toEvidenceToolResult(compared.value, contract, undefined)
|
|
: delivery.toErrorToolResult(
|
|
compared.error,
|
|
contract.returnsTargetContent,
|
|
);
|
|
},
|
|
);
|
|
try {
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
const advertised = (await client.listTools()).tools.find(
|
|
({ name }) => name === contract.name,
|
|
);
|
|
if (advertised === undefined) throw new Error("Missing comparison tool");
|
|
expect(advertised.inputSchema.type).toBe("object");
|
|
// Anthropic tool input schemas reject root combinators.
|
|
expect(advertised.inputSchema).not.toHaveProperty("anyOf");
|
|
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
|
expect(ajv.validateSchema(advertised.inputSchema)).toBe(true);
|
|
const validate = ajv.compile(advertised.inputSchema);
|
|
const passive = await capturePassivePair(provider, browser);
|
|
const scenario = contract.examples[0]?.input;
|
|
if (scenario === undefined) throw new Error("Missing scenario example");
|
|
const withoutNormalization = Object.fromEntries(
|
|
Object.entries(scenario).filter(([key]) => key !== "normalization"),
|
|
);
|
|
const scenarioCapture = browserScenarioCaptureSchema.parse(
|
|
scenario.before_scenario,
|
|
);
|
|
const invalid = [
|
|
{},
|
|
{ before: passive.before },
|
|
{ before: { inspection: {} }, after: passive.after },
|
|
{
|
|
before: passive.before,
|
|
after: { ...passive.after, webmcp: {} },
|
|
},
|
|
{ before_scenario: {}, after_scenario: {} },
|
|
{ before_scenario: scenario.before_scenario },
|
|
{ ...passive, unexpected: true },
|
|
{
|
|
...scenario,
|
|
before_scenario: { ...scenarioCapture, steps: [{}] },
|
|
},
|
|
{
|
|
...scenario,
|
|
normalization: {
|
|
rules: [
|
|
{ rule_id: "bad", artifacts: [], match: "x", replacement: "y" },
|
|
],
|
|
},
|
|
},
|
|
];
|
|
// Strict clients reject combinators and dependentRequired, so the
|
|
// projection states the groups in its description and leaves group
|
|
// completeness and exclusion to the canonical runtime parser.
|
|
expect(advertised.inputSchema.description).toContain(
|
|
"before + after; before_scenario + after_scenario + [normalization]",
|
|
);
|
|
const runtimeOnlyInvalid = [
|
|
{ ...passive, ...scenario },
|
|
{ ...passive, normalization: { rules: [] } },
|
|
{
|
|
before_scenario: scenario.before_scenario,
|
|
normalization: { rules: [] },
|
|
},
|
|
{
|
|
after_scenario: scenario.after_scenario,
|
|
normalization: { rules: [] },
|
|
},
|
|
];
|
|
for (const { input, advertisedValid, runtimeValid } of [
|
|
...[passive, scenario, withoutNormalization].map((input) => ({
|
|
input,
|
|
advertisedValid: true,
|
|
runtimeValid: true,
|
|
})),
|
|
...invalid.map((input) => ({
|
|
input,
|
|
advertisedValid: false,
|
|
runtimeValid: false,
|
|
})),
|
|
...runtimeOnlyInvalid.map((input) => ({
|
|
input,
|
|
advertisedValid: true,
|
|
runtimeValid: false,
|
|
})),
|
|
]) {
|
|
expect(validate(input), JSON.stringify(validate.errors)).toBe(
|
|
advertisedValid,
|
|
);
|
|
expect(
|
|
browserCaptureComparisonInputSchema.safeParse(input).success,
|
|
).toBe(runtimeValid);
|
|
const beforeCalls = calls;
|
|
const result = await client.callTool({
|
|
name: contract.name,
|
|
arguments: input,
|
|
});
|
|
expect(calls - beforeCalls).toBe(runtimeValid ? 1 : 0);
|
|
expect(result.isError === true).toBe(!runtimeValid);
|
|
if (runtimeValid)
|
|
expect(result.structuredContent).toMatchObject({
|
|
operation: contract.name,
|
|
normalized_result: compareBrowserCaptures(
|
|
browserCaptureComparisonInputSchema.parse(input),
|
|
),
|
|
});
|
|
}
|
|
const parsedPassive = browserCaptureComparisonInputSchema.parse(passive);
|
|
expect(parsedPassive).toEqual({
|
|
before: { ...passive.before, webmcp: null },
|
|
after: { ...passive.after, webmcp: null },
|
|
});
|
|
expect(compareBrowserCaptures(parsedPassive)).toMatchObject({
|
|
dimensions: { dom_structure: { status: "unchanged" } },
|
|
});
|
|
expect(compareBrowserCaptures(parsedPassive).overall_status).toBe(
|
|
"unknown",
|
|
);
|
|
const parsedScenario =
|
|
browserCaptureComparisonInputSchema.parse(withoutNormalization);
|
|
expect(parsedScenario).toHaveProperty("normalization", { rules: [] });
|
|
expect(compareBrowserCaptures(parsedScenario)).toMatchObject({
|
|
comparison_kind: "browser_scenario",
|
|
overall_status: "unchanged",
|
|
});
|
|
} finally {
|
|
await Promise.allSettled([
|
|
client.close(),
|
|
server.close(),
|
|
browser.close(),
|
|
]);
|
|
}
|
|
});
|
|
});
|