From 12c08cc95c2aedbcad50fe0e6392b30fd8438e49 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 20 Apr 2026 10:22:31 -0700 Subject: [PATCH] fix: populate wmill.d.ts schemas in wmill app dev (#8882) * fix: populate wmill.d.ts schemas in wmill app dev Co-Authored-By: Claude Opus 4.5 * feat: seed inferred schemas at wmill app dev startup Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- cli/src/commands/app/app_metadata.ts | 109 ++++++-- cli/src/commands/app/dev.ts | 150 +++++++++-- cli/test/app_dev_path_schema_fetch.test.ts | 115 +++++++++ cli/test/app_dev_wmill_dts_unit.test.ts | 284 +++++++++++++++++++++ 4 files changed, 625 insertions(+), 33 deletions(-) create mode 100644 cli/test/app_dev_path_schema_fetch.test.ts create mode 100644 cli/test/app_dev_wmill_dts_unit.test.ts diff --git a/cli/src/commands/app/app_metadata.ts b/cli/src/commands/app/app_metadata.ts index ce8dcf8da9..d0b07c4872 100644 --- a/cli/src/commands/app/app_metadata.ts +++ b/cli/src/commands/app/app_metadata.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { readFile, mkdir } from "node:fs/promises"; +import { readFile, mkdir, readdir } from "node:fs/promises"; import { colors } from "@cliffy/ansi/colors"; import * as log from "../../core/log.ts"; import { sep as SEP } from "node:path"; @@ -34,6 +34,8 @@ import { } from "./raw_apps.ts"; import { replaceInlineScripts, AppFile as NormalAppFile } from "./app.ts"; import { + EXTENSION_TO_LANGUAGE, + getLanguageFromExtension, newPathAssigner, newRawAppPathAssigner, SupportedLanguage, @@ -788,9 +790,11 @@ export interface InferredSchemaResult { */ export async function inferRunnableSchemaFromFile( appFolder: string, - runnableFilePath: string + runnableFilePath: string, + defaultTs: "bun" | "deno" = "bun", ): Promise { - // Extract runnable ID from file path (e.g., "myRunnable.ts" -> "myRunnable") + // Extract runnable ID from file path (e.g., "myRunnable.ts" -> "myRunnable", + // "fetch_data.bun.ts" -> "fetch_data") const fileName = path.basename(runnableFilePath); // Skip lock files and yaml files (runnable metadata) @@ -798,14 +802,23 @@ export async function inferRunnableSchemaFromFile( return undefined; } - // Match pattern: {runnableId}.{ext} - extract the runnable ID (everything before the last dot) - const match = fileName.match(/^(.+)\.[^.]+$/); - if (!match) { + // Match the longest known extension so compound extensions like "bun.ts" or + // "pg.sql" win over the trailing single-segment extension. + let runnableId: string | undefined; + let ext: string | undefined; + for (const knownExt of Object.keys(EXTENSION_TO_LANGUAGE)) { + if ( + fileName.endsWith("." + knownExt) && + (!ext || knownExt.length > ext.length) + ) { + ext = knownExt; + runnableId = fileName.slice(0, -(knownExt.length + 1)); + } + } + if (!runnableId || !ext) { return undefined; } - const runnableId = match[1]; - // Read the runnable from its separate YAML file (new format) const runnableFilePath2 = path.join( appFolder, @@ -829,20 +842,40 @@ export async function inferRunnableSchemaFromFile( } runnable = appFile.runnables[runnableId]; } catch { - log.warn( - colors.yellow(`Could not read runnable ${runnableId} from any source`) - ); - return undefined; + // No YAML at all - in the new backend-folder format a code-only runnable + // (no .yaml sibling) is treated as inline. We can still infer its schema. + runnable = { type: "inline" }; } } - // Only process inline scripts - if (!runnable?.inlineScript) { + // Determine language and current schema. Two cases: + // - Old format: the YAML carries an `inlineScript` block with language + schema + // - New format: the YAML only declares `type: inline` and the language is + // derived from the code file's extension (the inlineScript is reconstructed + // from sibling files at load time). + let language: SupportedLanguage | undefined; + let currentSchema: any; + if (runnable?.inlineScript) { + language = runnable.inlineScript.language as SupportedLanguage; + currentSchema = runnable.inlineScript.schema; + } else if ( + runnable?.type === "inline" || + runnable?.type === "runnableByName" + ) { + language = getLanguageFromExtension(ext, defaultTs); + } else { + // Path-based runnable or unknown type - schema lives at the script/flow path return undefined; } - const inlineScript = runnable.inlineScript; - const language = inlineScript.language as SupportedLanguage; + if (!language) { + log.warn( + colors.yellow( + `Could not determine language for ${runnableId} (ext: ${ext})` + ) + ); + return undefined; + } // Read the actual content from the file const fullFilePath = path.join( @@ -858,8 +891,6 @@ export async function inferRunnableSchemaFromFile( return undefined; } - // Infer schema from script content - const currentSchema = inlineScript.schema; const remotePath = appFolder.replaceAll(SEP, "/"); try { @@ -885,6 +916,48 @@ export async function inferRunnableSchemaFromFile( } } +/** + * Infers schemas for every inline runnable code file in the app's backend + * folder. Used at `wmill app dev` startup so the initial wmill.d.ts has typed + * args without waiting for a file change to trigger the watcher. + * + * Returns a map of runnableId -> schema. Files that fail inference or are not + * inline runnables are silently skipped - their entries will fall back to + * `args: {}` in the generated d.ts. + */ +export async function inferAllInlineSchemas( + appFolder: string, + defaultTs: "bun" | "deno" = "bun", +): Promise> { + const schemas: Record = {}; + const backendPath = path.join(appFolder, APP_BACKEND_FOLDER); + + let entries: Array<{ name: string; isFile: () => boolean }>; + try { + entries = await readdir(backendPath, { withFileTypes: true }); + } catch { + // No backend folder (e.g. old-format app using raw_app.yaml only) + return schemas; + } + + for (const entry of entries) { + if (!entry.isFile()) continue; + const fileName = entry.name; + if (fileName.endsWith(".yaml") || fileName.endsWith(".lock")) continue; + + const result = await inferRunnableSchemaFromFile( + appFolder, + fileName, + defaultTs, + ); + if (result) { + schemas[result.runnableId] = result.schema; + } + } + + return schemas; +} + export function getAppFolders(elems: Record, extension: string) { return Object.keys(elems) .filter((p) => p.endsWith(SEP + extension)) diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index ad704ee8dd..e120fcf08a 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -30,6 +30,7 @@ import { replaceInlineScripts, repopulateFields } from "./app.ts"; import { Runnable } from "./metadata.ts"; import { APP_BACKEND_FOLDER, + inferAllInlineSchemas, inferRunnableSchemaFromFile, } from "./app_metadata.ts"; import { loadRunnablesFromBackend } from "./raw_apps.ts"; @@ -417,9 +418,42 @@ async function dev(opts: DevOptions, appFolder?: string) { // In-memory cache of inferred schemas (runnableId -> schema) // Used to generate wmill.d.ts without modifying raw_app.yaml + // Seed with schemas inferred from every inline code file in the backend folder + // so the initial wmill.d.ts already has typed args (without waiting for a file + // change to trigger the watcher). const inferredSchemas: Record = {}; + try { + Object.assign(inferredSchemas, await inferAllInlineSchemas(process.cwd())); + } catch (err: any) { + log.warn( + colors.yellow( + `Could not seed inline schemas at startup: ${err.message}`, + ), + ); + } - genRunnablesTs(inferredSchemas); + // In-memory cache of schemas for path-based runnables fetched from the API. + // Path-based runnables don't carry their schema in the local YAML (the script / + // flow at the path is the source of truth), so we fetch once at dev start and + // reuse for every wmill.d.ts regeneration. + const pathRunnableSchemas: Record = {}; + try { + const initialRunnables = await loadRunnablesFromBackend( + path.join(process.cwd(), APP_BACKEND_FOLDER), + ); + Object.assign( + pathRunnableSchemas, + await fetchPathRunnableSchemas(workspaceId, initialRunnables), + ); + } catch (err: any) { + log.warn( + colors.yellow( + `Could not fetch schemas for path-based runnables: ${err.message}`, + ), + ); + } + + await genRunnablesTs(inferredSchemas, pathRunnableSchemas); // Ensure dist directory exists const distDir = path.join(process.cwd(), "dist"); @@ -589,7 +623,7 @@ async function dev(opts: DevOptions, appFolder?: string) { ), ); // Regenerate wmill.d.ts with updated schema from memory - await genRunnablesTs(inferredSchemas); + await genRunnablesTs(inferredSchemas, pathRunnableSchemas); } } catch (error: any) { log.error( @@ -1320,9 +1354,13 @@ export default command; * or falls back to raw_app.yaml (old format). * Merges in-memory inferred schemas with runnables. * - * @param schemaOverrides - In-memory schema overrides (runnableId -> schema) + * @param inlineSchemaOverrides - Inferred schemas for inline runnables (runnableId -> schema) + * @param pathSchemaOverrides - Schemas fetched from the API for path-based runnables (runnableId -> schema) */ -async function genRunnablesTs(schemaOverrides: Record = {}) { +async function genRunnablesTs( + inlineSchemaOverrides: Record = {}, + pathSchemaOverrides: Record = {}, +) { log.info(colors.blue("🔄 Generating wmill.d.ts...")); const localPath = process.cwd(); @@ -1343,24 +1381,106 @@ async function genRunnablesTs(schemaOverrides: Record = {}) { } } - // Apply schema overrides from in-memory cache - if (Object.keys(schemaOverrides).length > 0) { - for (const [runnableId, schema] of Object.entries(schemaOverrides)) { - if (runnables[runnableId]?.inlineScript) { - runnables[runnableId].inlineScript.schema = schema; - runnables[runnableId].type = "inline"; - } - } - } - try { - const newWmillTs = windmillUtils.genWmillTs(runnables); + const newWmillTs = buildWmillTs( + runnables, + inlineSchemaOverrides, + pathSchemaOverrides, + ); writeFileSync(path.join(process.cwd(), "wmill.d.ts"), newWmillTs); } catch (error: any) { log.error(colors.red(`Failed to generate wmill.d.ts: ${error.message}`)); } } +/** + * Merges inline + path schema overrides into the runnables map and renders the + * wmill.d.ts source via shared-utils. Exported so unit tests can exercise the + * exact pipeline without touching disk. + */ +export function buildWmillTs( + runnables: Record, + inlineSchemaOverrides: Record = {}, + pathSchemaOverrides: Record = {}, +): string { + // Apply inline schema overrides (inferred locally from script content) + for (const [runnableId, schema] of Object.entries(inlineSchemaOverrides)) { + if (runnables[runnableId]?.inlineScript) { + runnables[runnableId].inlineScript.schema = schema; + runnables[runnableId].type = "inline"; + } + } + + // Apply path-based runnable schemas (fetched from the API) + for (const [runnableId, schema] of Object.entries(pathSchemaOverrides)) { + const runnable = runnables[runnableId]; + if (runnable?.type === "path" && schema) { + runnable.schema = schema; + } + } + + // Defensive: shared-utils' genWmillTs crashes on path runnables with an + // undefined schema (it calls removeStaticFields(undefined, ...)). Stamp an + // empty schema so the generated d.ts falls back cleanly to `args: {}`. + for (const runnable of Object.values(runnables)) { + if (runnable?.type === "path" && !runnable.schema) { + runnable.schema = {}; + } + } + + return windmillUtils.genWmillTs(runnables); +} + +/** + * Fetches schemas from the Windmill API for path-based runnables (script / flow). + * Returns a map of runnableId -> schema. Path-based runnables don't store their + * schema locally (the script or flow at the path is the source of truth), so we + * fetch them once at dev start to type the args in wmill.d.ts. + * + * Failures (network, missing script) are logged but never thrown - the + * corresponding runnable will fall back to `args: {}` in the generated types. + * + * Exported for testing. + */ +export async function fetchPathRunnableSchemas( + workspaceId: string, + runnables: Record, +): Promise> { + const schemas: Record = {}; + for (const [runnableId, runnable] of Object.entries(runnables)) { + if (runnable?.type !== "path" || !runnable.path) continue; + if (runnable.schema) { + // Already populated locally - keep it (e.g. fixture / offline mode) + schemas[runnableId] = runnable.schema; + continue; + } + try { + if (runnable.runType === "script") { + const script = await wmill.getScriptByPath({ + workspace: workspaceId, + path: runnable.path, + }); + if (script.schema) schemas[runnableId] = script.schema; + } else if (runnable.runType === "flow") { + const flow = await wmill.getFlowByPath({ + workspace: workspaceId, + path: runnable.path, + }); + const flowSchema = (flow as any)?.value?.schema ?? (flow as any)?.schema; + if (flowSchema) schemas[runnableId] = flowSchema; + } + // hubscript schemas are not fetched (no scoped API); falls back to {} + } catch (err: any) { + log.warn( + colors.yellow( + `Failed to fetch schema for ${runnable.runType} ${runnable.path}: ${err.message}`, + ), + ); + } + } + return schemas; +} + /** * Convert runnables from file format to API format. * File format uses type: "script"|"hubscript"|"flow" for path-based runnables. diff --git a/cli/test/app_dev_path_schema_fetch.test.ts b/cli/test/app_dev_path_schema_fetch.test.ts new file mode 100644 index 0000000000..d2109de873 --- /dev/null +++ b/cli/test/app_dev_path_schema_fetch.test.ts @@ -0,0 +1,115 @@ +/** + * Integration test for `wmill app dev`'s schema-fetch path. + * + * Verifies that path-based runnables (type: script) get a real schema in the + * generated wmill.d.ts after fetchPathRunnableSchemas() pulls it from the + * Windmill API. Without this fix the runnable would always render as + * `args: {}` because the local YAML doesn't carry the schema. + */ + +import { expect, test } from "bun:test"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { mkdir, writeFile } from "node:fs/promises"; +import { setClient } from "../src/core/client.ts"; +import { APP_BACKEND_FOLDER } from "../src/commands/app/app_metadata.ts"; +import { loadRunnablesFromBackend } from "../src/commands/app/raw_apps.ts"; +import { + buildWmillTs, + fetchPathRunnableSchemas, +} from "../src/commands/app/dev.ts"; + +const TYPED_SCRIPT_CONTENT = + `export async function main(userId: string, includeProfile: boolean) {\n` + + ` return { userId, includeProfile };\n` + + `}\n`; + +test("fetchPathRunnableSchemas pulls schema for a path-based script and feeds wmill.d.ts", async () => { + await withTestBackend(async (backend, tempDir) => { + // Bind CLI workspace + arm the OpenAPI client + await addWorkspace( + { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "app_dev_schema_fetch", + token: backend.token, + }, + { force: true, configDir: backend.testConfigDir }, + ); + setClient(backend.token, backend.baseUrl); + + // Create the folder + script that the raw app will reference by path + await backend + .apiRequest!(`/api/w/${backend.workspace}/folders/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "test" }), + }) + .then((r) => r.text()); + + const scriptPath = "f/test/fetch_user"; + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: scriptPath, + content: TYPED_SCRIPT_CONTENT, + language: "bun", + summary: "Fetch user", + description: "Test script with typed args", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + userId: { type: "string", description: "user id" }, + includeProfile: { type: "boolean" }, + }, + required: ["userId", "includeProfile"], + }, + }), + }, + ); + expect(createResp.status).toBeLessThan(300); + + // Lay out a raw app with a backend folder that references the path script + const appDir = path.join(tempDir, "f", "test", "schema_fetch_app.raw_app"); + const backendDir = path.join(appDir, APP_BACKEND_FOLDER); + await mkdir(backendDir, { recursive: true }); + await writeFile( + path.join(backendDir, "fetchUser.yaml"), + `type: script\npath: ${scriptPath}\nfields: {}\nname: fetchUser\n`, + "utf-8", + ); + + // Drive the dev pipeline directly: load -> enrich from API -> render + const runnables = await loadRunnablesFromBackend(backendDir); + expect(runnables.fetchUser?.type).toBe("path"); + expect(runnables.fetchUser?.runType).toBe("script"); + expect(runnables.fetchUser?.schema).toBeUndefined(); + + const pathSchemas = await fetchPathRunnableSchemas( + backend.workspace, + runnables, + ); + expect(pathSchemas.fetchUser).toBeDefined(); + expect(pathSchemas.fetchUser.properties.userId).toBeDefined(); + expect(pathSchemas.fetchUser.properties.includeProfile).toBeDefined(); + + const ts = buildWmillTs(runnables, {}, pathSchemas); + + // Without the fix the line would be `fetchUser: (args: {}) => ...` + expect(ts).toContain("userId"); + expect(ts).toContain("includeProfile"); + expect(ts).toMatch(/fetchUser:\s*\(args:\s*\{[^}]*userId/); + expect(ts).not.toMatch(/fetchUser:\s*\(args:\s*\{\s*\}\)/); + + // Sanity: the generated d.ts is syntactically what the dev server writes + const outPath = path.join(tempDir, "wmill.d.ts.out"); + fs.writeFileSync(outPath, ts); + expect(fs.statSync(outPath).size).toBeGreaterThan(0); + }); +}); diff --git a/cli/test/app_dev_wmill_dts_unit.test.ts b/cli/test/app_dev_wmill_dts_unit.test.ts new file mode 100644 index 0000000000..c2fdfda4ff --- /dev/null +++ b/cli/test/app_dev_wmill_dts_unit.test.ts @@ -0,0 +1,284 @@ +/** + * 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("); + }); +}); + +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; + 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; + 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*\}\)/); + }); +});