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