Files
windmill/cli/test/script_modules_unit.test.ts
T
2026-07-27 16:00:08 +02:00

404 lines
15 KiB
TypeScript

/**
* Unit tests for script module utilities.
* These tests require no backend — they test standalone logic.
*/
import { expect, test, describe, beforeEach, afterEach } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
getModuleFolderSuffix,
isScriptModulePath,
buildModuleFolderPath,
isModuleEntryPoint,
getScriptBasePathFromModulePath,
} from "../src/utils/resource_folders.ts";
import {
writeModulesToDisk,
readModulesFromDisk,
findContentFile,
UnresolvableScriptContentFileError,
} from "../src/commands/script/script.ts";
import { getTypeStrFromPath } from "../src/types.ts";
// =============================================================================
// Module Path Utilities
// =============================================================================
describe("getModuleFolderSuffix", () => {
test("returns __mod", () => {
expect(getModuleFolderSuffix()).toBe("__mod");
});
});
describe("isScriptModulePath", () => {
test("detects module paths", () => {
expect(isScriptModulePath("f/my_script__mod/helper.ts")).toBe(true);
expect(isScriptModulePath("u/admin/script__mod/utils/math.py")).toBe(true);
});
test("rejects non-module paths", () => {
expect(isScriptModulePath("f/my_script.ts")).toBe(false);
expect(isScriptModulePath("f/my_script__mod")).toBe(false); // no trailing /
expect(isScriptModulePath("f/my_flow__flow/flow.yaml")).toBe(false);
});
test("handles windows-style separators", () => {
expect(isScriptModulePath("f\\my_script__mod\\helper.ts")).toBe(true);
});
});
describe("buildModuleFolderPath", () => {
test("appends __mod suffix", () => {
expect(buildModuleFolderPath("f/my_script")).toBe("f/my_script__mod");
expect(buildModuleFolderPath("u/admin/tool")).toBe("u/admin/tool__mod");
});
});
// =============================================================================
// writeModulesToDisk / readModulesFromDisk round-trip
// =============================================================================
describe("module read/write round-trip", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "wm-module-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("writes and reads back a simple module", async () => {
const moduleFolderPath = path.join(tempDir, "my_script__mod");
const modules = {
"helper.ts": {
content: 'export function greet() { return "hi"; }\n',
language: "bun" as const,
},
};
await writeModulesToDisk(moduleFolderPath, modules, "bun");
// Verify file exists on disk
expect(fs.existsSync(path.join(moduleFolderPath, "helper.ts"))).toBe(true);
// Read back
const result = await readModulesFromDisk(moduleFolderPath, "bun", false);
expect(result).toBeDefined();
expect(Object.keys(result!)).toEqual(["helper.ts"]);
expect(result!["helper.ts"].content).toBe('export function greet() { return "hi"; }\n');
expect(result!["helper.ts"].language).toBe("bun");
});
test("writes and reads back module with lock file", async () => {
const moduleFolderPath = path.join(tempDir, "my_script__mod");
const modules = {
"helper.py": {
content: "def greet():\n return 'hi'\n",
language: "python3" as const,
lock: "requests==2.31.0\n",
},
};
await writeModulesToDisk(moduleFolderPath, modules, undefined);
// Verify lock file exists
expect(fs.existsSync(path.join(moduleFolderPath, "helper.lock"))).toBe(true);
// Read back
const result = await readModulesFromDisk(moduleFolderPath, undefined, false);
expect(result).toBeDefined();
expect(result!["helper.py"].lock).toBe("requests==2.31.0\n");
});
test("writes and reads back nested module paths", async () => {
const moduleFolderPath = path.join(tempDir, "my_script__mod");
const modules = {
"utils/math.py": {
content: "def add(a, b):\n return a + b\n",
language: "python3" as const,
},
"utils/__init__.py": {
content: "",
language: "python3" as const,
},
};
await writeModulesToDisk(moduleFolderPath, modules, undefined);
// Verify nested structure
expect(fs.existsSync(path.join(moduleFolderPath, "utils", "math.py"))).toBe(true);
expect(fs.existsSync(path.join(moduleFolderPath, "utils", "__init__.py"))).toBe(true);
// Read back
const result = await readModulesFromDisk(moduleFolderPath, undefined, false);
expect(result).toBeDefined();
expect(Object.keys(result!).sort()).toEqual(["utils/__init__.py", "utils/math.py"]);
expect(result!["utils/math.py"].content).toBe("def add(a, b):\n return a + b\n");
});
test("returns undefined for non-existent folder", async () => {
const result = await readModulesFromDisk(path.join(tempDir, "nonexistent__mod"), undefined, false);
expect(result).toBeUndefined();
});
test("returns undefined for empty folder", async () => {
const emptyFolder = path.join(tempDir, "empty__mod");
fs.mkdirSync(emptyFolder);
const result = await readModulesFromDisk(emptyFolder, undefined, false);
expect(result).toBeUndefined();
});
test("multiple modules of different languages", async () => {
const moduleFolderPath = path.join(tempDir, "my_script__mod");
const modules = {
"helper.ts": {
content: "export const x = 1;\n",
language: "bun" as const,
},
"other.ts": {
content: "export const y = 2;\n",
language: "bun" as const,
lock: "some-lock-content\n",
},
};
await writeModulesToDisk(moduleFolderPath, modules, "bun");
const result = await readModulesFromDisk(moduleFolderPath, "bun", false);
expect(result).toBeDefined();
expect(Object.keys(result!).sort()).toEqual(["helper.ts", "other.ts"]);
expect(result!["other.ts"].lock).toBe("some-lock-content\n");
expect(result!["helper.ts"].lock).toBeUndefined();
});
test("folder layout skips entry point files", async () => {
const moduleFolderPath = path.join(tempDir, "my_script__mod");
fs.mkdirSync(moduleFolderPath, { recursive: true });
// Simulate folder layout: script.ts is the entry point, helper.ts is a module
fs.writeFileSync(path.join(moduleFolderPath, "script.ts"), "export function main() {}\n");
fs.writeFileSync(path.join(moduleFolderPath, "script.yaml"), "description: test\n");
fs.writeFileSync(path.join(moduleFolderPath, "script.lock"), "");
fs.writeFileSync(path.join(moduleFolderPath, "helper.ts"), "export const x = 1;\n");
// With folderLayout=true, entry point files should be skipped
const result = await readModulesFromDisk(moduleFolderPath, "bun", true);
expect(result).toBeDefined();
expect(Object.keys(result!)).toEqual(["helper.ts"]);
// With folderLayout=false, all files are included
const resultFlat = await readModulesFromDisk(moduleFolderPath, "bun", false);
expect(resultFlat).toBeDefined();
expect(Object.keys(resultFlat!).sort()).toContain("script.ts");
expect(Object.keys(resultFlat!).sort()).toContain("helper.ts");
});
});
// =============================================================================
// getTypeStrFromPath with module paths
// =============================================================================
describe("getTypeStrFromPath with module paths", () => {
test("recognizes module content files as scripts", () => {
expect(getTypeStrFromPath("f/my_script__mod/helper.ts")).toBe("script");
expect(getTypeStrFromPath("u/admin/tool__mod/utils/math.py")).toBe("script");
expect(getTypeStrFromPath("f/x__mod/helper.go")).toBe("script");
});
test("recognizes module lock files as scripts", () => {
// Lock files inside __mod/ should NOT throw — they are valid module files
expect(getTypeStrFromPath("f/my_script__mod/helper.lock")).toBe("script");
expect(getTypeStrFromPath("u/admin/tool__mod/utils/math.lock")).toBe("script");
});
test("recognizes module entry points as scripts", () => {
expect(getTypeStrFromPath("f/my_script__mod/script.ts")).toBe("script");
expect(getTypeStrFromPath("f/my_script__mod/script.py")).toBe("script");
});
test("recognizes module metadata files as scripts", () => {
// .yaml/.json files inside __mod/ should be recognized
expect(getTypeStrFromPath("f/my_script__mod/script.yaml")).toBe("script");
expect(getTypeStrFromPath("f/my_script__mod/script.json")).toBe("script");
});
});
// =============================================================================
// Module read/write with multiple lock files
// =============================================================================
describe("module lock file handling", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "wm-module-lock-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("writes and reads multiple modules each with their own lock", async () => {
const moduleFolderPath = path.join(tempDir, "my_script__mod");
const modules = {
"api_client.py": {
content: "import requests\ndef fetch(): pass\n",
language: "python3" as const,
lock: "requests==2.31.0\nurllib3==2.0.4\n",
},
"data_processor.py": {
content: "import pandas\ndef process(): pass\n",
language: "python3" as const,
lock: "pandas==2.1.0\nnumpy==1.25.0\n",
},
"utils.py": {
content: "def helper(): pass\n",
language: "python3" as const,
// No lock — no external deps
},
};
await writeModulesToDisk(moduleFolderPath, modules, undefined);
// Verify lock files exist only for modules with locks
expect(fs.existsSync(path.join(moduleFolderPath, "api_client.lock"))).toBe(true);
expect(fs.existsSync(path.join(moduleFolderPath, "data_processor.lock"))).toBe(true);
expect(fs.existsSync(path.join(moduleFolderPath, "utils.lock"))).toBe(false);
// Read back and verify
const result = await readModulesFromDisk(moduleFolderPath, undefined, false);
expect(result).toBeDefined();
expect(Object.keys(result!).sort()).toEqual(["api_client.py", "data_processor.py", "utils.py"]);
expect(result!["api_client.py"].lock).toBe("requests==2.31.0\nurllib3==2.0.4\n");
expect(result!["data_processor.py"].lock).toBe("pandas==2.1.0\nnumpy==1.25.0\n");
expect(result!["utils.py"].lock).toBeUndefined();
});
test("nested modules with lock files", async () => {
const moduleFolderPath = path.join(tempDir, "my_script__mod");
const modules = {
"services/api.ts": {
content: "export function callApi() {}\n",
language: "bun" as const,
lock: "axios@1.5.0\n",
},
"services/db.ts": {
content: "export function query() {}\n",
language: "bun" as const,
lock: "pg@8.11.0\n",
},
};
await writeModulesToDisk(moduleFolderPath, modules, "bun");
// Verify nested lock files
expect(fs.existsSync(path.join(moduleFolderPath, "services", "api.lock"))).toBe(true);
expect(fs.existsSync(path.join(moduleFolderPath, "services", "db.lock"))).toBe(true);
const result = await readModulesFromDisk(moduleFolderPath, "bun", false);
expect(result).toBeDefined();
expect(result!["services/api.ts"].lock).toBe("axios@1.5.0\n");
expect(result!["services/db.ts"].lock).toBe("pg@8.11.0\n");
});
test("overwrites existing module folder on re-write", async () => {
const moduleFolderPath = path.join(tempDir, "my_script__mod");
// First write
await writeModulesToDisk(moduleFolderPath, {
"old.ts": { content: "old content\n", language: "bun" as const },
}, "bun");
expect(fs.existsSync(path.join(moduleFolderPath, "old.ts"))).toBe(true);
// Second write with different module
await writeModulesToDisk(moduleFolderPath, {
"new.ts": { content: "new content\n", language: "bun" as const },
}, "bun");
expect(fs.existsSync(path.join(moduleFolderPath, "new.ts"))).toBe(true);
// old.ts should still exist (writeModulesToDisk doesn't clean up)
// This is intentional — cleanup happens at the sync level
});
});
// =============================================================================
// findContentFile
// =============================================================================
describe("findContentFile", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "wm-find-content-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("resolves the entry point of a module folder", async () => {
const modDir = path.join(tempDir, "my_script__mod");
fs.mkdirSync(modDir, { recursive: true });
fs.writeFileSync(path.join(modDir, "script.ts"), "export async function main() {}\n");
fs.writeFileSync(path.join(modDir, "script.yaml"), "summary: ''\n");
expect(await findContentFile(path.join(modDir, "script.yaml"))).toBe(
path.join(modDir, "script.ts")
);
});
// `isModuleEntryPoint` treats `\` as a separator on every platform, so
// findContentFile must too or it rejects every Windows module path. Runnable
// on POSIX because there the backslash is merely part of the file name.
test("resolves a module entry point written with a backslash separator", async () => {
const meta = path.join(tempDir, "my_script__mod") + "\\script.yaml";
const content = path.join(tempDir, "my_script__mod") + "\\script.ts";
fs.mkdirSync(path.dirname(meta), { recursive: true });
fs.writeFileSync(content, "export async function main() {}\n");
fs.writeFileSync(meta, "summary: ''\n");
expect(await findContentFile(meta)).toBe(content);
});
// sync push catches UnresolvableScriptContentFileError to record the change as
// failed and carry on; any other error aborts the whole push, so every branch
// that simply cannot pair a metadata file with one script file must use it.
test("signals every unpairable case with UnresolvableScriptContentFileError", async () => {
const dir = path.join(tempDir, "f", "test");
fs.mkdirSync(dir, { recursive: true });
// no script file at all
const orphan = path.join(dir, "orphan.script.yaml");
fs.writeFileSync(orphan, "summary: ''\n");
await expect(findContentFile(orphan)).rejects.toBeInstanceOf(
UnresolvableScriptContentFileError
);
// two script files, so the metadata cannot name one
const ambiguous = path.join(dir, "both.script.yaml");
fs.writeFileSync(ambiguous, "summary: ''\n");
fs.writeFileSync(path.join(dir, "both.ts"), "export async function main() {}\n");
fs.writeFileSync(path.join(dir, "both.py"), "def main():\n pass\n");
await expect(findContentFile(ambiguous)).rejects.toBeInstanceOf(
UnresolvableScriptContentFileError
);
});
// A script.yaml nested deeper in the module tree is a module file, not the
// script's metadata — resolving it to itself would make the caller deploy it
// as if it were a script.
test("rejects a script.yaml nested below the module entry point", async () => {
const nested = path.join(tempDir, "my_script__mod", "config");
fs.mkdirSync(nested, { recursive: true });
const nestedMeta = path.join(nested, "script.yaml");
fs.writeFileSync(nestedMeta, "nested: data\n");
await expect(findContentFile(nestedMeta)).rejects.toThrow(
"is not a script metadata file"
);
});
});