Files
windmill/cli/test/sync_pull_push.test.ts
Pyra 9643006f1e feat(cli): better stale scripts detection #3 (#8480)
* fix

Signed-off-by: pyranota <pyra@duck.com>

* reduce tests

Signed-off-by: pyranota <pyra@duck.com>

* update

Signed-off-by: pyranota <pyra@duck.com>

* fix

Signed-off-by: pyranota <pyra@duck.com>

* update

Signed-off-by: pyranota <pyra@duck.com>

* WIP: stash changes after merge with origin/main

* Delete backend/parsers/windmill-parser-wasm/Cargo.lock

* reset cargo.toml

* feat(cli): integrate dependency tree into generate-metadata command

- Add isDirectlyStale field to DependencyNode for staleness tracking
- Update addScript to accept itemType, folder, isRawApp, isDirectlyStale
- Update propagateStaleness to use isDirectlyStale field instead of parameter
- Handlers now determine staleness and pass it to tree.addScript
- generate-metadata calls propagateStaleness() and populates staleItems from tree
- Pass legacyBehaviour=false and tree to handlers during generation phase

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): store originalPath in tree for correct handler invocation

Scripts need the path with extension to be passed to the handler.
Added originalPath field to DependencyNode to track this.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix parsers

Signed-off-by: pyranota <pyra@duck.com>

* rever sqlx removal

* update sqlx

* feat: make py-imports parser WASM-compatible and add as separate WASM package

Gate heavy deps (sqlx, windmill-common, async-recursion, toml, pep440_rs,
tracing) behind cfg(not(wasm32)). Make parse_code_for_imports,
parse_relative_imports, NImport, and ImportPin public. Remove duplicate
import_parser from parser-py (reset to origin/main). Add py-imports-parser
feature to windmill-parser-wasm and py-imports target to build.nu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* safer return

* update

* fix: CLI metadata fixes - folder filter, staleness detection, WASM py-imports setup

- Fix lazy_static cfg gating for WASM compatibility (split into separate blocks)
- Fix folder argument filter to match specific file paths (not just directories)
- Fix staleness detection to use checkHash with conf (includes module hashes)
- Convert relative_imports_skip tests from Deno to bun APIs
- Add windmill-parser-wasm-py-imports to CLI and build-npm dependencies
- Relax module stale test to not require per-module change detail in output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: restore temp_script_refs parameter in parse_python_imports

Re-adds the temp_script_refs parameter that was lost when resetting
py-imports crate to origin/main. This enables resolving relative imports
from not-yet-deployed scripts during CLI lock generation.

* fixes

* extend testsuit

* update ee repo ref

* fix: diff endpoint bytea cast, upload only mismatched scripts

- Add POST /scripts/raw_temp/diff endpoint to batch-compare local content
  hashes against deployed versions using Postgres sha256()
- Use convert_to(content, 'UTF8') instead of content::bytea to avoid
  failure on scripts containing backslash sequences (e.g. \n)
- CLI now diffs all scripts against deployed, uploads only mismatched ones
- propagateStaleness no longer deletes non-stale nodes (needed for diff)
- Suppress verbose log.info messages during metadata generation
- Add E2E tests for locally modified and unpushed helper scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* rework

* sqlx

* fixes

* add index

* expand tests

* fix flows

* archive script before executing

* disable tests for ci

* skip Python-dependent E2E tests on CI

Tests requiring the python backend feature are skipped when
CI_MINIMAL_FEATURES=true since CI builds with zip-only features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: make flow fixture lock optional and reset nonDottedPaths after tests

Flow fixtures no longer emit an empty lock file by default. The lockContent
parameter controls whether a lock: "!inline ..." line appears in flow.yaml.
This prevents flows from appearing "up-to-date" when they should be processed
by generate-metadata.

Also adds afterAll to reset setNonDottedPaths(false) so global state doesn't
leak between test files when run together.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: add error logging in withTestBackend to diagnose CI failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: add --bail 1 to CI test runner to show full error on first failure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: include CLI stdout/stderr in assertion message for workspace deps test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: set WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES in test backend

The workspace deps feature requires workers to report their version, but
in test/CI there are no separate workers (standalone mode). The version
check fails because workers haven't had time to ping yet. Setting this
env var bypasses the version check.

Also reverts --bail 1 from CI workflow now that the root cause is fixed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: add --bail 1 to Windows CI and assertion messages for Windows failure diagnosis

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace TEMP_SCRIPT_REFS_PLACEHOLDER in bun builder tests

The loader.bun.js now includes a TEMP_SCRIPT_REFS_PLACEHOLDER that must
be replaced before execution. The builder tests were missing this
replacement, causing all 6 bun_builder_tests to fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use cdirFwd in Windows loader filterLoad regex

Raw cdir (with backslashes) interpolated into RegExp causes \r to
become carriage return and \w to become word-char, so filterLoad
never matches main.ts. This prevents replaceRelativeImports from
running, leaving bare relative imports like "./script_b" in the
bundled output, which scanImports then misparses as package ".".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Windows filterLoad regex + graceful fallback for old backends

- Fix filterLoad in loader.bun.windows.js to match both native backslash
  and forward-slash paths from Bun's resolver by escaping cdir for regex
- Wrap uploadScripts in try/catch so generate-metadata degrades gracefully
  when the backend lacks /raw_temp endpoints (locks use deployed versions)
- Add TODO for missing TEMP_SCRIPT_REFS support in Windows loader

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: add loader/builder debug logging for Windows CI diagnosis

Temporary console.log statements to understand:
- What path Bun passes to onLoad for main.ts
- Whether filterLoad regex matches
- Whether replaceRelativeImports fires
- What the bundled output contains
- What imports scanImports extracts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: trigger CI for cli path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: trigger CI via workflow file change

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add TEMP_SCRIPT_REFS to Windows loader, use .ts extensions in test imports

- Add TEMP_SCRIPT_REFS_PLACEHOLDER support to loader.bun.windows.js
  (mirrors loader.bun.js) so CLI lock generation can resolve imports
  from locally-modified scripts on Windows
- Use .ts extensions in all test relative imports to work around the
  Windows filterLoad regex bug (replaceRelativeImports doesn't fire
  on Windows, so extensionless imports fail)
- Remove unused uploadSucceeded variable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove debug logging from loader_builder.bun.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove windmill-parser-wasm-py-imports from frontend package.json

This dependency is only needed by the CLI, not the frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* debug: add temp_script_refs logging for Windows CI investigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: remove --bail 1 from Windows CLI tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: normalize backslashes in folder filter treePath lookup (Windows)

On Windows, item.path (originalPath) uses backslashes but tree keys
use forward slashes. The isRelevant filter's touchesFolder call
passed the unnormalized path to traverseTransitive, which couldn't
find the node. This caused cross-folder importers to be excluded
from generate-metadata when a folder argument was specified.

Also removes debug logging from previous commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update cli-tests.yml

* fix: normalize backslashes in strict-folder-boundaries warning message (Windows)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update ee-repo-ref to fe8f0d1d7448464c98474d994e6492c0a45e8e38

This commit updates the EE repository reference after PR #467 was merged in windmill-ee-private.

Previous ee-repo-ref: 03e6eaf950776c96b9581848a583af9ad735be60

New ee-repo-ref: fe8f0d1d7448464c98474d994e6492c0a45e8e38

Automated by sync-ee-ref workflow.

* revert cli-tests.yml

---------

Signed-off-by: pyranota <pyra@duck.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-23 18:20:19 +00:00

2718 lines
92 KiB
TypeScript

/**
* 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,
} 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<string, string> = {
python3: ".py",
deno: ".ts",
bun: ".ts",
bash: ".sh",
go: ".go",
postgresql: ".sql",
};
const ext = extensions[language];
const defaultContent: Record<string, string> = {
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<string, { path: string; content: string }> {
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<string, { path: string; content: string }> {
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<string, { path: string; content: string }> {
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: `<!DOCTYPE html>
<html>
<head><title>${name}</title></head>
<body><h1>Hello from ${name}</h1></body>
</html>`,
},
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<string, unknown>,
): { 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<void> {
// 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<string, string>): Promise<JSZip> {
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<Record<string, string>> {
const files: Record<string, string> = {};
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<string> {
return await mkdtemp(join(tmpdir(), "wmill_sync_test_"));
}
/**
* Cleans up a temporary directory
*/
async function cleanupTempDir(dir: string): Promise<void> {
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);
});
// =============================================================================
// nonDottedPaths Fixture Functions
// =============================================================================
/**
* Creates a mock flow file structure using the current global nonDottedPaths setting
*/
function createFlowFixtureWithCurrentConfig(name: string): Record<string, { path: string; content: string }> {
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<string, { path: string; content: string }> {
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<string, { path: string; content: string }> {
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: `<!DOCTYPE html>
<html>
<head><title>${name}</title></head>
<body><h1>Hello from ${name}</h1></body>
</html>`,
},
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<void> {
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<void> {
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<string[]> {
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_<id>__mod/script.ts (entry point)
// f/test/modular_<id>__mod/script.yaml (metadata)
// f/test/modular_<id>__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}`);
});
});
});