windmill-yaml-validator
Version:
YAML validator for Windmill flow, schedule, and trigger files
511 lines (510 loc) • 23.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const yaml_validator_1 = require("../yaml-validator");
describe("WindmillYamlValidator resource validation", () => {
let validator;
beforeEach(() => {
validator = new yaml_validator_1.WindmillYamlValidator();
});
// ── Schedules ──────────────────────────────────────────────────────────
describe("schedules", () => {
it("validates a minimal schedule", () => {
const result = validator.validate(JSON.stringify({
schedule: "0 0 12 * * *",
timezone: "UTC",
enabled: true,
script_path: "f/jobs/daily_sync",
is_flow: false,
}), { type: "schedule" });
expect(result.errors).toHaveLength(0);
});
it("validates a fully-configured schedule with handlers and retry", () => {
const result = validator.validate(JSON.stringify({
schedule: "0 */5 * * * *",
timezone: "Europe/Paris",
enabled: true,
script_path: "f/jobs/full_sync",
is_flow: true,
args: { batch_size: 100, dry_run: false },
on_failure: "f/handlers/on_fail",
on_failure_times: 3,
on_failure_exact: true,
on_failure_extra_args: { notify: true },
on_recovery: "f/handlers/on_recover",
on_recovery_times: 2,
on_recovery_extra_args: { channel: "#ops" },
on_success: "f/handlers/on_success",
on_success_extra_args: { log: true },
ws_error_handler_muted: true,
retry: {
constant: { attempts: 3, seconds: 10 },
exponential: {
attempts: 5,
multiplier: 2,
seconds: 1,
random_factor: 50,
},
retry_if: { expr: "error.message.includes('timeout')" },
},
summary: "Full sync every 5 minutes",
description: "Runs the full synchronization pipeline",
no_flow_overlap: true,
tag: "heavy",
paused_until: "2026-03-01T00:00:00Z",
cron_version: "v2",
dynamic_skip: "f/helpers/should_skip",
}), { type: "schedule" });
expect(result.errors).toHaveLength(0);
});
it("accepts null for all nullable optional fields", () => {
const result = validator.validate(JSON.stringify({
schedule: "0 0 12 * * *",
timezone: "UTC",
enabled: true,
script_path: "f/jobs/daily_sync",
is_flow: false,
args: null,
on_failure: null,
tag: null,
retry: null,
paused_until: null,
summary: null,
description: null,
cron_version: null,
dynamic_skip: null,
}), { type: "schedule" });
expect(result.errors).toHaveLength(0);
});
it("rejects a schedule missing required fields", () => {
const result = validator.validate(JSON.stringify({ timezone: "UTC" }), { type: "schedule" });
expect(result.errors.length).toBeGreaterThan(0);
});
it("rejects wrong types in schedule fields", () => {
const result = validator.validate(JSON.stringify({
schedule: 12345,
timezone: "UTC",
enabled: "yes",
script_path: "f/jobs/daily_sync",
is_flow: "true",
}), { type: "schedule" });
// Should catch schedule (not string), enabled (not boolean), is_flow (not boolean)
expect(result.errors.length).toBeGreaterThanOrEqual(3);
});
it("rejects invalid retry constraints", () => {
const result = validator.validate(JSON.stringify({
schedule: "0 0 12 * * *",
timezone: "UTC",
enabled: true,
script_path: "f/jobs/daily_sync",
is_flow: false,
retry: {
exponential: { attempts: 3, seconds: 0, random_factor: 150 },
retry_if: {},
},
}), { type: "schedule" });
// seconds < 1, random_factor > 100, retry_if missing expr
expect(result.errors.length).toBeGreaterThanOrEqual(3);
});
});
// ── Triggers — valid minimal + valid with missing required ─────────────
describe("trigger schemas", () => {
const validTriggers = {
http: {
script_path: "f/triggers/http_handler",
is_flow: false,
route_path: "api/webhook",
request_type: "sync",
authentication_method: "none",
http_method: "post",
is_static_website: false,
workspaced_route: false,
wrap_body: false,
raw_string: false,
},
websocket: {
script_path: "f/triggers/ws_handler",
is_flow: false,
url: "wss://example.com/socket",
filters: [],
can_return_message: false,
can_return_error_result: true,
},
kafka: {
script_path: "f/triggers/kafka_handler",
is_flow: false,
kafka_resource_path: "f/resources/kafka",
group_id: "group-a",
topics: ["topic-a"],
filters: [],
},
nats: {
script_path: "f/triggers/nats_handler",
is_flow: false,
nats_resource_path: "f/resources/nats",
use_jetstream: false,
subjects: ["events.>"],
},
postgres: {
script_path: "f/triggers/postgres_handler",
is_flow: false,
postgres_resource_path: "f/resources/postgres",
publication_name: "pub_main",
replication_slot_name: "slot_main",
},
mqtt: {
script_path: "f/triggers/mqtt_handler",
is_flow: false,
mqtt_resource_path: "f/resources/mqtt",
subscribe_topics: [],
},
sqs: {
script_path: "f/triggers/sqs_handler",
is_flow: false,
queue_url: "https://sqs.us-east-1.amazonaws.com/12345/my-queue",
aws_resource_path: "f/resources/aws",
aws_auth_resource_type: "credentials",
},
gcp: {
script_path: "f/triggers/gcp_handler",
is_flow: false,
gcp_resource_path: "f/resources/gcp",
topic_id: "topic-a",
subscription_id: "sub-a",
delivery_type: "pull",
subscription_mode: "existing",
},
email: {
script_path: "f/triggers/email_handler",
is_flow: false,
local_part: "inbox",
},
};
const missingRequiredField = {
http: "request_type",
websocket: "url",
kafka: "topics",
nats: "subjects",
postgres: "publication_name",
mqtt: "mqtt_resource_path",
sqs: "queue_url",
gcp: "topic_id",
email: "local_part",
};
for (const [kind, validDocument] of Object.entries(validTriggers)) {
it(`validates a valid ${kind} trigger file`, () => {
const result = validator.validate(JSON.stringify(validDocument), {
type: "trigger",
triggerKind: kind,
});
expect(result.errors).toHaveLength(0);
});
it(`returns required-field errors for invalid ${kind} trigger file`, () => {
const missingField = missingRequiredField[kind];
const invalidDocument = { ...validDocument };
delete invalidDocument[missingField];
const result = validator.validate(JSON.stringify(invalidDocument), {
type: "trigger",
triggerKind: kind,
});
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors.some((error) => error.keyword === "required" &&
error.params?.missingProperty ===
missingField)).toBe(true);
});
}
});
it("throws for unsupported trigger kinds", () => {
expect(() => validator.validate("{}", {
type: "trigger",
triggerKind: "foobar",
})).toThrow("Unsupported trigger kind: foobar");
});
// ── Triggers — realistic full documents with all optional fields ───────
describe("fully-configured triggers", () => {
it("validates http trigger with static assets, auth, error handling and retry", () => {
const result = validator.validate(JSON.stringify({
script_path: "f/triggers/http_handler",
is_flow: false,
route_path: "api/webhook",
request_type: "sync_sse",
authentication_method: "api_key",
http_method: "put",
is_static_website: true,
workspaced_route: true,
wrap_body: true,
raw_string: false,
summary: "Incoming webhook",
description: "Handles external webhook deliveries",
authentication_resource_path: "f/resources/api_key_config",
static_asset_config: { s3: "my-bucket/assets", filename: "index.html" },
error_handler_path: "f/handlers/on_error",
error_handler_args: { notify: true },
retry: {
constant: { attempts: 2, seconds: 5 },
retry_if: { expr: "error.status === 429" },
},
}), { type: "trigger", triggerKind: "http" });
expect(result.errors).toHaveLength(0);
});
it("validates websocket trigger with initial messages and filters", () => {
const result = validator.validate(JSON.stringify({
script_path: "f/triggers/ws_handler",
is_flow: true,
url: "wss://stream.example.com/v1",
filters: [{ key: "event", value: "trade" }],
can_return_message: true,
can_return_error_result: false,
initial_messages: [
{ raw_message: '{"action":"subscribe","channel":"trades"}' },
{
runnable_result: {
path: "f/helpers/ws_auth",
args: { token: "abc" },
is_flow: false,
},
},
],
url_runnable_args: { env: "production" },
error_handler_path: "f/handlers/on_error",
retry: { exponential: { attempts: 5, multiplier: 2, seconds: 1, random_factor: 25 } },
}), { type: "trigger", triggerKind: "websocket" });
expect(result.errors).toHaveLength(0);
});
it("validates mqtt trigger with v5 config and subscribe topics", () => {
const result = validator.validate(JSON.stringify({
script_path: "f/triggers/mqtt_handler",
is_flow: false,
mqtt_resource_path: "f/resources/mqtt",
subscribe_topics: [
{ topic: "sensor/+/data", qos: "qos1" },
{ topic: "alerts/#", qos: "qos2" },
],
client_version: "v5",
client_id: "windmill-consumer-1",
v5_config: {
clean_start: true,
topic_alias_maximum: 10,
session_expiry_interval: 300,
},
error_handler_path: "f/handlers/on_error",
retry: { constant: { attempts: 3, seconds: 10 } },
}), { type: "trigger", triggerKind: "mqtt" });
expect(result.errors).toHaveLength(0);
});
it("validates nats trigger with jetstream config", () => {
const result = validator.validate(JSON.stringify({
script_path: "f/triggers/nats_handler",
is_flow: true,
nats_resource_path: "f/resources/nats",
use_jetstream: true,
subjects: ["orders.>"],
stream_name: "ORDERS",
consumer_name: "windmill-consumer",
error_handler_path: "f/handlers/on_error",
error_handler_args: {},
retry: { constant: { attempts: 5, seconds: 30 } },
}), { type: "trigger", triggerKind: "nats" });
expect(result.errors).toHaveLength(0);
});
it("validates sqs trigger with message attributes and oidc auth", () => {
const result = validator.validate(JSON.stringify({
script_path: "f/triggers/sqs_handler",
is_flow: false,
queue_url: "https://sqs.us-east-1.amazonaws.com/12345/my-queue",
aws_resource_path: "f/resources/aws",
aws_auth_resource_type: "oidc",
message_attributes: ["traceId", "source"],
error_handler_path: "f/handlers/on_error",
}), { type: "trigger", triggerKind: "sqs" });
expect(result.errors).toHaveLength(0);
});
it("validates gcp trigger with push delivery config", () => {
const result = validator.validate(JSON.stringify({
script_path: "f/triggers/gcp_handler",
is_flow: false,
gcp_resource_path: "f/resources/gcp",
topic_id: "topic-a",
subscription_id: "sub-push",
delivery_type: "push",
subscription_mode: "create_update",
delivery_config: {
authenticate: true,
base_endpoint: "https://app.example.com/webhook",
audience: "https://app.example.com",
},
error_handler_path: "f/handlers/on_error",
retry: { retry_if: { expr: "error.message.includes('quota')" } },
}), { type: "trigger", triggerKind: "gcp" });
expect(result.errors).toHaveLength(0);
});
});
// ── Realistic user mistakes ────────────────────────────────────────────
describe("realistic user mistakes", () => {
it("catches invalid enum values across trigger types", () => {
// User typos an http_method as uppercase
const httpResult = validator.validate(JSON.stringify({
script_path: "f/triggers/http_handler",
is_flow: false,
route_path: "api/webhook",
request_type: "sync",
authentication_method: "none",
http_method: "POST",
is_static_website: false,
workspaced_route: false,
wrap_body: false,
raw_string: false,
}), { type: "trigger", triggerKind: "http" });
expect(httpResult.errors.length).toBeGreaterThan(0);
// User writes "iam_role" instead of "oidc" or "credentials"
const sqsResult = validator.validate(JSON.stringify({
script_path: "f/triggers/sqs_handler",
is_flow: false,
queue_url: "https://sqs.us-east-1.amazonaws.com/12345/q",
aws_resource_path: "f/resources/aws",
aws_auth_resource_type: "iam_role",
}), { type: "trigger", triggerKind: "sqs" });
expect(sqsResult.errors.length).toBeGreaterThan(0);
// User writes "stream" instead of "push" or "pull"
const gcpResult = validator.validate(JSON.stringify({
script_path: "f/triggers/gcp_handler",
is_flow: false,
gcp_resource_path: "f/resources/gcp",
topic_id: "t",
subscription_id: "s",
delivery_type: "stream",
subscription_mode: "existing",
}), { type: "trigger", triggerKind: "gcp" });
expect(gcpResult.errors.length).toBeGreaterThan(0);
// User writes "v4" instead of "v3" or "v5"
const mqttResult = validator.validate(JSON.stringify({
script_path: "f/triggers/mqtt_handler",
is_flow: false,
mqtt_resource_path: "f/resources/mqtt",
subscribe_topics: [{ topic: "t", qos: "qos1" }],
client_version: "v4",
}), { type: "trigger", triggerKind: "mqtt" });
expect(mqttResult.errors.length).toBeGreaterThan(0);
});
it("catches malformed nested structures", () => {
// MQTT topic entry missing qos
const mqttResult = validator.validate(JSON.stringify({
script_path: "f/triggers/mqtt_handler",
is_flow: false,
mqtt_resource_path: "f/resources/mqtt",
subscribe_topics: [{ topic: "test/topic" }],
}), { type: "trigger", triggerKind: "mqtt" });
expect(mqttResult.errors.length).toBeGreaterThan(0);
// HTTP static_asset_config without required s3 field
const httpResult = validator.validate(JSON.stringify({
script_path: "f/triggers/http_handler",
is_flow: false,
route_path: "api/assets",
request_type: "sync",
authentication_method: "none",
http_method: "get",
is_static_website: true,
workspaced_route: false,
wrap_body: false,
raw_string: false,
static_asset_config: { filename: "index.html" },
}), { type: "trigger", triggerKind: "http" });
expect(httpResult.errors.length).toBeGreaterThan(0);
// GCP delivery_config without required authenticate / base_endpoint
const gcpResult = validator.validate(JSON.stringify({
script_path: "f/triggers/gcp_handler",
is_flow: false,
gcp_resource_path: "f/resources/gcp",
topic_id: "t",
subscription_id: "s",
delivery_type: "push",
subscription_mode: "create_update",
delivery_config: { audience: "test" },
}), { type: "trigger", triggerKind: "gcp" });
expect(gcpResult.errors.length).toBeGreaterThan(0);
// Websocket filter missing key
const wsResult = validator.validate(JSON.stringify({
script_path: "f/triggers/ws_handler",
is_flow: false,
url: "wss://example.com/socket",
filters: [{ value: "test" }],
can_return_message: false,
can_return_error_result: true,
}), { type: "trigger", triggerKind: "websocket" });
expect(wsResult.errors.length).toBeGreaterThan(0);
});
it("catches wrong types in YAML (string-for-boolean, object-for-array)", () => {
// is_flow: "false" — YAML without quotes would parse to boolean,
// but a quoted "false" parses as string
const natsResult = validator.validate(JSON.stringify({
script_path: "f/triggers/nats_handler",
is_flow: "false",
nats_resource_path: "f/resources/nats",
use_jetstream: "yes",
subjects: ["events.>"],
}), { type: "trigger", triggerKind: "nats" });
expect(natsResult.errors.length).toBeGreaterThanOrEqual(2);
// topics as {} instead of []
const kafkaResult = validator.validate(JSON.stringify({
script_path: "f/triggers/kafka_handler",
is_flow: false,
kafka_resource_path: "f/resources/kafka",
group_id: "g",
topics: {},
filters: "not-an-array",
}), { type: "trigger", triggerKind: "kafka" });
expect(kafkaResult.errors.length).toBeGreaterThanOrEqual(2);
});
});
});
describe("getValidationTargetFromFilename", () => {
it("detects flow files", () => {
expect((0, yaml_validator_1.getValidationTargetFromFilename)("f/my.flow/flow.yaml")).toEqual({
type: "flow",
});
expect((0, yaml_validator_1.getValidationTargetFromFilename)("flow.yml")).toEqual({
type: "flow",
});
});
it("detects schedule files", () => {
expect((0, yaml_validator_1.getValidationTargetFromFilename)("f/folder/daily.schedule.yaml")).toEqual({
type: "schedule",
});
});
it("detects all 9 trigger kinds", () => {
const kinds = [
"http",
"websocket",
"kafka",
"nats",
"postgres",
"mqtt",
"sqs",
"gcp",
"email",
];
for (const kind of kinds) {
expect((0, yaml_validator_1.getValidationTargetFromFilename)(`f/triggers/handler.${kind}_trigger.yaml`)).toEqual({
type: "trigger",
triggerKind: kind,
});
}
});
it("is case-insensitive for extensions", () => {
expect((0, yaml_validator_1.getValidationTargetFromFilename)("f/my.flow/flow.YAML")).toEqual({
type: "flow",
});
expect((0, yaml_validator_1.getValidationTargetFromFilename)("f/folder/daily.schedule.YML")).toEqual({
type: "schedule",
});
});
it("handles dots in directory names", () => {
expect((0, yaml_validator_1.getValidationTargetFromFilename)("f/my.app.flow/flow.yaml")).toEqual({
type: "flow",
});
});
it("returns null for unsupported trigger kinds and non-windmill files", () => {
expect((0, yaml_validator_1.getValidationTargetFromFilename)("README.md")).toBeNull();
expect((0, yaml_validator_1.getValidationTargetFromFilename)("f/folder/script.py")).toBeNull();
expect((0, yaml_validator_1.getValidationTargetFromFilename)("f/folder/resource.yaml")).toBeNull();
});
});