Files
windmill/cli/test/inline_scripts_failure_preprocessor_unit.test.ts
centdix 5fd2c1a129 chore(cli): separate unit tests from integration tests and fix test cleanup (#8562)
* fix(cli): separate unit tests from integration tests and fix test cleanup

- Rename 14 non-backend test files to *_unit.test.ts convention
- Add UNIT_ONLY env var guard in setup.ts to skip cargo build/backend startup
- Add test:unit and test:integration scripts to package.json
- Use setsid on Linux for process group management so stop() kills both
  cargo and the windmill child process
- Fix exit handler to kill process group instead of just the direct child
- Add cleanupStaleTestResources() to drop orphaned windmill_test_* databases
  and kill orphaned backend processes on startup
- Rewrite TESTING.md with current bun-based instructions

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

* fix(cli): fix process group approach - kill by db name instead of setsid

The setsid approach didn't work because setsid forks, making the PID
we get from Bun.spawn ephemeral. Instead, kill orphaned windmill child
processes by matching our unique database name in /proc/pid/environ.

Also add afterAll hook in setup.ts so full async cleanup (process kill
+ database drop) runs when all tests complete normally, not just on
SIGINT/SIGTERM.

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

* fix(cli): address PR review feedback

- Remove duplicate cleanupStaleTestResources() call in getTestBackend()
  (already called in setup.ts)
- Add regex guard on database names before SQL interpolation
- Extract shared killWindmillProcessesByEnvMatch() helper to deduplicate
  process-killing logic
- Remove redundant test:integration script (test already runs everything)
- Flip setup.ts to if/else pattern for readability

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:13:33 +00:00

499 lines
16 KiB
TypeScript

/**
* Unit tests for failure_module and preprocessor_module inline script
* extraction (pull) and replacement (push).
*
* These tests verify that rawscript content in failure_module and
* preprocessor_module is correctly extracted to !inline references
* and resolved back, matching the existing behavior for regular modules.
*/
import { expect, test, describe } from "bun:test";
import { extractInlineScripts, extractCurrentMapping } from "../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { replaceInlineScripts } from "../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path-assigner.ts";
import type { FlowModule } from "../windmill-utils-internal/src/gen/types.gen.ts";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeRawscriptModule(
id: string,
content: string,
language: "bun" | "python3" | "deno" = "bun",
lock?: string,
): FlowModule {
return {
id,
value: {
type: "rawscript" as const,
content,
language,
lock: lock,
input_transforms: {},
},
};
}
const noopLogger = {
info: () => {},
error: () => {},
};
// ---------------------------------------------------------------------------
// extractInlineScripts — PULL direction
// ---------------------------------------------------------------------------
describe("extractInlineScripts for failure_module / preprocessor_module", () => {
test("extracts rawscript from failure_module wrapped in array", () => {
const failureModule = makeRawscriptModule(
"failure",
'export function main() { throw new Error("handler"); }',
"bun",
);
const scripts = extractInlineScripts([failureModule], {}, "/", "bun");
expect(scripts.length).toBeGreaterThanOrEqual(1);
const script = scripts.find((s) => !s.is_lock);
expect(script).toBeDefined();
expect(script!.content).toBe(
'export function main() { throw new Error("handler"); }',
);
// The module content should have been replaced with an !inline reference
expect(failureModule.value.content).toStartWith("!inline ");
});
test("extracts rawscript from preprocessor_module wrapped in array", () => {
const preprocessorModule = makeRawscriptModule(
"preprocessor",
"export function main() { return {}; }",
"python3",
);
const scripts = extractInlineScripts(
[preprocessorModule],
{},
"/",
"bun",
);
expect(scripts.length).toBeGreaterThanOrEqual(1);
const script = scripts.find((s) => !s.is_lock);
expect(script).toBeDefined();
expect(script!.content).toBe("export function main() { return {}; }");
expect(script!.language).toBe("python3");
expect(preprocessorModule.value.content).toStartWith("!inline ");
});
test("extracts lock alongside content", () => {
const mod = makeRawscriptModule(
"failure",
"console.log('hi')",
"bun",
"some-lock-content",
);
const scripts = extractInlineScripts([mod], {}, "/", "bun");
const contentScript = scripts.find((s) => !s.is_lock);
const lockScript = scripts.find((s) => s.is_lock);
expect(contentScript).toBeDefined();
expect(lockScript).toBeDefined();
expect(lockScript!.content).toBe("some-lock-content");
expect((mod.value as any).lock).toStartWith("!inline ");
});
test("shared pathAssigner prevents collisions when summaries match", () => {
// If a regular module and failure_module share the same summary,
// a shared PathAssigner deduplicates via its internal counter.
const regular = makeRawscriptModule("a", "code_a", "bun");
regular.summary = "my step";
const failure = makeRawscriptModule("failure", "code_failure", "bun");
failure.summary = "my step"; // same summary — would collide without shared assigner
const assigner = newPathAssigner("bun");
const scripts1 = extractInlineScripts([regular], {}, "/", "bun", assigner);
const scripts2 = extractInlineScripts([failure], {}, "/", "bun", assigner);
const allPaths = [...scripts1, ...scripts2]
.filter((s) => !s.is_lock)
.map((s) => s.path);
// All paths should be unique despite identical summaries
expect(allPaths.length).toBe(2);
expect(new Set(allPaths).size).toBe(2);
});
test("without shared pathAssigner, identical summaries produce duplicate paths", () => {
// Demonstrates the problem that sharing a PathAssigner solves.
const regular = makeRawscriptModule("a", "code_a", "bun");
regular.summary = "my step";
const failure = makeRawscriptModule("failure", "code_failure", "bun");
failure.summary = "my step";
// Separate assigners — each starts with a fresh counter
const scripts1 = extractInlineScripts([regular], {}, "/", "bun");
const scripts2 = extractInlineScripts([failure], {}, "/", "bun");
const allPaths = [...scripts1, ...scripts2]
.filter((s) => !s.is_lock)
.map((s) => s.path);
// Without a shared assigner, the paths collide
expect(allPaths.length).toBe(2);
expect(new Set(allPaths).size).toBe(1); // both got the same path
});
test("skips non-rawscript failure_module (identity type)", () => {
const identityModule: FlowModule = {
id: "failure",
value: { type: "identity" as any },
};
const scripts = extractInlineScripts([identityModule], {}, "/", "bun");
expect(scripts).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// replaceInlineScripts — PUSH direction
// ---------------------------------------------------------------------------
describe("replaceInlineScripts for failure_module / preprocessor_module", () => {
test("resolves !inline reference back to file content", async () => {
const failureModule = makeRawscriptModule(
"failure",
"!inline failure.inline_script.ts",
"bun",
);
const files: Record<string, string> = {
"failure.inline_script.ts": 'export function main() { return "error handled"; }',
};
await replaceInlineScripts(
[failureModule],
async (path) => {
if (!(path in files)) throw new Error(`File not found: ${path}`);
return files[path];
},
noopLogger,
"/tmp/test/",
"/",
);
expect(failureModule.value.content).toBe(
'export function main() { return "error handled"; }',
);
});
test("resolves !inline reference for preprocessor_module", async () => {
const preprocessorModule = makeRawscriptModule(
"preprocessor",
"!inline preprocessor.inline_script.py",
"python3",
);
const files: Record<string, string> = {
"preprocessor.inline_script.py": "def main(): return {}",
};
await replaceInlineScripts(
[preprocessorModule],
async (path) => {
if (!(path in files)) throw new Error(`File not found: ${path}`);
return files[path];
},
noopLogger,
"/tmp/test/",
"/",
);
expect(preprocessorModule.value.content).toBe("def main(): return {}");
});
test("resolves !inline lock reference", async () => {
const mod = makeRawscriptModule(
"failure",
"!inline failure.inline_script.ts",
"bun",
"!inline failure.inline_script.lock",
);
const files: Record<string, string> = {
"failure.inline_script.ts": "code here",
"failure.inline_script.lock": "lock-data-here",
};
await replaceInlineScripts(
[mod],
async (path) => {
if (!(path in files)) throw new Error(`File not found: ${path}`);
return files[path];
},
noopLogger,
"/tmp/test/",
"/",
);
expect(mod.value.content).toBe("code here");
expect((mod.value as any).lock).toBe("lock-data-here");
});
test("leaves non-inline content untouched", async () => {
const mod = makeRawscriptModule(
"failure",
"export function main() { return 1; }",
"bun",
);
await replaceInlineScripts(
[mod],
async () => {
throw new Error("should not be called");
},
noopLogger,
"/tmp/test/",
"/",
);
expect(mod.value.content).toBe("export function main() { return 1; }");
});
});
// ---------------------------------------------------------------------------
// Round-trip: extract then replace
// ---------------------------------------------------------------------------
describe("round-trip extract → replace for failure_module / preprocessor_module", () => {
test("failure_module content survives extract + replace", async () => {
const originalContent = 'export function main(error: any) {\n console.error(error);\n return { handled: true };\n}';
const failureModule = makeRawscriptModule(
"failure",
originalContent,
"bun",
);
// PULL: extract inline scripts (mutates module in place)
const extracted = extractInlineScripts([failureModule], {}, "/", "bun");
expect(failureModule.value.content).toStartWith("!inline ");
// Build a virtual filesystem from extracted scripts
const files: Record<string, string> = {};
for (const s of extracted) {
files[s.path] = s.content;
}
// PUSH: replace inline references back
await replaceInlineScripts(
[failureModule],
async (path) => {
if (!(path in files)) throw new Error(`File not found: ${path}`);
return files[path];
},
noopLogger,
"/tmp/test/",
"/",
);
expect(failureModule.value.content).toBe(originalContent);
});
test("preprocessor_module content survives extract + replace", async () => {
const originalContent = "def main():\n return {\"preprocessed\": True}";
const preprocessorModule = makeRawscriptModule(
"preprocessor",
originalContent,
"python3",
);
const extracted = extractInlineScripts(
[preprocessorModule],
{},
"/",
"bun",
);
expect(preprocessorModule.value.content).toStartWith("!inline ");
const files: Record<string, string> = {};
for (const s of extracted) {
files[s.path] = s.content;
}
await replaceInlineScripts(
[preprocessorModule],
async (path) => {
if (!(path in files)) throw new Error(`File not found: ${path}`);
return files[path];
},
noopLogger,
"/tmp/test/",
"/",
);
expect(preprocessorModule.value.content).toBe(originalContent);
});
test("failure_module with lock survives extract + replace", async () => {
const originalContent = "export function main() { return 42; }";
const originalLock = "package-lock-contents-here";
const mod = makeRawscriptModule(
"failure",
originalContent,
"bun",
originalLock,
);
const extracted = extractInlineScripts([mod], {}, "/", "bun");
const files: Record<string, string> = {};
for (const s of extracted) {
files[s.path] = s.content;
}
await replaceInlineScripts(
[mod],
async (path) => {
if (!(path in files)) throw new Error(`File not found: ${path}`);
return files[path];
},
noopLogger,
"/tmp/test/",
"/",
);
expect(mod.value.content).toBe(originalContent);
expect((mod.value as any).lock).toBe(originalLock);
});
test("full flow with modules + failure_module + preprocessor_module round-trips", async () => {
const regularContent = "export function main() { return 'step1'; }";
const failureContent = "export function main(e: any) { return e; }";
const preprocessorContent = "def main():\n pass";
const modules = [makeRawscriptModule("a", regularContent, "bun")];
const failureModule = makeRawscriptModule("failure", failureContent, "bun");
const preprocessorModule = makeRawscriptModule("preprocessor", preprocessorContent, "python3");
// Extract all (mimicking sync.ts pull logic)
const allExtracted = [
...extractInlineScripts(modules, {}, "/", "bun"),
...extractInlineScripts([failureModule], {}, "/", "bun"),
...extractInlineScripts([preprocessorModule], {}, "/", "bun"),
];
// All modules should now have !inline references
expect(modules[0].value.content).toStartWith("!inline ");
expect(failureModule.value.content).toStartWith("!inline ");
expect(preprocessorModule.value.content).toStartWith("!inline ");
// All paths should be unique
const paths = allExtracted.filter((s) => !s.is_lock).map((s) => s.path);
expect(new Set(paths).size).toBe(paths.length);
// Build filesystem
const files: Record<string, string> = {};
for (const s of allExtracted) {
files[s.path] = s.content;
}
const fileReader = async (path: string) => {
if (!(path in files)) throw new Error(`File not found: ${path}`);
return files[path];
};
// Replace all (mimicking flow.ts push logic)
await replaceInlineScripts(modules, fileReader, noopLogger, "/tmp/", "/");
await replaceInlineScripts([failureModule], fileReader, noopLogger, "/tmp/", "/");
await replaceInlineScripts([preprocessorModule], fileReader, noopLogger, "/tmp/", "/");
expect(modules[0].value.content).toBe(regularContent);
expect(failureModule.value.content).toBe(failureContent);
expect(preprocessorModule.value.content).toBe(preprocessorContent);
});
});
// ---------------------------------------------------------------------------
// extractCurrentMapping
// ---------------------------------------------------------------------------
describe("extractCurrentMapping for failure_module / preprocessor_module", () => {
test("extracts mapping from failure_module via optional param", () => {
const failureModule: FlowModule = makeRawscriptModule(
"failure",
"!inline failure.inline_script.ts",
"bun",
);
const mapping = extractCurrentMapping(
undefined,
{},
failureModule,
undefined,
);
expect(mapping["failure"]).toBe("failure.inline_script.ts");
});
test("extracts mapping from preprocessor_module via optional param", () => {
const preprocessorModule: FlowModule = makeRawscriptModule(
"preprocessor",
"!inline preprocessor.inline_script.py",
"python3",
);
const mapping = extractCurrentMapping(
undefined,
{},
undefined,
preprocessorModule,
);
expect(mapping["preprocessor"]).toBe("preprocessor.inline_script.py");
});
test("extracts mapping from modules + failure + preprocessor combined", () => {
const modules: FlowModule[] = [
makeRawscriptModule("a", "!inline a.inline_script.ts", "bun"),
];
const failureModule = makeRawscriptModule(
"failure",
"!inline failure.inline_script.ts",
"bun",
);
const preprocessorModule = makeRawscriptModule(
"preprocessor",
"!inline preprocessor.inline_script.py",
"python3",
);
const mapping = extractCurrentMapping(
modules,
{},
failureModule,
preprocessorModule,
);
expect(mapping["a"]).toBe("a.inline_script.ts");
expect(mapping["failure"]).toBe("failure.inline_script.ts");
expect(mapping["preprocessor"]).toBe("preprocessor.inline_script.py");
});
test("ignores non-inline content in failure_module", () => {
const failureModule = makeRawscriptModule(
"failure",
"export function main() {}",
"bun",
);
const mapping = extractCurrentMapping(
undefined,
{},
failureModule,
undefined,
);
expect(mapping["failure"]).toBeUndefined();
});
});