Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
579ecbd032 | ||
|
|
4758ba30b6 |
@@ -8,4 +8,10 @@ export function assertLinuxPackageProviderFailure(execution: {
|
||||
export function verifyPackagePlatform(input: {
|
||||
readonly cli: string;
|
||||
readonly environment: Readonly<Record<string, string | undefined>>;
|
||||
readonly platform?: string;
|
||||
readonly runCommand?: (
|
||||
command: string,
|
||||
arguments_: readonly string[],
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
) => Promise<string>;
|
||||
}): Promise<void>;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { json, run, runWithStatus } from "./lib/verify-package-core.mjs";
|
||||
|
||||
/** Run platform-specific deep-analysis smoke tests. */
|
||||
export async function verifyPackagePlatform({ cli, environment }) {
|
||||
if (process.platform === "linux") {
|
||||
export async function verifyPackagePlatform({
|
||||
cli,
|
||||
environment,
|
||||
platform = process.platform,
|
||||
runCommand = run,
|
||||
}) {
|
||||
if (platform === "linux") {
|
||||
const unsupportedExecution = await runWithStatus(
|
||||
cli,
|
||||
["analyze", process.execPath, "--json"],
|
||||
@@ -11,25 +16,27 @@ export async function verifyPackagePlatform({ cli, environment }) {
|
||||
assertLinuxPackageProviderFailure(unsupportedExecution);
|
||||
} else {
|
||||
const overview = json(
|
||||
await run(cli, ["analyze", process.execPath, "--json"], environment),
|
||||
await runCommand(
|
||||
cli,
|
||||
["analyze", process.execPath, "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (
|
||||
overview.operation !== "binary_overview" ||
|
||||
overview.normalized_result?.procedure_count < 1
|
||||
)
|
||||
if (!hasPositiveProcedureCount(overview))
|
||||
throw new Error(
|
||||
`packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
|
||||
);
|
||||
const inspected = json(
|
||||
await run(cli, ["inspect", process.execPath, "--json"], environment),
|
||||
await runCommand(
|
||||
cli,
|
||||
["inspect", process.execPath, "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (
|
||||
inspected.operation !== "binary_overview" ||
|
||||
inspected.normalized_result?.procedure_count < 1
|
||||
)
|
||||
if (!hasPositiveProcedureCount(inspected))
|
||||
throw new Error("packaged inspect CLI failed");
|
||||
const functionResult = json(
|
||||
await run(
|
||||
await runCommand(
|
||||
cli,
|
||||
["function", process.execPath, "0x1000", "--json"],
|
||||
environment,
|
||||
@@ -44,7 +51,7 @@ export async function verifyPackagePlatform({ cli, environment }) {
|
||||
`packaged function CLI failed: ${JSON.stringify(functionResult)}`,
|
||||
);
|
||||
const xrefs = json(
|
||||
await run(
|
||||
await runCommand(
|
||||
cli,
|
||||
["xrefs", process.execPath, "0x1000", "--json"],
|
||||
environment,
|
||||
@@ -56,7 +63,7 @@ export async function verifyPackagePlatform({ cli, environment }) {
|
||||
)
|
||||
throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`);
|
||||
const trace = json(
|
||||
await run(
|
||||
await runCommand(
|
||||
cli,
|
||||
["trace", process.execPath, "fixture", "--json"],
|
||||
environment,
|
||||
@@ -93,3 +100,10 @@ export const assertLinuxPackageProviderFailure = (execution) => {
|
||||
`packaged Linux Hopper verification did not fail closed: ${JSON.stringify(failure)}`,
|
||||
);
|
||||
};
|
||||
|
||||
const hasPositiveProcedureCount = (result) =>
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
result.operation === "binary_overview" &&
|
||||
Number.isInteger(result.normalized_result?.procedure_count) &&
|
||||
result.normalized_result.procedure_count > 0;
|
||||
|
||||
@@ -43,10 +43,7 @@ import {
|
||||
type WebScreenshotDiff,
|
||||
} from "../domain/webScreenshot.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
|
||||
@@ -167,8 +164,6 @@ export class CdpBrowserProvider implements BrowserObservationPort {
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (options.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("analyze_web_bundle", { cause }));
|
||||
return err(providerError(cause, "analyze_web_bundle"));
|
||||
}
|
||||
}
|
||||
@@ -340,10 +335,6 @@ const withTargetSession = async <
|
||||
);
|
||||
// An unadopted browser socket is closed here; closing is idempotent.
|
||||
await endpoint.connection?.close();
|
||||
// Cleanup can observe an interrupt after work returns. Do not publish its
|
||||
// result once cancellation arrived while the target session was closing.
|
||||
if (options.signal?.aborted === true)
|
||||
throw new AnalysisCancelledError(operation);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TOOL_CONTRACTS } from "./toolContracts.js";
|
||||
import { TOOL_EFFECTS, annotationsFromEffects } from "./toolEffects.js";
|
||||
import {
|
||||
TOOL_EFFECTS,
|
||||
annotationsFromEffects,
|
||||
type ToolEffects,
|
||||
} from "./toolEffects.js";
|
||||
|
||||
/**
|
||||
* Descriptions that name running, launching, executing, starting, installing,
|
||||
@@ -148,8 +152,19 @@ describe("tool effects", () => {
|
||||
});
|
||||
|
||||
it("derives destructiveHint from each destructive effect alone", () => {
|
||||
const base = TOOL_EFFECTS["binary_session"];
|
||||
if (base === undefined) throw new Error("binary_session is unaudited");
|
||||
// Keep annotation algebra independent of any operation's audited effects.
|
||||
const base: ToolEffects = {
|
||||
mutatesTarget: false,
|
||||
mutatesSession: false,
|
||||
writesFilesystem: false,
|
||||
launchesProcess: false,
|
||||
accessesNetwork: false,
|
||||
changesUiState: false,
|
||||
mayDiscardData: false,
|
||||
executesForeignCode: false,
|
||||
mutatesDevice: false,
|
||||
idempotent: true,
|
||||
};
|
||||
expect(annotationsFromEffects(base)).toEqual({
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
|
||||
@@ -605,7 +605,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
mayDiscardData: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
binary_session: effects(),
|
||||
binary_session: effects({ launchesProcess: true }),
|
||||
export_evidence_bundle: effects({
|
||||
writesFilesystem: true,
|
||||
mayDiscardData: true,
|
||||
|
||||
@@ -7,205 +7,12 @@ import {
|
||||
} from "../../../src/domain/browserObservation.js";
|
||||
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
|
||||
import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js";
|
||||
import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js";
|
||||
import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js";
|
||||
import {
|
||||
startFakeCdpBrowser,
|
||||
type FakeCdpBrowser,
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { trackBrowser } from "./cdpBrowserProvider.support.js";
|
||||
|
||||
const waitFor = async (ready: () => boolean, description: string) => {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!ready()) {
|
||||
if (Date.now() > deadline) throw new Error(description);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
|
||||
describe("CdpBrowserProvider: cancellation during cleanup", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "WebMCP discovery",
|
||||
operation: "discover_webmcp_tools",
|
||||
invoke: (
|
||||
provider: CdpBrowserProvider,
|
||||
browser: FakeCdpBrowser,
|
||||
signal: AbortSignal,
|
||||
) =>
|
||||
provider.discoverWebMcpTools(
|
||||
discoverWebMcpToolsInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 0,
|
||||
}),
|
||||
{ signal },
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "screenshot capture",
|
||||
operation: "capture_web_screenshot",
|
||||
invoke: (
|
||||
provider: CdpBrowserProvider,
|
||||
browser: FakeCdpBrowser,
|
||||
signal: AbortSignal,
|
||||
) =>
|
||||
provider.captureScreenshot(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
}),
|
||||
{ signal },
|
||||
),
|
||||
},
|
||||
])(
|
||||
"reports cancellation during $name cleanup",
|
||||
async ({ operation, invoke }) => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
hangOnMethod: "Page.disable",
|
||||
webMcpTools: true,
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const controller = new AbortController();
|
||||
const pending = invoke(
|
||||
new CdpBrowserProvider(),
|
||||
browser,
|
||||
controller.signal,
|
||||
);
|
||||
await waitFor(
|
||||
() => browser.commands.some(({ method }) => method === "Page.disable"),
|
||||
"page cleanup did not start",
|
||||
);
|
||||
controller.abort();
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError", operation },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reports cancellation when an interrupt arrives during page cleanup", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
hangOnMethod: "Network.disable",
|
||||
});
|
||||
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: 0,
|
||||
}),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await waitFor(
|
||||
() => browser.commands.some(({ method }) => method === "Network.disable"),
|
||||
"page cleanup did not start",
|
||||
);
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "inspect_web_page",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports cancellation when an interrupt arrives during session cleanup", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
hangOnMethod: "Network.disable",
|
||||
});
|
||||
trackBrowser(browser);
|
||||
const controller = new AbortController();
|
||||
const pending = new CdpBrowserProvider().observeSession(
|
||||
observeWebSessionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 1,
|
||||
}),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await waitFor(
|
||||
() => browser.commands.some(({ method }) => method === "Network.disable"),
|
||||
"session cleanup did not start",
|
||||
);
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "observe_web_session",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports cancellation when an interrupt arrives during source-map download", async () => {
|
||||
const browser = await startFakeCdpBrowser({ hangSourceMap: true });
|
||||
trackBrowser(browser);
|
||||
const controller = new AbortController();
|
||||
const pending = new CdpBrowserProvider().analyzeBundle(
|
||||
analyzeWebBundleInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 0,
|
||||
fetch_source_maps: true,
|
||||
}),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await waitFor(
|
||||
() =>
|
||||
browser.httpRequests.some(({ url }) => url.startsWith("/app.js.map")),
|
||||
"source-map download did not start",
|
||||
);
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "analyze_web_bundle",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies custom source-map abort reasons as cancellation", async () => {
|
||||
const browser = await startFakeCdpBrowser({ hangSourceMap: true });
|
||||
trackBrowser(browser);
|
||||
const controller = new AbortController();
|
||||
const pending = new CdpBrowserProvider().analyzeBundle(
|
||||
analyzeWebBundleInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 0,
|
||||
fetch_source_maps: true,
|
||||
}),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await waitFor(
|
||||
() =>
|
||||
browser.httpRequests.some(({ url }) => url.startsWith("/app.js.map")),
|
||||
"source-map download did not start",
|
||||
);
|
||||
controller.abort(new Error("stopped"));
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "analyze_web_bundle",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CdpBrowserProvider: cancellation and session events", () => {
|
||||
it("honors cancellation during the final progress callback before publishing inspection", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
|
||||
@@ -73,13 +73,7 @@ it("releases cleanup when cancellation arrives after capture completes", async (
|
||||
});
|
||||
controller.abort();
|
||||
const result = await pending;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "observe_web_session",
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(browser.commands.map(({ method }) => method)).toContain(
|
||||
"Target.detachFromTarget",
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { assertLinuxPackageProviderFailure } from "../../../scripts/verify-package-platform.mjs";
|
||||
import {
|
||||
assertLinuxPackageProviderFailure,
|
||||
verifyPackagePlatform,
|
||||
} from "../../../scripts/verify-package-platform.mjs";
|
||||
|
||||
const missingDisplay = {
|
||||
code: "provider_unavailable",
|
||||
@@ -22,6 +25,37 @@ const execution = (failure: unknown, status = 1) => ({
|
||||
stdout: JSON.stringify(failure),
|
||||
});
|
||||
|
||||
const overview = (procedure_count?: unknown, operation = "binary_overview") => ({
|
||||
operation,
|
||||
...(procedure_count === undefined
|
||||
? {}
|
||||
: { normalized_result: { procedure_count } }),
|
||||
});
|
||||
|
||||
const verifyMacOS = async (
|
||||
analyze: unknown,
|
||||
inspect: unknown,
|
||||
): Promise<void> => {
|
||||
const outputs: Readonly<Record<string, unknown>> = {
|
||||
analyze,
|
||||
inspect,
|
||||
function: {
|
||||
operation: "analyze_function",
|
||||
provider: { id: "hopper" },
|
||||
normalized_result: { procedure: { address: "0x1000" } },
|
||||
},
|
||||
xrefs: { operation: "xrefs", normalized_result: ["0x1000"] },
|
||||
trace: { operation: "trace_feature" },
|
||||
};
|
||||
await verifyPackagePlatform({
|
||||
cli: "fixture-cli",
|
||||
environment: {},
|
||||
platform: "darwin",
|
||||
runCommand: async (_cli, arguments_) =>
|
||||
JSON.stringify(outputs[arguments_[0]!]) ?? "",
|
||||
});
|
||||
};
|
||||
|
||||
describe("packaged Linux failure verification", () => {
|
||||
it("accepts the unsupported fixture build or an exact missing display dependency", () => {
|
||||
expect(() =>
|
||||
@@ -71,3 +105,29 @@ describe("packaged Linux failure verification", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("packaged analyze and inspect result guards", () => {
|
||||
it("accepts positive integer procedure counts for both commands", async () => {
|
||||
await verifyMacOS(overview(1), overview(2));
|
||||
});
|
||||
|
||||
const malformed = [
|
||||
["missing result", undefined],
|
||||
["null result", null],
|
||||
["missing normalized count", overview()],
|
||||
["zero count", overview(0)],
|
||||
["non-integer count", overview(1.5)],
|
||||
["wrong operation", overview(2, "other_operation")],
|
||||
] as const;
|
||||
|
||||
it.each(["analyze", "inspect"] as const)(
|
||||
"rejects malformed %s results before trusting the package",
|
||||
async (command) => {
|
||||
for (const [label, invalid] of malformed) {
|
||||
const analyze = command === "analyze" ? invalid : overview(4);
|
||||
const inspect = command === "inspect" ? invalid : overview(4);
|
||||
await expect(verifyMacOS(analyze, inspect), label).rejects.toThrow();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Vendored
-5
@@ -229,11 +229,6 @@ const serveSourceMap = (
|
||||
response: ServerResponse,
|
||||
options: FakeOptions,
|
||||
): boolean => {
|
||||
if (
|
||||
options.hangSourceMap === true &&
|
||||
request.url?.startsWith("/app.js.map") === true
|
||||
)
|
||||
return true;
|
||||
if (options.sourceMapBody === undefined) return false;
|
||||
if (request.url?.startsWith("/app.js.map") === true) {
|
||||
if (options.sourceMapRedirect !== undefined)
|
||||
|
||||
-2
@@ -95,8 +95,6 @@ export interface FakeOptions {
|
||||
readonly webSocketWithoutFrames?: boolean;
|
||||
readonly invalidBinaryWebSocketEvent?: boolean;
|
||||
readonly sourceMapBody?: string;
|
||||
/** Accept a source-map request and leave the response open. */
|
||||
readonly hangSourceMap?: boolean;
|
||||
readonly sourceMapRedirect?: string;
|
||||
readonly sessionTimeline?:
|
||||
| "same_origin"
|
||||
|
||||
Reference in New Issue
Block a user