609 lines
18 KiB
TypeScript
609 lines
18 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);
|
|
}
|
|
|
|
// Verify esbuild bundle is served and contains valid JS
|
|
const bundleRes = await fetch(`http://localhost:${port}/dist/bundle.js`);
|
|
expect(bundleRes.status).toEqual(200);
|
|
expect(bundleRes.headers.get("content-type")).toContain("application/javascript");
|
|
const bundleJs = await bundleRes.text();
|
|
expect(bundleJs.length).toBeGreaterThan(100);
|
|
// The bundle should contain the React app code
|
|
expect(bundleJs).toContain("Hello from test app");
|
|
|
|
// Verify source map is served
|
|
const mapRes = await fetch(`http://localhost:${port}/dist/bundle.js.map`);
|
|
expect(mapRes.status).toEqual(200);
|
|
expect(mapRes.headers.get("content-type")).toContain("application/json");
|
|
const sourceMap = await mapRes.json() as any;
|
|
expect(sourceMap.version).toEqual(3);
|
|
expect(sourceMap.sources).toBeArray();
|
|
} finally {
|
|
if (proc) {
|
|
proc.kill();
|
|
await proc.exited;
|
|
}
|
|
}
|
|
});
|
|
},
|
|
{ timeout: 120000 },
|
|
);
|
|
|
|
// =============================================================================
|
|
// TEST 3: `wmill app dev` with Svelte app — verifies esbuild + Svelte plugin
|
|
// =============================================================================
|
|
|
|
test(
|
|
"wmill app dev: Svelte app bundles correctly and serves valid JS",
|
|
async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await writeFile(
|
|
join(tempDir, "wmill.yaml"),
|
|
"defaultTs: bun\n",
|
|
"utf-8",
|
|
);
|
|
|
|
const appDir = join(tempDir, "f", "test", "svelteapp.raw_app");
|
|
await mkdir(appDir, { recursive: true });
|
|
|
|
await writeFile(
|
|
join(appDir, "raw_app.yaml"),
|
|
`custom_path: f/test/svelteapp\n`,
|
|
"utf-8",
|
|
);
|
|
|
|
await writeFile(
|
|
join(appDir, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "test-svelte-app",
|
|
private: true,
|
|
dependencies: {
|
|
svelte: "^5.0.0",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
// Svelte apps use index.ts (not index.tsx)
|
|
await writeFile(
|
|
join(appDir, "index.ts"),
|
|
`import App from "./App.svelte";
|
|
import { mount } from "svelte";
|
|
|
|
const app = mount(App, { target: document.getElementById("root")! });
|
|
export default app;
|
|
`,
|
|
"utf-8",
|
|
);
|
|
|
|
await writeFile(
|
|
join(appDir, "App.svelte"),
|
|
`<script>
|
|
let count = $state(0);
|
|
</script>
|
|
|
|
<h1>Svelte Dev Test</h1>
|
|
<button onclick={() => count++}>clicks: {count}</button>
|
|
`,
|
|
"utf-8",
|
|
);
|
|
|
|
// npm install
|
|
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(),
|
|
]);
|
|
expect(await npmInstall.exited).toEqual(0);
|
|
|
|
const port = await findFreePort();
|
|
|
|
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 {
|
|
proc = Bun.spawn(["bun", ...args], {
|
|
cwd: tempDir,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: { ...process.env },
|
|
});
|
|
|
|
// Collect stderr for debugging on failure
|
|
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 the bundle to be ready (poll /dist/bundle.js instead of just /)
|
|
await waitFor(
|
|
async () => {
|
|
try {
|
|
const res = await fetch(`http://localhost:${port}/dist/bundle.js`, {
|
|
signal: AbortSignal.timeout(1000),
|
|
});
|
|
if (res.ok) {
|
|
const text = await res.text();
|
|
// Only consider ready when bundle has real content (not empty/placeholder)
|
|
return text.length > 100;
|
|
}
|
|
await res.text();
|
|
} catch {
|
|
// Not ready yet
|
|
}
|
|
return false;
|
|
},
|
|
60000,
|
|
"Svelte app dev server bundle to be ready",
|
|
);
|
|
|
|
// Verify HTML is served with correct structure
|
|
const htmlRes = await fetch(`http://localhost:${port}/`);
|
|
const htmlBody = await htmlRes.text();
|
|
expect(htmlBody).toContain("<!DOCTYPE html>");
|
|
expect(htmlBody).toContain('<div id="root">');
|
|
|
|
// Verify the JS bundle contains compiled Svelte code
|
|
const bundleRes = await fetch(`http://localhost:${port}/dist/bundle.js`);
|
|
expect(bundleRes.status).toEqual(200);
|
|
const bundleJs = await bundleRes.text();
|
|
expect(bundleJs.length).toBeGreaterThan(100);
|
|
// The bundle should contain the Svelte component text
|
|
expect(bundleJs).toContain("Svelte Dev Test");
|
|
|
|
// Verify source map
|
|
const mapRes = await fetch(`http://localhost:${port}/dist/bundle.js.map`);
|
|
expect(mapRes.status).toEqual(200);
|
|
const sourceMap = await mapRes.json() as any;
|
|
expect(sourceMap.version).toEqual(3);
|
|
} finally {
|
|
if (proc) {
|
|
proc.kill();
|
|
await proc.exited;
|
|
}
|
|
}
|
|
});
|
|
},
|
|
{ timeout: 120000 },
|
|
);
|
|
|