mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 08:02:19 +00:00
* feat: lint AI agent tool names and flow groups in wmill lint * refactor: assemble chat and CLI AI guidance from one topic table * feat: share flow groups, reuse and pipeline guidance with the CLI skills * feat: share raw app, data table and secret guidance between chat and CLI * docs: document the shared AI guidance source for contributors * fix: keep wmill lint running on flows with malformed collections * fix: reject skill descriptions that are not plain YAML text * fix: tighten fence typos, script base scope and app prompt order * fix: skip tool name checks on agent steps linked to a saved agent * fix: catch any misspelled prompt fence and soften the tool name claim * fix: align cli eval harness with the files and steps wmill init adds * fix: drop cli eval checks that expect unrequested deploy commands * fix: list ansible as mainless and c# Main in script base guidance
553 lines
17 KiB
TypeScript
553 lines
17 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import * as path from "node:path";
|
|
import {
|
|
formatValidationError,
|
|
runLint,
|
|
} from "../src/commands/lint/lint.ts";
|
|
|
|
async function withTempDir(
|
|
fn: (tempDir: string) => Promise<void>,
|
|
): Promise<void> {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "wmill_lint_test_"));
|
|
const originalCwd = process.cwd();
|
|
try {
|
|
process.chdir(tempDir);
|
|
await fn(tempDir);
|
|
} finally {
|
|
process.chdir(originalCwd);
|
|
await rm(tempDir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
test("lint: validates flow, schedule, and trigger yaml files", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/my_flow.flow`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/my_flow.flow/flow.yaml`,
|
|
`summary: My flow
|
|
value:
|
|
modules: []
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
await mkdir(`${tempDir}/f/jobs`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/jobs/daily.schedule.yaml`,
|
|
`schedule: "0 0 12 * * *"
|
|
timezone: "UTC"
|
|
enabled: true
|
|
script_path: "f/jobs/daily_sync"
|
|
is_flow: false
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
await mkdir(`${tempDir}/f/triggers`, { recursive: true });
|
|
await writeFile(
|
|
`${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
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
await writeFile(
|
|
`${tempDir}/f/triggers/inbox.email_trigger.yaml`,
|
|
`script_path: "f/triggers/email_handler"
|
|
is_flow: false
|
|
local_part: "inbox"
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
const report = await runLint({} as any, tempDir);
|
|
|
|
expect(report.exitCode).toEqual(0);
|
|
expect(report.validatedFiles).toEqual(4);
|
|
expect(report.validFiles).toEqual(4);
|
|
expect(report.invalidFiles).toEqual(0);
|
|
expect(report.warnings.length).toEqual(0);
|
|
});
|
|
});
|
|
|
|
// Guards against lint validating flows with a schema older than the checkout: a step
|
|
// linked to an `ai_agent` resource carries neither `tools` nor provider/output_type.
|
|
test("lint: accepts an aiagent step linked to an ai_agent resource", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/agents/support.flow`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/agents/support.flow/flow.yaml`,
|
|
`summary: Support triage
|
|
value:
|
|
modules:
|
|
- id: a
|
|
value:
|
|
type: aiagent
|
|
agent: f/agents/support_agent
|
|
input_transforms:
|
|
user_message:
|
|
type: javascript
|
|
expr: flow_input.question
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
const report = await runLint({} as any, tempDir);
|
|
|
|
expect(report.issues).toEqual([]);
|
|
expect(report.exitCode).toEqual(0);
|
|
expect(report.validFiles).toEqual(1);
|
|
});
|
|
});
|
|
|
|
test("lint: returns errors for invalid schedule documents", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/jobs`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/jobs/broken.schedule.yaml`,
|
|
`timezone: "UTC"
|
|
enabled: true
|
|
script_path: "f/jobs/broken"
|
|
is_flow: false
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
const report = await runLint({} as any, tempDir);
|
|
|
|
expect(report.exitCode).toEqual(1);
|
|
expect(report.validatedFiles).toEqual(1);
|
|
expect(report.invalidFiles).toEqual(1);
|
|
expect(report.issues[0].path).toEqual("f/jobs/broken.schedule.yaml");
|
|
expect(
|
|
report.issues[0].errors.some((message) =>
|
|
message.includes("missing required property 'schedule'")
|
|
),
|
|
).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test("lint: warns and skips unsupported native trigger schemas", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/triggers`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/triggers/webhook.script.123.nextcloud_native_trigger.yaml`,
|
|
`path: "f/triggers/native"
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
const report = await runLint({} as any, tempDir);
|
|
|
|
expect(report.exitCode).toEqual(0);
|
|
expect(report.validatedFiles).toEqual(0);
|
|
expect(report.skippedUnsupportedFiles).toEqual(1);
|
|
expect(report.warnings.length).toEqual(1);
|
|
expect(
|
|
report.warnings[0].message,
|
|
).toContain("Unsupported trigger schema");
|
|
|
|
const failOnWarnReport = await runLint(
|
|
{ failOnWarn: true } as any,
|
|
tempDir,
|
|
);
|
|
expect(failOnWarnReport.exitCode).toEqual(1);
|
|
});
|
|
});
|
|
|
|
test("lint: uses wmill.yaml include filters for file discovery", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await writeFile(
|
|
`${tempDir}/wmill.yaml`,
|
|
`defaultTs: bun
|
|
includes:
|
|
- "f/allowed/**"
|
|
excludes: []
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
await mkdir(`${tempDir}/f/allowed`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/allowed/ok.schedule.yaml`,
|
|
`schedule: "0 0 12 * * *"
|
|
timezone: "UTC"
|
|
enabled: true
|
|
script_path: "f/jobs/ok"
|
|
is_flow: false
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
await mkdir(`${tempDir}/f/blocked`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/blocked/bad.schedule.yaml`,
|
|
`timezone: "UTC"
|
|
enabled: true
|
|
script_path: "f/jobs/bad"
|
|
is_flow: false
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
const report = await runLint({} as any, tempDir);
|
|
|
|
expect(report.exitCode).toEqual(0);
|
|
expect(report.validatedFiles).toEqual(1);
|
|
expect(report.validFiles).toEqual(1);
|
|
expect(report.invalidFiles).toEqual(0);
|
|
expect(report.issues.length).toEqual(0);
|
|
});
|
|
});
|
|
|
|
// --- formatValidationError unit tests ---
|
|
|
|
test("formatValidationError: required keyword", () => {
|
|
expect(
|
|
formatValidationError({
|
|
instancePath: "/value",
|
|
keyword: "required",
|
|
message: "must have required property 'modules'",
|
|
params: { missingProperty: "modules" },
|
|
}),
|
|
).toEqual("/value missing required property 'modules'");
|
|
});
|
|
|
|
test("formatValidationError: additionalProperties keyword", () => {
|
|
expect(
|
|
formatValidationError({
|
|
instancePath: "/value",
|
|
keyword: "additionalProperties",
|
|
message: "must NOT have additional properties",
|
|
params: { additionalProperty: "typo_field" },
|
|
}),
|
|
).toEqual("/value has unknown property 'typo_field'");
|
|
});
|
|
|
|
test("formatValidationError: enum keyword filters null values", () => {
|
|
expect(
|
|
formatValidationError({
|
|
instancePath: "/http_method",
|
|
keyword: "enum",
|
|
message: "must be equal to one of the allowed values",
|
|
params: { allowedValues: [null, "get", "post", "put"] },
|
|
}),
|
|
).toEqual("/http_method must be one of: 'get', 'post', 'put'");
|
|
});
|
|
|
|
test("formatValidationError: falls back to message", () => {
|
|
expect(
|
|
formatValidationError({
|
|
instancePath: "/timeout",
|
|
keyword: "type",
|
|
message: "must be integer",
|
|
}),
|
|
).toEqual("/timeout must be integer");
|
|
});
|
|
|
|
test("formatValidationError: uses / for empty instancePath", () => {
|
|
expect(
|
|
formatValidationError({
|
|
instancePath: "",
|
|
keyword: "required",
|
|
message: "must have required property 'summary'",
|
|
params: { missingProperty: "summary" },
|
|
}),
|
|
).toEqual("/ missing required property 'summary'");
|
|
});
|
|
|
|
test("formatValidationError: generic fallback when no message", () => {
|
|
expect(
|
|
formatValidationError({ instancePath: "/field", keyword: "custom" }),
|
|
).toEqual("/field validation error");
|
|
});
|
|
|
|
// --- runLint integration tests ---
|
|
|
|
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;
|
|
expect((e as Error).message).toContain("Directory not found");
|
|
}
|
|
expect(threw).toBeTruthy();
|
|
});
|
|
|
|
test("lint: json-shaped report contains all fields", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/jobs`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/jobs/ok.schedule.yaml`,
|
|
`schedule: "0 0 * * *"
|
|
timezone: "UTC"
|
|
enabled: true
|
|
script_path: "f/jobs/ok"
|
|
is_flow: false
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
const report = await runLint({ json: true } as any, tempDir);
|
|
|
|
// Verify the report object has the shape expected by --json output
|
|
expect(typeof report.scannedFiles).toEqual("number");
|
|
expect(typeof report.validatedFiles).toEqual("number");
|
|
expect(typeof report.validFiles).toEqual("number");
|
|
expect(typeof report.invalidFiles).toEqual("number");
|
|
expect(typeof report.skippedUnsupportedFiles).toEqual("number");
|
|
expect(Array.isArray(report.warnings)).toBeTruthy();
|
|
expect(Array.isArray(report.issues)).toBeTruthy();
|
|
expect(typeof report.success).toEqual("boolean");
|
|
expect(typeof report.exitCode).toEqual("number");
|
|
|
|
// JSON.stringify should round-trip cleanly
|
|
const json = JSON.parse(JSON.stringify(report));
|
|
expect(json.success).toEqual(true);
|
|
expect(json.exitCode).toEqual(0);
|
|
});
|
|
});
|
|
|
|
test("lint: --fail-on-warn with mixed valid and warning files", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
// A valid schedule
|
|
await mkdir(`${tempDir}/f/jobs`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/jobs/ok.schedule.yaml`,
|
|
`schedule: "0 0 * * *"
|
|
timezone: "UTC"
|
|
enabled: true
|
|
script_path: "f/jobs/ok"
|
|
is_flow: false
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
// An unsupported native trigger that produces a warning
|
|
await mkdir(`${tempDir}/f/triggers`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/triggers/webhook.script.123.nextcloud_native_trigger.yaml`,
|
|
`path: "f/triggers/native"
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
// Without --fail-on-warn: passes
|
|
const normalReport = await runLint({} as any, tempDir);
|
|
expect(normalReport.exitCode).toEqual(0);
|
|
expect(normalReport.success).toEqual(true);
|
|
expect(normalReport.validFiles).toEqual(1);
|
|
expect(normalReport.warnings.length).toEqual(1);
|
|
|
|
// With --fail-on-warn: fails due to warning
|
|
const strictReport = await runLint({ failOnWarn: true } as any, tempDir);
|
|
expect(strictReport.exitCode).toEqual(1);
|
|
expect(strictReport.success).toEqual(false);
|
|
expect(strictReport.validFiles).toEqual(1);
|
|
expect(strictReport.warnings.length).toEqual(1);
|
|
});
|
|
});
|
|
|
|
test("lint: reports enum errors with allowed values for invalid trigger", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/triggers`, { recursive: true });
|
|
await writeFile(
|
|
`${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
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
const report = await runLint({} as any, tempDir);
|
|
|
|
expect(report.invalidFiles).toEqual(1);
|
|
expect(
|
|
report.issues[0].errors.some((msg) => msg.includes("must be one of:")),
|
|
).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
async function lintFlow(tempDir: string, value: string) {
|
|
await mkdir(`${tempDir}/f/sem/check.flow`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/sem/check.flow/flow.yaml`,
|
|
`summary: Semantic checks\nvalue:\n${value}`,
|
|
"utf-8",
|
|
);
|
|
return await runLint({} as any, tempDir);
|
|
}
|
|
|
|
test("lint: rejects AI agent tool names the worker refuses", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
const report = await lintFlow(
|
|
tempDir,
|
|
` modules:
|
|
- id: agent
|
|
value:
|
|
type: aiagent
|
|
input_transforms: {}
|
|
tools:
|
|
- id: t_spaced
|
|
summary: Search docs
|
|
value: { tool_type: flowmodule, type: script, path: f/sem/search, input_transforms: {} }
|
|
- id: t_missing
|
|
value: { tool_type: flowmodule, type: script, path: f/sem/search, input_transforms: {} }
|
|
- id: t_first
|
|
summary: get_user
|
|
value: { tool_type: flowmodule, type: script, path: f/sem/get_user, input_transforms: {} }
|
|
- id: t_dup
|
|
summary: get_user
|
|
value: { tool_type: flowmodule, type: script, path: f/sem/get_user, input_transforms: {} }
|
|
- id: t_reserved
|
|
summary: if
|
|
value: { tool_type: flowmodule, type: script, path: f/sem/branch, input_transforms: {} }
|
|
- id: t_mcp
|
|
summary: ""
|
|
value: { tool_type: mcp, resource_path: f/sem/mcp }
|
|
- id: t_web
|
|
summary: Web Search
|
|
value: { tool_type: websearch }
|
|
- id: linked
|
|
value:
|
|
type: aiagent
|
|
agent: f/sem/support_agent
|
|
input_transforms: {}
|
|
tools:
|
|
- id: t_ignored
|
|
summary: Stale name
|
|
value: { tool_type: flowmodule, type: script, path: f/sem/search, input_transforms: {} }
|
|
`,
|
|
);
|
|
|
|
const errors = report.issues.flatMap((i) => i.errors);
|
|
expect(errors).toEqual([
|
|
"/value/modules/0/value/tools/0 tool name 'Search docs' may only contain letters, numbers and underscores (a run that offers this tool fails with 'Invalid tool name')",
|
|
"/value/modules/0/value/tools/1 has no summary: a tool's summary is the name the agent calls it by",
|
|
"/value/modules/0/value/tools/3 tool name 'get_user' is already used by tool 2 of this agent",
|
|
]);
|
|
expect(report.warnings.map((w) => w.message)).toEqual([
|
|
"/value/modules/0/value/tools/4 tool name 'if' is a reserved id; the flow editor refuses it",
|
|
]);
|
|
expect(report.exitCode).toEqual(1);
|
|
});
|
|
});
|
|
|
|
test("lint: rejects flow groups the editor cannot draw", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
const step = (id: string) =>
|
|
` - id: ${id}\n value: { type: identity }\n`;
|
|
const report = await lintFlow(
|
|
tempDir,
|
|
` modules:
|
|
${step("a")}${step("b")}${step("c")} - id: loop
|
|
value:
|
|
type: forloopflow
|
|
iterator: { type: javascript, expr: "[1]" }
|
|
skip_failures: false
|
|
modules:
|
|
- id: inner
|
|
value: { type: identity }
|
|
groups:
|
|
- { start_id: c, end_id: a }
|
|
- { start_id: a, end_id: inner }
|
|
- { start_id: a, end_id: gone }
|
|
- { start_id: a, end_id: b }
|
|
- { start_id: b, end_id: c }
|
|
- { start_id: a, end_id: b }
|
|
`,
|
|
);
|
|
|
|
expect(report.issues.flatMap((i) => i.errors)).toEqual([
|
|
"/value/groups/0 starts at 'c', which comes after its end 'a'",
|
|
"/value/groups/1 starts at 'a' and ends at 'inner', which are not in the same branch",
|
|
"/value/groups/2 references 'gone', which is not a step of this flow",
|
|
"/value/groups/5 spans the same steps as another group (a to b)",
|
|
"/value/groups/4 partly overlaps /value/groups/3: groups may nest but not overlap",
|
|
]);
|
|
});
|
|
});
|
|
|
|
test("lint: accepts nested groups and flags notes the editor renders wrong", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
const report = await lintFlow(
|
|
tempDir,
|
|
` modules:
|
|
- { id: a, value: { type: identity } }
|
|
- { id: b, value: { type: identity } }
|
|
- id: loop
|
|
value:
|
|
type: forloopflow
|
|
iterator: { type: javascript, expr: "[1]" }
|
|
skip_failures: false
|
|
modules:
|
|
- { id: x, value: { type: identity } }
|
|
- { id: y, value: { type: identity } }
|
|
groups:
|
|
- { summary: Fetch, start_id: a, end_id: loop, color: blue }
|
|
- { summary: Prep, start_id: a, end_id: b }
|
|
- { summary: Body, start_id: x, end_id: y, color: "#ff0000" }
|
|
notes:
|
|
- { id: n1, text: Purpose, color: yellow, type: free, position: { x: -400, y: 0 }, size: { width: 275, height: 80 } }
|
|
- { id: n2, text: Unplaced, color: yellow, type: free }
|
|
- { id: n3, text: Old, color: gray, type: group, contained_node_ids: [a] }
|
|
`,
|
|
);
|
|
|
|
expect(report.issues).toEqual([]);
|
|
expect(report.warnings.map((w) => w.message)).toEqual([
|
|
"/value/groups/2 color '#ff0000' renders unstyled; use one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray",
|
|
"/value/notes/1 has no position or size, so the editor places it at the origin and cannot resize it",
|
|
"/value/notes/2 is a 'group' note, which is deprecated: use value.groups instead",
|
|
]);
|
|
expect(report.exitCode).toEqual(0);
|
|
});
|
|
});
|
|
|
|
test("lint: reports a flow with malformed collections instead of aborting the run", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/sem/other.flow`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/sem/other.flow/flow.yaml`,
|
|
`summary: Other\nvalue:\n modules: {}\n`,
|
|
"utf-8",
|
|
);
|
|
const report = await lintFlow(
|
|
tempDir,
|
|
` modules:
|
|
- id: route
|
|
value: { type: branchall, branches: {} }
|
|
- id: agent
|
|
value: { type: aiagent, input_transforms: {}, tools: {} }
|
|
groups: {}
|
|
notes: {}
|
|
`,
|
|
);
|
|
|
|
expect(report.issues.map((i) => i.path).sort()).toEqual([
|
|
"f/sem/check.flow/flow.yaml",
|
|
"f/sem/other.flow/flow.yaml",
|
|
]);
|
|
expect(report.exitCode).toEqual(1);
|
|
});
|
|
});
|