mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-04 16:03:37 +00:00
* test(config): add unit tests for mapZodIssueToConfigIssue * fix: remove unused ts-expect-error directive
437 lines
15 KiB
TypeScript
437 lines
15 KiB
TypeScript
// Unit tests for mapZodIssueToConfigIssue (config validation issue mapper).
|
|
import { describe, expect, it } from "vitest";
|
|
import { testing } from "./validation.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Basic path and message mapping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("mapZodIssueToConfigIssue", () => {
|
|
describe("basic path and message", () => {
|
|
it("maps a simple path and message", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "custom",
|
|
path: ["channels", "telegram", "botToken"],
|
|
message: "invalid bot token",
|
|
});
|
|
expect(result).toEqual({
|
|
path: "channels.telegram.botToken",
|
|
message: "invalid bot token",
|
|
});
|
|
});
|
|
|
|
it("defaults message to 'Invalid input' when message is missing", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "custom",
|
|
path: ["foo"],
|
|
});
|
|
expect(result.path).toBe("foo");
|
|
expect(result.message).toBe("Invalid input");
|
|
});
|
|
|
|
it("defaults message to 'Invalid input' when message is not a string", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "custom",
|
|
path: ["foo"],
|
|
message: 42,
|
|
});
|
|
expect(result.message).toBe("Invalid input");
|
|
});
|
|
|
|
it("handles empty path array", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "custom",
|
|
path: [],
|
|
message: "root error",
|
|
});
|
|
expect(result.path).toBe("");
|
|
});
|
|
|
|
it("handles non-array path", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "custom",
|
|
path: "scalar",
|
|
message: "bad path",
|
|
});
|
|
expect(result.path).toBe("");
|
|
});
|
|
|
|
it("includes numeric array-index segments in path", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "custom",
|
|
path: ["agents", "list", 0, "name"],
|
|
message: "missing name",
|
|
});
|
|
expect(result.path).toBe("agents.list.0.name");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Numeric bound hints (too_big / too_small)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("numeric bound hints", () => {
|
|
it("appends (maximum: N) for too_big inclusive with number origin", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "too_big",
|
|
path: ["timeoutMs"],
|
|
message: "Value is too big",
|
|
origin: "number",
|
|
maximum: 100,
|
|
inclusive: true,
|
|
});
|
|
expect(result.message).toMatch(/\(maximum: 100\)/);
|
|
});
|
|
|
|
it("appends (must be less than N) for too_big exclusive with number origin", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "too_big",
|
|
path: ["timeoutMs"],
|
|
message: "Value is too big",
|
|
origin: "number",
|
|
maximum: 100,
|
|
inclusive: false,
|
|
});
|
|
expect(result.message).toMatch(/\(must be less than 100\)/);
|
|
});
|
|
|
|
it("appends (minimum: N) for too_small inclusive with number origin", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "too_small",
|
|
path: ["maxTokens"],
|
|
message: "Value is too small",
|
|
origin: "number",
|
|
minimum: 1,
|
|
inclusive: true,
|
|
});
|
|
expect(result.message).toMatch(/\(minimum: 1\)/);
|
|
});
|
|
|
|
it("appends (must be greater than N) for too_small exclusive with number origin", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "too_small",
|
|
path: ["maxTokens"],
|
|
message: "Value is too small",
|
|
origin: "number",
|
|
minimum: 1,
|
|
inclusive: false,
|
|
});
|
|
expect(result.message).toMatch(/\(must be greater than 1\)/);
|
|
});
|
|
|
|
it("does not add bound hint when maximum is missing for too_big", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "too_big",
|
|
path: ["timeoutMs"],
|
|
message: "Value is too big",
|
|
origin: "number",
|
|
inclusive: true,
|
|
});
|
|
expect(result.message).toBe("Value is too big");
|
|
});
|
|
|
|
it("does not add bound hint when minimum is missing for too_small", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "too_small",
|
|
path: ["maxTokens"],
|
|
message: "Value is too small",
|
|
origin: "number",
|
|
inclusive: true,
|
|
});
|
|
expect(result.message).toBe("Value is too small");
|
|
});
|
|
|
|
it("does not add bound hint when origin is not 'number'", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "too_big",
|
|
path: ["strLength"],
|
|
message: "Value is too big",
|
|
origin: "string",
|
|
maximum: 10,
|
|
inclusive: true,
|
|
});
|
|
expect(result.message).toBe("Value is too big");
|
|
});
|
|
|
|
it("does not add bound hint for too_big with invalid inclusive default", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "too_big",
|
|
path: ["count"],
|
|
message: "Value is too big",
|
|
origin: "number",
|
|
maximum: 5,
|
|
// inclusive defaults to false when missing
|
|
});
|
|
expect(result.message).toMatch(/\(must be less than 5\)/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Allowed values collection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("allowed values", () => {
|
|
it("collects allowed values from invalid_value issue", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_value",
|
|
path: ["update", "channel"],
|
|
message: "Invalid enum value",
|
|
values: ["stable", "beta", "dev"],
|
|
});
|
|
expect(result).toMatchObject({
|
|
path: "update.channel",
|
|
message: expect.stringContaining("Invalid enum value"),
|
|
allowedValues: ["stable", "beta", "dev"],
|
|
});
|
|
});
|
|
|
|
it("collects boolean allowed values from invalid_type with expected boolean", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_type",
|
|
path: ["enabled"],
|
|
message: "Expected boolean, received string",
|
|
expected: "boolean",
|
|
});
|
|
expect(result).toMatchObject({
|
|
path: "enabled",
|
|
allowedValues: ["true", "false"],
|
|
});
|
|
});
|
|
|
|
it("does not collect allowed values for invalid_type with non-boolean expected", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_type",
|
|
path: ["port"],
|
|
message: "Expected number, received string",
|
|
expected: "number",
|
|
});
|
|
expect(result.allowedValues).toBeUndefined();
|
|
});
|
|
|
|
it("collects allowed values from custom issue with 'expected one of' message", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "custom",
|
|
path: ["mode"],
|
|
message: 'expected one of "fast"|"balanced"|"thorough"',
|
|
});
|
|
expect(result).toMatchObject({
|
|
path: "mode",
|
|
allowedValues: ["fast", "balanced", "thorough"],
|
|
});
|
|
});
|
|
|
|
it("does not collect allowed values from custom issue without expected one of pattern", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "custom",
|
|
path: ["plugins", "entries", "my-plugin"],
|
|
message: "plugin not found",
|
|
});
|
|
expect(result.allowedValues).toBeUndefined();
|
|
});
|
|
|
|
it("collects allowed values from invalid_union with complete nested branches", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_union",
|
|
path: ["dmPolicy"],
|
|
message: "Invalid input",
|
|
errors: [
|
|
[{ code: "invalid_value", path: [], values: ["pairing", "allowlist"] }],
|
|
[{ code: "invalid_value", path: [], values: ["open", "disabled"] }],
|
|
],
|
|
});
|
|
expect(result).toMatchObject({
|
|
path: "dmPolicy",
|
|
allowedValues: expect.arrayContaining(["pairing", "allowlist", "open", "disabled"]),
|
|
});
|
|
expect(result.allowedValues).toHaveLength(4);
|
|
});
|
|
|
|
it("returns no allowed values for invalid_union with incomplete branch", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_union",
|
|
path: ["dmPolicy"],
|
|
message: "Invalid input",
|
|
errors: [
|
|
[{ code: "invalid_value", path: [], values: ["pairing"] }],
|
|
// empty branch → incomplete
|
|
[],
|
|
],
|
|
});
|
|
expect(result.allowedValues).toBeUndefined();
|
|
expect(result.allowedValuesHiddenCount).toBeUndefined();
|
|
});
|
|
|
|
it("returns no allowed values for invalid_union with empty errors", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_union",
|
|
path: ["dmPolicy"],
|
|
message: "Invalid input",
|
|
errors: [],
|
|
});
|
|
expect(result.allowedValues).toBeUndefined();
|
|
});
|
|
|
|
it("returns no allowed values for invalid_union with non-array errors", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_union",
|
|
path: ["dmPolicy"],
|
|
message: "Invalid input",
|
|
errors: "not-an-array",
|
|
});
|
|
expect(result.allowedValues).toBeUndefined();
|
|
});
|
|
|
|
it("returns no allowed values for invalid_value with non-array values", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_value",
|
|
path: ["color"],
|
|
message: "Invalid value",
|
|
values: "not-an-array",
|
|
});
|
|
expect(result.allowedValues).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AllowedValuesHiddenCount when > MAX_ALLOWED_VALUES_HINT (12)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("allowed values hidden count", () => {
|
|
it("includes hiddenCount when allowed values exceed 12", () => {
|
|
const values = Array.from({ length: 15 }, (_, i) => `value-${i}`);
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_value",
|
|
path: ["many"],
|
|
message: "too many options",
|
|
values,
|
|
});
|
|
expect(result.allowedValues).toHaveLength(12);
|
|
expect(result.allowedValuesHiddenCount).toBe(3);
|
|
});
|
|
|
|
it("has hiddenCount 0 when allowed values are 12 or fewer", () => {
|
|
const values = Array.from({ length: 5 }, (_, i) => `v${i}`);
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_value",
|
|
path: ["few"],
|
|
message: "some options",
|
|
values,
|
|
});
|
|
expect(result.allowedValues).toHaveLength(5);
|
|
expect(result.allowedValuesHiddenCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bindings-specific union extraction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("bindings-specific union extraction", () => {
|
|
it("extracts a matching branch issue for bindings path with route mismatch", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_union",
|
|
path: ["bindings", 0],
|
|
message: "Invalid input",
|
|
errors: [
|
|
// Route type mismatch — triggers the extraction, will be filtered
|
|
[{ code: "invalid_value", path: ["type"], values: ["route"] }],
|
|
// Valid branch — this one gets extracted
|
|
[{ code: "invalid_type", path: ["url"], message: "Expected string" }],
|
|
],
|
|
});
|
|
expect(result).toEqual({
|
|
path: "bindings.0.url",
|
|
message: "Expected string",
|
|
});
|
|
});
|
|
|
|
it("filters out route-type-mismatch branches", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_union",
|
|
path: ["bindings", 0],
|
|
message: "Invalid input",
|
|
errors: [
|
|
// Route type mismatch — should be skipped
|
|
[{ code: "invalid_value", path: ["type"], values: ["route"] }],
|
|
// Valid branch
|
|
[{ code: "invalid_type", path: ["channel"], message: "Expected string" }],
|
|
],
|
|
});
|
|
expect(result).toEqual({
|
|
path: "bindings.0.channel",
|
|
message: "Expected string",
|
|
});
|
|
});
|
|
|
|
it("returns null (no extraction) when multiple branches match", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_union",
|
|
path: ["bindings", 0],
|
|
message: "Invalid input",
|
|
errors: [
|
|
[{ code: "invalid_type", path: ["url"], message: "Bad url" }],
|
|
[{ code: "invalid_type", path: ["channel"], message: "Bad channel" }],
|
|
],
|
|
});
|
|
// Ambiguous — falls back to the original union message
|
|
expect(result.path).toBe("bindings.0");
|
|
expect(result.message).toBe("Invalid input");
|
|
});
|
|
|
|
it("returns null for non-bindings invalid_union", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_union",
|
|
path: ["logging", "level"],
|
|
message: "Invalid input",
|
|
errors: [[{ code: "invalid_value", path: [], values: ["debug", "info"] }]],
|
|
});
|
|
// Non-bindings path with allowed values → shows allowed values
|
|
expect(result.allowedValues).toBeDefined();
|
|
});
|
|
|
|
it("returns null for bindings with no array errors", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "invalid_union",
|
|
path: ["bindings", 0],
|
|
message: "Invalid input",
|
|
});
|
|
expect(result.path).toBe("bindings.0");
|
|
expect(result.message).toBe("Invalid input");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Combined scenarios
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("combined scenarios", () => {
|
|
it("includes both numeric bound hint and allowed values when both apply", () => {
|
|
const result = testing.mapZodIssueToConfigIssue({
|
|
code: "custom",
|
|
path: ["maxTokens"],
|
|
message: 'expected one of "100"|"200"|"500"|"1000"',
|
|
origin: "number",
|
|
maximum: 1000,
|
|
inclusive: true,
|
|
});
|
|
// Message already contains allowed values, so no "(allowed: ...)" suffix
|
|
expect(result.message).toMatch(/expected one of/);
|
|
// But the allowed values metadata is still attached
|
|
expect(result.allowedValues).toContain("100");
|
|
expect(result.allowedValues).toContain("200");
|
|
});
|
|
|
|
it("handles malformed input gracefully", () => {
|
|
const result = testing.mapZodIssueToConfigIssue(null);
|
|
expect(result.path).toBe("");
|
|
expect(result.message).toBe("Invalid input");
|
|
});
|
|
|
|
it("handles undefined input gracefully", () => {
|
|
const result = testing.mapZodIssueToConfigIssue(undefined);
|
|
expect(result.path).toBe("");
|
|
expect(result.message).toBe("Invalid input");
|
|
});
|
|
});
|
|
});
|