mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 00:02:23 +00:00
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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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*\}\)/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user