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 <noreply@anthropic.com>

* feat: seed inferred schemas at wmill app dev startup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-04-20 10:22:31 -07:00
committed by GitHub
parent f35e10cc0a
commit 12c08cc95c
4 changed files with 625 additions and 33 deletions
+91 -18
View File
@@ -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<InferredSchemaResult | undefined> {
// 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<Record<string, any>> {
const schemas: Record<string, any> = {};
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<string, any>, extension: string) {
return Object.keys(elems)
.filter((p) => p.endsWith(SEP + extension))
+135 -15
View File
@@ -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<string, any> = {};
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<string, any> = {};
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<string, any> = {}) {
async function genRunnablesTs(
inlineSchemaOverrides: Record<string, any> = {},
pathSchemaOverrides: Record<string, any> = {},
) {
log.info(colors.blue("🔄 Generating wmill.d.ts..."));
const localPath = process.cwd();
@@ -1343,24 +1381,106 @@ async function genRunnablesTs(schemaOverrides: Record<string, any> = {}) {
}
}
// 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<string, any>,
inlineSchemaOverrides: Record<string, any> = {},
pathSchemaOverrides: Record<string, any> = {},
): 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<string, any>,
): Promise<Record<string, any>> {
const schemas: Record<string, any> = {};
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.
+115
View File
@@ -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);
});
});
+284
View File
@@ -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<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*\}\)/);
});
});