feat(cli): add lint command (#7917)
* feat(yaml-validator)!: unify flow, schedule, and trigger validation - replace FlowValidator with WindmillYamlValidator.validate(doc, target) - generate schedule/trigger schemas from backend OpenAPI and OpenFlow refs - add schedule/trigger/filename-target tests and update AI agent fixtures - bump windmill-yaml-validator to 2.0.0 BREAKING CHANGE: FlowValidator and validateFlow() are replaced by WindmillYamlValidator.validate(doc, target). * add lint command * add deno-compat script and docs for local yaml-validator testing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: make nullable fields pass yaml validation Add nullable: true to static_asset_config and authentication_resource_path in HttpTrigger schema. Post-process generated JSON schemas to add null to enums with nullable: true (AJV doesn't handle OpenAPI 3.0 nullable + enum). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add nullable to all Option<T> fields in trigger and schedule OpenAPI schemas Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): handle nullable fields from updated OpenAPI types Add ?? undefined coalescing at assignment sites where generated types now include | null from the OpenAPI nullable additions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(lint): show allowed values in enum validation errors Instead of "must be equal to one of the allowed values", now shows "must be one of: 'r', 'w', 'rw'" for enum validation failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add nullable to Edit/New trigger and schedule OpenAPI schemas Ensures create/update request body types accept null for the same fields that GET response types return as nullable, enabling clean round-tripping without type mismatches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * use published package * publish * refactor(lint): remove unused --includes/--excludes/--extra-includes CLI options These options were defined but never wired to the file filtering logic. The lint command still respects includes/excludes from wmill.yaml via mergeConfigWithConfigFile. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(lint): handle additionalProperties errors and expand test coverage Add formatting for AJV additionalProperties keyword to show the unknown property name. Add unit tests for all formatValidationError branches and integration tests for --json report shape, --fail-on-warn with mixed files, non-existent directory, and enum error output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add realistic validator tests for schedules, triggers, and edge cases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add email trigger validation support Add email trigger schema generation, validation, and linting. Email triggers are no longer skipped with a warning — they are validated like all other trigger types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore(cli): bump windmill-yaml-validator to 1.1.1 (email trigger support) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * publish * rm * fix: address PR review feedback for lint command - Add email to trigger kinds test loop instead of separate test - Add email to ValidationTarget docs in README - Type formatYamlDiagnostics param directly instead of unsafe cast - Destructure json option before mergeConfigWithConfigFile for clarity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(cli): add --lint option to sync push command Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
354
cli/test/lint_command.test.ts
Normal file
354
cli/test/lint_command.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import {
|
||||
assert,
|
||||
assertEquals,
|
||||
assertStringIncludes,
|
||||
} from "https://deno.land/std@0.224.0/assert/mod.ts";
|
||||
import {
|
||||
formatValidationError,
|
||||
runLint,
|
||||
} from "../src/commands/lint/lint.ts";
|
||||
|
||||
async function withTempDir(
|
||||
fn: (tempDir: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const tempDir = await Deno.makeTempDir({ prefix: "wmill_lint_test_" });
|
||||
const originalCwd = Deno.cwd();
|
||||
try {
|
||||
Deno.chdir(tempDir);
|
||||
await fn(tempDir);
|
||||
} finally {
|
||||
Deno.chdir(originalCwd);
|
||||
await Deno.remove(tempDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("lint: validates flow, schedule, and trigger yaml files", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
await Deno.mkdir(`${tempDir}/f/my_flow.flow`, { recursive: true });
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/f/my_flow.flow/flow.yaml`,
|
||||
`summary: My flow
|
||||
value:
|
||||
modules: []
|
||||
`,
|
||||
);
|
||||
|
||||
await Deno.mkdir(`${tempDir}/f/jobs`, { recursive: true });
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/f/jobs/daily.schedule.yaml`,
|
||||
`schedule: "0 0 12 * * *"
|
||||
timezone: "UTC"
|
||||
enabled: true
|
||||
script_path: "f/jobs/daily_sync"
|
||||
is_flow: false
|
||||
`,
|
||||
);
|
||||
|
||||
await Deno.mkdir(`${tempDir}/f/triggers`, { recursive: true });
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/f/triggers/hook.http_trigger.yaml`,
|
||||
`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
|
||||
`,
|
||||
);
|
||||
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/f/triggers/inbox.email_trigger.yaml`,
|
||||
`script_path: "f/triggers/email_handler"
|
||||
is_flow: false
|
||||
local_part: "inbox"
|
||||
`,
|
||||
);
|
||||
|
||||
const report = await runLint({} as any, tempDir);
|
||||
|
||||
assertEquals(report.exitCode, 0);
|
||||
assertEquals(report.validatedFiles, 4);
|
||||
assertEquals(report.validFiles, 4);
|
||||
assertEquals(report.invalidFiles, 0);
|
||||
assertEquals(report.warnings.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("lint: returns errors for invalid schedule documents", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
await Deno.mkdir(`${tempDir}/f/jobs`, { recursive: true });
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/f/jobs/broken.schedule.yaml`,
|
||||
`timezone: "UTC"
|
||||
enabled: true
|
||||
script_path: "f/jobs/broken"
|
||||
is_flow: false
|
||||
`,
|
||||
);
|
||||
|
||||
const report = await runLint({} as any, tempDir);
|
||||
|
||||
assertEquals(report.exitCode, 1);
|
||||
assertEquals(report.validatedFiles, 1);
|
||||
assertEquals(report.invalidFiles, 1);
|
||||
assertEquals(report.issues[0].path, "f/jobs/broken.schedule.yaml");
|
||||
assert(
|
||||
report.issues[0].errors.some((message) =>
|
||||
message.includes("missing required property 'schedule'")
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("lint: warns and skips unsupported native trigger schemas", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
await Deno.mkdir(`${tempDir}/f/triggers`, { recursive: true });
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/f/triggers/webhook.script.123.nextcloud_native_trigger.yaml`,
|
||||
`path: "f/triggers/native"
|
||||
`,
|
||||
);
|
||||
|
||||
const report = await runLint({} as any, tempDir);
|
||||
|
||||
assertEquals(report.exitCode, 0);
|
||||
assertEquals(report.validatedFiles, 0);
|
||||
assertEquals(report.skippedUnsupportedFiles, 1);
|
||||
assertEquals(report.warnings.length, 1);
|
||||
assertStringIncludes(
|
||||
report.warnings[0].message,
|
||||
"Unsupported trigger schema",
|
||||
);
|
||||
|
||||
const failOnWarnReport = await runLint(
|
||||
{ failOnWarn: true } as any,
|
||||
tempDir,
|
||||
);
|
||||
assertEquals(failOnWarnReport.exitCode, 1);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("lint: uses wmill.yaml include filters for file discovery", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/wmill.yaml`,
|
||||
`defaultTs: bun
|
||||
includes:
|
||||
- "f/allowed/**"
|
||||
excludes: []
|
||||
`,
|
||||
);
|
||||
|
||||
await Deno.mkdir(`${tempDir}/f/allowed`, { recursive: true });
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/f/allowed/ok.schedule.yaml`,
|
||||
`schedule: "0 0 12 * * *"
|
||||
timezone: "UTC"
|
||||
enabled: true
|
||||
script_path: "f/jobs/ok"
|
||||
is_flow: false
|
||||
`,
|
||||
);
|
||||
|
||||
await Deno.mkdir(`${tempDir}/f/blocked`, { recursive: true });
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/f/blocked/bad.schedule.yaml`,
|
||||
`timezone: "UTC"
|
||||
enabled: true
|
||||
script_path: "f/jobs/bad"
|
||||
is_flow: false
|
||||
`,
|
||||
);
|
||||
|
||||
const report = await runLint({} as any, tempDir);
|
||||
|
||||
assertEquals(report.exitCode, 0);
|
||||
assertEquals(report.validatedFiles, 1);
|
||||
assertEquals(report.validFiles, 1);
|
||||
assertEquals(report.invalidFiles, 0);
|
||||
assertEquals(report.issues.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- formatValidationError unit tests ---
|
||||
|
||||
Deno.test("formatValidationError: required keyword", () => {
|
||||
assertEquals(
|
||||
formatValidationError({
|
||||
instancePath: "/value",
|
||||
keyword: "required",
|
||||
message: "must have required property 'modules'",
|
||||
params: { missingProperty: "modules" },
|
||||
}),
|
||||
"/value missing required property 'modules'",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("formatValidationError: additionalProperties keyword", () => {
|
||||
assertEquals(
|
||||
formatValidationError({
|
||||
instancePath: "/value",
|
||||
keyword: "additionalProperties",
|
||||
message: "must NOT have additional properties",
|
||||
params: { additionalProperty: "typo_field" },
|
||||
}),
|
||||
"/value has unknown property 'typo_field'",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("formatValidationError: enum keyword filters null values", () => {
|
||||
assertEquals(
|
||||
formatValidationError({
|
||||
instancePath: "/http_method",
|
||||
keyword: "enum",
|
||||
message: "must be equal to one of the allowed values",
|
||||
params: { allowedValues: [null, "get", "post", "put"] },
|
||||
}),
|
||||
"/http_method must be one of: 'get', 'post', 'put'",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("formatValidationError: falls back to message", () => {
|
||||
assertEquals(
|
||||
formatValidationError({
|
||||
instancePath: "/timeout",
|
||||
keyword: "type",
|
||||
message: "must be integer",
|
||||
}),
|
||||
"/timeout must be integer",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("formatValidationError: uses / for empty instancePath", () => {
|
||||
assertEquals(
|
||||
formatValidationError({
|
||||
instancePath: "",
|
||||
keyword: "required",
|
||||
message: "must have required property 'summary'",
|
||||
params: { missingProperty: "summary" },
|
||||
}),
|
||||
"/ missing required property 'summary'",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("formatValidationError: generic fallback when no message", () => {
|
||||
assertEquals(
|
||||
formatValidationError({ instancePath: "/field", keyword: "custom" }),
|
||||
"/field validation error",
|
||||
);
|
||||
});
|
||||
|
||||
// --- runLint integration tests ---
|
||||
|
||||
Deno.test("lint: throws for non-existent directory", async () => {
|
||||
let threw = false;
|
||||
try {
|
||||
await runLint({} as any, "/tmp/wmill_lint_nonexistent_" + Date.now());
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
assertStringIncludes((e as Error).message, "Directory not found");
|
||||
}
|
||||
assert(threw, "Expected runLint to throw for non-existent directory");
|
||||
});
|
||||
|
||||
Deno.test("lint: json-shaped report contains all fields", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
await Deno.mkdir(`${tempDir}/f/jobs`, { recursive: true });
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/f/jobs/ok.schedule.yaml`,
|
||||
`schedule: "0 0 * * *"
|
||||
timezone: "UTC"
|
||||
enabled: true
|
||||
script_path: "f/jobs/ok"
|
||||
is_flow: false
|
||||
`,
|
||||
);
|
||||
|
||||
const report = await runLint({ json: true } as any, tempDir);
|
||||
|
||||
// Verify the report object has the shape expected by --json output
|
||||
assertEquals(typeof report.scannedFiles, "number");
|
||||
assertEquals(typeof report.validatedFiles, "number");
|
||||
assertEquals(typeof report.validFiles, "number");
|
||||
assertEquals(typeof report.invalidFiles, "number");
|
||||
assertEquals(typeof report.skippedUnsupportedFiles, "number");
|
||||
assert(Array.isArray(report.warnings));
|
||||
assert(Array.isArray(report.issues));
|
||||
assertEquals(typeof report.success, "boolean");
|
||||
assertEquals(typeof report.exitCode, "number");
|
||||
|
||||
// JSON.stringify should round-trip cleanly
|
||||
const json = JSON.parse(JSON.stringify(report));
|
||||
assertEquals(json.success, true);
|
||||
assertEquals(json.exitCode, 0);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("lint: --fail-on-warn with mixed valid and warning files", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
// A valid schedule
|
||||
await Deno.mkdir(`${tempDir}/f/jobs`, { recursive: true });
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/f/jobs/ok.schedule.yaml`,
|
||||
`schedule: "0 0 * * *"
|
||||
timezone: "UTC"
|
||||
enabled: true
|
||||
script_path: "f/jobs/ok"
|
||||
is_flow: false
|
||||
`,
|
||||
);
|
||||
|
||||
// An unsupported native trigger that produces a warning
|
||||
await Deno.mkdir(`${tempDir}/f/triggers`, { recursive: true });
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/f/triggers/webhook.script.123.nextcloud_native_trigger.yaml`,
|
||||
`path: "f/triggers/native"
|
||||
`,
|
||||
);
|
||||
|
||||
// Without --fail-on-warn: passes
|
||||
const normalReport = await runLint({} as any, tempDir);
|
||||
assertEquals(normalReport.exitCode, 0);
|
||||
assertEquals(normalReport.success, true);
|
||||
assertEquals(normalReport.validFiles, 1);
|
||||
assertEquals(normalReport.warnings.length, 1);
|
||||
|
||||
// With --fail-on-warn: fails due to warning
|
||||
const strictReport = await runLint({ failOnWarn: true } as any, tempDir);
|
||||
assertEquals(strictReport.exitCode, 1);
|
||||
assertEquals(strictReport.success, false);
|
||||
assertEquals(strictReport.validFiles, 1);
|
||||
assertEquals(strictReport.warnings.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("lint: reports enum errors with allowed values for invalid trigger", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
await Deno.mkdir(`${tempDir}/f/triggers`, { recursive: true });
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/f/triggers/hook.http_trigger.yaml`,
|
||||
`script_path: "f/triggers/http_handler"
|
||||
is_flow: false
|
||||
route_path: "api/webhook"
|
||||
authentication_method: "none"
|
||||
http_method: "invalid_method"
|
||||
is_static_website: false
|
||||
workspaced_route: false
|
||||
wrap_body: false
|
||||
raw_string: false
|
||||
`,
|
||||
);
|
||||
|
||||
const report = await runLint({} as any, tempDir);
|
||||
|
||||
assertEquals(report.invalidFiles, 1);
|
||||
assert(
|
||||
report.issues[0].errors.some((msg) => msg.includes("must be one of:")),
|
||||
`Expected 'must be one of' error but got: ${report.issues[0].errors}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user