Files
windmill/cli/test/script_modules_unit.test.ts
T
Ruben Fiszel cdd8718a93 fix(cli): sync push no longer reports success on a script it never deployed (#10353)
* fix(cli): do not report success when sync push drops a script metadata change

`sync push` skipped every added `.script.yaml` / `.script.json` / `.script.lock`
on the assumption that the sibling content file in the same group carried the
deploy. When the content file was not in the changeset — e.g. filtered out by
`excludes` — nothing was sent to the remote, yet the push still printed
"Done! All N changes pushed" and exited 0, so CI gating on the exit code went
green on a deploy that never happened.

Route those changes through `handleScriptMetadata` (as the "edited" branch
already does), which resolves the content file from disk and deploys it. The
deploy stays idempotent via `alreadySynced`, and a metadata file with no content
file now fails the push instead of being counted as pushed.

Fixes WIN-2254

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): treat only the module entry point as script metadata

`handleScriptMetadata` / `findContentFile` matched any `script.yaml`,
`script.json` or `script.lock` anywhere under a `__mod/` tree, so a module file
nested deeper (e.g. `f/foo__mod/config/script.yaml`) was mistaken for the
script's own metadata. Gate on `isModuleEntryPoint`, which requires the file to
sit directly under `__mod/`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): reject non-metadata paths in findContentFile

Every candidate-path replacement in findContentFile is a no-op on a path that
is neither flat `*.script.{yaml,json,lock}` nor a module entry point, so the
input resolved to itself and the caller got a "more than one candidate found"
list of 25 copies of the same path. Reject those inputs up front.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): report an unpushable script as a failure instead of aborting the push

Throwing out of the apply loop for a metadata file with no script file left the
push partially applied: every change queued behind it was dropped, including
ones with nothing wrong. Collect these into a failed list, log each one, keep
applying the rest, and report `N of M changes pushed; K failed` with a non-zero
exit (`success: false` plus a `failed` array under --json-output).

Only the content-resolution failure is soft, via MissingScriptContentFileError.
A deploy the remote rejected still aborts, since it says nothing about whether
the remaining changes are safe to apply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): let stdout drain before sync push returns a failure exit code

process.exit does not wait for a pending piped stdout write, so a --json-output
push with enough changes was cut off at the 64KiB pipe buffer, handing CI
consumers unparseable JSON. Set process.exitCode instead and return normally,
matching how main.ts already reports failures.

Reproduced with a 1904-change push: process.exit truncated the result at exactly
65536 bytes; process.exitCode emits all 432KiB and still exits 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): treat an ambiguous script file as a recoverable push failure

findContentFile has two ways to fail to pair a metadata file with a script
file, and only the "none found" one threw the class sync push catches. Two
script files for the same name (a .ts and a .py both being in exts) therefore
still aborted the whole push, dropping every change queued behind it — the
failure mode failedChanges exists to prevent, newly reachable now that an added
.script.yaml reaches findContentFile at all.

Both branches now throw the same class, renamed to
UnresolvableScriptContentFileError since it no longer only covers a missing
file, and the ambiguous case gets a message that names the clashing files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 13:21:50 +02:00

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