* feat(cli): add consistent get/list/new subcommands for all item types Make the CLI consistent so every item type (script, flow, app, resource, resource-type, variable, schedule, folder, trigger) supports get/list/new subcommands, enabling the CLI to be used as a full API client in bash scripts with jq piping. - Add --json flag to all list commands for machine-readable output - Register explicit "list" subcommand alongside default action - Add "get <path> [--json]" subcommand to fetch single items from API - Rename "bootstrap" to "new" for script/flow, keep "bootstrap" as alias - Add "new" subcommand for resource, resource-type, variable, schedule, folder, and trigger to create local template YAML files - Update cli-commands skill documentation for wmill init - Add integration tests for all new commands Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * all * feat: install wmill CLI in Docker images and use it for bash variable/resource access - Install windmill-cli via bun in all Dockerfiles that include bun - DockerfileCli: switch from node:slim to oven/bun:slim - CLI: auto-configure from WM_WORKSPACE/WM_TOKEN/BASE_INTERNAL_URL env vars as last-resort fallback when no workspace is configured - Frontend: replace curl-based bash snippets with wmill variable/resource get - Add backend integration tests for wmill CLI in bash scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): install windmill-cli in backend test workflow Ensures wmill is available on PATH for bash integration tests that use `wmill variable get` and `wmill resource get`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(cli): replace @std/* Deno dependencies with Node.js equivalents Replace @std/log with a lightweight custom logger (core/log.ts), @std/path with node:path, and @std/yaml with the yaml npm package. Also fix process hang on exit, add --node option to install_dev.sh, and add missing hasRequiredPermissions to NpmProvider. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * all * all * all * refactor(cli): replace @ayonli/jsext and @std/encoding with lightweight alternatives Replace @ayonli/jsext (8.4MB) with tar-stream (32kB) for tar creation, replace @std/encoding with Node.js Buffer.toString("hex"), and fix @windmill-labs/shared-utils to use direct npm instead of JSR mirror. Also resolve merge conflicts in sync.ts and fix pre-existing type errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): use singleQuote YAML output and pass yamlOptions in gitsync pull The yaml library defaults to double quotes, but the codebase (and tests) expect single-quoted strings. Add singleQuote: true to yamlOptions and pass yamlOptions to gitsync-settings pull writeFile calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * all * all * fix(cli): address code review feedback - Install CLI from source in backend tests instead of npm - Fix script bootstrap catch block to re-throw "File already exists" - Add type-safe local variable after trigger kind validation - Use created_by instead of policy.on_behalf_of for app get output - Note --kind is recommended for faster trigger lookup in help text - Document node symlink purpose in Dockerfiles Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): use /usr/bin for wmill wrapper to ensure it's in PATH Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): install wmill to ~/.local/bin to avoid permission issues Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci(backend): switch to Blacksmith runner and add cargo caching - Switch from ubicloud-standard-16 to blacksmith-16vcpu-ubuntu-2404 for faster NVMe-backed builds - Add stickydisk for cargo target directory (persistent NVMe cache across runs) - Add cache for cargo registry and git dependencies - Upgrade DuckDB FFI cache from actions/cache@v3 to useblacksmith/cache@v1 - Enable CARGO_INCREMENTAL=1 to benefit from persistent target cache Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix ci --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
340 lines
14 KiB
TypeScript
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);
|
|
}
|
|
});
|