Files
windmill/cli/test/dev_server.test.ts
Ruben Fiszel a2cefdf0a2 refactor(cli): migrate CLI from Deno to Bun/Node.js (#8041)
* fix: only enable EE features in test backend when license key is available

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

* fix: skip EE tests without license key and exclude test-skills from test discovery

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

* fix: unskip passing tests and add duplicate (remote, workspaceId) check in addWorkspace

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

* refactor(cli): migrate from Deno APIs to Node.js/Bun-compatible APIs

Replace Deno-specific APIs with Node.js equivalents across the entire CLI
codebase to enable running on Node.js/Bun. Switch build system from dnt
to bun, update imports from jsr:/npm: prefixed to bare specifiers, and
add package.json/tsconfig.json for the Node.js ecosystem.

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

* all

* test(cli): expand test coverage with new integration and unit tests

Add standalone_commands.test.ts covering folder list, schedule list,
resource-type list/push/update, script show/run/bootstrap, and user
commands. Add unit tests for filePathExtensionFromContentType and
removeExtensionToPath. Add git_unit, local_encryption_unit,
resource_folders_unit, and settings_unit test files. Fix schedule
cron expressions (6-field format), add includeSchedules flag, improve
test setup with pre-build and auto-cleanup, and support TEST_CLI_RUNTIME=node.

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

* fix(cli): replace Deno.readFile with node:fs in WASM loaders and add schema parsing tests

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

* refactor(cli): switch WASM parsers from local files to npm packages

Use published windmill-parser-wasm-* npm packages instead of local
wasm/ files. A loadParser() helper uses createRequire to resolve the
.wasm binary from node_modules and passes it to init() via
readFileSync, avoiding fetch() and Deno.readFile() patches.

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

* test(cli): add coverage for --locks-required lint feature

Add 15 tests covering the lock-checking functionality merged from main:
- checkMissingLocks: standalone scripts (python, bun, bash), inline
  lock file resolution (valid, empty, missing), flow inline rawscripts
  (with/without locks, nested forloopflow), app inline scripts, raw
  apps without backend folder
- runLint --locks-required integration: reports issues when locks
  missing, skips checks when flag absent, passes when locks exist

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

* ci(cli): replace Deno with Bun in CI workflows

- cli-tests.yml: remove Deno setup, use `bun test` instead of
  `deno test`, add `bun install` step for dependency installation
- npm_on_release.yml: replace Deno setup with Bun setup for CLI
  publishing
- build.sh: add `bun install` before building so CI has dependencies

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

* fix(cli): pre-start backend in test preload and remove Deno test leftovers

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

* fix(cli): normalize path separators for Windows compatibility

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

* more tests + windows

* ci(cli): use Blacksmith runner for Windows tests

Switch test-windows job from windows-latest to blacksmith-16vcpu-windows-2025
for faster CI execution.

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

* fix(cli): fix Windows path separator expectations in unit tests

buildMetadataPath and extractResourceName normalize to forward slashes
internally, so tests should not expect platform-specific separators in
their output.

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

* fix(cli): fix Windows CI test failures for dev_server and script_run

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

* fix(cli): set BUN_PATH and NODE_BIN_PATH for backend worker on Windows

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

* ci(cli): add SSH debug step on Windows test failure

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

* fix(cli): use native path separators for ignore check in dev mode on Windows

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:19:04 +00:00

418 lines
12 KiB
TypeScript

/**
* Dev Server Smoke Tests
*
* Tests for `wmill dev` and `wmill app dev` commands.
* Verifies server startup, WebSocket connectivity, and file-change broadcasting.
*
* Run with:
* bun test test/dev_server.test.ts
*/
import { expect, test } from "bun:test";
import { writeFile, mkdir } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createServer } from "node:net";
import { Subprocess } from "bun";
import WebSocket from "ws";
import { withTestBackend } from "./test_backend.ts";
/** Find a free port by binding to port 0 */
async function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.listen(0, () => {
const port = (server.address() as any).port;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
/** Wait for a condition with timeout */
async function waitFor<T>(
fn: () => T | Promise<T>,
timeoutMs: number,
label: string,
): Promise<T> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const result = await fn();
if (result) return result;
} catch (e) {
lastError = e;
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`Timed out waiting for: ${label} (after ${timeoutMs}ms). Last error: ${lastError}`);
}
/** Get CLI main.ts path */
function getCLIMainPath(): string {
return join(dirname(fileURLToPath(import.meta.url)), "..", "src", "main.ts");
}
// =============================================================================
// TEST 1: `wmill dev` smoke test
// =============================================================================
test(
"wmill dev: starts server, broadcasts file changes over WebSocket",
async () => {
await withTestBackend(async (backend, tempDir) => {
// Create wmill.yaml config
await writeFile(
join(tempDir, "wmill.yaml"),
"defaultTs: bun\n",
"utf-8",
);
// Create a script file
const scriptDir = join(tempDir, "f", "test");
await mkdir(scriptDir, { recursive: true });
await writeFile(
join(scriptDir, "hello.ts"),
'export function main() { return "hello"; }\n',
"utf-8",
);
await writeFile(
join(scriptDir, "hello.script.yaml"),
`summary: "test"\ndescription: ""\nlock: ""\nschema:\n $schema: "https://json-schema.org/draft/2020-12/schema"\n type: object\n properties: {}\n required: []\n`,
"utf-8",
);
// Push the script so the workspace has content
const pushResult = await backend.runCLICommand(
["sync", "push", "--yes"],
tempDir,
);
if (pushResult.code !== 0) {
console.error("Push stderr:", pushResult.stderr);
console.error("Push stdout:", pushResult.stdout);
}
expect(pushResult.code).toEqual(0);
// Build the CLI command for `wmill dev`
const cliMainPath = getCLIMainPath();
const args = [
"run",
cliMainPath,
"--base-url",
backend.baseUrl,
"--workspace",
backend.workspace,
"--token",
backend.token!,
"--config-dir",
backend.testConfigDir,
"dev",
];
let proc: Subprocess | null = null;
let ws: WebSocket | null = null;
try {
// Spawn wmill dev as background process
proc = Bun.spawn(["bun", ...args], {
cwd: tempDir,
stdout: "pipe",
stderr: "pipe",
env: { ...process.env },
});
// Read stdout to find the port
const stdoutReader = proc.stdout.getReader();
let stdoutBuffer = "";
let port: number | null = null;
// Wait for "Server listening on port XXXX" message
const portMatch = await waitFor(
async () => {
try {
const { done, value } = await Promise.race([
stdoutReader.read(),
new Promise<{ done: true; value: undefined }>((r) =>
setTimeout(() => r({ done: true, value: undefined }), 500),
),
]);
if (!done && value) {
stdoutBuffer += new TextDecoder().decode(value);
}
} catch {
// Reader may be exhausted
}
const match = stdoutBuffer.match(
/Server listening on port (\d+)/,
);
return match;
},
30000,
"dev server to start",
);
port = parseInt(portMatch[1], 10);
expect(port).toBeGreaterThan(0);
stdoutReader.releaseLock();
// Connect WebSocket
ws = new WebSocket(`ws://localhost:${port}`);
// Wait for connection to open
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("WebSocket connection timeout")),
5000,
);
ws!.on("open", () => {
clearTimeout(timeout);
resolve();
});
ws!.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});
expect(ws.readyState).toEqual(WebSocket.OPEN);
// Set up a promise to receive the next WebSocket message
const isWindows = process.platform === "win32";
const messagePromise = new Promise<any>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("WebSocket message timeout")),
isWindows ? 30000 : 10000,
);
ws!.on("message", (data) => {
clearTimeout(timeout);
try {
resolve(JSON.parse(data.toString()));
} catch (e) {
reject(e);
}
});
});
// Modify the script file on disk
// Windows fs.watch() needs more time to initialize with recursive: true
await new Promise((r) => setTimeout(r, isWindows ? 2000 : 300));
await writeFile(
join(scriptDir, "hello.ts"),
'export function main() { return "modified"; }\n',
"utf-8",
);
// Wait for WebSocket message
const message = await messagePromise;
// Verify the message
expect(message.type).toEqual("script");
expect(message.content).toContain("modified");
expect(message.path).toContain("f/test/hello");
expect(message.language).toBeTruthy();
} finally {
if (ws) {
ws.close();
}
if (proc) {
proc.kill();
await proc.exited;
}
}
});
},
{ timeout: 60000 },
);
// =============================================================================
// TEST 2: `wmill app dev` smoke test
// =============================================================================
test(
"wmill app dev: starts HTTP server, serves HTML, provides SSE endpoint",
async () => {
await withTestBackend(async (backend, tempDir) => {
// Create wmill.yaml config
await writeFile(
join(tempDir, "wmill.yaml"),
"defaultTs: bun\n",
"utf-8",
);
// Create a raw app directory with the right suffix
const appDir = join(tempDir, "f", "test", "myapp.raw_app");
await mkdir(appDir, { recursive: true });
// Create raw_app.yaml
await writeFile(
join(appDir, "raw_app.yaml"),
`custom_path: f/test/myapp\n`,
"utf-8",
);
// Create package.json (minimal, with react dependency)
await writeFile(
join(appDir, "package.json"),
JSON.stringify(
{
name: "test-app",
private: true,
dependencies: {
react: "^18.0.0",
"react-dom": "^18.0.0",
},
},
null,
2,
),
"utf-8",
);
// Create index.tsx entry point
await writeFile(
join(appDir, "index.tsx"),
`import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
const root = createRoot(document.getElementById("root")!);
root.render(<App />);
`,
"utf-8",
);
// Create App.tsx
await writeFile(
join(appDir, "App.tsx"),
`import React from "react";
export default function App() {
return <div>Hello from test app</div>;
}
`,
"utf-8",
);
// Run npm install in the app directory
const npmInstall = Bun.spawn(["npm", "install"], {
cwd: appDir,
stdout: "pipe",
stderr: "pipe",
});
await Promise.all([
new Response(npmInstall.stdout).text(),
new Response(npmInstall.stderr).text(),
]);
const npmExitCode = await npmInstall.exited;
expect(npmExitCode).toEqual(0);
// Find a free port
const port = await findFreePort();
// Build the CLI command for `wmill app dev`
const cliMainPath = getCLIMainPath();
const args = [
"run",
cliMainPath,
"--base-url",
backend.baseUrl,
"--workspace",
backend.workspace,
"--token",
backend.token!,
"--config-dir",
backend.testConfigDir,
"app",
"dev",
appDir,
"--no-open",
"--port",
String(port),
];
let proc: Subprocess | null = null;
try {
// Spawn wmill app dev as background process
proc = Bun.spawn(["bun", ...args], {
cwd: tempDir,
stdout: "pipe",
stderr: "pipe",
env: { ...process.env },
});
// Collect stderr in background for debugging
const stderrReader = proc.stderr.getReader();
let stderrBuffer = "";
(async () => {
try {
while (true) {
const { done, value } = await stderrReader.read();
if (done) break;
stderrBuffer += new TextDecoder().decode(value);
}
} catch {
// Process may have exited
}
})();
// Wait for server to be ready by polling the HTTP endpoint
await waitFor(
async () => {
try {
const res = await fetch(`http://localhost:${port}/`, {
signal: AbortSignal.timeout(1000),
});
if (res.ok) {
await res.text();
return true;
}
await res.text();
} catch {
// Not ready yet
}
return false;
},
60000,
"app dev server to be ready",
);
// Verify GET / returns HTML
const htmlRes = await fetch(`http://localhost:${port}/`);
const contentType = htmlRes.headers.get("content-type");
const htmlBody = await htmlRes.text();
expect(contentType).toContain("text/html");
expect(htmlBody).toContain("<!DOCTYPE html>");
expect(htmlBody).toContain("<div id=\"root\">");
// Verify GET /__events returns SSE stream
const controller = new AbortController();
const sseTimeout = setTimeout(() => controller.abort(), 5000);
try {
const sseRes = await fetch(`http://localhost:${port}/__events`, {
signal: controller.signal,
});
const sseContentType = sseRes.headers.get("content-type");
expect(sseContentType).toContain("text/event-stream");
// Read a small chunk to verify SSE sends data
const reader = sseRes.body!.getReader();
const { value } = await reader.read();
const chunk = new TextDecoder().decode(value);
expect(chunk).toContain("data: connected");
reader.cancel();
} finally {
clearTimeout(sseTimeout);
}
} finally {
if (proc) {
proc.kill();
await proc.exited;
}
}
});
},
{ timeout: 120000 },
);