Files
windmill/cli/test/app_dev_wmill_dts_unit.test.ts
Ruben Fiszel 2f58a31d00 fix(cli): preserve case in raw-app runnable filenames (#8940)
* 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>
2026-04-27 04:02:39 +00:00

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*\}\)/);
});
});