/** * Sync Pull/Push Integration Tests * * Tests the sync pull and push functionality with a simulated filesystem * containing every kind of Windmill resource type. * * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): * @see test_fixtures.ts - Shared local fixtures (prefer using this module for new tests) * @see test_backend.ts - API-based creation helpers (createTestApp, createTestResource, * createAppWithInlineScript, createFlowWithInlineScript, etc.) * * This file contains: Local fixtures (should migrate to test_fixtures.ts) + createRemoteScript * If you add new helpers, update cross-links in the files above. */ import { expect, test, describe } from "bun:test"; import * as path from "node:path"; import { sep as SEP } from "node:path"; import { writeFile, readFile, readdir, rm, mkdir, mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import JSZip from "jszip"; import { getFolderSuffix, getMetadataFileName, buildFolderPath, getFolderSuffixes, isFlowPath, isAppPath, isRawAppPath, buildMetadataPath, extractResourceName, hasFolderSuffix, setNonDottedPaths, getNonDottedPaths, isFlowMetadataFile, isAppMetadataFile, isRawAppMetadataFile, transformJsonPathToDir, isAppInlineScriptPath, isFlowInlineScriptPath, isRawAppBackendPath, getModuleFolderSuffix, hasWrongFormatSuffix, } from "../src/utils/resource_folders.ts"; import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path-assigner.ts"; // ============================================================================= // Test Fixtures - Every Type of Windmill Resource // See file header for cross-links to related helpers. // Consider migrating these to test_fixtures.ts for reuse across tests. // ============================================================================= /** * Creates a mock script file structure. * See file header for cross-links to related helpers. */ function createScriptFixture( name: string, language: "python3" | "deno" | "bun" | "bash" | "go" | "postgresql", content?: string, ): { contentFile: { path: string; content: string }; metadataFile: { path: string; content: string } } { const extensions: Record = { python3: ".py", deno: ".ts", bun: ".ts", bash: ".sh", go: ".go", postgresql: ".sql", }; const ext = extensions[language]; const defaultContent: Record = { python3: `def main():\n return "Hello from ${name}"`, deno: `export async function main() {\n return "Hello from ${name}";\n}`, bun: `export async function main() {\n return "Hello from ${name}";\n}`, bash: `#!/bin/bash\necho "Hello from ${name}"`, go: `package inner\n\nfunc main() string {\n return "Hello from ${name}"\n}`, postgresql: `-- ${name}\nSELECT 'Hello from ${name}';`, }; return { contentFile: { path: `${name}${ext}`, content: content ?? defaultContent[language], }, metadataFile: { path: `${name}.script.yaml`, content: `summary: "${name} script" description: "A ${language} script for testing" schema: $schema: "https://json-schema.org/draft/2020-12/schema" type: object properties: {} required: [] is_template: false lock: [] kind: script `, }, }; } /** * Creates a mock flow file structure. * See file header for cross-links to related helpers. */ function createFlowFixture(name: string): Record { const flowSuffix = getFolderSuffix("flow"); const metadataFile = getMetadataFileName("flow", "yaml"); return { metadata: { path: `${name}${flowSuffix}/${metadataFile}`, content: `summary: "${name} flow" description: "A flow for testing" value: modules: - id: a value: type: rawscript content: "!inline ${name}${flowSuffix}/a.ts" language: bun input_transforms: {} schema: $schema: "https://json-schema.org/draft/2020-12/schema" type: object properties: {} required: [] `, }, inlineScript: { path: `${name}${flowSuffix}/a.ts`, content: `export async function main() {\n return "Hello from flow ${name}";\n}`, }, }; } /** * Creates a mock app file structure. * See file header for cross-links to related helpers. */ function createAppFixture(name: string): Record { const appSuffix = getFolderSuffix("app"); const metadataFile = getMetadataFileName("app", "yaml"); return { metadata: { path: `${name}${appSuffix}/${metadataFile}`, content: `summary: "${name} app" value: grid: - data: id: "text_1" type: textcomponent componentInput: type: static value: "Hello from ${name}" id: "a" hiddenInlineScripts: [] policy: execution_mode: viewer `, }, }; } /** * Creates a mock raw_app file structure. * See file header for cross-links to related helpers. */ function createRawAppFixture(name: string): Record { const rawAppSuffix = getFolderSuffix("raw_app"); const metadataFile = getMetadataFileName("raw_app", "yaml"); return { metadata: { path: `${name}${rawAppSuffix}/${metadataFile}`, content: `summary: "${name} raw app" runnables: a: path: u/test/my_script type: script `, }, indexHtml: { path: `${name}${rawAppSuffix}/index.html`, content: ` ${name}

Hello from ${name}

`, }, indexJs: { path: `${name}${rawAppSuffix}/index.js`, content: `console.log("Hello from ${name}");`, }, }; } /** * Creates a mock resource file */ function createResourceFixture( name: string, resourceType: string, value: Record, ): { path: string; content: string } { return { path: `${name}.resource.yaml`, content: `resource_type: "${resourceType}" value: ${Object.entries(value).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join("\n")} `, }; } /** * Creates a mock variable file */ function createVariableFixture( name: string, value: string, isSecret: boolean = false, ): { path: string; content: string } { return { path: `${name}.variable.yaml`, content: `value: "${value}" is_secret: ${isSecret} description: "Variable ${name} for testing" `, }; } /** * Creates a mock schedule file */ function createScheduleFixture( name: string, scriptPath: string, schedule: string = "0 * * * *", ): { path: string; content: string } { return { path: `${name}.schedule.yaml`, content: `path: "${name}" schedule: "${schedule}" script_path: "${scriptPath}" is_flow: false args: {} enabled: true timezone: "UTC" `, }; } /** * Creates a mock HTTP trigger file */ function createHttpTriggerFixture( name: string, routePath: string, scriptPath: string, ): { path: string; content: string } { return { path: `${name}.http_trigger.yaml`, content: `path: "${name}" route_path: "${routePath}" script_path: "${scriptPath}" is_flow: false http_method: post authentication_method: none is_async: false `, }; } /** * Creates a mock WebSocket trigger file */ function createWebsocketTriggerFixture( name: string, url: string, scriptPath: string, ): { path: string; content: string } { return { path: `${name}.websocket_trigger.yaml`, content: `path: "${name}" url: "${url}" script_path: "${scriptPath}" is_flow: false enabled: true filters: [] initial_messages: [] `, }; } /** * Creates a mock folder meta file */ function createFolderFixture(name: string): { path: string; content: string } { return { path: `${name}/folder.meta.yaml`, content: `display_name: "${name}" owners: [] extra_perms: {} `, }; } /** * Creates a mock user file */ function createUserFixture( username: string, email: string, isAdmin: boolean = false, ): { path: string; content: string } { return { path: `${username}.user.yaml`, content: `username: "${username}" email: "${email}" is_admin: ${isAdmin} operator: false disabled: false `, }; } /** * Creates a mock group file */ function createGroupFixture(name: string, members: string[] = []): { path: string; content: string } { return { path: `${name}.group.yaml`, content: `name: "${name}" summary: "Group ${name} for testing" members: ${members.map((m) => ` - ${m}`).join("\n") || " []"} `, }; } // ============================================================================= // Test Helper Functions // ============================================================================= /** * Creates a complete local filesystem with all resource types */ async function createLocalFilesystem(baseDir: string): Promise { // Create folder structure const folders = ["f/scripts", "f/flows", "f/apps", "f/resources"]; for (const folder of folders) { await mkdir(path.join(baseDir, folder), { recursive: true }); } // Create scripts const scripts = [ createScriptFixture("f/scripts/python_script", "python3"), createScriptFixture("f/scripts/deno_script", "deno"), createScriptFixture("f/scripts/bash_script", "bash"), createScriptFixture("f/scripts/go_script", "go"), createScriptFixture("f/scripts/sql_script", "postgresql"), ]; for (const script of scripts) { await writeFile( path.join(baseDir, script.contentFile.path), script.contentFile.content, "utf-8", ); await writeFile( path.join(baseDir, script.metadataFile.path), script.metadataFile.content, "utf-8", ); } // Create flows const flowFixture = createFlowFixture("f/flows/test_flow"); await mkdir(path.join(baseDir, `f/flows/test_flow${getFolderSuffix("flow")}`), { recursive: true }); for (const file of Object.values(flowFixture)) { await writeFile(path.join(baseDir, file.path), file.content, "utf-8"); } // Create apps const appFixture = createAppFixture("f/apps/test_app"); await mkdir(path.join(baseDir, `f/apps/test_app${getFolderSuffix("app")}`), { recursive: true }); for (const file of Object.values(appFixture)) { await writeFile(path.join(baseDir, file.path), file.content, "utf-8"); } // Create raw apps const rawAppFixture = createRawAppFixture("f/apps/test_raw_app"); await mkdir(path.join(baseDir, `f/apps/test_raw_app${getFolderSuffix("raw_app")}`), { recursive: true }); for (const file of Object.values(rawAppFixture)) { await writeFile(path.join(baseDir, file.path), file.content, "utf-8"); } // Create resources const resources = [ createResourceFixture("f/resources/postgres_db", "postgresql", { host: "localhost", port: 5432, user: "admin", dbname: "test_db", }), createResourceFixture("f/resources/api_config", "c_api_config", { base_url: "https://api.example.com", api_key: "$var:f/secrets/api_key", }), ]; for (const resource of resources) { await writeFile(path.join(baseDir, resource.path), resource.content, "utf-8"); } // Create variables const variables = [ createVariableFixture("f/resources/config_value", "test_value", false), createVariableFixture("f/resources/secret_key", "secret123", true), ]; for (const variable of variables) { await writeFile(path.join(baseDir, variable.path), variable.content, "utf-8"); } // Create folder metadata await mkdir(path.join(baseDir, "f"), { recursive: true }); const folderMeta = createFolderFixture("f"); await writeFile(path.join(baseDir, folderMeta.path), folderMeta.content, "utf-8"); } /** * Creates a mock zip file representing remote workspace state */ async function createMockRemoteZip(items: Record): Promise { const zip = new JSZip(); for (const [filePath, content] of Object.entries(items)) { zip.file(filePath, content); } return zip; } /** * Reads all files from a directory recursively * Normalizes path separators to forward slashes for cross-platform compatibility */ async function readDirRecursive( dir: string, baseDir: string = dir, ): Promise> { const files: Record = {}; const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); // Normalize path separators to forward slashes for cross-platform compatibility const relativePath = fullPath.substring(baseDir.length + 1).replaceAll("\\", "/"); if (entry.isDirectory()) { const subFiles = await readDirRecursive(fullPath, baseDir); Object.assign(files, subFiles); } else { files[relativePath] = await readFile(fullPath, "utf-8"); } } return files; } /** * Creates a temporary directory for testing */ async function createTempDir(): Promise { return await mkdtemp(join(tmpdir(), "wmill_sync_test_")); } /** * Cleans up a temporary directory */ async function cleanupTempDir(dir: string): Promise { try { await rm(dir, { recursive: true }); } catch { // Ignore cleanup errors } } // ============================================================================= // Tests // ============================================================================= test("Resource folder suffixes are correct", () => { expect(getFolderSuffix("flow")).toEqual(".flow"); expect(getFolderSuffix("app")).toEqual(".app"); expect(getFolderSuffix("raw_app")).toEqual(".raw_app"); }); test("Metadata file names are correct", () => { expect(getMetadataFileName("flow", "yaml")).toEqual("flow.yaml"); expect(getMetadataFileName("flow", "json")).toEqual("flow.json"); expect(getMetadataFileName("app", "yaml")).toEqual("app.yaml"); expect(getMetadataFileName("raw_app", "yaml")).toEqual("raw_app.yaml"); }); test("buildFolderPath creates correct paths", () => { expect(buildFolderPath("my_flow", "flow")).toEqual("my_flow.flow"); expect(buildFolderPath("f/test/my_app", "app")).toEqual("f/test/my_app.app"); expect(buildFolderPath("u/admin/raw_app", "raw_app")).toEqual("u/admin/raw_app.raw_app"); }); // ============================================================================= // nonDottedPaths Tests - API format detection and transformation // ============================================================================= test("Metadata file detection works with dotted format (default)", () => { // Ensure we're in default mode setNonDottedPaths(false); // API always returns dotted format expect(isFlowMetadataFile("f/my_flow.flow.json")).toBeTruthy(); expect(isFlowMetadataFile("f/my_flow.flow.yaml")).toBeTruthy(); expect(isAppMetadataFile("f/my_app.app.json")).toBeTruthy(); expect(isAppMetadataFile("f/my_app.app.yaml")).toBeTruthy(); expect(isRawAppMetadataFile("f/my_raw.raw_app.json")).toBeTruthy(); expect(isRawAppMetadataFile("f/my_raw.raw_app.yaml")).toBeTruthy(); // Non-matching should return false expect(!isFlowMetadataFile("f/my_script.ts")).toBeTruthy(); expect(!isAppMetadataFile("f/my_script.ts")).toBeTruthy(); }); test("Metadata file detection works with nonDottedPaths=true", () => { // Store original value const wasNonDotted = getNonDottedPaths(); try { setNonDottedPaths(true); // API format (dotted) should still be detected expect(isFlowMetadataFile("f/my_flow.flow.json")).toBeTruthy(); expect(isAppMetadataFile("f/my_app.app.json")).toBeTruthy(); expect(isRawAppMetadataFile("f/my_raw.raw_app.json")).toBeTruthy(); // Local format (non-dotted) should also be detected expect(isFlowMetadataFile("f/my_flow__flow.json")).toBeTruthy(); expect(isFlowMetadataFile("f/my_flow__flow.yaml")).toBeTruthy(); expect(isAppMetadataFile("f/my_app__app.json")).toBeTruthy(); expect(isRawAppMetadataFile("f/my_raw__raw_app.json")).toBeTruthy(); } finally { // Restore original value setNonDottedPaths(wasNonDotted); } }); test("transformJsonPathToDir transforms API format to local format", () => { // Store original value const wasNonDotted = getNonDottedPaths(); try { // Test with dotted paths (default) setNonDottedPaths(false); expect(transformJsonPathToDir("f/my_flow.flow.json", "flow")).toEqual("f/my_flow.flow"); expect(transformJsonPathToDir("f/my_app.app.json", "app")).toEqual("f/my_app.app"); expect(transformJsonPathToDir("f/my_raw.raw_app.json", "raw_app")).toEqual("f/my_raw.raw_app"); // Test with non-dotted paths setNonDottedPaths(true); expect(transformJsonPathToDir("f/my_flow.flow.json", "flow")).toEqual("f/my_flow__flow"); expect(transformJsonPathToDir("f/my_app.app.json", "app")).toEqual("f/my_app__app"); expect(transformJsonPathToDir("f/my_raw.raw_app.json", "raw_app")).toEqual("f/my_raw__raw_app"); // Non-matching paths should be returned unchanged expect(transformJsonPathToDir("f/my_script.ts", "flow")).toEqual("f/my_script.ts"); } finally { // Restore original value setNonDottedPaths(wasNonDotted); } }); test("getFolderSuffix returns correct suffix based on nonDottedPaths setting", () => { // Store original value const wasNonDotted = getNonDottedPaths(); try { setNonDottedPaths(false); expect(getFolderSuffix("flow")).toEqual(".flow"); expect(getFolderSuffix("app")).toEqual(".app"); expect(getFolderSuffix("raw_app")).toEqual(".raw_app"); setNonDottedPaths(true); expect(getFolderSuffix("flow")).toEqual("__flow"); expect(getFolderSuffix("app")).toEqual("__app"); expect(getFolderSuffix("raw_app")).toEqual("__raw_app"); } finally { // Restore original value setNonDottedPaths(wasNonDotted); } }); test("newPathAssigner with skipInlineScriptSuffix removes .inline_script. from paths", () => { // Test default behavior (with .inline_script. suffix) const defaultAssigner = newPathAssigner("bun"); const [defaultPath, defaultExt] = defaultAssigner.assignPath("my_script", "bun"); expect(defaultPath).toEqual("my_script.inline_script."); expect(defaultExt).toEqual("ts"); // Test with skipInlineScriptSuffix = false (explicit) const withSuffixAssigner = newPathAssigner("bun", { skipInlineScriptSuffix: false }); const [withSuffixPath, withSuffixExt] = withSuffixAssigner.assignPath("another_script", "python3"); expect(withSuffixPath).toEqual("another_script.inline_script."); expect(withSuffixExt).toEqual("py"); // Test with skipInlineScriptSuffix = true (no .inline_script. suffix) const noSuffixAssigner = newPathAssigner("bun", { skipInlineScriptSuffix: true }); const [noSuffixPath, noSuffixExt] = noSuffixAssigner.assignPath("clean_script", "bun"); expect(noSuffixPath).toEqual("clean_script."); expect(noSuffixExt).toEqual("ts"); // Test with skipInlineScriptSuffix = true and different language const noSuffixPyAssigner = newPathAssigner("bun", { skipInlineScriptSuffix: true }); const [noSuffixPyPath, noSuffixPyExt] = noSuffixPyAssigner.assignPath("python_script", "python3"); expect(noSuffixPyPath).toEqual("python_script."); expect(noSuffixPyExt).toEqual("py"); }); test("newPathAssigner generates unique paths for duplicate names", () => { const assigner = newPathAssigner("bun", { skipInlineScriptSuffix: true }); // First script const [path1, ext1] = assigner.assignPath("my_script", "bun"); expect(path1).toEqual("my_script."); expect(ext1).toEqual("ts"); // Second script with same name should get counter const [path2, ext2] = assigner.assignPath("my_script", "bun"); expect(path2).toEqual("my_script_1."); expect(ext2).toEqual("ts"); // Third script with same name should get incremented counter const [path3, ext3] = assigner.assignPath("my_script", "python3"); expect(path3).toEqual("my_script_2."); expect(ext3).toEqual("py"); }); test("isAppInlineScriptPath detects app inline scripts correctly", () => { // Store original value const wasNonDotted = getNonDottedPaths(); try { // Test with dotted paths (default) setNonDottedPaths(false); expect(isAppInlineScriptPath("f/my_app.app/my_script.ts")).toBeTruthy(); expect(isAppInlineScriptPath("f/my_app.app/app.yaml")).toBeTruthy(); expect(!isAppInlineScriptPath("f/my_script.ts")).toBeTruthy(); expect(!isAppInlineScriptPath("f/my_flow.flow/flow.yaml")).toBeTruthy(); // Test with non-dotted paths setNonDottedPaths(true); expect(isAppInlineScriptPath("f/my_app__app/my_script.ts")).toBeTruthy(); expect(isAppInlineScriptPath("f/my_app__app/app.yaml")).toBeTruthy(); expect(!isAppInlineScriptPath("f/my_script.ts")).toBeTruthy(); expect(!isAppInlineScriptPath("f/my_flow__flow/flow.yaml")).toBeTruthy(); } finally { // Restore original value setNonDottedPaths(wasNonDotted); } }); test("isFlowInlineScriptPath detects flow inline scripts correctly", () => { // Store original value const wasNonDotted = getNonDottedPaths(); try { // Test with dotted paths (default) setNonDottedPaths(false); expect(isFlowInlineScriptPath("f/my_flow.flow/my_script.ts")).toBeTruthy(); expect(isFlowInlineScriptPath("f/my_flow.flow/flow.yaml")).toBeTruthy(); expect(!isFlowInlineScriptPath("f/my_script.ts")).toBeTruthy(); expect(!isFlowInlineScriptPath("f/my_app.app/app.yaml")).toBeTruthy(); // Test with non-dotted paths setNonDottedPaths(true); expect(isFlowInlineScriptPath("f/my_flow__flow/my_script.ts")).toBeTruthy(); expect(isFlowInlineScriptPath("f/my_flow__flow/flow.yaml")).toBeTruthy(); expect(!isFlowInlineScriptPath("f/my_script.ts")).toBeTruthy(); expect(!isFlowInlineScriptPath("f/my_app__app/app.yaml")).toBeTruthy(); } finally { // Restore original value setNonDottedPaths(wasNonDotted); } }); test("isRawAppBackendPath detects raw app backend paths correctly", () => { // Store original value const wasNonDotted = getNonDottedPaths(); try { // Test with dotted paths (default) setNonDottedPaths(false); expect(isRawAppBackendPath("f/my_app.raw_app/backend/script.ts")).toBeTruthy(); expect(!isRawAppBackendPath("f/my_app.raw_app/index.html")).toBeTruthy(); expect(!isRawAppBackendPath("f/my_script.ts")).toBeTruthy(); // Test with non-dotted paths setNonDottedPaths(true); expect(isRawAppBackendPath("f/my_app__raw_app/backend/script.ts")).toBeTruthy(); expect(!isRawAppBackendPath("f/my_app__raw_app/index.html")).toBeTruthy(); expect(!isRawAppBackendPath("f/my_script.ts")).toBeTruthy(); } finally { // Restore original value setNonDottedPaths(wasNonDotted); } }); test("Script fixture creates valid structure", () => { const pythonScript = createScriptFixture("test_script", "python3"); expect(pythonScript.contentFile.path).toEqual("test_script.py"); expect(pythonScript.metadataFile.path).toEqual("test_script.script.yaml"); expect(pythonScript.contentFile.content).toContain("def main()"); expect(pythonScript.metadataFile.content).toContain("summary:"); expect(pythonScript.metadataFile.content).toContain("kind: script"); }); test("Flow fixture creates valid structure", () => { const flow = createFlowFixture("test_flow"); expect(flow.metadata.path).toEqual("test_flow.flow/flow.yaml"); expect(flow.inlineScript.path).toEqual("test_flow.flow/a.ts"); expect(flow.metadata.content).toContain("summary:"); expect(flow.metadata.content).toContain("modules:"); expect(flow.inlineScript.content).toContain("export async function main"); }); test("App fixture creates valid structure", () => { const app = createAppFixture("test_app"); expect(app.metadata.path).toEqual("test_app.app/app.yaml"); expect(app.metadata.content).toContain("summary:"); expect(app.metadata.content).toContain("grid:"); expect(app.metadata.content).toContain("policy:"); }); test("Raw app fixture creates valid structure", () => { const rawApp = createRawAppFixture("test_raw_app"); expect(rawApp.metadata.path).toEqual("test_raw_app.raw_app/raw_app.yaml"); expect(rawApp.indexHtml.path).toEqual("test_raw_app.raw_app/index.html"); expect(rawApp.indexJs.path).toEqual("test_raw_app.raw_app/index.js"); expect(rawApp.metadata.content).toContain("summary:"); expect(rawApp.metadata.content).toContain("runnables:"); }); test("Resource fixture creates valid YAML", () => { const resource = createResourceFixture("postgres", "postgresql", { host: "localhost", port: 5432, }); expect(resource.path).toEqual("postgres.resource.yaml"); expect(resource.content).toContain('resource_type: "postgresql"'); expect(resource.content).toContain("value:"); }); test("Variable fixture creates valid YAML", () => { const variable = createVariableFixture("my_var", "test_value", false); expect(variable.path).toEqual("my_var.variable.yaml"); expect(variable.content).toContain('value: "test_value"'); expect(variable.content).toContain("is_secret: false"); }); test("Schedule fixture creates valid YAML", () => { const schedule = createScheduleFixture("hourly_job", "u/admin/my_script", "0 * * * *"); expect(schedule.path).toEqual("hourly_job.schedule.yaml"); expect(schedule.content).toContain('schedule: "0 * * * *"'); expect(schedule.content).toContain('script_path: "u/admin/my_script"'); }); test("HTTP trigger fixture creates valid YAML", () => { const trigger = createHttpTriggerFixture("webhook", "/api/webhook", "u/admin/handler"); expect(trigger.path).toEqual("webhook.http_trigger.yaml"); expect(trigger.content).toContain('route_path: "/api/webhook"'); expect(trigger.content).toContain("http_method: post"); }); test("Folder fixture creates valid YAML", () => { const folder = createFolderFixture("my_folder"); expect(folder.path).toEqual("my_folder/folder.meta.yaml"); expect(folder.content).toContain('display_name: "my_folder"'); }); test("User fixture creates valid YAML", () => { const user = createUserFixture("test_user", "test@example.com", true); expect(user.path).toEqual("test_user.user.yaml"); expect(user.content).toContain('username: "test_user"'); expect(user.content).toContain('email: "test@example.com"'); expect(user.content).toContain("is_admin: true"); }); test("Group fixture creates valid YAML", () => { const group = createGroupFixture("developers", ["user1", "user2"]); expect(group.path).toEqual("developers.group.yaml"); expect(group.content).toContain('name: "developers"'); expect(group.content).toContain("- user1"); expect(group.content).toContain("- user2"); }); test("Local filesystem creation creates all expected files", async () => { const tempDir = await createTempDir(); try { await createLocalFilesystem(tempDir); const files = await readDirRecursive(tempDir); // Check scripts exist expect("f/scripts/python_script.py" in files).toBeTruthy(); expect("f/scripts/python_script.script.yaml" in files).toBeTruthy(); expect("f/scripts/deno_script.ts" in files).toBeTruthy(); expect("f/scripts/bash_script.sh" in files).toBeTruthy(); expect("f/scripts/go_script.go" in files).toBeTruthy(); expect("f/scripts/sql_script.sql" in files).toBeTruthy(); // Check flows exist expect("f/flows/test_flow.flow/flow.yaml" in files).toBeTruthy(); expect("f/flows/test_flow.flow/a.ts" in files).toBeTruthy(); // Check apps exist expect("f/apps/test_app.app/app.yaml" in files).toBeTruthy(); // Check raw apps exist expect("f/apps/test_raw_app.raw_app/raw_app.yaml" in files).toBeTruthy(); expect("f/apps/test_raw_app.raw_app/index.html" in files).toBeTruthy(); expect("f/apps/test_raw_app.raw_app/index.js" in files).toBeTruthy(); // Check resources exist expect("f/resources/postgres_db.resource.yaml" in files).toBeTruthy(); expect("f/resources/api_config.resource.yaml" in files).toBeTruthy(); // Check variables exist expect("f/resources/config_value.variable.yaml" in files).toBeTruthy(); expect("f/resources/secret_key.variable.yaml" in files).toBeTruthy(); // Check folder metadata expect("f/folder.meta.yaml" in files).toBeTruthy(); } finally { await cleanupTempDir(tempDir); } }); test("Mock remote zip can be created and read", async () => { const items = { "test_script.py": 'def main():\n return "hello"', "test_script.script.json": '{"summary":"test","schema":{}}', "test_flow.flow.json": '{"summary":"flow","value":{"modules":[]}}', "test_app.app.json": '{"summary":"app","value":{}}', }; const zip = await createMockRemoteZip(items); // Verify files exist in zip const scriptContent = await zip.file("test_script.py")?.async("text"); expect(scriptContent).toEqual('def main():\n return "hello"'); const flowContent = await zip.file("test_flow.flow.json")?.async("text"); expect(flowContent!).toContain('"summary":"flow"'); }); test("readDirRecursive reads all files correctly", async () => { const tempDir = await createTempDir(); try { // Create a simple structure await mkdir(path.join(tempDir, "subdir"), { recursive: true }); await writeFile(path.join(tempDir, "file1.txt"), "content1", "utf-8"); await writeFile(path.join(tempDir, "subdir", "file2.txt"), "content2", "utf-8"); const files = await readDirRecursive(tempDir); expect(files["file1.txt"]).toEqual("content1"); expect(files["subdir/file2.txt"]).toEqual("content2"); expect(Object.keys(files).length).toEqual(2); } finally { await cleanupTempDir(tempDir); } }); // ============================================================================= // Integration Tests (use withTestBackend for automated backend setup) // ============================================================================= import { yamlParseFile } from "../src/utils/yaml.ts"; import { withTestBackend } from "./test_backend.ts"; import { shouldSkipOnCI } from "./cargo_backend.ts"; test("Integration: Pull creates correct local structure", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, "utf-8", ); // Run sync pull const result = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); expect(result.code).toEqual(0); // Verify files were created const files = await readDirRecursive(tempDir); const hasYamlFiles = Object.keys(files).some((f) => f.endsWith(".yaml") && f !== "wmill.yaml"); expect(hasYamlFiles || Object.keys(files).length > 1).toBeTruthy(); }); }); test("Integration: Push uploads local changes correctly", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, "utf-8", ); // Create a test script locally with a unique name // Path must have at least 2 segments after prefix (e.g., f/folder/name) const uniqueId = Date.now(); await mkdir(`${tempDir}/f/test`, { recursive: true }); const script = createScriptFixture(`f/test/push_script_${uniqueId}`, "deno"); await writeFile(`${tempDir}/${script.contentFile.path}`, script.contentFile.content, "utf-8"); await writeFile(`${tempDir}/${script.metadataFile.path}`, script.metadataFile.content, "utf-8"); // Run sync push with dry-run first (only push our test script, not everything) const dryRunResult = await backend.runCLICommand( ["sync", "push", "--dry-run", "--includes", `f/test/push_script_${uniqueId}**`], tempDir, ); expect(dryRunResult.code).toEqual(0); expect(dryRunResult.stdout + dryRunResult.stderr).toContain(`push_script_${uniqueId}`); // Run actual push (only push our test script) const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--includes", `f/test/push_script_${uniqueId}**`], tempDir, ); expect(pushResult.code).toEqual(0); }); }); test("Integration: Pull then Push is idempotent", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, "utf-8", ); // Pull from remote const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); expect(pullResult.code).toEqual(0); // Push back without changes (should be no-op) const pushResult = await backend.runCLICommand(["sync", "push", "--dry-run"], tempDir); expect(pushResult.code).toEqual(0); // Should report 0 changes (check both stdout and stderr) const output = (pushResult.stdout + pushResult.stderr).toLowerCase(); expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); }); }); test("Integration: Include/exclude filters work correctly", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with restrictive filters await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "f/scripts/**" excludes: - "**/*.test.ts" skipVariables: true skipResources: true `, "utf-8", ); // Run sync pull const result = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); expect(result.code).toEqual(0); // Verify only scripts in f/scripts/ were pulled (if any exist) const files = await readDirRecursive(tempDir); // Should not have variables or resources const hasVariables = Object.keys(files).some((f) => f.includes(".variable.")); const hasResources = Object.keys(files).some((f) => f.includes(".resource.")); expect(!hasVariables).toBeTruthy(); expect(!hasResources).toBeTruthy(); }); }); test("Integration: Flow folder structure is created correctly", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, "utf-8", ); // Create a local flow with unique name // Path must have at least 2 segments after prefix (e.g., f/folder/name) const uniqueId = Date.now(); const flowName = `f/test/flow_${uniqueId}`; const flowFixture = createFlowFixture(flowName); await mkdir(`${tempDir}/f/test/flow_${uniqueId}${getFolderSuffix("flow")}`, { recursive: true }); for (const file of Object.values(flowFixture)) { await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } // Push the flow (only push our test flow, not everything) // Pattern needs to match the .flow folder, so use * to match the suffix const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--includes", `f/test/flow_${uniqueId}*/**`], tempDir, ); expect(pushResult.code).toEqual(0); // Pull back and verify structure is preserved const tempDir2 = await mkdtemp(join(tmpdir(), "wmill_flow_verify_")); try { // Use template literal properly for the includes pattern const wmillConfig = `defaultTs: bun includes: - "f/test/flow_${uniqueId}*/**" excludes: [] `; await writeFile(`${tempDir2}/wmill.yaml`, wmillConfig, "utf-8"); const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir2); expect(pullResult.code).toEqual(0); // Verify flow folder structure const files = await readDirRecursive(tempDir2); const allFiles = Object.keys(files); const flowFiles = allFiles.filter((f) => f.includes(`flow_${uniqueId}`)); expect(flowFiles.length > 0).toBeTruthy(); expect(flowFiles.some((f) => f.includes(".flow/"))).toBeTruthy(); } finally { await cleanupTempDir(tempDir2); } }); }); test("Integration: Raw app folder structure is handled correctly", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, "utf-8", ); // Create a local raw app with unique name // Path must have at least 2 segments after prefix (e.g., f/folder/name) const uniqueId = Date.now(); const rawAppFixture = createRawAppFixture(`f/test/raw_app_${uniqueId}`); await mkdir(`${tempDir}/f/test/raw_app_${uniqueId}${getFolderSuffix("raw_app")}`, { recursive: true }); for (const file of Object.values(rawAppFixture)) { await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } // Push the raw app (only push our test raw app, not everything) const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--includes", `f/test/raw_app_${uniqueId}**`], tempDir, ); // Note: This may fail if raw apps require specific validation // The test verifies the CLI handles the folder structure correctly if (pushResult.code === 0) { expect(pushResult.stdout + pushResult.stderr).toContain(""); } }); }); // ============================================================================= // nonDottedPaths Unit Tests // ============================================================================= test("getFolderSuffixes returns correct suffixes for dotted paths (default)", () => { setNonDottedPaths(false); const suffixes = getFolderSuffixes(); expect(suffixes.flow).toEqual(".flow"); expect(suffixes.app).toEqual(".app"); expect(suffixes.raw_app).toEqual(".raw_app"); }); test("getFolderSuffixes returns correct suffixes for non-dotted paths", () => { setNonDottedPaths(true); const suffixes = getFolderSuffixes(); expect(suffixes.flow).toEqual("__flow"); expect(suffixes.app).toEqual("__app"); expect(suffixes.raw_app).toEqual("__raw_app"); setNonDottedPaths(false); // Reset }); test("getFolderSuffix with nonDottedPaths returns dunder suffixes", () => { setNonDottedPaths(true); expect(getFolderSuffix("flow")).toEqual("__flow"); expect(getFolderSuffix("app")).toEqual("__app"); expect(getFolderSuffix("raw_app")).toEqual("__raw_app"); setNonDottedPaths(false); // Reset }); test("buildFolderPath with nonDottedPaths creates correct paths", () => { setNonDottedPaths(true); expect(buildFolderPath("my_flow", "flow")).toEqual("my_flow__flow"); expect(buildFolderPath("f/test/my_app", "app")).toEqual("f/test/my_app__app"); expect(buildFolderPath("u/admin/raw_app", "raw_app")).toEqual("u/admin/raw_app__raw_app"); setNonDottedPaths(false); // Reset }); test("buildMetadataPath with nonDottedPaths creates correct paths", () => { setNonDottedPaths(true); // buildMetadataPath always uses forward slashes internally expect(buildMetadataPath("my_flow", "flow", "yaml")).toEqual("my_flow__flow/flow.yaml"); expect(buildMetadataPath("f/test/my_app", "app", "yaml")).toEqual("f/test/my_app__app/app.yaml"); setNonDottedPaths(false); // Reset }); test("isFlowPath detects non-dotted paths when configured", () => { // Default (dotted) paths setNonDottedPaths(false); expect(isFlowPath(`f${SEP}test${SEP}my_flow.flow${SEP}flow.yaml`)).toBeTruthy(); expect(!isFlowPath(`f${SEP}test${SEP}my_flow__flow${SEP}flow.yaml`)).toBeTruthy(); // Non-dotted paths setNonDottedPaths(true); expect(isFlowPath(`f${SEP}test${SEP}my_flow__flow${SEP}flow.yaml`)).toBeTruthy(); expect(!isFlowPath(`f${SEP}test${SEP}my_flow.flow${SEP}flow.yaml`)).toBeTruthy(); setNonDottedPaths(false); // Reset }); test("isAppPath detects non-dotted paths when configured", () => { // Default (dotted) paths setNonDottedPaths(false); expect(isAppPath(`f${SEP}test${SEP}my_app.app${SEP}app.yaml`)).toBeTruthy(); expect(!isAppPath(`f${SEP}test${SEP}my_app__app${SEP}app.yaml`)).toBeTruthy(); // Non-dotted paths setNonDottedPaths(true); expect(isAppPath(`f${SEP}test${SEP}my_app__app${SEP}app.yaml`)).toBeTruthy(); expect(!isAppPath(`f${SEP}test${SEP}my_app.app${SEP}app.yaml`)).toBeTruthy(); setNonDottedPaths(false); // Reset }); test("isRawAppPath detects non-dotted paths when configured", () => { // Default (dotted) paths setNonDottedPaths(false); expect(isRawAppPath(`f${SEP}test${SEP}my_raw_app.raw_app${SEP}raw_app.yaml`)).toBeTruthy(); expect(!isRawAppPath(`f${SEP}test${SEP}my_raw_app__raw_app${SEP}raw_app.yaml`)).toBeTruthy(); // Non-dotted paths setNonDottedPaths(true); expect(isRawAppPath(`f${SEP}test${SEP}my_raw_app__raw_app${SEP}raw_app.yaml`)).toBeTruthy(); expect(!isRawAppPath(`f${SEP}test${SEP}my_raw_app.raw_app${SEP}raw_app.yaml`)).toBeTruthy(); setNonDottedPaths(false); // Reset }); test("extractResourceName works with non-dotted paths", () => { setNonDottedPaths(true); // extractResourceName normalizes separators to forward slashes expect(extractResourceName(`f${SEP}test${SEP}my_flow__flow${SEP}flow.yaml`, "flow")).toEqual("f/test/my_flow"); expect(extractResourceName(`f${SEP}test${SEP}my_app__app${SEP}app.yaml`, "app")).toEqual("f/test/my_app"); setNonDottedPaths(false); // Reset }); test("hasFolderSuffix works with non-dotted paths", () => { setNonDottedPaths(true); expect(hasFolderSuffix("my_flow__flow", "flow")).toBeTruthy(); expect(!hasFolderSuffix("my_flow.flow", "flow")).toBeTruthy(); expect(hasFolderSuffix("my_app__app", "app")).toBeTruthy(); expect(!hasFolderSuffix("my_app.app", "app")).toBeTruthy(); setNonDottedPaths(false); // Reset }); test("setNonDottedPaths and getNonDottedPaths work correctly", () => { // Default should be false setNonDottedPaths(false); expect(getNonDottedPaths()).toEqual(false); // Set to true setNonDottedPaths(true); expect(getNonDottedPaths()).toEqual(true); // Set back to false setNonDottedPaths(false); expect(getNonDottedPaths()).toEqual(false); }); test("hasWrongFormatSuffix detects mismatched folder suffixes", () => { // In dotted mode (default), non-dotted dirs are wrong setNonDottedPaths(false); expect(hasWrongFormatSuffix("my_flow__flow")).toEqual("flow"); expect(hasWrongFormatSuffix("my_app__app")).toEqual("app"); expect(hasWrongFormatSuffix("my_raw_app__raw_app")).toEqual("raw_app"); // Correct format should return null expect(hasWrongFormatSuffix("my_flow.flow")).toBeNull(); expect(hasWrongFormatSuffix("my_app.app")).toBeNull(); expect(hasWrongFormatSuffix("my_raw_app.raw_app")).toBeNull(); // Non-resource dirs return null expect(hasWrongFormatSuffix("some_folder")).toBeNull(); expect(hasWrongFormatSuffix("node_modules")).toBeNull(); // In non-dotted mode, dotted dirs are wrong setNonDottedPaths(true); expect(hasWrongFormatSuffix("my_flow.flow")).toEqual("flow"); expect(hasWrongFormatSuffix("my_app.app")).toEqual("app"); expect(hasWrongFormatSuffix("my_raw_app.raw_app")).toEqual("raw_app"); // Correct format should return null expect(hasWrongFormatSuffix("my_flow__flow")).toBeNull(); expect(hasWrongFormatSuffix("my_app__app")).toBeNull(); expect(hasWrongFormatSuffix("my_raw_app__raw_app")).toBeNull(); setNonDottedPaths(false); // Reset }); // ============================================================================= // nonDottedPaths Fixture Functions // ============================================================================= /** * Creates a mock flow file structure using the current global nonDottedPaths setting */ function createFlowFixtureWithCurrentConfig(name: string): Record { const flowSuffix = getFolderSuffix("flow"); const metadataFile = getMetadataFileName("flow", "yaml"); return { metadata: { path: `${name}${flowSuffix}/${metadataFile}`, content: `summary: "${name} flow" description: "A flow for testing" value: modules: - id: a value: type: rawscript content: "!inline ${name}${flowSuffix}/a.ts" language: bun input_transforms: {} schema: $schema: "https://json-schema.org/draft/2020-12/schema" type: object properties: {} required: [] `, }, inlineScript: { path: `${name}${flowSuffix}/a.ts`, content: `export async function main() {\n return "Hello from flow ${name}";\n}`, }, }; } /** * Creates a mock app file structure using the current global nonDottedPaths setting */ function createAppFixtureWithCurrentConfig(name: string): Record { const appSuffix = getFolderSuffix("app"); const metadataFile = getMetadataFileName("app", "yaml"); return { metadata: { path: `${name}${appSuffix}/${metadataFile}`, content: `summary: "${name} app" value: grid: - data: id: "text_1" type: textcomponent componentInput: type: static value: "Hello from ${name}" id: "a" hiddenInlineScripts: [] policy: execution_mode: viewer `, }, }; } test("Flow fixture with nonDottedPaths creates __flow structure", () => { setNonDottedPaths(true); const flow = createFlowFixtureWithCurrentConfig("test_flow"); expect(flow.metadata.path).toEqual("test_flow__flow/flow.yaml"); expect(flow.inlineScript.path).toEqual("test_flow__flow/a.ts"); expect(flow.metadata.content).toContain("summary:"); expect(flow.metadata.content).toContain("modules:"); setNonDottedPaths(false); // Reset }); test("App fixture with nonDottedPaths creates __app structure", () => { setNonDottedPaths(true); const app = createAppFixtureWithCurrentConfig("test_app"); expect(app.metadata.path).toEqual("test_app__app/app.yaml"); expect(app.metadata.content).toContain("summary:"); expect(app.metadata.content).toContain("grid:"); setNonDottedPaths(false); // Reset }); test("Local filesystem with nonDottedPaths creates correct folder structure", async () => { setNonDottedPaths(true); const tempDir = await createTempDir(); try { // Create folder structure await mkdir(path.join(tempDir, "f/flows"), { recursive: true }); await mkdir(path.join(tempDir, "f/apps"), { recursive: true }); // Create flows with non-dotted paths const flowFixture = createFlowFixtureWithCurrentConfig("f/flows/test_flow"); await mkdir(path.join(tempDir, `f/flows/test_flow${getFolderSuffix("flow")}`), { recursive: true }); for (const file of Object.values(flowFixture)) { await writeFile(path.join(tempDir, file.path), file.content, "utf-8"); } // Create apps with non-dotted paths const appFixture = createAppFixtureWithCurrentConfig("f/apps/test_app"); await mkdir(path.join(tempDir, `f/apps/test_app${getFolderSuffix("app")}`), { recursive: true }); for (const file of Object.values(appFixture)) { await writeFile(path.join(tempDir, file.path), file.content, "utf-8"); } const files = await readDirRecursive(tempDir); // Check flows exist with __flow suffix expect("f/flows/test_flow__flow/flow.yaml" in files).toBeTruthy(); expect("f/flows/test_flow__flow/a.ts" in files).toBeTruthy(); // Check apps exist with __app suffix expect("f/apps/test_app__app/app.yaml" in files).toBeTruthy(); // Verify old-style paths don't exist expect(!("f/flows/test_flow.flow/flow.yaml" in files)).toBeTruthy(); expect(!("f/apps/test_app.app/app.yaml" in files)).toBeTruthy(); } finally { await cleanupTempDir(tempDir); setNonDottedPaths(false); // Reset } }); // ============================================================================= // nonDottedPaths Integration Tests // ============================================================================= test("Integration: wmill.yaml with nonDottedPaths is read correctly", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths option await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true includes: - "f/**" excludes: [] `, "utf-8", ); // Create a test script with non-dotted flow folder setNonDottedPaths(true); const uniqueId = Date.now(); const flowName = `f/test/nondot_flow_${uniqueId}`; const flowFixture = createFlowFixtureWithCurrentConfig(flowName); await mkdir(`${tempDir}/f/test/nondot_flow_${uniqueId}${getFolderSuffix("flow")}`, { recursive: true }); for (const file of Object.values(flowFixture)) { await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } setNonDottedPaths(false); // Reset // Run sync push with dry-run to verify the config is being read const dryRunResult = await backend.runCLICommand( ["sync", "push", "--dry-run", "--includes", `f/test/nondot_flow_${uniqueId}**`], tempDir, ); expect(dryRunResult.code).toEqual(0); }); }); test("Integration: Pull then Push with nonDottedPaths is idempotent", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths enabled await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true includes: - "**" excludes: [] `, "utf-8", ); // Pull from remote with nonDottedPaths enabled const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); expect(pullResult.code).toEqual(0); // Verify that pulled files use __flow/__app/__raw_app suffixes const filesAfterPull = await readDirRecursive(tempDir); const flowFiles = Object.keys(filesAfterPull).filter((f) => f.includes("__flow/")); const appFiles = Object.keys(filesAfterPull).filter((f) => f.includes("__app/")); // Verify NO dotted paths were created const dottedFlowFiles = Object.keys(filesAfterPull).filter((f) => f.includes(".flow/")); const dottedAppFiles = Object.keys(filesAfterPull).filter((f) => f.includes(".app/")); // Only check if there are actually flows/apps in the workspace // If there are flows, they should use __flow not .flow if (flowFiles.length > 0 || dottedFlowFiles.length > 0) { expect(dottedFlowFiles.length === 0).toBeTruthy(); } if (appFiles.length > 0 || dottedAppFiles.length > 0) { expect(dottedAppFiles.length === 0).toBeTruthy(); } // Push back without changes (should be no-op / idempotent) const pushResult = await backend.runCLICommand(["sync", "push", "--dry-run"], tempDir); expect(pushResult.code).toEqual(0); // Should report 0 changes (check both stdout and stderr) const output = (pushResult.stdout + pushResult.stderr).toLowerCase(); expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); }); }); test("Integration: Push flow with nonDottedPaths creates __flow structure on server", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths enabled await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true includes: - "**" excludes: [] `, "utf-8", ); // Create a local flow with __flow suffix setNonDottedPaths(true); const uniqueId = Date.now(); const flowName = `f/test/nondot_idem_flow_${uniqueId}`; const flowFixture = createFlowFixtureWithCurrentConfig(flowName); await mkdir(`${tempDir}/f/test/nondot_idem_flow_${uniqueId}${getFolderSuffix("flow")}`, { recursive: true }); for (const file of Object.values(flowFixture)) { await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } setNonDottedPaths(false); // Reset global state // Push the flow const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--includes", `f/test/nondot_idem_flow_${uniqueId}**`], tempDir, ); expect(pushResult.code).toEqual(0); // Pull back to same directory to verify round-trip (idempotency) const pullResult = await backend.runCLICommand( ["sync", "pull", "--yes", "--includes", `f/test/nondot_idem_flow_${uniqueId}**`], tempDir, ); expect(pullResult.code).toEqual(0); // Verify flow still has __flow suffix after round-trip const filesAfterPull = await readDirRecursive(tempDir); const allFiles = Object.keys(filesAfterPull); const flowFiles = allFiles.filter((f) => f.includes(`nondot_idem_flow_${uniqueId}`)); expect(flowFiles.length > 0).toBeTruthy(); expect(flowFiles.some((f) => f.includes("__flow/"))).toBeTruthy(); expect(!flowFiles.some((f) => f.includes(".flow/"))).toBeTruthy(); // Push again (should be idempotent - no changes) const push2 = await backend.runCLICommand( ["sync", "push", "--dry-run", "--includes", `f/test/nondot_idem_flow_${uniqueId}**`], tempDir, ); expect(push2.code).toEqual(0); const output = (push2.stdout + push2.stderr).toLowerCase(); expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); }); }); test("Integration: Multiple pull/push cycles with nonDottedPaths remain idempotent", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths enabled await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true includes: - "**" excludes: [] `, "utf-8", ); // First pull const pull1 = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); expect(pull1.code).toEqual(0); // First push (should be no-op) const push1 = await backend.runCLICommand(["sync", "push", "--dry-run"], tempDir); expect(push1.code).toEqual(0); // Second pull (should have no changes) const pull2 = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); expect(pull2.code).toEqual(0); // Second push (should still be no-op) const push2 = await backend.runCLICommand(["sync", "push", "--dry-run"], tempDir); expect(push2.code).toEqual(0); // Verify no changes after multiple cycles const output = (push2.stdout + push2.stderr).toLowerCase(); expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); // Third pull to verify consistency const pull3 = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); expect(pull3.code).toEqual(0); // Final push check const push3 = await backend.runCLICommand(["sync", "push", "--dry-run"], tempDir); expect(push3.code).toEqual(0); const finalOutput = (push3.stdout + push3.stderr).toLowerCase(); expect(finalOutput.includes("0 change") || finalOutput.includes("no change") || finalOutput.includes("nothing")).toBeTruthy(); }); }); test("Integration: App with nonDottedPaths creates __app structure and is idempotent", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths enabled await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true includes: - "**" excludes: [] `, "utf-8", ); // Create a local app with __app suffix setNonDottedPaths(true); const uniqueId = Date.now(); const appName = `f/test/nondot_app_${uniqueId}`; const appFixture = createAppFixtureWithCurrentConfig(appName); await mkdir(`${tempDir}/f/test/nondot_app_${uniqueId}${getFolderSuffix("app")}`, { recursive: true }); for (const file of Object.values(appFixture)) { await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } setNonDottedPaths(false); // Reset global state // Push the app const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--includes", `f/test/nondot_app_${uniqueId}**`], tempDir, ); expect(pushResult.code).toEqual(0); // Pull back to same directory const pullResult = await backend.runCLICommand( ["sync", "pull", "--yes", "--includes", `f/test/nondot_app_${uniqueId}**`], tempDir, ); expect(pullResult.code).toEqual(0); // Verify app structure uses __app const files = await readDirRecursive(tempDir); const appFiles = Object.keys(files).filter((f) => f.includes(`nondot_app_${uniqueId}`)); expect(appFiles.length > 0).toBeTruthy(); expect(appFiles.some((f) => f.includes("__app/"))).toBeTruthy(); // Push again (should be idempotent) const push2 = await backend.runCLICommand( ["sync", "push", "--dry-run", "--includes", `f/test/nondot_app_${uniqueId}**`], tempDir, ); expect(push2.code).toEqual(0); const output = (push2.stdout + push2.stderr).toLowerCase(); expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); }); }); /** * Creates a mock raw_app file structure using the current global nonDottedPaths setting */ function createRawAppFixtureWithCurrentConfig(name: string): Record { const rawAppSuffix = getFolderSuffix("raw_app"); const metadataFile = getMetadataFileName("raw_app", "yaml"); return { metadata: { path: `${name}${rawAppSuffix}/${metadataFile}`, content: `summary: "${name} raw app" runnables: a: path: u/test/my_script type: script `, }, indexHtml: { path: `${name}${rawAppSuffix}/index.html`, content: ` ${name}

Hello from ${name}

`, }, indexJs: { path: `${name}${rawAppSuffix}/index.js`, content: `console.log("Hello from ${name}");`, }, }; } test("Integration: Raw app with nonDottedPaths creates __raw_app structure", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths enabled await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true includes: - "**" excludes: [] `, "utf-8", ); // Create a local raw app with __raw_app suffix setNonDottedPaths(true); const uniqueId = Date.now(); const rawAppName = `f/test/nondot_rawapp_${uniqueId}`; const rawAppFixture = createRawAppFixtureWithCurrentConfig(rawAppName); await mkdir(`${tempDir}/f/test/nondot_rawapp_${uniqueId}${getFolderSuffix("raw_app")}`, { recursive: true }); for (const file of Object.values(rawAppFixture)) { await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } setNonDottedPaths(false); // Reset global state // Verify the files are created with __raw_app suffix const files = await readDirRecursive(tempDir); const rawAppFiles = Object.keys(files).filter((f) => f.includes(`nondot_rawapp_${uniqueId}`)); expect(rawAppFiles.length > 0).toBeTruthy(); expect(rawAppFiles.some((f) => f.includes("__raw_app/"))).toBeTruthy(); expect(!rawAppFiles.some((f) => f.includes(".raw_app/"))).toBeTruthy(); // Push the raw app (may fail if raw apps require specific validation) const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--includes", `f/test/nondot_rawapp_${uniqueId}**`], tempDir, ); // Note: Raw apps may have additional validation requirements // This test primarily verifies the folder structure is correct if (pushResult.code === 0) { // If push succeeded, verify idempotency const push2 = await backend.runCLICommand( ["sync", "push", "--dry-run", "--includes", `f/test/nondot_rawapp_${uniqueId}**`], tempDir, ); expect(push2.code).toEqual(0); } }); }); test("Integration: Mixed scripts and flows with nonDottedPaths are idempotent", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths enabled await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true includes: - "**" excludes: [] `, "utf-8", ); const uniqueId = Date.now(); await mkdir(`${tempDir}/f/test`, { recursive: true }); // Create a script (scripts don't use folder suffixes, so they're unaffected) const script = createScriptFixture(`f/test/mixed_script_${uniqueId}`, "deno"); await writeFile(`${tempDir}/${script.contentFile.path}`, script.contentFile.content, "utf-8"); await writeFile(`${tempDir}/${script.metadataFile.path}`, script.metadataFile.content, "utf-8"); // Create a flow with __flow suffix setNonDottedPaths(true); const flowName = `f/test/mixed_flow_${uniqueId}`; const flowFixture = createFlowFixtureWithCurrentConfig(flowName); await mkdir(`${tempDir}/f/test/mixed_flow_${uniqueId}${getFolderSuffix("flow")}`, { recursive: true }); for (const file of Object.values(flowFixture)) { await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } setNonDottedPaths(false); // Reset global state // Push both const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--includes", `f/test/mixed_*_${uniqueId}**`], tempDir, ); expect(pushResult.code).toEqual(0); // Pull back const pullResult = await backend.runCLICommand( ["sync", "pull", "--yes", "--includes", `f/test/mixed_*_${uniqueId}**`], tempDir, ); expect(pullResult.code).toEqual(0); // Verify idempotency const push2 = await backend.runCLICommand( ["sync", "push", "--dry-run", "--includes", `f/test/mixed_*_${uniqueId}**`], tempDir, ); expect(push2.code).toEqual(0); const output = (push2.stdout + push2.stderr).toLowerCase(); expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); }); }); test("Integration: Sync pull with nonDottedPaths uses non-dotted inline script filenames", async () => { await withTestBackend(async (backend, tempDir) => { // Push a flow using default dotted paths await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, "utf-8", ); const uniqueId = Date.now(); const flowName = `f/test/nondot_pull_inline_${uniqueId}`; const flowFixture = createFlowFixture(flowName); await mkdir(`${tempDir}/f/test/nondot_pull_inline_${uniqueId}${getFolderSuffix("flow")}`, { recursive: true }); for (const file of Object.values(flowFixture)) { await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--includes", `f/test/nondot_pull_inline_${uniqueId}*/**`], tempDir, ); expect(pushResult.code).toEqual(0); // Pull into a fresh directory with nonDottedPaths enabled const tempDir2 = await mkdtemp(join(tmpdir(), "wmill_nondot_inline_")); try { await writeFile( `${tempDir2}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true includes: - "**" excludes: [] `, "utf-8", ); const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir2); expect(pullResult.code).toEqual(0); // Verify pulled files use non-dotted inline script naming const files = await listFilesRecursive(tempDir2); const flowFiles = files.filter((f) => f.includes(`nondot_pull_inline_${uniqueId}`)); expect(flowFiles.length > 0).toBeTruthy(); // Should use __flow folder, not .flow expect(flowFiles.some((f) => f.includes("__flow/"))).toBeTruthy(); // No files should have .inline_script. in their name const dottedInlineFiles = flowFiles.filter((f) => f.includes(".inline_script.")); expect(dottedInlineFiles.length).toEqual(0); } finally { await cleanupTempDir(tempDir2); } }); }); // ============================================================================= // ws_error_handler_muted Persistence Tests // ============================================================================= test.skipIf(shouldSkipOnCI())("Integration: Script ws_error_handler_muted is persisted through push/pull", async () => { await withTestBackend(async (backend, tempDir) => { await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, "utf-8", ); const uniqueId = Date.now(); await mkdir(`${tempDir}/f/test`, { recursive: true }); // Create a script with ws_error_handler_muted: true const scriptName = `f/test/muted_script_${uniqueId}`; const script = createScriptFixture(scriptName, "deno"); await writeFile(`${tempDir}/${script.contentFile.path}`, script.contentFile.content, "utf-8"); // Add ws_error_handler_muted to the metadata const metadataWithMuted = script.metadataFile.content + `ws_error_handler_muted: true\n`; await writeFile(`${tempDir}/${script.metadataFile.path}`, metadataWithMuted, "utf-8"); // Push const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--includes", `f/test/muted_script_${uniqueId}**`], tempDir, ); expect(pushResult.code).toEqual(0); // Verify via API that ws_error_handler_muted was persisted const apiResp = await backend.apiRequest!( `/api/w/${backend.workspace}/scripts/get/p/${scriptName}`, ); expect(apiResp.status).toEqual(200); const scriptData = await apiResp.json(); expect(scriptData.ws_error_handler_muted).toEqual(true); // Pull into a fresh directory and verify the field round-trips const pullDir = await mkdtemp(join(tmpdir(), "wmill_muted_script_pull_")); try { await writeFile( `${pullDir}/wmill.yaml`, `defaultTs: bun includes: - "f/test/muted_script_${uniqueId}**" excludes: [] `, "utf-8", ); const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], pullDir); expect(pullResult.code).toEqual(0); // Verify ws_error_handler_muted is in the pulled metadata const pulledMetadata = await readFile(`${pullDir}/${script.metadataFile.path}`, "utf-8"); expect(pulledMetadata).toContain("ws_error_handler_muted: true"); // Verify push from pulled dir is idempotent (no changes) const push2 = await backend.runCLICommand( ["sync", "push", "--dry-run", "--includes", `f/test/muted_script_${uniqueId}**`], pullDir, ); expect(push2.code).toEqual(0); const output = (push2.stdout + push2.stderr).toLowerCase(); expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); } finally { await rm(pullDir, { recursive: true }).catch(() => {}); } }); }); test.skipIf(shouldSkipOnCI())("Integration: Flow ws_error_handler_muted is persisted through push/pull", async () => { await withTestBackend(async (backend, tempDir) => { await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, "utf-8", ); const uniqueId = Date.now(); const flowName = `f/test/muted_flow_${uniqueId}`; const flowFixture = createFlowFixture(flowName); // Create flow directory and files await mkdir(`${tempDir}/f/test/muted_flow_${uniqueId}${getFolderSuffix("flow")}`, { recursive: true }); for (const [key, file] of Object.entries(flowFixture)) { if (key === "metadata") { // Add ws_error_handler_muted to flow metadata const contentWithMuted = file.content + `ws_error_handler_muted: true\n`; await writeFile(`${tempDir}/${file.path}`, contentWithMuted, "utf-8"); } else { await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } } // Push const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--includes", `f/test/muted_flow_${uniqueId}*/**`], tempDir, ); expect(pushResult.code).toEqual(0); // Verify via API that ws_error_handler_muted was persisted const apiResp = await backend.apiRequest!( `/api/w/${backend.workspace}/flows/get/${flowName}`, ); expect(apiResp.status).toEqual(200); const flowData = await apiResp.json(); expect(flowData.ws_error_handler_muted).toEqual(true); // Pull into a fresh directory and verify the field round-trips const pullDir = await mkdtemp(join(tmpdir(), "wmill_muted_flow_pull_")); try { await writeFile( `${pullDir}/wmill.yaml`, `defaultTs: bun includes: - "f/test/muted_flow_${uniqueId}*/**" excludes: [] `, "utf-8", ); const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], pullDir); expect(pullResult.code).toEqual(0); // Verify ws_error_handler_muted is in the pulled flow.yaml const flowYamlPath = `${pullDir}/${flowFixture.metadata.path}`; const pulledFlowYaml = await readFile(flowYamlPath, "utf-8"); expect(pulledFlowYaml).toContain("ws_error_handler_muted: true"); // Parse the YAML to confirm it's a proper boolean value // deno-lint-ignore no-explicit-any const parsed = await yamlParseFile(flowYamlPath) as any; expect(parsed.ws_error_handler_muted).toEqual(true); // Verify push from pulled dir is idempotent (no changes) const push2 = await backend.runCLICommand( ["sync", "push", "--dry-run", "--includes", `f/test/muted_flow_${uniqueId}*/**`], pullDir, ); expect(push2.code).toEqual(0); const output = (push2.stdout + push2.stderr).toLowerCase(); expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); } finally { await rm(pullDir, { recursive: true }).catch(() => {}); } }); }); // ============================================================================= // Sync tests for groups, settings, resource types, schedules, and HTTP triggers // ============================================================================= import type { TestBackend } from "./test_backend.ts"; /** Create a script on the remote via API. See file header for cross-links. */ async function createRemoteScript( backend: TestBackend, scriptPath: string, content: string = 'export async function main() { return "hello"; }' ): Promise { const resp = await backend.apiRequest!( `/api/w/${backend.workspace}/scripts/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: scriptPath, content, language: "bun", summary: "Test script", description: "Created by integration test", schema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: {}, required: [], }, }), } ); expect(resp.status).toBeLessThan(300); await resp.text(); } /** Write a standard wmill.yaml with the given extra flags */ async function writeWmillYaml( tempDir: string, extraFlags: string = "" ): Promise { await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] ${extraFlags}`, "utf-8" ); } /** Recursively list all files relative to baseDir, returning forward-slash paths */ async function listFilesRecursive( dir: string, baseDir: string = dir ): Promise { const entries = await readdir(dir, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relativePath = fullPath .substring(baseDir.length + 1) .replaceAll("\\", "/"); if (entry.isDirectory()) { files.push(...(await listFilesRecursive(fullPath, baseDir))); } else { files.push(relativePath); } } return files; } describe("group sync", () => { test("Integration: Group pull/push round-trip", async () => { await withTestBackend(async (backend, tempDir) => { await writeWmillYaml(tempDir, "includeGroups: true"); // Pull with --include-groups const pullResult = await backend.runCLICommand( ["sync", "pull", "--yes", "--include-groups"], tempDir ); expect(pullResult.code).toEqual(0); // Verify group file was created (seedTestData creates test_group) const files = await listFilesRecursive(tempDir); const groupFiles = files.filter((f) => f.endsWith(".group.yaml")); expect(groupFiles.length).toBeGreaterThan(0); const testGroupFile = groupFiles.find((f) => f.includes("test_group")); expect(testGroupFile).toBeDefined(); // Read the group file and modify const groupContent = await readFile(`${tempDir}/${testGroupFile!}`, "utf-8"); expect(groupContent).toContain("summary"); const modifiedContent = groupContent.replace( /summary:.*/, 'summary: "Modified group summary from test"' ); await writeFile(`${tempDir}/${testGroupFile!}`, modifiedContent, "utf-8"); // Push the modification const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--include-groups"], tempDir ); expect(pushResult.code).toEqual(0); // Verify via API const apiResp = await backend.apiRequest!( `/api/w/${backend.workspace}/groups/get/test_group` ); expect(apiResp.status).toEqual(200); const groupData = await apiResp.json(); expect(groupData.summary).toEqual("Modified group summary from test"); }); }); }); describe("settings sync", () => { test("Integration: Settings pull/push round-trip", async () => { await withTestBackend(async (backend, tempDir) => { await writeWmillYaml(tempDir, "includeSettings: true"); // Pull with --include-settings const pullResult = await backend.runCLICommand( ["sync", "pull", "--yes", "--include-settings"], tempDir ); expect(pullResult.code).toEqual(0); // Verify settings.yaml exists const files = await listFilesRecursive(tempDir); expect(files).toContain("settings.yaml"); // Read and modify a safe setting (webhook URL) const settingsContent = await readFile(`${tempDir}/settings.yaml`, "utf-8"); let modifiedSettings: string; if (settingsContent.includes("webhook:")) { modifiedSettings = settingsContent.replace( /webhook:.*/, 'webhook: "https://test-webhook.example.com/hook"' ); } else { modifiedSettings = settingsContent + '\nwebhook: "https://test-webhook.example.com/hook"\n'; } await writeFile(`${tempDir}/settings.yaml`, modifiedSettings, "utf-8"); // Push the modification const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--include-settings"], tempDir ); expect(pushResult.code).toEqual(0); // Verify via API const apiResp = await backend.apiRequest!( `/api/w/${backend.workspace}/workspaces/get_settings` ); expect(apiResp.status).toEqual(200); const settingsData = await apiResp.json(); expect(settingsData.webhook).toEqual("https://test-webhook.example.com/hook"); }); }); }); describe("resource type sync", () => { test("Integration: Resource type pull/push round-trip", async () => { await withTestBackend(async (backend, tempDir) => { const uniqueId = Date.now(); const rtName = `test_sync_rt_${uniqueId}`; // Create a resource type via API const createResp = await backend.apiRequest!( `/api/w/${backend.workspace}/resources/type/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: rtName, schema: { type: "object", properties: { host: { type: "string", description: "Hostname" }, port: { type: "integer", description: "Port number" }, }, }, description: "Test resource type for sync", }), } ); expect(createResp.status).toBeLessThan(300); await createResp.text(); // Resource types are included by default (not skipped) await writeWmillYaml(tempDir); // Pull const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); expect(pullResult.code).toEqual(0); // Verify resource type file exists const files = await listFilesRecursive(tempDir); const rtFile = files.find((f) => f.includes(`${rtName}.resource-type.yaml`)); expect(rtFile).toBeDefined(); // Read and modify the description const rtContent = await readFile(`${tempDir}/${rtFile!}`, "utf-8"); expect(rtContent).toContain("host"); const modifiedContent = rtContent.replace( "Test resource type for sync", "Updated resource type description" ); await writeFile(`${tempDir}/${rtFile!}`, modifiedContent, "utf-8"); // Push const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); expect(pushResult.code).toEqual(0); // Verify via API const apiResp = await backend.apiRequest!( `/api/w/${backend.workspace}/resources/type/get/${rtName}` ); expect(apiResp.status).toEqual(200); const rtData = await apiResp.json(); expect(rtData.description).toEqual("Updated resource type description"); }); }); }); describe("schedule sync", () => { test("Integration: Schedule pull/push round-trip", async () => { await withTestBackend(async (backend, tempDir) => { const uniqueId = Date.now(); const scriptPath = `f/test/sched_sync_target_${uniqueId}`; const schedulePath = `f/test/sched_sync_${uniqueId}`; // Create target script via API await createRemoteScript(backend, scriptPath); // Create schedule via API const createResp = await backend.apiRequest!( `/api/w/${backend.workspace}/schedules/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: schedulePath, schedule: "0 0 */6 * * *", script_path: scriptPath, is_flow: false, args: {}, enabled: false, timezone: "UTC", }), } ); expect(createResp.status).toBeLessThan(300); await createResp.text(); await writeWmillYaml(tempDir, "includeSchedules: true"); // Pull with --include-schedules const pullResult = await backend.runCLICommand( ["sync", "pull", "--yes", "--include-schedules"], tempDir ); expect(pullResult.code).toEqual(0); // Verify schedule file exists const files = await listFilesRecursive(tempDir); const scheduleFile = files.find( (f) => f.includes(`sched_sync_${uniqueId}`) && f.endsWith(".schedule.yaml") ); expect(scheduleFile).toBeDefined(); // Read and verify content const schedContent = await readFile(`${tempDir}/${scheduleFile!}`, "utf-8"); expect(schedContent).toContain("0 0 */6 * * *"); // Modify the cron expression const modifiedContent = schedContent.replace("0 0 */6 * * *", "0 0 */12 * * *"); await writeFile(`${tempDir}/${scheduleFile!}`, modifiedContent, "utf-8"); // Push const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--include-schedules"], tempDir ); expect(pushResult.code).toEqual(0); // Verify via API const apiResp = await backend.apiRequest!( `/api/w/${backend.workspace}/schedules/get/${schedulePath}` ); expect(apiResp.status).toEqual(200); const schedData = await apiResp.json(); expect(schedData.schedule).toEqual("0 0 */12 * * *"); }); }); test("Integration: Schedule push-only creates from local file", async () => { await withTestBackend(async (backend, tempDir) => { const uniqueId = Date.now(); const scriptPath = `f/test/sched_pushonly_target_${uniqueId}`; const schedulePath = `f/test/sched_pushonly_${uniqueId}`; // Create target script via API await createRemoteScript(backend, scriptPath); await writeWmillYaml(tempDir, "includeSchedules: true"); // Create schedule YAML locally await mkdir(`${tempDir}/f/test`, { recursive: true }); await writeFile( `${tempDir}/${schedulePath}.schedule.yaml`, `path: "${schedulePath}" schedule: "0 30 2 * * 1" script_path: "${scriptPath}" is_flow: false args: {} enabled: false timezone: "UTC" `, "utf-8" ); // Push const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--include-schedules", "--includes", `f/test/sched_pushonly_${uniqueId}**`], tempDir ); expect(pushResult.code).toEqual(0); // Verify schedule was created via API const apiResp = await backend.apiRequest!( `/api/w/${backend.workspace}/schedules/get/${schedulePath}` ); expect(apiResp.status).toEqual(200); const schedData = await apiResp.json(); expect(schedData.schedule).toEqual("0 30 2 * * 1"); expect(schedData.script_path).toEqual(scriptPath); }); }); }); describe("http trigger sync", () => { test.skipIf(shouldSkipOnCI())("Integration: HTTP trigger pull/push is idempotent", async () => { await withTestBackend(async (backend, tempDir) => { const uniqueId = Date.now(); const scriptPath = `f/test/http_trig_target_${uniqueId}`; // Create target script via API await createRemoteScript(backend, scriptPath); // Create HTTP trigger via API const createResp = await backend.apiRequest!( `/api/w/${backend.workspace}/http_triggers/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: `f/test/http_trig_${uniqueId}`, script_path: scriptPath, route_path: `/test/hook_${uniqueId}`, is_flow: false, http_method: "post", is_async: false, requires_auth: false, }), } ); // If the feature is not enabled, the create will fail - skip gracefully if (createResp.status >= 400) { console.log("HTTP trigger creation failed (feature may not be enabled), skipping"); return; } await createResp.text(); await writeWmillYaml(tempDir, "includeTriggers: true"); // Pull with --include-triggers const pullResult = await backend.runCLICommand( ["sync", "pull", "--yes", "--include-triggers"], tempDir ); expect(pullResult.code).toEqual(0); // Verify http_trigger file exists const files = await listFilesRecursive(tempDir); const triggerFile = files.find( (f) => f.includes(`http_trig_${uniqueId}`) && f.endsWith(".http_trigger.yaml") ); expect(triggerFile).toBeDefined(); // Push back (verify idempotent) const pushResult = await backend.runCLICommand( ["sync", "push", "--dry-run", "--include-triggers"], tempDir ); expect(pushResult.code).toEqual(0); const output = (pushResult.stdout + pushResult.stderr).toLowerCase(); expect( output.includes("0 change") || output.includes("no change") || output.includes("nothing") ).toBeTruthy(); }); }); }); // ============================================================================= // Script Module Sync Tests // ============================================================================= describe("script module sync", () => { test.skipIf(process.platform === "win32")("push script with modules via API and pull back", async () => { await withTestBackend(async (backend, tempDir) => { const uniqueId = Date.now(); const scriptPath = `f/test/module_script_${uniqueId}`; const modSuffix = getModuleFolderSuffix(); // Create a script with modules via API const resp = await backend.apiRequest!( `/api/w/${backend.workspace}/scripts/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: scriptPath, content: 'import { greet } from "./helper";\nexport async function main() { return greet(); }', language: "bun", summary: "Script with modules", description: "Test script with module files", schema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: {}, required: [], }, modules: { "helper.ts": { content: 'export function greet() { return "hello from module"; }\n', language: "bun", }, }, }), } ); expect(resp.status).toBeLessThan(300); await resp.text(); await writeWmillYaml(tempDir); // Pull the script const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); expect(pullResult.code).toEqual(0); // Verify module files exist on disk const files = await listFilesRecursive(tempDir); const moduleFile = files.find( (f) => f.includes(`module_script_${uniqueId}${modSuffix}`) && f.endsWith("helper.ts") ); expect(moduleFile).toBeDefined(); // Scripts with modules use folder layout: main content is at __mod/script.ts const mainFile = files.find( (f) => f.includes(`module_script_${uniqueId}${modSuffix}`) && f.endsWith("script.ts") ); expect(mainFile).toBeDefined(); // Verify module content if (moduleFile) { const content = await readFile(`${tempDir}/${moduleFile}`, "utf-8"); expect(content).toContain("greet"); expect(content).toContain("hello from module"); } }); }); test("push script with module lock files and pull back", async () => { await withTestBackend(async (backend, tempDir) => { const uniqueId = Date.now(); const scriptPath = `f/test/locked_module_${uniqueId}`; const modSuffix = getModuleFolderSuffix(); // Create a script with a module that has a lock const resp = await backend.apiRequest!( `/api/w/${backend.workspace}/scripts/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: scriptPath, content: 'export async function main() { return "main"; }', language: "bun", summary: "Script with locked module", schema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: {}, required: [], }, modules: { "dep.ts": { content: 'import lodash from "lodash";\nexport const x = lodash.identity(1);\n', language: "bun", lock: "lodash@4.17.21\n", }, }, }), } ); expect(resp.status).toBeLessThan(300); await resp.text(); await writeWmillYaml(tempDir); // Pull const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); expect(pullResult.code).toEqual(0); // Verify lock file exists on disk const files = await listFilesRecursive(tempDir); const lockFile = files.find( (f) => f.includes(`locked_module_${uniqueId}${modSuffix}`) && f.endsWith("dep.lock") ); expect(lockFile).toBeDefined(); // Verify lock content if (lockFile) { const lockContent = await readFile(`${tempDir}/${lockFile}`, "utf-8"); expect(lockContent).toContain("lodash"); } }); }); test("local script with modules pushes and round-trips", async () => { await withTestBackend(async (backend, tempDir) => { const uniqueId = Date.now(); const modSuffix = getModuleFolderSuffix(); await writeWmillYaml(tempDir); // Create local script with modules using folder layout: // f/test/modular___mod/script.ts (entry point) // f/test/modular___mod/script.yaml (metadata) // f/test/modular___mod/helper.ts (module file) const scriptDir = `${tempDir}/f/test`; const scriptName = `modular_${uniqueId}`; const modDir = `${scriptDir}/${scriptName}${modSuffix}`; await mkdir(modDir, { recursive: true }); // Entry point (main script content) await writeFile( `${modDir}/script.ts`, 'import { helper } from "./helper";\nexport async function main() { return helper(); }', "utf-8" ); // Script metadata await writeFile( `${modDir}/script.yaml`, `summary: "Script with modules" description: "Test" schema: $schema: "https://json-schema.org/draft/2020-12/schema" type: object properties: {} required: [] is_template: false lock: "" kind: script `, "utf-8" ); // Module file await writeFile( `${modDir}/helper.ts`, 'export function helper() { return "from module"; }\n', "utf-8" ); // Push const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); expect(pushResult.code).toEqual(0); // Pull into a fresh directory to verify round-trip const pullDir = await mkdtemp(join(tmpdir(), "windmill_module_pull_")); try { await writeWmillYaml(pullDir); const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], pullDir); expect(pullResult.code).toEqual(0); // Verify module file came back const files = await listFilesRecursive(pullDir); const pulledModule = files.find( (f) => f.includes(`${scriptName}${modSuffix}`) && f.endsWith("helper.ts") ); expect(pulledModule).toBeDefined(); if (pulledModule) { const content = await readFile(`${pullDir}/${pulledModule}`, "utf-8"); expect(content).toContain("from module"); } } finally { await rm(pullDir, { recursive: true }); } }); }); test("script with nested module paths pushes and pulls correctly", async () => { await withTestBackend(async (backend, tempDir) => { const uniqueId = Date.now(); const scriptPath = `f/test/nested_mod_${uniqueId}`; const modSuffix = getModuleFolderSuffix(); // Create script with nested modules via API const resp = await backend.apiRequest!( `/api/w/${backend.workspace}/scripts/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: scriptPath, content: 'export async function main() { return "main"; }', language: "bun", summary: "Script with nested modules", schema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: {}, required: [], }, modules: { "utils/format.ts": { content: 'export function format(s: string) { return s.trim(); }\n', language: "bun", }, "utils/validate.ts": { content: 'export function validate(s: string) { return s.length > 0; }\n', language: "bun", }, }, }), } ); expect(resp.status).toBeLessThan(300); await resp.text(); await writeWmillYaml(tempDir); const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); expect(pullResult.code).toEqual(0); // Verify nested module files exist const files = await listFilesRecursive(tempDir); const formatFile = files.find( (f) => f.includes(`nested_mod_${uniqueId}${modSuffix}`) && f.includes("utils/format.ts") ); const validateFile = files.find( (f) => f.includes(`nested_mod_${uniqueId}${modSuffix}`) && f.includes("utils/validate.ts") ); expect(formatFile).toBeDefined(); expect(validateFile).toBeDefined(); }); }); test.skipIf(process.platform === "win32")("pull script with modules does not create stale metadata", async () => { await withTestBackend(async (backend, tempDir) => { const uniqueId = Date.now(); const scriptPath = `f/test/fresh_mod_${uniqueId}`; // Create a script with modules on remote const resp = await backend.apiRequest!( `/api/w/${backend.workspace}/scripts/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: scriptPath, content: 'export async function main() { return "main"; }', language: "bun", summary: "Script with modules for freshness test", schema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: {}, required: [], }, modules: { "helper.ts": { content: 'export function help() { return true; }\n', language: "bun", }, }, }), } ); expect(resp.status).toBeLessThan(300); await resp.text(); await writeWmillYaml(tempDir); // Pull const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); expect(pullResult.code).toEqual(0); // Run generate-metadata — should show up-to-date for pulled scripts const metaResult = await backend.runCLICommand( ["generate-metadata", "--dry-run"], tempDir ); expect(metaResult.code).toEqual(0); // The script we just pulled should NOT be stale // (it may still show other scripts as stale from seedTestData) const output = metaResult.stdout + metaResult.stderr; expect(output).not.toContain(`fresh_mod_${uniqueId}`); }); }); });