diff --git a/src/browser/PlaywrightScenarioEvents.ts b/src/browser/PlaywrightScenarioEvents.ts index e0a863ad3f..44d7701558 100644 --- a/src/browser/PlaywrightScenarioEvents.ts +++ b/src/browser/PlaywrightScenarioEvents.ts @@ -19,8 +19,10 @@ import { type BrowserScenarioEventOmissions, } from "../domain/browserScenarioCaptureValues.js"; import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; -import type { BrowserNetworkContentSelection } from "../domain/browserNetworkEvidence.js"; -import type { BrowserNetworkHeaders } from "../domain/browserNetworkEvidence.js"; +import type { + BrowserNetworkContentSelection, + BrowserNetworkHeaders, +} from "../domain/browserNetworkEvidence.js"; import type { BrowserScenarioCaptureLimits } from "../domain/browserScenarioCaptureLimits.js"; import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js"; import { captureNetworkBody } from "./network/PlaywrightNetworkContent.js"; diff --git a/src/domain/analysisView/scenarioCaptureRecords.test.ts b/src/domain/analysisView/scenarioCaptureRecords.test.ts index 8b05021e83..6f9516cb08 100644 --- a/src/domain/analysisView/scenarioCaptureRecords.test.ts +++ b/src/domain/analysisView/scenarioCaptureRecords.test.ts @@ -9,6 +9,7 @@ import { import { admitScenarioCaptureParent, scenarioCaptureRecords, + scenarioContentMediaType, } from "./scenarioCaptureRecords.js"; import { EnvironmentManifestAccumulator } from "./EnvironmentManifestAccumulator.js"; @@ -28,6 +29,90 @@ const manifestLimitations = ( return result.value.limitations; }; +describe("scenario Content-Type evidence", () => { + it.each( + [ + ["application/json", "application/octet-stream"], + ["application/octet-stream", "application/json"], + ["application/json", null], + [null, "application/json"], + ["application/json", "application/octet-stream", "application/json"], + ].map((values) => ({ values })), + )( + "keeps conflicting or excluded Content-Type declarations unknown: $values", + ({ values }) => { + const capture = scenarioCaptureFixture([ + { + method: "GET", + url: "/download", + status: 200, + responseHeaders: values.map((value, index) => ({ + name: index % 2 === 0 ? "Content-Type" : "content-type", + value, + })), + }, + ]); + const original = JSON.stringify(capture); + const content = capture.events.items.find( + (event) => + event.kind === "network-content" && event.phase === "response", + ); + if (content?.kind !== "network-content") + throw new Error("Missing content"); + expect(scenarioContentMediaType(content)).toBeUndefined(); + for (const purpose of ["manifest", "replay"] as const) { + const projected = scenarioCaptureRecords(capture, purpose); + expect(projected.records[0]?.reported).toMatchObject({ + response: { status: 200, content: { mimeType: "" } }, + }); + const accumulator = new EnvironmentManifestAccumulator(); + for (const record of projected.records) { + const added = accumulator.add( + record, + projected.responseBodies.get(record.ordinal), + ); + if (!added.ok) throw added.error; + } + const manifest = accumulator.finish(); + if (!manifest.ok) throw manifest.error; + expect(manifest.value.tools).toEqual([]); + } + expect(JSON.stringify(capture)).toBe(original); + }, + ); + + it("retains agreeing repeated Content-Type values and independently captured body metadata", () => { + const capture = scenarioCaptureFixture([ + { + method: "GET", + url: "/headers", + status: 200, + responseHeaders: [ + { name: "Content-Type", value: "application/json" }, + { name: "content-type", value: "application/json" }, + ], + }, + { + method: "GET", + url: "/body", + status: 200, + body: "{}", + mediaType: "application/json", + responseHeaders: [ + { name: "Content-Type", value: "application/json" }, + { name: "content-type", value: null }, + ], + }, + ]); + const projected = scenarioCaptureRecords(capture, "manifest"); + expect(projected.records).toHaveLength(2); + for (const record of projected.records) + expect(record.reported).toMatchObject({ + response: { content: { mimeType: "application/json" } }, + }); + }); +}); + describe("scenarioCaptureRecords", () => { it("projects each transaction, in request order, as a HAR-shaped record with its retained bytes", () => { const projected = scenarioCaptureRecords( diff --git a/src/domain/analysisView/scenarioCaptureRecords.ts b/src/domain/analysisView/scenarioCaptureRecords.ts index 59e7294293..243231b708 100644 --- a/src/domain/analysisView/scenarioCaptureRecords.ts +++ b/src/domain/analysisView/scenarioCaptureRecords.ts @@ -51,10 +51,16 @@ const headerValue = ( name: string, ): string | undefined => { if (headers?.state !== "captured") return undefined; - const item = headers.items.find( - (candidate) => candidate.name.toLowerCase() === name, - ); - return item?.value ?? undefined; + let value: string | undefined; + for (const item of headers.items) { + if (item.name.toLowerCase() !== name) continue; + // A singleton field has no certain value if declarations disagree or one + // was excluded. Never let ordering choose an interpretation of Evidence. + if (item.value === null || (value !== undefined && value !== item.value)) + return undefined; + value = item.value; + } + return value; }; const captured = ( diff --git a/src/domain/javascript/javascriptSemanticGraphSchemas.ts b/src/domain/javascript/javascriptSemanticGraphSchemas.ts index 686f0b4822..78abf10781 100644 --- a/src/domain/javascript/javascriptSemanticGraphSchemas.ts +++ b/src/domain/javascript/javascriptSemanticGraphSchemas.ts @@ -8,8 +8,7 @@ import { applicationGraphEvidenceSchema, } from "./javascriptApplicationEvidenceSchemas.js"; import { jsonValueSchema } from "../jsonValue.js"; -import { digestSchema } from "../digests.js"; -import { prefixedDigestSchema } from "../digests.js"; +import { digestSchema, prefixedDigestSchema } from "../digests.js"; const boundedTextSchema = z.string().min(1); const semanticNodeIdSchema = prefixedDigestSchema("jsrg_node"); diff --git a/src/hopper/HopperCallerReconciliation.test.ts b/src/hopper/HopperCallerReconciliation.test.ts index 615914d37b..fbf9cc7dbc 100644 --- a/src/hopper/HopperCallerReconciliation.test.ts +++ b/src/hopper/HopperCallerReconciliation.test.ts @@ -3,7 +3,10 @@ import { err, ok } from "../domain/result.js"; import type { HopperClient } from "./HopperClient.js"; import { reconcileHopperCallers } from "./HopperCallerReconciliation.js"; import { AnalysisWorkScope } from "../application/AnalysisWorkScope.js"; -import { formatHopperCallerScanReason } from "../domain/native/hopperCallerReconciliation.js"; +import { + formatHopperCallerScanReason, + HOPPER_CALLER_SCAN_RETENTION_BYTES, +} from "../domain/native/hopperCallerReconciliation.js"; import { hopperCallerScanBudgetForMilliseconds, hopperCallerScanRecovery, @@ -12,7 +15,6 @@ import { AnalysisOutputError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; -import { HOPPER_CALLER_SCAN_RETENTION_BYTES } from "../domain/native/hopperCallerReconciliation.js"; const identity = (address: string) => ({ address, diff --git a/src/server/McpInputCapacity.test.ts b/src/server/McpInputCapacity.test.ts index 9ab1afd14b..a4d8678b02 100644 --- a/src/server/McpInputCapacity.test.ts +++ b/src/server/McpInputCapacity.test.ts @@ -14,7 +14,7 @@ const fixture = async (maximum?: number, element: z.ZodType = z.unknown()) => { const canonical = z.strictObject({ values: z.array(element) }); const server = new EvidenceMcpServerCore( { name: "input-capacity-fixture", version: "1" }, - { ...(maximum === undefined ? {} : { maxToolInputElements: maximum }) }, + maximum === undefined ? {} : { maxToolInputElements: maximum }, undefined, createToolResultDelivery(16_384), ); diff --git a/tests/boundary/cli/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts index 40b1299153..0d0401ef4f 100644 --- a/tests/boundary/cli/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -6,7 +6,10 @@ import { describe, expect, it } from "vitest"; import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; -import { normalizeCliArguments } from "../../../src/cliBuiltinArguments.js"; +import { + normalizeCliArguments, + adaptCliOutputArgumentsForIncur, +} from "../../../src/cliBuiltinArguments.js"; import { renderCliOutputArgumentError, renderCliDocument, @@ -14,7 +17,6 @@ import { renderVersionReport, parseCliOutputArguments, } from "../../../src/cliOutput.js"; -import { adaptCliOutputArgumentsForIncur } from "../../../src/cliBuiltinArguments.js"; const CLI_INTEGRATION_TIMEOUT_MS = 60_000; diff --git a/tests/boundary/cli/scenarioManifestCoverage.test.ts b/tests/boundary/cli/scenarioManifestCoverage.test.ts index 38d60315e1..09c6df079a 100644 --- a/tests/boundary/cli/scenarioManifestCoverage.test.ts +++ b/tests/boundary/cli/scenarioManifestCoverage.test.ts @@ -125,3 +125,59 @@ it.each(["absent", "redacted"] as const)( ]); }, ); + +it("does not derive a tool from conflicting header-only MIME evidence through the CLI handler", async () => { + for (const values of [ + ["application/json", "application/octet-stream"], + ["application/octet-stream", "application/json"], + ["application/json", null], + ["application/json", "application/json"], + ]) { + const capture = scenarioCaptureFixture([ + { + method: "GET", + url: "/download", + status: 200, + responseHeaders: values.map((value) => ({ + name: "Content-Type", + value, + })), + }, + ]); + const cli = Cli.create("scenario-mime", { sync: false }); + registerAnalysisViewCommands(cli, silentLogger); + let stdout = ""; + let exitCode = 0; + await cli.serve( + [ + "inspect-analysis-view", + JSON.stringify({ + source: { + kind: "inline", + evidence: scenarioCaptureEvidence(capture), + }, + view: { kind: "environment-manifest" }, + }), + "--json", + ], + { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: (code) => { + exitCode = code; + }, + }, + ); + expect(exitCode, stdout).toBe(0); + const result = ( + JSON.parse(stdout) as { + normalized_result: { tools: unknown[] }; + } + ).normalized_result; + expect(result.tools, JSON.stringify(values)).toHaveLength( + values.every((value) => value === "application/json") ? 1 : 0, + ); + } +}); diff --git a/tests/boundary/mcp/flutterMcp.test.ts b/tests/boundary/mcp/flutterMcp.test.ts index 1d005b0be2..f5336230e1 100644 --- a/tests/boundary/mcp/flutterMcp.test.ts +++ b/tests/boundary/mcp/flutterMcp.test.ts @@ -245,14 +245,14 @@ it("advertises and enforces the same Unicode selector grammar through MCP", asyn ).isError, ).not.toBe(true); } - for (const abi of [ - "", - "lib/x86_64", - "😀".repeat(1011), + for (const [abi, reason] of [ + ["", "out_of_range"], + ["lib/x86_64", "invalid_format"], + ["😀".repeat(1011), "invalid_value"], ...["\n", "\r", "\u2028", "\u2029"].map( - (terminator) => "l".repeat(1010) + terminator, + (terminator) => ["l".repeat(1010) + terminator, "invalid_value"] as const, ), - ]) { + ] as const) { const arguments_ = { path: "/fixture.apk", abi }; expect(ajv.validate(tool.inputSchema, arguments_)).toBe(false); const refused = await harness.client.callTool({ @@ -265,10 +265,11 @@ it("advertises and enforces the same Unicode selector grammar through MCP", asyn details: { operation: "inspect_dart_aot", issues: [ - { + expect.objectContaining({ path: ["abi"], + reason, message: ABI_SELECTOR_MESSAGE, - }, + }), ], }, }); diff --git a/tests/boundary/mcp/gdbRetirementCancellation.test.ts b/tests/boundary/mcp/gdbRetirementCancellation.test.ts index 1959318672..54e0e41c43 100644 --- a/tests/boundary/mcp/gdbRetirementCancellation.test.ts +++ b/tests/boundary/mcp/gdbRetirementCancellation.test.ts @@ -7,8 +7,7 @@ import { import { createToolResultDelivery } from "../../../src/server/toolResult.js"; import { silentLogger } from "../../../src/logger.js"; import { createEvidence } from "../../../src/domain/evidence.js"; -import { ok } from "../../../src/domain/result.js"; -import { err } from "../../../src/domain/result.js"; +import { ok, err } from "../../../src/domain/result.js"; import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; import { primaryAnalysisError } from "../../../src/domain/analysisErrorBase.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; diff --git a/tests/boundary/mcp/sessionMcpHarness.ts b/tests/boundary/mcp/sessionMcpHarness.ts index c4e1ee7b97..f718990681 100644 --- a/tests/boundary/mcp/sessionMcpHarness.ts +++ b/tests/boundary/mcp/sessionMcpHarness.ts @@ -10,7 +10,10 @@ import { z } from "zod"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { createEvidence } from "../../../src/domain/evidence.js"; -import type { ProviderAvailability } from "../../../src/application/AnalysisProvider.js"; +import type { + ProviderAvailability, + AnalysisProvider, +} from "../../../src/application/AnalysisProvider.js"; import type { AndroidAnalysisPort } from "../../../src/application/android/AndroidAnalysisPort.js"; import type { AdbDeviceAnalysisPort } from "../../../src/application/adb/AdbDeviceAnalysisPort.js"; import type { ApktoolResourceAnalysisPort } from "../../../src/application/apktool/ApktoolResourceAnalysisPort.js"; @@ -21,7 +24,6 @@ import { import { ok } from "../../../src/domain/result.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; -import type { AnalysisProvider } from "../../../src/application/AnalysisProvider.js"; const SNAPSHOT_PROFILE = createAnalysisProfile( { id: "fixture", name: "Fixture analysis provider", version: "1" }, diff --git a/tests/composition/analysis-sessions/backwardCallerEvidence.test.ts b/tests/composition/analysis-sessions/backwardCallerEvidence.test.ts index 842720e1da..86ffa90992 100644 --- a/tests/composition/analysis-sessions/backwardCallerEvidence.test.ts +++ b/tests/composition/analysis-sessions/backwardCallerEvidence.test.ts @@ -1,7 +1,9 @@ import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; import type { AnalysisProvider } from "../../../src/application/AnalysisProvider.js"; -import { executeComposedWorkflow } from "../../../src/application/WorkflowEvidence.js"; -import { createWorkflowEvidence } from "../../../src/application/WorkflowEvidence.js"; +import { + executeComposedWorkflow, + createWorkflowEvidence, +} from "../../../src/application/WorkflowEvidence.js"; import { EnhancedTools } from "../../../src/application/EnhancedTools.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { ok } from "../../../src/domain/result.js";