* 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>
418 lines
12 KiB
TypeScript
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 },
|
|
);
|
|
|