mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 16:01:42 +00:00
5088e13705
* test: assert the unpacked repo symlink without following it `unpack_keeps_a_link_that_stays_in_the_repo` read through the link it had just unpacked. Windows stores a symlink's target verbatim and its object manager rejects the `/` in a POSIX one, so `read_to_string` came back with `ERROR_INVALID_NAME` and the release's `cargo_test_windows` job was red. Pin what the function is responsible for on every platform — the link is kept and materialized — and read through it only where a POSIX relative target resolves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P3WRtxdKNGdomWX9vaAYGx * test: key the cli sync-map fixtures with the platform separator A sync map is keyed with the platform separator on both sides — `FSFSElement` walks the tree with `path.join`, and the remote `ZipFSElement` starts at `"." + SEP` and joins from there — while an `!inline` reference is always forward-slash. `lock_dedup.ts` follows that convention; the fixtures did not, so on Windows they built a map shape the CLI never produces and 12 of them failed. `getTypeStrFromPath` is the same story: it matches `"dependencies" + SEP`, and the test handed it a forward-slashed path. Build the fixture keys through the separator, leaving the `!inline` references and the `present` map forward-slash, as `sync.ts` hands them over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P3WRtxdKNGdomWX9vaAYGx * ci: skip the discord comment relay when the thread lookup returns none A rate-limited or unauthorized Discord response carries no thread list, and under `bash -e` that aborted the step — jq cannot iterate null, nor parse the HTML error page Cloudflare answers a 429 with — before it reached the "thread not found, skipping" branch right below. Three comment relays failed that way on the 1.794.0 head. Keep the step green for both, but tell them apart: a response with no thread list is a delivery that was dropped for a reason worth seeing, so it warns with the body it got, while a PR that genuinely has no thread stays quiet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P3WRtxdKNGdomWX9vaAYGx --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
810 lines
29 KiB
TypeScript
810 lines
29 KiB
TypeScript
/**
|
||
* Unit tests for pure utility functions.
|
||
* These tests require no backend — they test standalone logic.
|
||
*/
|
||
|
||
import { expect, test, describe } from "bun:test";
|
||
import { deepEqual, isFileResource, isFilesetResource, removeResourceSuffix, toCamel, capitalize, validateRequiredArgs, stripBom, readTextFile, readTextFileSync } from "../src/utils/utils.ts";
|
||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import {
|
||
getTypeStrFromPath,
|
||
removeType,
|
||
isSuperset,
|
||
extractNativeTriggerInfo,
|
||
removePathPrefix,
|
||
} from "../src/types.ts";
|
||
import { validatePath } from "../src/core/context.ts";
|
||
import { inferContentTypeFromFilePath } from "../src/utils/script_common.ts";
|
||
import {
|
||
filePathExtensionFromContentType,
|
||
removeExtensionToPath,
|
||
} from "../src/commands/script/script.ts";
|
||
|
||
// =============================================================================
|
||
// deepEqual
|
||
// =============================================================================
|
||
|
||
describe("deepEqual", () => {
|
||
test("primitives", () => {
|
||
expect(deepEqual(1, 1)).toBe(true);
|
||
expect(deepEqual(1, 2)).toBe(false);
|
||
expect(deepEqual("a", "a")).toBe(true);
|
||
expect(deepEqual("a", "b")).toBe(false);
|
||
expect(deepEqual(true, true)).toBe(true);
|
||
expect(deepEqual(true, false)).toBe(false);
|
||
expect(deepEqual(null, null)).toBe(true);
|
||
expect(deepEqual(undefined, undefined)).toBe(true);
|
||
expect(deepEqual(null, undefined)).toBe(false);
|
||
});
|
||
|
||
test("NaN equality", () => {
|
||
expect(deepEqual(NaN, NaN)).toBe(true);
|
||
expect(deepEqual(NaN, 1)).toBe(false);
|
||
});
|
||
|
||
test("arrays", () => {
|
||
expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true);
|
||
expect(deepEqual([1, 2, 3], [1, 2, 4])).toBe(false);
|
||
expect(deepEqual([1, 2], [1, 2, 3])).toBe(false);
|
||
expect(deepEqual([], [])).toBe(true);
|
||
});
|
||
|
||
test("nested arrays", () => {
|
||
expect(deepEqual([[1, 2], [3]], [[1, 2], [3]])).toBe(true);
|
||
expect(deepEqual([[1, 2], [3]], [[1, 2], [4]])).toBe(false);
|
||
});
|
||
|
||
test("objects", () => {
|
||
expect(deepEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true);
|
||
expect(deepEqual({ a: 1, b: 2 }, { a: 1, b: 3 })).toBe(false);
|
||
expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false);
|
||
expect(deepEqual({}, {})).toBe(true);
|
||
});
|
||
|
||
test("nested objects", () => {
|
||
expect(deepEqual({ a: { b: 1 } }, { a: { b: 1 } })).toBe(true);
|
||
expect(deepEqual({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false);
|
||
});
|
||
|
||
test("mixed nested structures", () => {
|
||
const a = { arr: [1, { x: "hello" }], n: null };
|
||
const b = { arr: [1, { x: "hello" }], n: null };
|
||
expect(deepEqual(a, b)).toBe(true);
|
||
|
||
const c = { arr: [1, { x: "world" }], n: null };
|
||
expect(deepEqual(a, c)).toBe(false);
|
||
});
|
||
|
||
test("Maps", () => {
|
||
const m1 = new Map([["a", 1], ["b", 2]]);
|
||
const m2 = new Map([["a", 1], ["b", 2]]);
|
||
const m3 = new Map([["a", 1], ["b", 3]]);
|
||
expect(deepEqual(m1, m2)).toBe(true);
|
||
expect(deepEqual(m1, m3)).toBe(false);
|
||
});
|
||
|
||
test("Sets", () => {
|
||
const s1 = new Set([1, 2, 3]);
|
||
const s2 = new Set([1, 2, 3]);
|
||
const s3 = new Set([1, 2, 4]);
|
||
expect(deepEqual(s1, s2)).toBe(true);
|
||
expect(deepEqual(s1, s3)).toBe(false);
|
||
});
|
||
|
||
test("RegExp", () => {
|
||
expect(deepEqual(/abc/g, /abc/g)).toBe(true);
|
||
expect(deepEqual(/abc/g, /abc/i)).toBe(false);
|
||
expect(deepEqual(/abc/, /def/)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// toCamel & capitalize
|
||
// =============================================================================
|
||
|
||
describe("toCamel", () => {
|
||
test("converts snake_case to camelCase", () => {
|
||
expect(toCamel("hello_world")).toBe("helloWorld");
|
||
expect(toCamel("my_variable_name")).toBe("myVariableName");
|
||
});
|
||
|
||
test("converts kebab-case to camelCase", () => {
|
||
expect(toCamel("hello-world")).toBe("helloWorld");
|
||
});
|
||
|
||
test("handles no separators", () => {
|
||
expect(toCamel("hello")).toBe("hello");
|
||
});
|
||
});
|
||
|
||
describe("capitalize", () => {
|
||
test("capitalizes first character", () => {
|
||
expect(capitalize("hello")).toBe("Hello");
|
||
expect(capitalize("world")).toBe("World");
|
||
});
|
||
|
||
test("handles single character", () => {
|
||
expect(capitalize("a")).toBe("A");
|
||
});
|
||
|
||
test("handles already capitalized", () => {
|
||
expect(capitalize("Hello")).toBe("Hello");
|
||
});
|
||
|
||
test("handles empty string", () => {
|
||
expect(capitalize("")).toBe("");
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// isFileResource
|
||
// =============================================================================
|
||
|
||
describe("isFileResource", () => {
|
||
test("detects resource file paths", () => {
|
||
expect(isFileResource("f/test/my_file.resource.file.txt")).toBe(true);
|
||
expect(isFileResource("u/admin/config.resource.file.json")).toBe(true);
|
||
});
|
||
|
||
test("rejects non-resource-file paths", () => {
|
||
expect(isFileResource("f/test/my_resource.resource.yaml")).toBe(false);
|
||
expect(isFileResource("f/test/my_script.ts")).toBe(false);
|
||
expect(isFileResource("f/test/my_flow.flow/flow.yaml")).toBe(false);
|
||
});
|
||
|
||
test("detects branch-specific resource file paths", () => {
|
||
expect(isFileResource("f/test/config.main.resource.file.json")).toBe(true);
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// removeResourceSuffix
|
||
// =============================================================================
|
||
|
||
describe("removeResourceSuffix", () => {
|
||
test("strips the metadata suffix", () => {
|
||
expect(removeResourceSuffix("f/test/my_resource.resource.yaml")).toBe(
|
||
"f/test/my_resource"
|
||
);
|
||
expect(removeResourceSuffix("f/test/my_resource.resource.json")).toBe(
|
||
"f/test/my_resource"
|
||
);
|
||
});
|
||
|
||
test("strips the file-resource suffix", () => {
|
||
expect(removeResourceSuffix("f/test/my_file.resource.file.txt")).toBe(
|
||
"f/test/my_file"
|
||
);
|
||
expect(removeResourceSuffix("u/admin/config.resource.file.json")).toBe(
|
||
"u/admin/config"
|
||
);
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// isFilesetResource
|
||
// =============================================================================
|
||
|
||
describe("isFilesetResource", () => {
|
||
test("detects fileset resource paths (unix separator)", () => {
|
||
expect(isFilesetResource("f/test/my_config.fileset/config.yaml")).toBe(true);
|
||
expect(isFilesetResource("u/admin/templates.fileset/path/to/file.txt")).toBe(true);
|
||
});
|
||
|
||
test("detects fileset resource paths (windows separator)", () => {
|
||
expect(isFilesetResource("f\\test\\my_config.fileset\\config.yaml")).toBe(true);
|
||
});
|
||
|
||
test("rejects non-fileset paths", () => {
|
||
expect(isFilesetResource("f/test/my_resource.resource.yaml")).toBe(false);
|
||
expect(isFilesetResource("f/test/my_file.resource.file.txt")).toBe(false);
|
||
expect(isFilesetResource("f/test/my_script.ts")).toBe(false);
|
||
});
|
||
|
||
test("rejects paths ending with .fileset (no child file)", () => {
|
||
// The directory itself is not a fileset resource file - only children are
|
||
expect(isFilesetResource("f/test/my_config.fileset")).toBe(false);
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// removeType
|
||
// =============================================================================
|
||
|
||
describe("removeType", () => {
|
||
test("removes .variable.yaml suffix", () => {
|
||
expect(removeType("f/test/my_var.variable.yaml", "variable")).toBe("f/test/my_var");
|
||
});
|
||
|
||
test("removes .resource.yaml suffix", () => {
|
||
expect(removeType("f/test/my_res.resource.yaml", "resource")).toBe("f/test/my_res");
|
||
});
|
||
|
||
test("removes .schedule.yaml suffix", () => {
|
||
expect(removeType("u/admin/cron.schedule.yaml", "schedule")).toBe("u/admin/cron");
|
||
});
|
||
|
||
test("removes .json suffix too", () => {
|
||
expect(removeType("f/test/my_var.variable.json", "variable")).toBe("f/test/my_var");
|
||
});
|
||
|
||
test("passes through path with wrong type suffix as clean path", () => {
|
||
expect(removeType("f/test/my_var.variable.yaml", "resource")).toBe("f/test/my_var.variable.yaml");
|
||
});
|
||
|
||
test("passes through path with no type suffix as clean path", () => {
|
||
expect(removeType("f/test/my_script.ts", "variable")).toBe("f/test/my_script.ts");
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// removePathPrefix
|
||
// =============================================================================
|
||
|
||
describe("removePathPrefix", () => {
|
||
test("removes prefix from path", () => {
|
||
expect(removePathPrefix("f/test/my_script.ts", "f/test")).toBe("my_script.ts");
|
||
});
|
||
|
||
test("handles exact match", () => {
|
||
expect(removePathPrefix("f/test", "f/test")).toBe("");
|
||
});
|
||
|
||
test("throws when prefix doesn't match", () => {
|
||
expect(() => removePathPrefix("g/admin/script.ts", "f/test")).toThrow();
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// getTypeStrFromPath
|
||
// =============================================================================
|
||
|
||
describe("getTypeStrFromPath", () => {
|
||
test("detects script types by extension", () => {
|
||
expect(getTypeStrFromPath("f/test/my_script.ts")).toBe("script");
|
||
expect(getTypeStrFromPath("f/test/my_script.py")).toBe("script");
|
||
expect(getTypeStrFromPath("f/test/my_script.go")).toBe("script");
|
||
expect(getTypeStrFromPath("f/test/my_script.sh")).toBe("script");
|
||
expect(getTypeStrFromPath("f/test/my_script.sql")).toBe("script");
|
||
expect(getTypeStrFromPath("f/test/my_script.php")).toBe("script");
|
||
expect(getTypeStrFromPath("f/test/my_script.rs")).toBe("script");
|
||
});
|
||
|
||
test("a shared lockfile is its own type, not a workspace dependency", () => {
|
||
// A repo-side artifact with no object on the server: classified as a
|
||
// workspace dependency, `sync push` would try to deploy it as one.
|
||
// Sync paths carry the platform separator, so these do too.
|
||
expect(getTypeStrFromPath(join("locks", "requirements.in.lock"))).toBe(
|
||
"shared_lock",
|
||
);
|
||
expect(getTypeStrFromPath(join("dependencies", "requirements.in"))).toBe(
|
||
"workspace_dependencies",
|
||
);
|
||
// `locks/` is an ordinary word: only the names Windmill writes are claimed,
|
||
// so a repo that already keeps its own lockfiles there keeps them.
|
||
expect(() => getTypeStrFromPath(join("locks", "vendor.lock"))).toThrow();
|
||
});
|
||
|
||
test("detects metadata types by name suffix", () => {
|
||
expect(getTypeStrFromPath("f/test/my_var.variable.yaml")).toBe("variable");
|
||
expect(getTypeStrFromPath("f/test/my_res.resource.yaml")).toBe("resource");
|
||
expect(getTypeStrFromPath("f/test/my_sched.schedule.yaml")).toBe("schedule");
|
||
expect(getTypeStrFromPath("f/test/my_rt.resource-type.yaml")).toBe("resource-type");
|
||
});
|
||
|
||
test("detects trigger types", () => {
|
||
expect(getTypeStrFromPath("f/test/my_trig.http_trigger.yaml")).toBe("http_trigger");
|
||
expect(getTypeStrFromPath("f/test/my_trig.websocket_trigger.yaml")).toBe("websocket_trigger");
|
||
expect(getTypeStrFromPath("f/test/my_trig.kafka_trigger.yaml")).toBe("kafka_trigger");
|
||
});
|
||
|
||
test("detects folder metadata", () => {
|
||
expect(getTypeStrFromPath("f/test/folder.meta.yaml")).toBe("folder");
|
||
});
|
||
|
||
test("detects user and group", () => {
|
||
expect(getTypeStrFromPath("admin.user.yaml")).toBe("user");
|
||
expect(getTypeStrFromPath("devs.group.yaml")).toBe("group");
|
||
});
|
||
|
||
test("detects fileset resource files as resource type", () => {
|
||
expect(getTypeStrFromPath("f/test/my_config.fileset/config.yaml")).toBe("resource");
|
||
expect(getTypeStrFromPath("u/admin/templates.fileset/path/to/file.txt")).toBe("resource");
|
||
// fileset files with script-like extensions should still be detected as resource
|
||
expect(getTypeStrFromPath("f/test/my_queries.fileset/query.sql")).toBe("resource");
|
||
expect(getTypeStrFromPath("f/test/my_queries.fileset/script.py")).toBe("resource");
|
||
expect(getTypeStrFromPath("f/test/my_queries.fileset/nested/dir/file.ts")).toBe("resource");
|
||
});
|
||
|
||
test("throws for unknown type", () => {
|
||
expect(() => getTypeStrFromPath("f/test/unknown.xyz.yaml")).toThrow();
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// validatePath
|
||
// =============================================================================
|
||
|
||
describe("validatePath", () => {
|
||
test("accepts valid paths", () => {
|
||
expect(validatePath("f/test/my_script")).toBe(true);
|
||
expect(validatePath("u/admin/my_script")).toBe(true);
|
||
expect(validatePath("g/all/my_script")).toBe(true);
|
||
});
|
||
|
||
test("rejects invalid paths", () => {
|
||
expect(validatePath("invalid/path")).toBe(false);
|
||
expect(validatePath("test/my_script")).toBe(false);
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// inferContentTypeFromFilePath
|
||
// =============================================================================
|
||
|
||
describe("inferContentTypeFromFilePath", () => {
|
||
test("detects Python", () => {
|
||
expect(inferContentTypeFromFilePath("script.py", undefined)).toBe("python3");
|
||
});
|
||
|
||
test("detects Go", () => {
|
||
expect(inferContentTypeFromFilePath("script.go", undefined)).toBe("go");
|
||
});
|
||
|
||
test("detects Bash", () => {
|
||
expect(inferContentTypeFromFilePath("script.sh", undefined)).toBe("bash");
|
||
});
|
||
|
||
test("detects PHP", () => {
|
||
expect(inferContentTypeFromFilePath("script.php", undefined)).toBe("php");
|
||
});
|
||
|
||
test("detects Rust", () => {
|
||
expect(inferContentTypeFromFilePath("script.rs", undefined)).toBe("rust");
|
||
});
|
||
|
||
test("detects PowerShell", () => {
|
||
expect(inferContentTypeFromFilePath("script.ps1", undefined)).toBe("powershell");
|
||
});
|
||
|
||
test("detects GraphQL", () => {
|
||
expect(inferContentTypeFromFilePath("query.gql", undefined)).toBe("graphql");
|
||
});
|
||
|
||
test("defaults .ts to bun", () => {
|
||
expect(inferContentTypeFromFilePath("script.ts", undefined)).toBe("bun");
|
||
});
|
||
|
||
test("uses defaultTs for .ts files", () => {
|
||
expect(inferContentTypeFromFilePath("script.ts", "deno")).toBe("deno");
|
||
expect(inferContentTypeFromFilePath("script.ts", "bun")).toBe("bun");
|
||
});
|
||
|
||
test("explicit bun.ts and deno.ts override defaultTs", () => {
|
||
expect(inferContentTypeFromFilePath("script.bun.ts", "deno")).toBe("bun");
|
||
expect(inferContentTypeFromFilePath("script.deno.ts", "bun")).toBe("deno");
|
||
});
|
||
|
||
test("detects nativets with fetch.ts", () => {
|
||
expect(inferContentTypeFromFilePath("script.fetch.ts", "bun")).toBe("nativets");
|
||
});
|
||
|
||
test("detects SQL variants", () => {
|
||
expect(inferContentTypeFromFilePath("query.pg.sql", undefined)).toBe("postgresql");
|
||
expect(inferContentTypeFromFilePath("query.my.sql", undefined)).toBe("mysql");
|
||
expect(inferContentTypeFromFilePath("query.bq.sql", undefined)).toBe("bigquery");
|
||
expect(inferContentTypeFromFilePath("query.ms.sql", undefined)).toBe("mssql");
|
||
expect(inferContentTypeFromFilePath("query.sf.sql", undefined)).toBe("snowflake");
|
||
expect(inferContentTypeFromFilePath("query.duckdb.sql", undefined)).toBe("duckdb");
|
||
expect(inferContentTypeFromFilePath("query.odb.sql", undefined)).toBe("oracledb");
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// extractNativeTriggerInfo
|
||
// =============================================================================
|
||
|
||
describe("extractNativeTriggerInfo", () => {
|
||
test("extracts info from valid flow trigger path", () => {
|
||
const result = extractNativeTriggerInfo(
|
||
"u/admin/script.flow.12345.nextcloud_native_trigger.json"
|
||
);
|
||
expect(result).not.toBeNull();
|
||
expect(result!.scriptPath).toBe("u/admin/script");
|
||
expect(result!.isFlow).toBe(true);
|
||
expect(result!.externalId).toBe("12345");
|
||
expect(result!.serviceName).toBe("nextcloud");
|
||
});
|
||
|
||
test("detects script (non-flow) triggers", () => {
|
||
const result = extractNativeTriggerInfo(
|
||
"f/test/handler.script.abc123.nextcloud_native_trigger.json"
|
||
);
|
||
expect(result).not.toBeNull();
|
||
expect(result!.isFlow).toBe(false);
|
||
expect(result!.scriptPath).toBe("f/test/handler");
|
||
});
|
||
|
||
test("returns null for non-native trigger paths", () => {
|
||
expect(extractNativeTriggerInfo("f/test/my_var.variable.yaml")).toBeNull();
|
||
expect(extractNativeTriggerInfo("f/test/trig.http_trigger.yaml")).toBeNull();
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// isSuperset
|
||
// =============================================================================
|
||
|
||
describe("isSuperset", () => {
|
||
test("returns true when subset matches superset", () => {
|
||
expect(isSuperset({ a: 1 }, { a: 1, b: 2 })).toBe(true);
|
||
});
|
||
|
||
test("returns true when objects are identical", () => {
|
||
expect(isSuperset({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true);
|
||
});
|
||
|
||
test("returns false when values differ", () => {
|
||
expect(isSuperset({ a: 1 }, { a: 2 })).toBe(false);
|
||
});
|
||
|
||
test("handles nested objects", () => {
|
||
expect(isSuperset({ a: { x: 1 } }, { a: { x: 1 }, b: 2 })).toBe(true);
|
||
expect(isSuperset({ a: { x: 1 } }, { a: { x: 2 } })).toBe(false);
|
||
});
|
||
|
||
test("empty subset is always a superset match", () => {
|
||
expect(isSuperset({}, { a: 1, b: 2 })).toBe(true);
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// filePathExtensionFromContentType
|
||
// =============================================================================
|
||
|
||
describe("filePathExtensionFromContentType", () => {
|
||
test("returns .py for python3", () => {
|
||
expect(filePathExtensionFromContentType("python3", undefined)).toBe(".py");
|
||
});
|
||
|
||
test("returns .fetch.ts for nativets", () => {
|
||
expect(filePathExtensionFromContentType("nativets", undefined)).toBe(".fetch.ts");
|
||
});
|
||
|
||
test("returns .ts for bun when defaultTs is bun or undefined", () => {
|
||
expect(filePathExtensionFromContentType("bun", "bun")).toBe(".ts");
|
||
expect(filePathExtensionFromContentType("bun", undefined)).toBe(".ts");
|
||
});
|
||
|
||
test("returns .bun.ts for bun when defaultTs is deno", () => {
|
||
expect(filePathExtensionFromContentType("bun", "deno")).toBe(".bun.ts");
|
||
});
|
||
|
||
test("returns .ts for deno when defaultTs is deno", () => {
|
||
expect(filePathExtensionFromContentType("deno", "deno")).toBe(".ts");
|
||
});
|
||
|
||
test("returns .deno.ts for deno when defaultTs is bun or undefined", () => {
|
||
expect(filePathExtensionFromContentType("deno", "bun")).toBe(".deno.ts");
|
||
expect(filePathExtensionFromContentType("deno", undefined)).toBe(".deno.ts");
|
||
});
|
||
|
||
test("returns .go for go", () => {
|
||
expect(filePathExtensionFromContentType("go", undefined)).toBe(".go");
|
||
});
|
||
|
||
test("returns .sh for bash", () => {
|
||
expect(filePathExtensionFromContentType("bash", undefined)).toBe(".sh");
|
||
});
|
||
|
||
test("returns .ps1 for powershell", () => {
|
||
expect(filePathExtensionFromContentType("powershell", undefined)).toBe(".ps1");
|
||
});
|
||
|
||
test("returns .gql for graphql", () => {
|
||
expect(filePathExtensionFromContentType("graphql", undefined)).toBe(".gql");
|
||
});
|
||
|
||
test("returns .php for php", () => {
|
||
expect(filePathExtensionFromContentType("php", undefined)).toBe(".php");
|
||
});
|
||
|
||
test("returns .rs for rust", () => {
|
||
expect(filePathExtensionFromContentType("rust", undefined)).toBe(".rs");
|
||
});
|
||
|
||
test("returns .cs for csharp", () => {
|
||
expect(filePathExtensionFromContentType("csharp", undefined)).toBe(".cs");
|
||
});
|
||
|
||
test("returns .nu for nu", () => {
|
||
expect(filePathExtensionFromContentType("nu", undefined)).toBe(".nu");
|
||
});
|
||
|
||
test("returns .java for java", () => {
|
||
expect(filePathExtensionFromContentType("java", undefined)).toBe(".java");
|
||
});
|
||
|
||
test("returns .rb for ruby", () => {
|
||
expect(filePathExtensionFromContentType("ruby", undefined)).toBe(".rb");
|
||
});
|
||
|
||
test("returns .playbook.yml for ansible", () => {
|
||
expect(filePathExtensionFromContentType("ansible", undefined)).toBe(".playbook.yml");
|
||
});
|
||
|
||
test("returns correct SQL extensions", () => {
|
||
expect(filePathExtensionFromContentType("postgresql", undefined)).toBe(".pg.sql");
|
||
expect(filePathExtensionFromContentType("mysql", undefined)).toBe(".my.sql");
|
||
expect(filePathExtensionFromContentType("bigquery", undefined)).toBe(".bq.sql");
|
||
expect(filePathExtensionFromContentType("duckdb", undefined)).toBe(".duckdb.sql");
|
||
expect(filePathExtensionFromContentType("oracledb", undefined)).toBe(".odb.sql");
|
||
expect(filePathExtensionFromContentType("snowflake", undefined)).toBe(".sf.sql");
|
||
expect(filePathExtensionFromContentType("mssql", undefined)).toBe(".ms.sql");
|
||
});
|
||
|
||
test("throws for invalid language", () => {
|
||
expect(() =>
|
||
filePathExtensionFromContentType("invalid" as any, undefined)
|
||
).toThrow();
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// removeExtensionToPath
|
||
// =============================================================================
|
||
|
||
describe("removeExtensionToPath", () => {
|
||
test("removes .ts extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.ts")).toBe("f/test/script");
|
||
});
|
||
|
||
test("removes .py extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.py")).toBe("f/test/script");
|
||
});
|
||
|
||
test("removes .go extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.go")).toBe("f/test/script");
|
||
});
|
||
|
||
test("removes .sh extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.sh")).toBe("f/test/script");
|
||
});
|
||
|
||
test("removes .pg.sql extension", () => {
|
||
expect(removeExtensionToPath("f/test/query.pg.sql")).toBe("f/test/query");
|
||
});
|
||
|
||
test("removes .my.sql extension", () => {
|
||
expect(removeExtensionToPath("f/test/query.my.sql")).toBe("f/test/query");
|
||
});
|
||
|
||
test("removes .duckdb.sql extension", () => {
|
||
expect(removeExtensionToPath("f/test/query.duckdb.sql")).toBe("f/test/query");
|
||
});
|
||
|
||
test("removes .fetch.ts extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.fetch.ts")).toBe("f/test/script");
|
||
});
|
||
|
||
test("removes .bun.ts extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.bun.ts")).toBe("f/test/script");
|
||
});
|
||
|
||
test("removes .deno.ts extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.deno.ts")).toBe("f/test/script");
|
||
});
|
||
|
||
test("removes .gql extension", () => {
|
||
expect(removeExtensionToPath("f/test/query.gql")).toBe("f/test/query");
|
||
});
|
||
|
||
test("removes .ps1 extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.ps1")).toBe("f/test/script");
|
||
});
|
||
|
||
test("removes .php extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.php")).toBe("f/test/script");
|
||
});
|
||
|
||
test("removes .rs extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.rs")).toBe("f/test/script");
|
||
});
|
||
|
||
test("removes .cs extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.cs")).toBe("f/test/script");
|
||
});
|
||
|
||
test("removes .nu extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.nu")).toBe("f/test/script");
|
||
});
|
||
|
||
test("removes .playbook.yml extension", () => {
|
||
expect(removeExtensionToPath("f/test/play.playbook.yml")).toBe("f/test/play");
|
||
});
|
||
|
||
test("removes .java extension", () => {
|
||
expect(removeExtensionToPath("f/test/Script.java")).toBe("f/test/Script");
|
||
});
|
||
|
||
test("removes .rb extension", () => {
|
||
expect(removeExtensionToPath("f/test/script.rb")).toBe("f/test/script");
|
||
});
|
||
|
||
test("throws for unknown extension", () => {
|
||
expect(() => removeExtensionToPath("f/test/file.xyz")).toThrow();
|
||
});
|
||
|
||
test("prioritizes longer extensions (fetch.ts over .ts)", () => {
|
||
// fetch.ts should be recognized as nativets, not as bun .ts
|
||
expect(removeExtensionToPath("f/test/api.fetch.ts")).toBe("f/test/api");
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// validateRequiredArgs
|
||
// =============================================================================
|
||
|
||
describe("validateRequiredArgs", () => {
|
||
test("throws when required args are missing", () => {
|
||
expect(() =>
|
||
validateRequiredArgs({ required: ["name", "count"] })
|
||
).toThrow("Missing required arguments: name, count");
|
||
});
|
||
|
||
test("does not throw when no required args", () => {
|
||
expect(() => validateRequiredArgs({ required: [] })).not.toThrow();
|
||
});
|
||
|
||
test("does not throw for undefined schema", () => {
|
||
expect(() => validateRequiredArgs(undefined)).not.toThrow();
|
||
expect(() => validateRequiredArgs(null)).not.toThrow();
|
||
});
|
||
|
||
test("does not throw for schema without required field", () => {
|
||
expect(() => validateRequiredArgs({ type: "object", properties: {} })).not.toThrow();
|
||
});
|
||
|
||
test("error message includes usage hint", () => {
|
||
try {
|
||
validateRequiredArgs({ required: ["name"] });
|
||
} catch (e: any) {
|
||
expect(e.message).toContain('-d \'{"name":');
|
||
}
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// BOM handling
|
||
// =============================================================================
|
||
|
||
describe("stripBom", () => {
|
||
test("strips UTF-8 BOM", () => {
|
||
expect(stripBom("hello")).toBe("hello");
|
||
});
|
||
|
||
test("returns input unchanged when no BOM", () => {
|
||
expect(stripBom("hello")).toBe("hello");
|
||
expect(stripBom("")).toBe("");
|
||
});
|
||
});
|
||
|
||
describe("readTextFile / readTextFileSync", () => {
|
||
const tmp = mkdtempSync(join(tmpdir(), "wmill-bom-"));
|
||
|
||
test("reads plain UTF-8 file", async () => {
|
||
const f = join(tmp, "plain.txt");
|
||
writeFileSync(f, Buffer.from("hello world", "utf-8"));
|
||
expect(await readTextFile(f)).toBe("hello world");
|
||
expect(readTextFileSync(f)).toBe("hello world");
|
||
});
|
||
|
||
test("strips UTF-8 BOM", async () => {
|
||
const f = join(tmp, "bom.txt");
|
||
writeFileSync(f, Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("hello", "utf-8")]));
|
||
expect(await readTextFile(f)).toBe("hello");
|
||
expect(readTextFileSync(f)).toBe("hello");
|
||
});
|
||
|
||
test("throws on UTF-16 LE BOM", async () => {
|
||
const f = join(tmp, "utf16le.txt");
|
||
writeFileSync(f, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("hello", "utf16le")]));
|
||
await expect(readTextFile(f)).rejects.toThrow(/UTF-16 LE/);
|
||
expect(() => readTextFileSync(f)).toThrow(/UTF-16 LE/);
|
||
});
|
||
|
||
test("throws on UTF-16 BE BOM", async () => {
|
||
const f = join(tmp, "utf16be.txt");
|
||
writeFileSync(f, Buffer.from([0xfe, 0xff, 0x00, 0x68]));
|
||
await expect(readTextFile(f)).rejects.toThrow(/UTF-16 BE/);
|
||
expect(() => readTextFileSync(f)).toThrow(/UTF-16 BE/);
|
||
});
|
||
|
||
test("throws on UTF-32 LE BOM", async () => {
|
||
const f = join(tmp, "utf32le.txt");
|
||
writeFileSync(f, Buffer.from([0xff, 0xfe, 0x00, 0x00, 0x68, 0x00, 0x00, 0x00]));
|
||
await expect(readTextFile(f)).rejects.toThrow(/UTF-32 LE/);
|
||
});
|
||
|
||
test("empty file reads as empty string", async () => {
|
||
const f = join(tmp, "empty.txt");
|
||
writeFileSync(f, Buffer.alloc(0));
|
||
expect(await readTextFile(f)).toBe("");
|
||
expect(readTextFileSync(f)).toBe("");
|
||
});
|
||
|
||
// cleanup
|
||
test("cleanup", () => {
|
||
rmSync(tmp, { recursive: true, force: true });
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// TarAsZip adapter
|
||
// =============================================================================
|
||
|
||
describe("TarAsZip adapter", () => {
|
||
// Import the adapter — it's not exported but we can test via tar creation + parsing
|
||
const { extract } = require("tar-stream");
|
||
const { Readable } = require("node:stream");
|
||
|
||
// Helper: build a TarAsZip from entries via the actual class
|
||
async function buildTarAsZip(entries: Map<string, { content: string; isDir: boolean }>) {
|
||
// Dynamically import to get the class
|
||
const pullModule = await import("../src/commands/sync/pull.ts");
|
||
// TarAsZip is not exported, so we test indirectly via parseTarResponse
|
||
// Instead, test the tar creation → extraction round-trip
|
||
const { createTarBlob } = await import("../src/utils/tar.ts");
|
||
|
||
const tarEntries = Array.from(entries).map(([name, { content }]) => ({
|
||
name,
|
||
content,
|
||
}));
|
||
const blob = await createTarBlob(tarEntries);
|
||
|
||
// Parse via the same extract pattern used by TarAsZip
|
||
const buffer = Buffer.from(await blob.arrayBuffer());
|
||
const result = new Map<string, { content: string; isDir: boolean }>();
|
||
const ex = extract();
|
||
|
||
return new Promise<Map<string, string>>((resolve, reject) => {
|
||
ex.on("entry", (header: any, stream: any, next: () => void) => {
|
||
const chunks: Buffer[] = [];
|
||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||
stream.on("end", () => {
|
||
result.set(header.name, {
|
||
content: Buffer.concat(chunks).toString("utf-8"),
|
||
isDir: header.type === "directory",
|
||
});
|
||
next();
|
||
});
|
||
stream.on("error", reject);
|
||
stream.resume();
|
||
});
|
||
ex.on("finish", () => {
|
||
// Convert to simple map for assertions
|
||
const simpleMap = new Map<string, string>();
|
||
for (const [name, { content }] of result) {
|
||
simpleMap.set(name, content);
|
||
}
|
||
resolve(simpleMap);
|
||
});
|
||
ex.on("error", reject);
|
||
Readable.from(buffer).pipe(ex);
|
||
});
|
||
}
|
||
|
||
test("tar round-trip preserves content", async () => {
|
||
const entries = new Map([
|
||
["f/scripts/hello.ts", { content: 'export async function main() { return "hello"; }', isDir: false }],
|
||
["f/scripts/hello.script.yaml", { content: "summary: Hello\nkind: script\n", isDir: false }],
|
||
]);
|
||
|
||
const result = await buildTarAsZip(entries);
|
||
expect(result.get("f/scripts/hello.ts")).toBe('export async function main() { return "hello"; }');
|
||
expect(result.get("f/scripts/hello.script.yaml")).toContain("summary: Hello");
|
||
});
|
||
});
|