Files
windmill/cli/test/replace_path_scripts.test.ts
T
centdix 435de95e7d 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>
2026-03-18 10:29:30 +00:00

323 lines
11 KiB
TypeScript

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