feat(cli): use local scripts when previewing flows (#8365)

* feat(cli): use local scripts when previewing flows

When previewing a flow, PathScript modules (type: "script") now resolve
to local file content instead of remote versions. This ensures flow
preview and dev mode test the actual local changes.

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

* test(cli): add tests for PathScript local replacement in flow preview

Unit tests for replacePathScriptsWithLocal covering:
- basic PathScript→RawScript conversion
- tag_override preservation
- missing local file fallback
- mixed module types
- nested structures (loops, branches)

Integration test verifying flow preview with a PathScript step
uses the local script file content.

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

* refactor(cli): extract shared helpers and add aiagent support for PathScript replacement

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

* refactor(cli): replace `as any` casts with proper type assertions

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

* fix(cli): preserve local flow preview script context

* fix(cli): normalize inline flow preview bundles for bun

* fix(cli): make local flow path scripts opt-in

* fix(cli): only merge flow preview config for local mode

* chore(system-prompts): regenerate cli command guidance

* fix(cli): skip deno defaultTs test in CI without deno runtime

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

* chore(cli): clean up local path script helpers

* feat(cli): make flow preview use local path scripts

* fix(cli): ignore normalized preview metadata drift

* chore(cli): address review follow-ups

* test(cli): cover custom bundler path quoting

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-03-18 11:29:30 +01:00
committed by GitHub
parent 997dd6ac3a
commit 435de95e7d
11 changed files with 1142 additions and 15 deletions

View File

@@ -16,33 +16,34 @@ import { resolveWorkspace } from "../../core/context.ts";
import {
SyncOptions,
mergeConfigWithConfigFile,
readConfigFile,
} from "../../core/conf.ts";
import { exts, removeExtensionToPath } from "../script/script.ts";
import { inferContentTypeFromFilePath } from "../../utils/script_common.ts";
import { OpenFlow } from "../../../gen/types.gen.ts";
import { FlowFile } from "../flow/flow.ts";
import { replaceInlineScripts } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { replaceInlineScripts, replaceAllPathScriptsWithLocal } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { parseMetadataFile } from "../../utils/metadata.ts";
import {
getFolderSuffixWithSep,
getMetadataFileName,
extractFolderPath,
} from "../../utils/resource_folders.ts";
import { listSyncCodebases } from "../../utils/codebase.ts";
import { createPreviewLocalScriptReader } from "../../utils/local_path_scripts.ts";
const PORT = 3001;
async function dev(opts: GlobalOptions & SyncOptions) {
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
log.info("Started dev mode");
const conf = await readConfigFile();
let currentLastEdit: LastEditScript | LastEditFlow | undefined = undefined;
const fsWatcher = watch(".", { recursive: true });
const base = await realpath(".");
opts = await mergeConfigWithConfigFile(opts);
const ignore = await ignoreF(opts);
const codebases = await listSyncCodebases(opts);
const changesTimeouts: Record<string, ReturnType<typeof setTimeout>> = {};
function watchChanges() {
@@ -56,7 +57,11 @@ async function dev(opts: GlobalOptions & SyncOptions) {
}
changesTimeouts[key] = setTimeout(async () => {
delete changesTimeouts[key];
await loadPaths([filePath]);
await loadPaths([filePath]).catch((error) => {
log.error(
`Failed to reload ${filePath}: ${error instanceof Error ? error.message : error}`
);
});
}, 100);
});
fsWatcher.on("error", (err) => {
@@ -94,6 +99,13 @@ async function dev(opts: GlobalOptions & SyncOptions) {
SEP,
undefined,
);
// Replace PathScript modules with local file content so dev mode uses local versions
const localScriptReader = createPreviewLocalScriptReader({
exts,
defaultTs: opts.defaultTs,
codebases,
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
currentLastEdit = {
type: "flow",
flow: localFlow,
@@ -105,7 +117,7 @@ async function dev(opts: GlobalOptions & SyncOptions) {
const content = await readFile(cpath, "utf-8");
const splitted = cpath.split(".");
const wmPath = splitted[0];
const lang = inferContentTypeFromFilePath(cpath, conf.defaultTs);
const lang = inferContentTypeFromFilePath(cpath, opts.defaultTs);
const typed =
(await parseMetadataFile(
removeExtensionToPath(cpath),

View File

@@ -18,8 +18,19 @@ import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts";
import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts";
import { FSFSElement, elementsToMap, ignoreF } from "../sync/sync.ts";
import { Flow } from "../../../gen/types.gen.ts";
import { replaceInlineScripts } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import {
collectPathScriptPaths,
replaceInlineScripts,
replaceAllPathScriptsWithLocal,
} from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { generateFlowLockInternal } from "./flow_metadata.ts";
import { exts } from "../script/script.ts";
import type { SyncCodebase } from "../../utils/codebase.ts";
import { listSyncCodebases } from "../../utils/codebase.ts";
import {
createPreviewLocalScriptReader,
resolvePreviewLocalScriptState,
} from "../../utils/local_path_scripts.ts";
export interface FlowFile {
summary: string;
@@ -28,6 +39,90 @@ export interface FlowFile {
schema?: any;
}
function normalizeOptionalString(value: string | null | undefined): string | undefined {
return typeof value === "string" && value.trim() === "" ? undefined : value ?? undefined;
}
function normalizeComparableContent(value: string | undefined): string | undefined {
return value?.replaceAll("\r\n", "\n").replace(/\n$/, "");
}
async function findDivergedLocalPathScripts(
workspaceId: string,
scriptPaths: string[],
opts: {
exts: string[];
defaultTs?: "bun" | "deno";
codebases: SyncCodebase[];
}
): Promise<{ changed: string[]; missing: string[] }> {
const changed: string[] = [];
const missing: string[] = [];
for (const scriptPath of scriptPaths) {
const localScript = await resolvePreviewLocalScriptState(scriptPath, opts);
if (!localScript) {
continue;
}
let remoteScript;
try {
remoteScript = await wmill.getScriptByPath({
workspace: workspaceId,
path: scriptPath,
});
} catch {
missing.push(scriptPath);
continue;
}
const remoteLock = normalizeOptionalString(remoteScript.lock);
const diverged =
normalizeComparableContent(localScript.content) !==
normalizeComparableContent(remoteScript.content) ||
localScript.language !== remoteScript.language ||
(localScript.lock !== undefined &&
normalizeComparableContent(localScript.lock) !==
normalizeComparableContent(remoteLock)) ||
localScript.tag !== normalizeOptionalString(remoteScript.tag) ||
localScript.codebaseDigest !== normalizeOptionalString(remoteScript.codebase);
if (diverged) {
changed.push(scriptPath);
}
}
return { changed, missing };
}
function warnAboutLocalPathScriptDivergence(
divergence: { changed: string[]; missing: string[] }
): void {
if (divergence.changed.length === 0 && divergence.missing.length === 0) {
return;
}
const details: string[] = [];
if (divergence.changed.length > 0) {
details.push(
`These workspace scripts differ from the deployed version:\n${divergence.changed
.map((path) => `- ${path}`)
.join("\n")}`
);
}
if (divergence.missing.length > 0) {
details.push(
`These scripts do not exist in the workspace yet:\n${divergence.missing
.map((path) => `- ${path}`)
.join("\n")}`
);
}
log.warn(
`Using local PathScript files for flow preview.\n${details.join("\n")}\nUse --remote to preview deployed workspace scripts instead.`
);
}
const alreadySynced: string[] = [];
export async function pushFlow(
@@ -233,11 +328,17 @@ async function preview(
opts: GlobalOptions & {
data?: string;
silent: boolean;
},
remote?: boolean;
} & SyncOptions,
flowPath: string
) {
const useLocalPathScripts = !opts.remote;
if (useLocalPathScripts) {
opts = await mergeConfigWithConfigFile(opts);
}
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const codebases = useLocalPathScripts ? listSyncCodebases(opts) : [];
// Normalize path - ensure it's a directory path to a .flow folder
if (!flowPath.endsWith(".flow") && !flowPath.endsWith(".flow" + SEP)) {
@@ -274,6 +375,31 @@ async function preview(
await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, flowPath, SEP);
}
if (useLocalPathScripts) {
const scriptPaths = collectPathScriptPaths(localFlow.value);
if (scriptPaths.length > 0) {
const divergence = await findDivergedLocalPathScripts(
workspace.workspaceId,
scriptPaths,
{
exts,
defaultTs: opts.defaultTs,
codebases,
}
);
if (!opts.silent) {
warnAboutLocalPathScriptDivergence(divergence);
}
}
const localScriptReader = createPreviewLocalScriptReader({
exts,
defaultTs: opts.defaultTs,
codebases,
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
}
const input = opts.data ? await resolve(opts.data) : {};
if (!opts.silent) {
@@ -444,7 +570,7 @@ const command = new Command()
.action(run as any)
.command(
"preview",
"preview a local flow without deploying it. Runs the flow definition from local files."
"preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default."
)
.arguments("<flow_path:string>")
.option(
@@ -455,6 +581,10 @@ const command = new Command()
"-s --silent",
"Do not output anything other then the final output. Useful for scripting."
)
.option(
"--remote",
"Use deployed workspace scripts for PathScript steps instead of local files."
)
.action(preview as any)
.command(
"generate-locks",

View File

@@ -5039,9 +5039,10 @@ flow related commands
- \`flow run <path:string>\` - run a flow by path.
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting.
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files.
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- \`-s --silent\` - Do not output anything other then the final output. Useful for scripting.
- \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files.
- \`flow generate-locks [flow:file]\` - re-generate the lock files of all inline scripts of all updated flows
- \`--yes\` - Skip confirmation prompt
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)

View File

@@ -0,0 +1,155 @@
import { execFileSync } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import type { SyncCodebase } from "./codebase.ts";
import { parseMetadataFileIfExists } from "./metadata.ts";
import { inferContentTypeFromFilePath } from "./script_common.ts";
import { findCodebase } from "../commands/sync/sync.ts";
import type { LocalScriptInfo } from "../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import type { RawScript } from "../../../gen/types.gen.ts";
export class UnsupportedLocalPathScriptPreviewError extends Error {
constructor(message: string) {
super(message);
this.name = "UnsupportedLocalPathScriptPreviewError";
}
}
async function readOptionalLock(scriptPath: string): Promise<string | undefined> {
try {
return await readFile(scriptPath + ".script.lock", "utf-8");
} catch {
return undefined;
}
}
function normalizeOptionalLock(lock: string | undefined): string | undefined {
return typeof lock === "string" && lock.trim() === "" ? undefined : lock;
}
async function bundleSingleFileCodebaseScript(
filePath: string,
codebase: SyncCodebase
): Promise<string> {
if (codebase.customBundler) {
// Pass the script path as a positional shell argument so existing shell-based
// custom bundlers still work without interpolating the path into the command.
return execFileSync(
"sh",
["-lc", `${codebase.customBundler} "$1"`, "sh", filePath],
{
maxBuffer: 1024 * 1024 * 50,
}
).toString();
}
const esbuild = await import("esbuild");
const out = await esbuild.build({
entryPoints: [filePath],
// Inline rawscripts are executed through the standard module wrapper,
// so the bundle must expose `main` as an ESM export.
format: "esm",
bundle: true,
write: false,
external: codebase.external,
inject: codebase.inject,
define: codebase.define,
loader: codebase.loader ?? { ".node": "file" },
outdir: "/",
platform: "node",
packages: "bundle",
target: "esnext",
banner: codebase.banner,
});
if (out.outputFiles.length === 0) {
throw new Error(`No output files found for ${filePath}`);
}
if (out.outputFiles.length > 1) {
throw new UnsupportedLocalPathScriptPreviewError(
`Local PathScript ${filePath} requires a multi-file bundle, which flow preview/dev cannot inline yet`
);
}
if (Array.isArray(codebase.assets) && codebase.assets.length > 0) {
throw new UnsupportedLocalPathScriptPreviewError(
`Local PathScript ${filePath} requires codebase assets, which flow preview/dev cannot inline yet`
);
}
return out.outputFiles[0].text;
}
export function createPreviewLocalScriptReader(opts: {
exts: string[];
defaultTs?: "bun" | "deno";
codebases: SyncCodebase[];
}): (scriptPath: string) => Promise<LocalScriptInfo | undefined> {
return async (scriptPath) => {
const localScript = await resolvePreviewLocalScriptState(scriptPath, opts);
if (!localScript) {
return undefined;
}
const content = localScript.codebase
? await bundleSingleFileCodebaseScript(localScript.filePath, localScript.codebase)
: localScript.content;
return {
content,
language: localScript.language,
lock: localScript.lock,
tag: localScript.tag,
};
};
}
export type PreviewLocalScriptState = {
filePath: string;
content: string;
language: RawScript["language"];
lock?: string;
tag?: string;
codebase?: SyncCodebase;
codebaseDigest?: string;
};
export async function resolvePreviewLocalScriptState(
scriptPath: string,
opts: {
exts: string[];
defaultTs?: "bun" | "deno";
codebases: SyncCodebase[];
}
): Promise<PreviewLocalScriptState | undefined> {
for (const ext of opts.exts) {
const filePath = scriptPath + ext;
let fileStat;
try {
fileStat = await stat(filePath);
} catch {
continue;
}
if (!fileStat.isFile()) continue;
const language = inferContentTypeFromFilePath(filePath, opts.defaultTs);
const metadata = await parseMetadataFileIfExists(scriptPath);
const rawLock = metadata?.payload?.lock ?? (await readOptionalLock(scriptPath));
const codebase =
language === "bun" ? findCodebase(filePath, opts.codebases) : undefined;
return {
filePath,
content: await readFile(filePath, "utf-8"),
language,
lock: normalizeOptionalLock(rawLock),
tag: metadata?.payload?.tag,
codebase,
codebaseDigest: codebase
? await codebase.getDigest(
Array.isArray(codebase.assets) && codebase.assets.length > 0
)
: undefined,
};
}
return undefined;
}

View File

@@ -921,6 +921,38 @@ export function replaceLock(o?: { lock?: string | string[] }) {
}
}
}
export async function parseMetadataFileIfExists(
scriptPath: string
): Promise<{ isJson: boolean; payload: any; path: string } | undefined> {
let metadataFilePath = scriptPath + ".script.json";
try {
await stat(metadataFilePath);
const payload = JSON.parse(await readFile(metadataFilePath, "utf-8"));
replaceLock(payload);
return {
path: metadataFilePath,
payload,
isJson: true,
};
} catch {
try {
metadataFilePath = scriptPath + ".script.yaml";
await stat(metadataFilePath);
const payload: any = await yamlParseFile(metadataFilePath);
replaceLock(payload);
return {
path: metadataFilePath,
payload,
isJson: false,
};
} catch {
return undefined;
}
}
}
export async function parseMetadataFile(
scriptPath: string,
generateMetadataIfMissing:

View File

@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import { mkdir, writeFile } from "node:fs/promises";
import { withTestBackend } from "./test_backend.ts";
import { shouldSkipOnCI } from "./cargo_backend.ts";
// =============================================================================
// PREVIEW COMMAND INTEGRATION TESTS
@@ -27,6 +28,7 @@ async function createWmillConfig(
relative_path: string;
includes?: string[];
format?: "cjs" | "esm";
customBundler?: string;
assets?: Array<{ from: string; to: string }>;
}>;
}
@@ -44,6 +46,9 @@ async function createWmillConfig(
if (cb.format) {
yamlContent += ` format: ${cb.format}\n`;
}
if (cb.customBundler) {
yamlContent += ` customBundler: ${JSON.stringify(cb.customBundler)}\n`;
}
if (cb.assets && cb.assets.length > 0) {
yamlContent += " assets:\n";
for (const asset of cb.assets) {
@@ -122,6 +127,39 @@ schema:
await writeFile(`${dir}/flow.yaml`, flowYaml, "utf-8");
}
async function createPathScriptFlow(
tempDir: string,
flowPath: string,
options: {
summary: string;
scriptPath: string;
inputTransforms?: string;
}
): Promise<void> {
const dir = `${tempDir}/${flowPath}`;
await mkdir(dir, { recursive: true });
const inputTransforms = options.inputTransforms
? ` input_transforms:\n${options.inputTransforms}`
: " input_transforms: {}\n";
const flowYaml = `summary: "${options.summary}"
description: "Test flow"
value:
modules:
- id: "a"
value:
type: "script"
path: "${options.scriptPath}"
${inputTransforms}
schema:
$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
properties: {}
required: []
`;
await writeFile(`${dir}/flow.yaml`, flowYaml, "utf-8");
}
// =============================================================================
// SCRIPT PREVIEW TESTS
// =============================================================================
@@ -491,3 +529,280 @@ test("flow preview: simple flow", async () => {
});
});
test("flow preview: uses local PathScript by default and remote PathScript with --remote", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, { defaultTs: "bun" });
await createScript(
tempDir,
"f/test/helper_script.ts",
`export function main(name: string = "World") { return \`Remote script says: \${name}!\`; }`
);
const pushResult = await backend.runCLICommand(
["script", "push", "f/test/helper_script.ts"],
tempDir
);
expect(pushResult.code).toEqual(0);
await writeFile(
`${tempDir}/f/test/helper_script.ts`,
`export function main(name: string = "World") { return \`Local script says: \${name}!\`; }`,
"utf-8"
);
await createPathScriptFlow(tempDir, "f/test/path_flow.flow", {
summary: "Flow with PathScript",
scriptPath: "f/test/helper_script",
inputTransforms: ` name:
type: "static"
value: "PathTest"
`,
});
const localResult = await backend.runCLICommand(
["flow", "preview", "f/test/path_flow.flow"],
tempDir
);
expect(localResult.code).toEqual(0);
expect(localResult.stdout + localResult.stderr).toContain(
"Local script says: PathTest!"
);
expect(localResult.stdout + localResult.stderr).toContain(
"Using local PathScript files for flow preview."
);
expect(localResult.stdout + localResult.stderr).toContain(
"These workspace scripts differ from the deployed version:\n- f/test/helper_script"
);
const remoteResult = await backend.runCLICommand(
["flow", "preview", "--remote", "f/test/path_flow.flow"],
tempDir
);
expect(remoteResult.code).toEqual(0);
expect(remoteResult.stdout + remoteResult.stderr).toContain(
"Remote script says: PathTest!"
);
expect(remoteResult.stdout + remoteResult.stderr).not.toContain(
"Using local PathScript files for flow preview."
);
});
});
test.skipIf(shouldSkipOnCI())("flow preview: respects defaultTs when resolving local PathScripts", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, { defaultTs: "deno" });
await createScript(
tempDir,
"f/test/deno_helper.ts",
`export function main() { return Deno.version.deno ? "deno-runtime" : "missing"; }`
);
await createPathScriptFlow(tempDir, "f/test/deno_path_flow.flow", {
summary: "Flow with Deno PathScript",
scriptPath: "f/test/deno_helper",
});
const result = await backend.runCLICommand(
["flow", "preview", "f/test/deno_path_flow.flow"],
tempDir
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain("deno-runtime");
});
});
test("flow preview: bundles local PathScripts with local imports", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, {
defaultTs: "bun",
codebases: [{ relative_path: "f/flow_codebase", includes: ["**"] }],
});
await mkdir(`${tempDir}/f/flow_codebase`, { recursive: true });
await writeFile(
`${tempDir}/f/flow_codebase/helper.ts`,
`export function greet(name: string): string {
return \`Hello from local flow codebase, \${name}!\`;
}`,
"utf-8"
);
await createScript(
tempDir,
"f/flow_codebase/main_script.ts",
`import { greet } from "./helper";
export function main(name: string = "World") {
return greet(name);
}`
);
await createPathScriptFlow(tempDir, "f/test/importing_path_flow.flow", {
summary: "Flow with imported PathScript",
scriptPath: "f/flow_codebase/main_script",
inputTransforms: ` name:
type: "static"
value: "FlowTest"
`,
});
const result = await backend.runCLICommand(
["flow", "preview", "f/test/importing_path_flow.flow"],
tempDir
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain(
"Hello from local flow codebase, FlowTest!"
);
});
});
test("flow preview: customBundler handles script paths with spaces", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, {
defaultTs: "bun",
codebases: [{
relative_path: "f/codebase custom",
includes: ["f/codebase custom/**"],
customBundler: "cat",
}],
});
await createScript(
tempDir,
"f/codebase custom/custom bundler.ts",
`export function main() {
return "Custom bundler path with spaces";
}`
);
await createPathScriptFlow(tempDir, "f/test/custom_bundler_path.flow", {
summary: "Flow with customBundler path",
scriptPath: "f/codebase custom/custom bundler",
});
const result = await backend.runCLICommand(
["flow", "preview", "f/test/custom_bundler_path.flow"],
tempDir
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain(
"Custom bundler path with spaces"
);
});
});
test("flow preview: warns when local PathScript is not deployed remotely", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, { defaultTs: "bun" });
await createScript(
tempDir,
"f/test/undeployed_helper.ts",
`export function main() { return "Local only script"; }`
);
await createPathScriptFlow(tempDir, "f/test/undeployed_path_flow.flow", {
summary: "Flow with undeployed PathScript",
scriptPath: "f/test/undeployed_helper",
});
const result = await backend.runCLICommand(
["flow", "preview", "f/test/undeployed_path_flow.flow"],
tempDir
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain("Local only script");
expect(result.stdout + result.stderr).toContain(
"These scripts do not exist in the workspace yet:\n- f/test/undeployed_helper"
);
});
});
test("flow preview: does not warn when local and deployed PathScripts match", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, { defaultTs: "bun" });
await createScript(
tempDir,
"f/test/matching_helper.ts",
`export function main() { return "Matching script"; }`
);
const pushResult = await backend.runCLICommand(
["script", "push", "f/test/matching_helper.ts"],
tempDir
);
expect(pushResult.code).toEqual(0);
await createPathScriptFlow(tempDir, "f/test/matching_path_flow.flow", {
summary: "Flow with matching PathScript",
scriptPath: "f/test/matching_helper",
});
const result = await backend.runCLICommand(
["flow", "preview", "f/test/matching_path_flow.flow"],
tempDir
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain("Matching script");
expect(result.stdout + result.stderr).not.toContain(
"Using local PathScript files for flow preview."
);
});
});
test("flow preview: fails loudly for asset-backed codebase scripts", async () => {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, {
defaultTs: "bun",
codebases: [{
relative_path: "f/codebase_tar",
includes: ["**"],
assets: [{ from: "f/codebase_tar/data.json", to: "data.json" }],
}],
});
await mkdir(`${tempDir}/f/codebase_tar`, { recursive: true });
await writeFile(
`${tempDir}/f/codebase_tar/data.json`,
JSON.stringify({ message: "Hello from asset!" }),
"utf-8"
);
await createScript(
tempDir,
"f/codebase_tar/main_script.ts",
`import * as fs from "fs";
export function main() {
const data = JSON.parse(fs.readFileSync("data.json", "utf-8"));
return data.message;
}`
);
await createPathScriptFlow(tempDir, "f/test/assets_path_flow.flow", {
summary: "Flow with asset-backed PathScript",
scriptPath: "f/codebase_tar/main_script",
});
const localResult = await backend.runCLICommand(
["flow", "preview", "f/test/assets_path_flow.flow"],
tempDir
);
expect(localResult.code).not.toEqual(0);
expect(localResult.stdout + localResult.stderr).toContain(
"requires codebase assets"
);
});
});

View File

@@ -0,0 +1,322 @@
/**
* Unit tests for replacePathScriptsWithLocal.
*
* Tests that PathScript ("script" type) modules are correctly converted
* to RawScript ("rawscript" type) using local file content during
* flow preview / dev mode.
*/
import { expect, test, describe } from "bun:test";
import { replacePathScriptsWithLocal, type LocalScriptInfo } from "../windmill-utils-internal/src/inline-scripts/replacer.ts";
import type { FlowModule } from "../windmill-utils-internal/src/gen/types.gen.ts";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makePathScriptModule(
id: string,
path: string,
inputTransforms: Record<string, any> = {},
tagOverride?: string,
): FlowModule {
return {
id,
value: {
type: "script" as const,
path,
input_transforms: inputTransforms,
tag_override: tagOverride,
},
};
}
function makeRawscriptModule(
id: string,
content: string,
language: "bun" | "python3" | "deno" = "bun",
): FlowModule {
return {
id,
value: {
type: "rawscript" as const,
content,
language,
input_transforms: {},
},
};
}
const noopLogger = {
info: () => {},
error: () => {},
};
// ---------------------------------------------------------------------------
// Basic conversion tests
// ---------------------------------------------------------------------------
describe("replacePathScriptsWithLocal", () => {
test("converts PathScript to RawScript when local file exists", async () => {
const module = makePathScriptModule("a", "f/scripts/my_script", {
x: { type: "static", value: 42 },
});
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/my_script") {
return {
content: 'export function main() { return "hello"; }',
language: "bun",
lock: "some-lock",
};
}
return undefined;
};
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
expect(module.value.type).toBe("rawscript");
expect((module.value as any).content).toBe('export function main() { return "hello"; }');
expect((module.value as any).language).toBe("bun");
expect((module.value as any).lock).toBe("some-lock");
expect((module.value as any).path).toBe("f/scripts/my_script");
expect((module.value as any).input_transforms).toEqual({ x: { type: "static", value: 42 } });
});
test("preserves tag_override as tag", async () => {
const module = makePathScriptModule("a", "f/scripts/tagged", {}, "my-worker");
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/tagged") {
return { content: "code", language: "python3", tag: "script-worker" };
}
return undefined;
};
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
expect(module.value.type).toBe("rawscript");
expect((module.value as any).tag).toBe("my-worker");
});
test("uses local script tag when no tag_override is set", async () => {
const module = makePathScriptModule("a", "f/scripts/tagged");
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/tagged") {
return { content: "code", language: "python3", tag: "script-worker" };
}
return undefined;
};
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
expect(module.value.type).toBe("rawscript");
expect((module.value as any).tag).toBe("script-worker");
});
test("leaves PathScript untouched when local file not found", async () => {
const module = makePathScriptModule("a", "f/scripts/remote_only");
const scriptReader = async (): Promise<LocalScriptInfo | undefined> => undefined;
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
expect(module.value.type).toBe("script");
expect((module.value as any).path).toBe("f/scripts/remote_only");
});
test("does not affect rawscript modules", async () => {
const module = makeRawscriptModule("a", "existing code", "bun");
const scriptReader = async (): Promise<LocalScriptInfo | undefined> => {
throw new Error("should not be called for rawscript");
};
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
expect(module.value.type).toBe("rawscript");
expect((module.value as any).content).toBe("existing code");
});
test("handles mixed PathScript and RawScript modules", async () => {
const pathModule = makePathScriptModule("a", "f/scripts/local_script");
const rawModule = makeRawscriptModule("b", "inline code", "bun");
const remoteModule = makePathScriptModule("c", "f/scripts/remote_only");
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/local_script") {
return { content: "local code", language: "python3" };
}
return undefined;
};
await replacePathScriptsWithLocal(
[pathModule, rawModule, remoteModule],
scriptReader,
noopLogger,
);
// PathScript with local file → converted
expect(pathModule.value.type).toBe("rawscript");
expect((pathModule.value as any).content).toBe("local code");
// RawScript → untouched
expect(rawModule.value.type).toBe("rawscript");
expect((rawModule.value as any).content).toBe("inline code");
// PathScript without local file → untouched
expect(remoteModule.value.type).toBe("script");
expect((remoteModule.value as any).path).toBe("f/scripts/remote_only");
});
test("handles module without lock", async () => {
const module = makePathScriptModule("a", "f/scripts/no_lock");
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/no_lock") {
return { content: "code", language: "go" };
}
return undefined;
};
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
expect(module.value.type).toBe("rawscript");
expect((module.value as any).lock).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Nested structure tests
// ---------------------------------------------------------------------------
describe("replacePathScriptsWithLocal nested structures", () => {
test("processes modules inside forloopflow", async () => {
const innerModule = makePathScriptModule("inner", "f/scripts/loop_script");
const loopModule: FlowModule = {
id: "loop",
value: {
type: "forloopflow" as const,
iterator: { type: "static" as const, value: "" },
modules: [innerModule],
skip_failures: false,
},
};
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/loop_script") {
return { content: "loop code", language: "bun" };
}
return undefined;
};
await replacePathScriptsWithLocal([loopModule], scriptReader, noopLogger);
expect(innerModule.value.type).toBe("rawscript");
expect((innerModule.value as any).content).toBe("loop code");
});
test("processes modules inside whileloopflow", async () => {
const innerModule = makePathScriptModule("inner", "f/scripts/while_script");
const whileModule: FlowModule = {
id: "while",
value: {
type: "whileloopflow" as const,
modules: [innerModule],
skip_failures: false,
},
};
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/while_script") {
return { content: "while code", language: "bun" };
}
return undefined;
};
await replacePathScriptsWithLocal([whileModule], scriptReader, noopLogger);
expect(innerModule.value.type).toBe("rawscript");
expect((innerModule.value as any).content).toBe("while code");
});
test("processes modules inside branchall", async () => {
const branch1Module = makePathScriptModule("b1", "f/scripts/branch1");
const branch2Module = makePathScriptModule("b2", "f/scripts/branch2");
const branchAllModule: FlowModule = {
id: "branches",
value: {
type: "branchall" as const,
branches: [
{ modules: [branch1Module], skip_failure: false },
{ modules: [branch2Module], skip_failure: false },
],
},
};
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/branch1") {
return { content: "branch1 code", language: "bun" };
}
if (scriptPath === "f/scripts/branch2") {
return { content: "branch2 code", language: "python3" };
}
return undefined;
};
await replacePathScriptsWithLocal([branchAllModule], scriptReader, noopLogger);
expect(branch1Module.value.type).toBe("rawscript");
expect((branch1Module.value as any).content).toBe("branch1 code");
expect(branch2Module.value.type).toBe("rawscript");
expect((branch2Module.value as any).content).toBe("branch2 code");
expect((branch2Module.value as any).language).toBe("python3");
});
test("processes modules inside branchone (branches + default)", async () => {
const branchModule = makePathScriptModule("b1", "f/scripts/branch");
const defaultModule = makePathScriptModule("d1", "f/scripts/default");
const branchOneModule: FlowModule = {
id: "branchone",
value: {
type: "branchone" as const,
branches: [
{ modules: [branchModule], expr: "true" },
],
default: [defaultModule],
},
};
const scriptReader = async (scriptPath: string): Promise<LocalScriptInfo | undefined> => {
if (scriptPath === "f/scripts/branch") {
return { content: "branch code", language: "bun" };
}
if (scriptPath === "f/scripts/default") {
return { content: "default code", language: "bash" };
}
return undefined;
};
await replacePathScriptsWithLocal([branchOneModule], scriptReader, noopLogger);
expect(branchModule.value.type).toBe("rawscript");
expect((branchModule.value as any).content).toBe("branch code");
expect(defaultModule.value.type).toBe("rawscript");
expect((defaultModule.value as any).content).toBe("default code");
expect((defaultModule.value as any).language).toBe("bash");
});
test("handles empty modules array", async () => {
const scriptReader = async (): Promise<LocalScriptInfo | undefined> => undefined;
// Should not throw
await replacePathScriptsWithLocal([], scriptReader, noopLogger);
});
test("handles module with undefined value gracefully", async () => {
const module: FlowModule = { id: "x", value: undefined as any };
const scriptReader = async (): Promise<LocalScriptInfo | undefined> => undefined;
// Should not throw
await replacePathScriptsWithLocal([module], scriptReader, noopLogger);
});
});

View File

@@ -1,4 +1,11 @@
import { FlowModule, RawScript } from "../gen/types.gen.ts";
import { AiAgent, FlowModule, FlowValue, RawScript } from "../gen/types.gen.ts";
export type LocalScriptInfo = {
content: string;
language: RawScript["language"];
lock?: string;
tag?: string;
};
async function replaceRawscriptInline(
id: string,
@@ -118,4 +125,154 @@ export async function replaceInlineScripts(
}));
}
}));
}
}
/**
* Replaces PathScript ("script" type) modules with RawScript ("rawscript" type) using local file content.
* This is used during flow preview so that local script changes are tested instead of remote versions.
*
* @param modules - Array of flow modules to process
* @param scriptReader - Function that takes a script path and returns local content/language/lock, or undefined if not found locally
* @param logger - Logger for info/error messages
*/
export async function replacePathScriptsWithLocal(
modules: FlowModule[],
scriptReader: (scriptPath: string) => Promise<LocalScriptInfo | undefined>,
logger: {
info: (message: string) => void;
error: (message: string) => void;
} = {
info: () => {},
error: () => {},
}
): Promise<void> {
await Promise.all(modules.map(async (module) => {
if (!module.value) {
return;
}
if (module.value.type === "script") {
const scriptPath = module.value.path;
const localScript = await scriptReader(scriptPath);
if (localScript) {
const pathScript = module.value;
module.value = {
type: "rawscript",
content: localScript.content,
language: localScript.language,
lock: localScript.lock,
path: scriptPath,
input_transforms: pathScript.input_transforms,
tag: pathScript.tag_override ?? localScript.tag,
} satisfies RawScript;
}
} else if (module.value.type === "forloopflow" || module.value.type === "whileloopflow") {
await replacePathScriptsWithLocal(module.value.modules, scriptReader, logger);
} else if (module.value.type === "branchall") {
await Promise.all(module.value.branches.map(async (branch) => {
await replacePathScriptsWithLocal(branch.modules, scriptReader, logger);
}));
} else if (module.value.type === "branchone") {
await Promise.all(module.value.branches.map(async (branch) => {
await replacePathScriptsWithLocal(branch.modules, scriptReader, logger);
}));
await replacePathScriptsWithLocal(module.value.default, scriptReader, logger);
} else if (module.value.type === "aiagent") {
await Promise.all((module.value.tools ?? []).map(async (tool) => {
const toolValue = tool.value;
if (!toolValue || toolValue.tool_type !== "flowmodule" || toolValue.type !== "script") {
return;
}
const localScript = await scriptReader(toolValue.path);
if (localScript) {
(tool as AiAgent["tools"][number]).value = {
tool_type: "flowmodule",
type: "rawscript",
content: localScript.content,
language: localScript.language,
lock: localScript.lock,
path: toolValue.path,
input_transforms: toolValue.input_transforms,
tag: toolValue.tag_override ?? localScript.tag,
};
}
}));
}
}));
}
function collectPathScriptPathsFromModules(
modules: FlowModule[],
paths: Set<string>
): void {
for (const module of modules) {
if (!module.value) {
continue;
}
if (module.value.type === "script") {
paths.add(module.value.path);
} else if (
module.value.type === "forloopflow" ||
module.value.type === "whileloopflow"
) {
collectPathScriptPathsFromModules(module.value.modules, paths);
} else if (module.value.type === "branchall") {
for (const branch of module.value.branches) {
collectPathScriptPathsFromModules(branch.modules, paths);
}
} else if (module.value.type === "branchone") {
for (const branch of module.value.branches) {
collectPathScriptPathsFromModules(branch.modules, paths);
}
collectPathScriptPathsFromModules(module.value.default, paths);
} else if (module.value.type === "aiagent") {
for (const tool of module.value.tools ?? []) {
const toolValue = tool.value;
if (
toolValue &&
toolValue.tool_type === "flowmodule" &&
toolValue.type === "script"
) {
paths.add(toolValue.path);
}
}
}
}
}
/**
* Replaces all PathScript modules in a flow value (modules, failure_module, preprocessor_module)
* with RawScript using local file content.
*/
export async function replaceAllPathScriptsWithLocal(
flowValue: FlowValue,
scriptReader: (scriptPath: string) => Promise<LocalScriptInfo | undefined>,
logger: {
info: (message: string) => void;
error: (message: string) => void;
} = {
info: () => {},
error: () => {},
}
): Promise<void> {
await replacePathScriptsWithLocal(flowValue.modules, scriptReader, logger);
if (flowValue.failure_module) {
await replacePathScriptsWithLocal([flowValue.failure_module], scriptReader, logger);
}
if (flowValue.preprocessor_module) {
await replacePathScriptsWithLocal([flowValue.preprocessor_module], scriptReader, logger);
}
}
export function collectPathScriptPaths(flowValue: FlowValue): string[] {
const paths = new Set<string>();
collectPathScriptPathsFromModules(flowValue.modules, paths);
if (flowValue.failure_module) {
collectPathScriptPathsFromModules([flowValue.failure_module], paths);
}
if (flowValue.preprocessor_module) {
collectPathScriptPathsFromModules([flowValue.preprocessor_module], paths);
}
return [...paths];
}

View File

@@ -86,9 +86,10 @@ flow related commands
- `flow run <path:string>` - run a flow by path.
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.
- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files.
- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not output anything other then the final output. Useful for scripting.
- `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.
- `flow generate-locks [flow:file]` - re-generate the lock files of all inline scripts of all updated flows
- `--yes` - Skip confirmation prompt
- `-i --includes <patterns:file[]>` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)

View File

@@ -1460,9 +1460,10 @@ flow related commands
- \`flow run <path:string>\` - run a flow by path.
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting.
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files.
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- \`-s --silent\` - Do not output anything other then the final output. Useful for scripting.
- \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files.
- \`flow generate-locks [flow:file]\` - re-generate the lock files of all inline scripts of all updated flows
- \`--yes\` - Skip confirmation prompt
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)

View File

@@ -91,9 +91,10 @@ flow related commands
- `flow run <path:string>` - run a flow by path.
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.
- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files.
- `flow preview <flow_path:string>` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not output anything other then the final output. Useful for scripting.
- `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.
- `flow generate-locks [flow:file]` - re-generate the lock files of all inline scripts of all updated flows
- `--yes` - Skip confirmation prompt
- `-i --includes <patterns:file[]>` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)