mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 08:01:25 +00:00
2f58a31d00
* fix(cli): preserve case in raw-app runnable filenames The path assigner lowercased filesystem-safe names, so a raw-app runnable id like CamelCaseTSRunnable produced a YAML metadata file keeping the original case but a code file lowercased to camelcasetsrunnable.ts. On the next push, loadRunnablesFromBackend paired the two by case-sensitive name match, failed, and registered the lowercase code file as a separate empty runnable — surfacing as duplicate runnables in the app editor. Stop lowercasing in sanitizeForFilesystem and dedupe path assigners case-insensitively so case-only collisions still get a counter on case-insensitive filesystems. Also match content/lock files and the processed-id set case-insensitively in loadRunnablesFromBackend so repos already pulled by the buggy CLI can recover on the next push without re-pulling. Fixes #8939 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): extract readSiblingLock helper, drop test #ref comments Address review: - Both call sites in loadRunnablesFromBackend now use the same case-insensitive lock-file lookup, so a partial-legacy repo with a mixed-case code file and lowercase lock (or vice versa) works in the orphan-code branch too, not just the YAML branch. - Remove `Regression for #8939 …` comments from the new tests; CLAUDE.md bans referencing the current task/issue from source comments. Test names already describe the behavior under test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(cli): update assigner-path assertions for case preservation Three tests in inline_scripts_failure_preprocessor_unit.test.ts asserted that the assigner produced lowercased filenames (get_users_data, step_b) from mixed-case summaries — that was the buggy lowercasing behavior. With case preserved end-to-end, the assigner now returns Get_Users_Data and Step_B. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
/**
|
|
* Unit tests for `wmill app dev` wmill.d.ts generation.
|
|
*
|
|
* Two regressions covered:
|
|
*
|
|
* 1. Path-based runnables (type: script / flow) used to lose their schema and
|
|
* always render as `args: {}` in wmill.d.ts. Verified by feeding a
|
|
* fixture-supplied schema through the load -> override -> generate pipeline.
|
|
*
|
|
* 2. Inline runnables stored in the new backend-folder format (a `*.yaml`
|
|
* declaring `type: inline` plus a sibling code file) used to bail out of
|
|
* `inferRunnableSchemaFromFile` because the YAML no longer carries an
|
|
* `inlineScript` block. Verified by inferring a real schema from a TS file
|
|
* in that layout and confirming the generated wmill.d.ts uses it.
|
|
*
|
|
* These tests run without a backend - they exercise the local pipeline only.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import { loadRunnablesFromBackend } from "../src/commands/app/raw_apps.ts";
|
|
import {
|
|
APP_BACKEND_FOLDER,
|
|
inferAllInlineSchemas,
|
|
inferRunnableSchemaFromFile,
|
|
} from "../src/commands/app/app_metadata.ts";
|
|
import { buildWmillTs } from "../src/commands/app/dev.ts";
|
|
|
|
let tempDir: string;
|
|
let backendDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "app-dev-wmill-dts-"));
|
|
backendDir = path.join(tempDir, APP_BACKEND_FOLDER);
|
|
fs.mkdirSync(backendDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("loadRunnablesFromBackend", () => {
|
|
test("path-based runnable is converted to API format with runType", async () => {
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "myScript.yaml"),
|
|
"type: script\npath: u/admin/my_script\nfields: {}\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const runnables = await loadRunnablesFromBackend(backendDir);
|
|
|
|
expect(runnables.myScript).toBeDefined();
|
|
expect(runnables.myScript.type).toBe("path");
|
|
expect(runnables.myScript.runType).toBe("script");
|
|
expect(runnables.myScript.path).toBe("u/admin/my_script");
|
|
});
|
|
|
|
test("new-format inline runnable reconstructs inlineScript from sibling code", async () => {
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "inlineRunnable.yaml"),
|
|
"type: inline\nfields: {}\n",
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "inlineRunnable.ts"),
|
|
"export async function main(name: string, count: number) {\n" +
|
|
" return { name, count };\n" +
|
|
"}\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const runnables = await loadRunnablesFromBackend(backendDir);
|
|
|
|
expect(runnables.inlineRunnable).toBeDefined();
|
|
expect(runnables.inlineRunnable.type).toBe("inline");
|
|
expect(runnables.inlineRunnable.inlineScript).toBeDefined();
|
|
expect(runnables.inlineRunnable.inlineScript.language).toBe("bun");
|
|
expect(runnables.inlineRunnable.inlineScript.content).toContain("main(");
|
|
});
|
|
|
|
test("CamelCase YAML pairs with lowercased sibling code (legacy repo recovery)", async () => {
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "CamelCaseTSRunnable.yaml"),
|
|
"type: inline\nfields: {}\n",
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "camelcasetsrunnable.ts"),
|
|
"export async function main() { return 1; }\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const runnables = await loadRunnablesFromBackend(backendDir);
|
|
|
|
expect(Object.keys(runnables)).toEqual(["CamelCaseTSRunnable"]);
|
|
expect(runnables.CamelCaseTSRunnable.inlineScript.content).toContain("main(");
|
|
});
|
|
});
|
|
|
|
describe("genWmillTs (regression: path-based runnable schema)", () => {
|
|
test("path-based runnable with schema produces typed args", async () => {
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "fetchUser.yaml"),
|
|
"type: script\npath: u/admin/fetch_user\nfields: {}\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const runnables = await loadRunnablesFromBackend(backendDir);
|
|
|
|
// Simulate fetchPathRunnableSchemas() - schema fetched from the API.
|
|
const ts = buildWmillTs(runnables, {}, {
|
|
fetchUser: {
|
|
type: "object",
|
|
properties: {
|
|
userId: { type: "string", description: "The user id" },
|
|
includeProfile: { type: "boolean" },
|
|
},
|
|
required: ["userId"],
|
|
},
|
|
});
|
|
|
|
// Without the fix, the path-based runnable would render as `args: {}`.
|
|
expect(ts).toContain("fetchUser:");
|
|
expect(ts).toMatch(/fetchUser:\s*\(args:\s*\{[^}]*userId/);
|
|
expect(ts).toContain("userId");
|
|
expect(ts).toContain("includeProfile");
|
|
expect(ts).not.toMatch(/fetchUser:\s*\(args:\s*\{\s*\}\)/);
|
|
});
|
|
|
|
test("path-based runnable without schema falls back to {}", async () => {
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "noSchema.yaml"),
|
|
"type: script\npath: u/admin/no_schema\nfields: {}\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const runnables = await loadRunnablesFromBackend(backendDir);
|
|
const ts = buildWmillTs(runnables);
|
|
|
|
expect(ts).toMatch(/noSchema:\s*\(args:\s*\{\s*\}\)/);
|
|
});
|
|
});
|
|
|
|
describe("inferRunnableSchemaFromFile (regression: new backend-folder format)", () => {
|
|
test("infers schema from inline runnable using sibling .ts file", async () => {
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "compute.yaml"),
|
|
"type: inline\nfields: {}\n",
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "compute.ts"),
|
|
"export async function main(x: number, label: string) {\n" +
|
|
" return `${label}:${x}`;\n" +
|
|
"}\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const result = await inferRunnableSchemaFromFile(tempDir, "compute.ts");
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result!.runnableId).toBe("compute");
|
|
expect(result!.schema).toBeDefined();
|
|
|
|
// The TS parser produces an object schema with one property per arg.
|
|
const props = result!.schema.properties as Record<string, any>;
|
|
expect(Object.keys(props).sort()).toEqual(["label", "x"]);
|
|
expect(props.x.type).toBe("number");
|
|
expect(props.label.type).toBe("string");
|
|
});
|
|
|
|
test("end-to-end: inferred schema flows into wmill.d.ts via inline override", async () => {
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "compute.yaml"),
|
|
"type: inline\nfields: {}\n",
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "compute.ts"),
|
|
"export async function main(x: number) {\n return x;\n}\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const result = await inferRunnableSchemaFromFile(tempDir, "compute.ts");
|
|
expect(result).toBeDefined();
|
|
|
|
const runnables = await loadRunnablesFromBackend(backendDir);
|
|
const ts = buildWmillTs(runnables, { compute: result!.schema });
|
|
// Without the fix, inferRunnableSchemaFromFile would have returned undefined
|
|
// and the runnable would render as `args: {}`.
|
|
expect(ts).toContain("compute:");
|
|
expect(ts).toMatch(/compute:\s*\(args:\s*\{[^}]*x:/);
|
|
expect(ts).not.toMatch(/compute:\s*\(args:\s*\{\s*\}\)/);
|
|
});
|
|
|
|
test("returns undefined for path-based runnables", async () => {
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "remote.yaml"),
|
|
"type: script\npath: u/admin/remote\nfields: {}\n",
|
|
"utf-8",
|
|
);
|
|
// No code file - this is a path runnable. Simulate someone touching a file
|
|
// matching the runnableId pattern (shouldn't happen in practice but the
|
|
// function must not crash).
|
|
fs.writeFileSync(path.join(backendDir, "remote.ts"), "// placeholder", "utf-8");
|
|
|
|
const result = await inferRunnableSchemaFromFile(tempDir, "remote.ts");
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
test("handles compound extensions like bun.ts", async () => {
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "tagged.yaml"),
|
|
"type: inline\nfields: {}\n",
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "tagged.bun.ts"),
|
|
"export async function main(value: string) {\n return value;\n}\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const result = await inferRunnableSchemaFromFile(tempDir, "tagged.bun.ts");
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result!.runnableId).toBe("tagged");
|
|
const props = result!.schema.properties as Record<string, any>;
|
|
expect(props.value.type).toBe("string");
|
|
});
|
|
});
|
|
|
|
describe("inferAllInlineSchemas (startup seed for wmill.d.ts)", () => {
|
|
test("seeds schemas for every inline code file in the backend folder", async () => {
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "alpha.yaml"),
|
|
"type: inline\nfields: {}\n",
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "alpha.ts"),
|
|
"export async function main(a: string, b: number) {\n return { a, b };\n}\n",
|
|
"utf-8",
|
|
);
|
|
|
|
// Code-only (no YAML) - auto-detected as inline by loadRunnablesFromBackend
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "beta.bun.ts"),
|
|
"export async function main(flag: boolean) {\n return flag;\n}\n",
|
|
"utf-8",
|
|
);
|
|
|
|
// Path-based runnable - schema lives remotely, must be skipped here
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "gamma.yaml"),
|
|
"type: script\npath: u/admin/gamma\nfields: {}\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const seeded = await inferAllInlineSchemas(tempDir);
|
|
|
|
expect(Object.keys(seeded).sort()).toEqual(["alpha", "beta"]);
|
|
expect(seeded.alpha.properties.a.type).toBe("string");
|
|
expect(seeded.alpha.properties.b.type).toBe("number");
|
|
expect(seeded.beta.properties.flag.type).toBe("boolean");
|
|
expect(seeded.gamma).toBeUndefined();
|
|
});
|
|
|
|
test("returns empty map when backend folder is missing", async () => {
|
|
fs.rmSync(backendDir, { recursive: true });
|
|
const seeded = await inferAllInlineSchemas(tempDir);
|
|
expect(seeded).toEqual({});
|
|
});
|
|
|
|
test("e2e: initial wmill.d.ts has typed args without any file edits", async () => {
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "greet.yaml"),
|
|
"type: inline\nfields: {}\n",
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(backendDir, "greet.ts"),
|
|
"export async function main(name: string, times: number) {\n" +
|
|
" return Array(times).fill(`hi ${name}`);\n" +
|
|
"}\n",
|
|
"utf-8",
|
|
);
|
|
|
|
// Mirror the dev-server startup pipeline: seed schemas, then render.
|
|
const seeded = await inferAllInlineSchemas(tempDir);
|
|
const runnables = await loadRunnablesFromBackend(backendDir);
|
|
const ts = buildWmillTs(runnables, seeded);
|
|
|
|
// Without the startup seed this would render as `greet: (args: {}) => ...`
|
|
// because inferredSchemas would be empty until the watcher fires.
|
|
expect(ts).toMatch(/greet:\s*\(args:\s*\{[^}]*name/);
|
|
expect(ts).toContain("name");
|
|
expect(ts).toContain("times");
|
|
expect(ts).not.toMatch(/greet:\s*\(args:\s*\{\s*\}\)/);
|
|
});
|
|
});
|