Files
rea/tests/boundary/cli/cliInputSchemaRequired.test.ts
Cursor Agentandmorluto 5eebe2fdf8 test(cli): treat omitted exactOptional inputs as optional
The advertised-schema check parsed undefined on each field.
exactOptional rejects that value and still accepts a missing key, so
inspect-wasm-artifact looked required for commandTimeoutMs.

Judge omission with an object parse that leaves the key out. The
advertised required list already matched that parse for every other
primary command.

Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-21 18:14:39 +08:00

296 lines
11 KiB
TypeScript

import { Ajv2020 } from "ajv/dist/2020.js";
import { Cli, z } from "incur";
import { describe, expect, it } from "vitest";
import { createCli } from "../../../src/cli.js";
import { createCliInventory } from "../../../scripts/lib/catalog-cli.mjs";
import {
apktoolInputSchemas,
MAX_SELECTED_LOCALES,
} from "../../../src/domain/apktool/apktoolResourceAnalysis.js";
type CliInstance = ReturnType<typeof createCli>;
type CommandMap = NonNullable<ReturnType<typeof Cli.toCommands.get>>;
const INPUT_GROUPS = ["args", "options"] as const;
const registeredCommands: WeakMap<object, CommandMap> = Cli.toCommands;
const advertisedGroupSchema = z
.object({
properties: z.record(z.string(), z.unknown()),
required: z.array(z.string()).optional(),
})
.loose();
const advertisedInputSchema = z.object({
args: advertisedGroupSchema.optional(),
options: advertisedGroupSchema.optional(),
});
const serve = async (
cli: CliInstance,
argv: readonly string[],
): Promise<{ readonly stdout: string; readonly exitCode: number }> => {
let stdout = "";
let exitCode = 0;
await cli.serve([...argv], {
env: {},
exit: (code) => {
exitCode = code;
},
stdout: (text) => {
stdout += text;
},
});
return { stdout, exitCode };
};
const advertisedSchema = async (cli: CliInstance, command: string) => {
const result = await serve(cli, [command, "--schema", "--json"]);
expect(result.exitCode, command).toBe(0);
return advertisedInputSchema.parse(JSON.parse(result.stdout));
};
const parserSchema = (
cli: CliInstance,
command: string,
group: (typeof INPUT_GROUPS)[number],
): z.ZodObject | undefined => {
const entry = registeredCommands.get(cli)?.get(command);
if (entry === undefined || !(group in entry)) return undefined;
const schema: unknown = Reflect.get(entry, group);
return schema instanceof z.ZodObject ? schema : undefined;
};
/**
* Reject a key only when an object parse that omits it fails.
* `exactOptional()` rejects explicit `undefined` and still accepts omission.
*/
const omissionRejected = (schema: z.ZodObject | undefined): string[] =>
Object.entries(schema?.shape ?? {})
.filter(([key, field]) => !acceptsOmittedKey(key, field))
.map(([key]) => key)
.sort();
const acceptsOmittedKey = (key: string, field: z.ZodType): boolean =>
z.object({ [key]: field }).safeParse({}).success;
describe("CLI advertised input schemas", () => {
it("require exactly the inputs whose omission the parser rejects", async () => {
const cli = createCli({});
const mismatches: string[] = [];
for (const command of createCliInventory(cli).primary) {
const advertised = await advertisedSchema(cli, command);
for (const group of INPUT_GROUPS) {
const parser = parserSchema(cli, command, group);
const advertisedGroup = advertised[group];
if (parser === undefined) {
if (advertisedGroup !== undefined)
mismatches.push(`${command} ${group}: advertised without parser`);
continue;
}
if (advertisedGroup === undefined) {
mismatches.push(`${command} ${group}: parser group not advertised`);
continue;
}
const parserProperties = Object.keys(parser.shape).toSorted();
const advertisedProperties = Object.keys(
advertisedGroup.properties,
).toSorted();
if (advertisedProperties.join() !== parserProperties.join())
mismatches.push(
`${command} ${group}: advertised properties [${advertisedProperties.join()}], parser properties [${parserProperties.join()}]`,
);
const required = (advertisedGroup.required ?? []).toSorted();
const rejected = omissionRejected(parser);
if (required.join() !== rejected.join())
mismatches.push(
`${command} ${group}: advertised [${required.join()}], parser rejects [${rejected.join()}]`,
);
}
}
expect(mismatches).toEqual([]);
});
it("allows an omitted WASM timeout while validating explicitly supplied values", async () => {
const cli = createCli({});
const advertised = await advertisedSchema(cli, "inspect-wasm-artifact");
const options = parserSchema(cli, "inspect-wasm-artifact", "options");
expect(options).toBeDefined();
const validate = new Ajv2020({ strict: false }).compile(
advertised.options ?? {},
);
expect(options?.safeParse({}).success).toBe(true);
expect(validate({})).toBe(true);
expect(options?.safeParse({ commandTimeoutMs: undefined }).success).toBe(
false,
);
for (const commandTimeoutMs of [1, 30_000, 2_147_483_647]) {
expect(options?.safeParse({ commandTimeoutMs }).success).toBe(true);
expect(validate({ commandTimeoutMs })).toBe(true);
}
for (const commandTimeoutMs of [0, 1.5, 2_147_483_648]) {
expect(options?.safeParse({ commandTimeoutMs }).success).toBe(false);
expect(validate({ commandTimeoutMs })).toBe(false);
}
});
it("accepts the one-argument keyed-archive call and keeps its constraints", async () => {
const cli = createCli({});
const advertised = await advertisedSchema(cli, "inspect-keyed-archive");
const ajv = new Ajv2020({ strict: false });
const args = ajv.compile(advertised.args ?? {});
const options = ajv.compile(advertised.options ?? {});
expect(args({ path: "./keyed.plist" })).toBe(true);
expect(args({})).toBe(false);
expect(options({})).toBe(true);
expect(options({ limit: 0 })).toBe(false);
expect(advertised.args?.properties?.["archive"]).toMatchObject({
default: ".",
});
expect(advertised.options?.properties?.["limit"]).toMatchObject({
default: 20_000,
minimum: 1,
});
expect(
parserSchema(cli, "inspect-keyed-archive", "args")?.parse({ path: "x" }),
).toEqual({ path: "x", archive: "." });
const help = await serve(cli, ["inspect-keyed-archive", "--help"]);
expect(help.stdout).toContain(
"Usage: rea inspect-keyed-archive <path> [archive] [options]",
);
expect(help.stdout).toContain(
"archive Relative archive path when the target is a bundle",
);
});
});
describe("firmware CLI advertised input schema", () => {
it("keeps limits and defaults from its shared input contract", async () => {
const cli = createCli({});
const advertised = await advertisedSchema(cli, "extract-firmware");
const ajv = new Ajv2020({ strict: false });
const args = ajv.compile(advertised.args ?? {});
const options = ajv.compile(advertised.options ?? {});
expect(args({ path: "firmware.bin", outputDirectory: "output" })).toBe(
true,
);
expect(args({ path: "", outputDirectory: "output" })).toBe(false);
expect(args({ path: "firmware.bin", outputDirectory: "" })).toBe(false);
expect(options({})).toBe(true);
expect(options({ maxDepth: 0 })).toBe(false);
expect(options({ maxDepth: 11 })).toBe(false);
expect(options({ maxOutputBytes: 0 })).toBe(false);
expect(options({ maxOutputBytes: 512 * 1024 * 1024 + 1 })).toBe(false);
expect(options({ maxOutputFiles: 100_001 })).toBe(false);
expect(advertised.options?.properties?.["maxDepth"]).toMatchObject({
default: 3,
});
expect(advertised.options?.properties?.["maxOutputBytes"]).toMatchObject({
default: 256 * 1024 * 1024,
});
expect(advertised.options?.properties?.["maxOutputFiles"]).toMatchObject({
default: 10_000,
});
const maxOffset = Number.MAX_SAFE_INTEGER;
expect(options({ offset: maxOffset + 1 })).toBe(false);
expect(options({ length: maxOffset + 1 })).toBe(false);
expect(
parserSchema(cli, "extract-firmware", "options")?.parse({}),
).toMatchObject({
maxDepth: 3,
maxOutputBytes: 256 * 1024 * 1024,
maxOutputFiles: 10_000,
});
});
});
describe("web module CLI advertised input schema", () => {
it("advertises the shared path and URL constraints", async () => {
const cli = createCli({});
const advertised = await advertisedSchema(cli, "trace-web-module-imports");
const ajv = new Ajv2020({ strict: false });
const args = ajv.compile(advertised.args ?? {});
const options = ajv.compile(advertised.options ?? {});
expect(
args({ manifestPath: resolve("manifest.json"), scriptIndex: 0 }),
).toBe(true);
expect(args({ manifestPath: "", scriptIndex: 0 })).toBe(false);
expect(args({ manifestPath: "manifest\u0000.json", scriptIndex: 0 })).toBe(
false,
);
expect(args({ manifestPath: "./manifest.json", scriptIndex: -1 })).toBe(
false,
);
expect(options({})).toBe(true);
expect(options({ importerUrl: "" })).toBe(false);
expect(options({ importMapPath: "" })).toBe(false);
expect(options({ importMapPath: "map\u0000.json" })).toBe(false);
expect(options({ importMapBaseUrl: "" })).toBe(false);
});
});
describe("Apktool locale selection input schema", () => {
it("preserves the owner's array bounds through CLI advertisement and parsing", async () => {
const cli = createCli({});
const advertised = await advertisedSchema(cli, "decode-android-resources");
expect(advertised.options?.properties?.locales).toMatchObject({
type: "array",
minItems: 1,
maxItems: MAX_SELECTED_LOCALES,
});
const parser = parserSchema(cli, "decode-android-resources", "options");
const ajv = new Ajv2020({ strict: false });
const validate = ajv.compile({
...advertised.options,
$schema: "https://json-schema.org/draft/2020-12/schema",
});
for (const locales of [
undefined,
[],
["de"],
Array<string>(MAX_SELECTED_LOCALES).fill("de"),
Array<string>(MAX_SELECTED_LOCALES + 1).fill("de"),
["not a locale"],
]) {
const input = locales === undefined ? {} : { locales };
const expected = apktoolInputSchemas.decode_android_resources.safeParse({
path: "/targets/app.apk",
...input,
}).success;
expect(parser?.safeParse(input).success).toBe(expected);
expect(validate(input)).toBe(expected);
}
});
});
describe("reference-source outline-depth input schema", () => {
it("aligns advertised bounds with the parser's accepted values", async () => {
const cli = createCli({});
const advertised = await advertisedSchema(cli, "import-reference-source");
const outlineDepth = advertised.options?.properties?.outlineDepth;
expect(outlineDepth).toMatchObject({ type: "integer", minimum: 0 });
const parser = parserSchema(cli, "import-reference-source", "options");
expect(parser?.safeParse({ outlineDepth: -1 }).success).toBe(false);
expect(parser?.safeParse({ outlineDepth: 0 }).success).toBe(true);
expect(parser?.safeParse({ outlineDepth: 2 }).success).toBe(true);
const ajv = new Ajv2020({ strict: false });
const validate = ajv.compile({
...advertised.options,
$schema: "https://json-schema.org/draft/2020-12/schema",
});
expect(validate({ outlineDepth: -1 })).toBe(false);
expect(validate({ outlineDepth: 0 })).toBe(true);
expect(validate({ outlineDepth: 2 })).toBe(true);
});
});
import { resolve } from "node:path";