Files
windmill/cli/test/wmill_lock_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

340 lines
14 KiB
TypeScript

/**
* wmill-lock Tests
*
* Tests the wmill-lock.yaml path normalization functionality.
* Ensures paths are stored with Linux separators and can be correctly
* looked up on both Windows and Linux systems.
*/
import { expect, test } from "bun:test";
import * as path from "node:path";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import {
normalizeLockPath,
readLockfile,
updateMetadataGlobalLock,
checkifMetadataUptodate,
clearGlobalLock,
} from "../src/utils/metadata.ts";
import { generateHash } from "../src/utils/utils.ts";
import { stringify as yamlStringify } from "yaml";
import { yamlParseFile } from "../src/utils/yaml.ts";
// =============================================================================
// UNIT TESTS - Path Normalization
// =============================================================================
test("normalizeLockPath: converts Windows backslashes to forward slashes", () => {
expect(normalizeLockPath("f\\test\\script")).toEqual("f/test/script");
expect(normalizeLockPath("f\\deeply\\nested\\path\\script")).toEqual("f/deeply/nested/path/script");
});
test("normalizeLockPath: preserves already-normalized paths", () => {
expect(normalizeLockPath("f/test/script")).toEqual("f/test/script");
expect(normalizeLockPath("f/deeply/nested/path/script")).toEqual("f/deeply/nested/path/script");
});
test("normalizeLockPath: handles paths without separators", () => {
expect(normalizeLockPath("script")).toEqual("script");
expect(normalizeLockPath("")).toEqual("");
});
test("normalizeLockPath: handles mixed separators", () => {
expect(normalizeLockPath("f/test\\nested/script")).toEqual("f/test/nested/script");
expect(normalizeLockPath("f\\test/nested\\script")).toEqual("f/test/nested/script");
});
// =============================================================================
// INTEGRATION TESTS - Lock File Operations with Windows Paths
// =============================================================================
async function withTempDir(fn: (tempDir: string) => Promise<void>): Promise<void> {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "wmill_lock_test_"));
const originalCwd = process.cwd();
try {
process.chdir(tempDir);
await fn(tempDir);
} finally {
process.chdir(originalCwd);
await rm(tempDir, { recursive: true });
}
}
test("wmill-lock: stores paths with Linux separators even when given Windows paths", async () => {
await withTempDir(async (tempDir) => {
// Simulate a Windows-style path
const windowsPath = "f\\flows\\my-flow.flow";
const hash = "abc123";
await updateMetadataGlobalLock(windowsPath, hash);
// Read the lockfile directly to check stored format
const lockfile = await yamlParseFile("wmill-lock.yaml") as { version: string; locks: Record<string, string> };
// Path should be stored with forward slashes
expect(lockfile.locks["f/flows/my-flow.flow"]).toEqual(hash);
expect(lockfile.locks["f\\flows\\my-flow.flow"]).toEqual(undefined);
});
});
test("wmill-lock: checkifMetadataUptodate finds paths regardless of separator style", async () => {
await withTempDir(async (tempDir) => {
const linuxPath = "f/scripts/my-script";
const windowsPath = "f\\scripts\\my-script";
const hash = "def456";
// Store with Linux-style path
await updateMetadataGlobalLock(linuxPath, hash);
// Should find with Linux-style lookup
const conf = await readLockfile();
expect(await checkifMetadataUptodate(linuxPath, hash, conf)).toEqual(true);
// Should also find with Windows-style lookup (simulating Windows usage)
expect(await checkifMetadataUptodate(windowsPath, hash, conf)).toEqual(true);
// Should not find with wrong hash
expect(await checkifMetadataUptodate(linuxPath, "wrong", conf)).toEqual(false);
expect(await checkifMetadataUptodate(windowsPath, "wrong", conf)).toEqual(false);
});
});
test("wmill-lock: updateMetadataGlobalLock with subpath normalizes both path and subpath", async () => {
await withTempDir(async (tempDir) => {
const windowsPath = "f\\flows\\my-flow.flow";
const windowsSubpath = "inline\\script.ts";
const hash = "ghi789";
await updateMetadataGlobalLock(windowsPath, hash, windowsSubpath);
// Read the lockfile directly
const lockfile = await yamlParseFile("wmill-lock.yaml") as { version: string; locks: Record<string, string> };
// Both path and subpath should use forward slashes
expect(lockfile.locks["f/flows/my-flow.flow+inline/script.ts"]).toEqual(hash);
});
});
test("wmill-lock: checkifMetadataUptodate with subpath handles Windows separators", async () => {
await withTempDir(async (tempDir) => {
const linuxPath = "f/apps/my-app.app";
const linuxSubpath = "scripts/button.ts";
const windowsPath = "f\\apps\\my-app.app";
const windowsSubpath = "scripts\\button.ts";
const hash = "jkl012";
// Store with Linux-style paths
await updateMetadataGlobalLock(linuxPath, hash, linuxSubpath);
const conf = await readLockfile();
// Should find with Linux-style lookup
expect(await checkifMetadataUptodate(linuxPath, hash, conf, linuxSubpath)).toEqual(true);
// Should find with Windows-style lookup
expect(await checkifMetadataUptodate(windowsPath, hash, conf, windowsSubpath)).toEqual(true);
// Should find with mixed-style lookup
expect(await checkifMetadataUptodate(windowsPath, hash, conf, linuxSubpath)).toEqual(true);
expect(await checkifMetadataUptodate(linuxPath, hash, conf, windowsSubpath)).toEqual(true);
});
});
test("wmill-lock: clearGlobalLock clears paths regardless of separator style", async () => {
await withTempDir(async (tempDir) => {
const basePath = "f/flows/my-flow.flow";
const subpath1 = "scripts/a.ts";
const subpath2 = "scripts/b.ts";
// Store multiple entries for the same flow
await updateMetadataGlobalLock(basePath, "hash1", subpath1);
await updateMetadataGlobalLock(basePath, "hash2", subpath2);
await updateMetadataGlobalLock(basePath, "topHash", "__flow_hash");
// Verify they exist
let conf = await readLockfile();
expect(await checkifMetadataUptodate(basePath, "hash1", conf, subpath1)).toEqual(true);
expect(await checkifMetadataUptodate(basePath, "hash2", conf, subpath2)).toEqual(true);
// Clear using Windows-style path
await clearGlobalLock("f\\flows\\my-flow.flow");
// All entries should be cleared
conf = await readLockfile();
expect(await checkifMetadataUptodate(basePath, "hash1", conf, subpath1)).toEqual(false);
expect(await checkifMetadataUptodate(basePath, "hash2", conf, subpath2)).toEqual(false);
expect(await checkifMetadataUptodate(basePath, "topHash", conf, "__flow_hash")).toEqual(false);
});
});
test("wmill-lock: lock file created on Linux can be used on Windows (simulated)", async () => {
await withTempDir(async (tempDir) => {
// Simulate a lock file created on Linux
const linuxLockContent = {
version: "v2" as const,
locks: {
"f/scripts/utility": "hash1",
"f/flows/main.flow+scripts/step1.ts": "hash2",
"f/apps/dashboard.app+components/chart.ts": "hash3",
},
};
await writeFile(
"wmill-lock.yaml",
yamlStringify(linuxLockContent as Record<string, unknown>),
"utf-8"
);
const conf = await readLockfile();
// Simulate Windows lookups (using backslashes)
expect(await checkifMetadataUptodate("f\\scripts\\utility", "hash1", conf)).toEqual(true);
expect(await checkifMetadataUptodate("f\\flows\\main.flow", "hash2", conf, "scripts\\step1.ts")).toEqual(true);
expect(await checkifMetadataUptodate("f\\apps\\dashboard.app", "hash3", conf, "components\\chart.ts")).toEqual(true);
});
});
test("wmill-lock: multiple updates with different separator styles result in single entry", async () => {
await withTempDir(async (tempDir) => {
const linuxPath = "f/scripts/shared";
const windowsPath = "f\\scripts\\shared";
// Update with Linux-style path
await updateMetadataGlobalLock(linuxPath, "hash1");
// Update same path with Windows-style path (should overwrite, not create new entry)
await updateMetadataGlobalLock(windowsPath, "hash2");
const lockfile = await yamlParseFile("wmill-lock.yaml") as { version: string; locks: Record<string, string> };
// Should only have one entry with the latest hash
const lockKeys = Object.keys(lockfile.locks);
expect(lockKeys.length).toEqual(1);
expect(lockKeys[0]).toEqual("f/scripts/shared");
expect(lockfile.locks["f/scripts/shared"]).toEqual("hash2");
});
});
// =============================================================================
// HASH COMPUTATION TESTS - OS-Independent Hash Generation
// =============================================================================
test("hash computation: normalized paths produce same hash on Windows and Linux", async () => {
// Simulate how generateFlowHash/generateAppHash compute hashes
// by using paths as keys in an object that gets stringified
const fileContents = {
"script1.ts": "export function main() { return 1; }",
"nested/script2.ts": "export function main() { return 2; }",
};
// Simulate Windows paths
const windowsHashes: Record<string, string> = {};
for (const [relativePath, content] of Object.entries(fileContents)) {
const windowsPath = relativePath.replace(/\//g, "\\");
// Normalize before using as key (this is what the fix does)
const normalizedPath = normalizeLockPath(windowsPath);
windowsHashes[normalizedPath] = await generateHash(content);
}
const windowsTopHash = await generateHash(JSON.stringify(windowsHashes));
// Simulate Linux paths
const linuxHashes: Record<string, string> = {};
for (const [relativePath, content] of Object.entries(fileContents)) {
// Linux paths are already normalized
const normalizedPath = normalizeLockPath(relativePath);
linuxHashes[normalizedPath] = await generateHash(content);
}
const linuxTopHash = await generateHash(JSON.stringify(linuxHashes));
// Both should produce the same top hash
expect(windowsTopHash).toEqual(linuxTopHash);
// And the individual hashes should have the same keys
expect(Object.keys(windowsHashes).sort()).toEqual(Object.keys(linuxHashes).sort());
});
test("hash computation: without normalization, Windows and Linux would produce different hashes", async () => {
// This test demonstrates the problem that normalization fixes
const fileContents = {
"script1.ts": "export function main() { return 1; }",
"nested/script2.ts": "export function main() { return 2; }",
};
// Simulate Windows paths WITHOUT normalization
const windowsHashesNoNormalize: Record<string, string> = {};
for (const [relativePath, content] of Object.entries(fileContents)) {
const windowsPath = relativePath.replace(/\//g, "\\");
// NOT normalizing - simulating the old behavior
windowsHashesNoNormalize[windowsPath] = await generateHash(content);
}
const windowsTopHashNoNormalize = await generateHash(JSON.stringify(windowsHashesNoNormalize));
// Simulate Linux paths WITHOUT normalization
const linuxHashesNoNormalize: Record<string, string> = {};
for (const [relativePath, content] of Object.entries(fileContents)) {
// NOT normalizing - simulating the old behavior
linuxHashesNoNormalize[relativePath] = await generateHash(content);
}
const linuxTopHashNoNormalize = await generateHash(JSON.stringify(linuxHashesNoNormalize));
// Without normalization, the hashes WOULD be different (this is the bug we fixed)
// The keys are different: "nested\\script2.ts" vs "nested/script2.ts"
const windowsKeys = Object.keys(windowsHashesNoNormalize).sort();
const linuxKeys = Object.keys(linuxHashesNoNormalize).sort();
// Keys should be different without normalization
expect(windowsKeys.includes("nested\\script2.ts")).toEqual(true);
expect(linuxKeys.includes("nested/script2.ts")).toEqual(true);
expect(windowsKeys.includes("nested/script2.ts")).toEqual(false);
expect(linuxKeys.includes("nested\\script2.ts")).toEqual(false);
});
test("hash computation: deeply nested paths are normalized correctly", async () => {
const deepWindowsPath = "f\\flows\\my-flow.flow\\inline\\scripts\\deeply\\nested\\handler.ts";
const deepLinuxPath = "f/flows/my-flow.flow/inline/scripts/deeply/nested/handler.ts";
const content = "export function main() { return 'deeply nested'; }";
// Hash with Windows path (normalized)
const windowsHashes: Record<string, string> = {};
windowsHashes[normalizeLockPath(deepWindowsPath)] = await generateHash(content);
const windowsTopHash = await generateHash(JSON.stringify(windowsHashes));
// Hash with Linux path (normalized)
const linuxHashes: Record<string, string> = {};
linuxHashes[normalizeLockPath(deepLinuxPath)] = await generateHash(content);
const linuxTopHash = await generateHash(JSON.stringify(linuxHashes));
expect(windowsTopHash).toEqual(linuxTopHash);
expect(Object.keys(windowsHashes)[0]).toEqual(Object.keys(linuxHashes)[0]);
expect(Object.keys(windowsHashes)[0]).toEqual(deepLinuxPath);
});
test("hash computation: changedScripts comparison works with inline module paths", () => {
// This test simulates the comparison done in replaceInlineScripts
// where changedScripts (from hashes keys) is compared with paths from flow module content
// Simulate changedScripts populated from hashes (normalized from Windows paths)
const changedScripts = [
normalizeLockPath("step1.ts"),
normalizeLockPath("nested\\step2.ts"), // Windows-style from FSFSElement
normalizeLockPath("deeply\\nested\\step3.ts"),
];
// Simulate paths extracted from flow module content (!inline paths are always forward slashes)
const inlineModulePaths = [
"step1.ts",
"nested/step2.ts", // Forward slashes as stored in flow.yaml
"deeply/nested/step3.ts",
];
// All inline module paths should be found in changedScripts
for (const inlinePath of inlineModulePaths) {
expect(
changedScripts.includes(inlinePath)
).toEqual(true);
}
});