fix(cli): reject empty observation duration selections (#7710)

This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-10-28 12:57:14 +08:00
1 parent c36971953c
commit fbb0e79354
3 files changed
+148 -5

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
import { Parser, Schema, z } from "incur";
import { expect, it } from "vitest";
import { observationDuration } from "./cliObservationOptions.js";
const options = z.object({ observationMs: observationDuration(500) });
it.each(["", " ", "\t"])(
"rejects an empty selected duration before SDK numeric coercion (%j)",
(value) => {
for (const argv of [
[`--observation-ms=${value}`],
["--observation-ms", value],
])
expect(() => Parser.parse(argv, { options })).toThrow();
},
);
it.each([
{ argv: [], expected: 500 },
{ argv: ["--observation-ms=0"], expected: 0 },
{ argv: ["--observation-ms", "0x10"], expected: 16 },
{ argv: ["--observation-ms", "1e3"], expected: 1000 },
{ argv: ["--observation-ms", " 16 "], expected: 16 },
])("preserves caller selection and omission ($argv)", ({ argv, expected }) => {
expect(Parser.parse(argv, { options }).options.observationMs).toBe(expected);
});
it.each(["-1", "0.5", "NaN", "Infinity"])(
"keeps integer and range validation (%s)",
(value) => {
expect(() =>
Parser.parse(["--observation-ms", value], { options }),
).toThrow();
},
);
it("advertises numeric bounds, a nonblank text branch and a valid numeric default", () => {
expect(Schema.toJsonSchema(options)).toMatchObject({
properties: {
observationMs: {
default: 500,
anyOf: [
{ type: "integer", minimum: 0 },
{ type: "string", pattern: "\\S" },
],
},
},
});
});
+20 -5
View File
@@ -5,13 +5,28 @@ import { javaScriptAnalysisResourceControlsSchema } from "./domain/javascript/ja
import { javaScriptSourcePathsSchema } from "./domain/javascript/javascriptSourceSelection.js";
/** Accept an agent-selected browser observation duration without an artificial ceiling. */
export const observationDuration = (fallback: number, minimum = 0) =>
z
.number()
.int()
.min(minimum)
export const observationDuration = (fallback: number, minimum = 0) => {
const duration = z.number().int().min(minimum);
// Keep argv text until its presence is checked. Incur eagerly converts a
// ZodNumber field with Number(), which turns empty/blank selections into 0.
const selected = z
.union([
duration,
z
.string()
.regex(/\S/u, "Expected a nonempty observation duration")
.pipe(z.coerce.number<string>())
.pipe(duration),
])
.default(fallback)
.describe("Observation duration in milliseconds");
// Incur advertises Zod's output view for CLI inputs. Publish this field's
// input view so the lexical guard survives the SDK conversion as well.
const { $schema: _dialect, ...inputSchema } = z.toJSONSchema(selected, {
io: "input",
});
return selected.meta(inputSchema);
};
/** Shared passive browser origin options. */
export const browserScopeOptions = {
@@ -0,0 +1,79 @@
import { expect } from "vitest";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"rejects empty observation selections before connecting to a browser",
async ({ cli }) => {
for (const selection of [["--observation-ms="], ["--observation-ms", ""]]) {
const result = await cli.run({
arguments: [
"inspect-web-page",
"http://127.0.0.1:9",
"page",
...selection,
"--json",
],
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: expect.arrayContaining([
expect.objectContaining({ path: "observationMs" }),
]),
});
}
},
);
cliTest(
"preserves the default capture window and explicit zero through CDP",
async ({ cli }) => {
const browser = await startFakeCdpBrowser();
try {
for (const [selection, expected] of [
[[], 500],
[["--observation-ms=0"], 0],
] as const) {
const result = await cli.run({
arguments: [
"inspect-web-page",
browser.endpoint,
"allowed-page",
...selection,
"--json",
],
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.json).toMatchObject({
normalized_result: { capture_window: { observation_ms: expected } },
});
}
} finally {
await browser.close();
}
},
);
cliTest(
"advertises the lexical guard after SDK conversion",
async ({ cli }) => {
const result = await cli.run({
arguments: ["inspect-web-page", "--schema", "--json"],
});
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
options: {
properties: {
observationMs: {
default: 500,
anyOf: [
{ type: "integer", minimum: 0 },
{ type: "string", pattern: "\\S" },
],
},
},
},
});
},
);