Files
windmill/cli/test/cargo_backend.ts
centdix 835db5d290 feat(cli): detect missing folders on sync push and add 'wmill folder add-missing' (#8011)
* fix: auto-create missing folders during sync push for non-admin users

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

* fix: show missing folders in sync push summary before confirmation

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

* fix: improve sync push folder auto-creation error handling and json output

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

* fix: only treat 404 as missing folder in getFolder check

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

* chore: remove obsolete Deno compatibility layer from yaml-validator

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

* chore(cli): add @types/bun dev dependency

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

* feat(cli): replace auto-create folders with `wmill folder add-missing` command

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

* fix(cli): improve folder commands with summary field and simpler push API

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

* feat(cli): add confirmation prompt to folder add-missing command

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

* refactor(cli): simplify missing folder check to use local stat instead of remote API

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

* update skills

* feat(cli): warn admins but block non-admins on missing folder.meta.yaml

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

* cleaning

* cleaning

* test(cli): add tests for missing folder detection and folder commands

- Add tests for `folder new`, `folder push`, `folder add-missing` commands
- Add tests for sync push missing folder.meta.yaml detection (admin warning, non-admin block)
- Fix getBasePostgresUrl to strip query params (e.g. ?sslmode=disable) from DATABASE_URL
- Add createNonAdminUser and runCLIWithToken test utilities to test_backend.ts

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

* refactor(cli): unify runCLICommand with optional token parameter

Replace separate runCLIWithToken utility with an optional { workspace?, token? }
options object on the existing runCLICommand across all backends.

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

* own workspace

* test(cli): isolate folder_missing_meta tests with per-test workspace

* test(cli): shorten isolated workspace id/name for workspace limits

* test(cli): archive temp isolated workspaces after each folder test

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-24 08:38:17 +00:00

788 lines
23 KiB
TypeScript

/**
* Cargo-based Backend Test Utilities
* Runs Windmill backend directly via `cargo run` for CLI testing
*
* Prerequisites:
* - PostgreSQL server running (default: localhost:5432)
* - Rust toolchain installed
* - Backend code compiled or ready to compile
*
* Usage:
* DATABASE_URL=postgres://postgres:changeme@localhost:5432 bun test test/my_test.ts
*/
import { resolve, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { statSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { createServer } from "node:net";
import { Subprocess } from "bun";
export interface CargoBackendConfig {
/** PostgreSQL connection string (without database name) */
postgresUrl?: string;
/** Port for the backend server (0 = auto-select) */
port?: number;
/** Path to the backend directory */
backendDir?: string;
/** Path to pre-built windmill binary (optional, uses cargo run if not set) */
binaryPath?: string;
/** Cargo features to enable (default: ["zip"]) */
features?: string[];
/** Use release build (default: false) */
release?: boolean;
/** Workspace ID for tests */
workspace?: string;
/** Admin username */
username?: string;
/** Admin password */
password?: string;
/** Timeout for backend startup (ms) */
timeout?: number;
/** Test config directory */
testConfigDir?: string;
/** Enable verbose output */
verbose?: boolean;
}
export class CargoBackend {
private config: Required<CargoBackendConfig>;
private process: Subprocess | null = null;
private dbName: string;
private isRunning = false;
private actualPort: number;
private token = "";
constructor(config: Partial<CargoBackendConfig> = {}) {
// Generate unique database name for this test run
this.dbName = `windmill_test_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
this.actualPort = config.port || 0;
const backendDir = config.backendDir || this.findBackendDir();
// Determine default features based on environment
// CI mode: minimal features (zip only)
// Local mode with license key: full features (zip, private, enterprise, license)
// Local mode without license key: zip only (EE features reject API calls without valid license)
const isCI = process.env["CI_MINIMAL_FEATURES"] === "true";
const hasLicenseKey = !!process.env["EE_LICENSE_KEY"];
const defaultFeatures = isCI ? ["zip"] : (hasLicenseKey ? ["zip", "private", "enterprise", "license"] : ["zip"]);
// Parse additional features from environment variable
const envFeatures = process.env["TEST_FEATURES"]?.split(",").filter(f => f.trim()) || [];
const allFeatures = [...new Set([...defaultFeatures, ...envFeatures, ...(config.features || [])])];
this.config = {
postgresUrl: config.postgresUrl || process.env["DATABASE_URL"] || "postgres://postgres:changeme@localhost:5432",
port: config.port || 0,
backendDir,
binaryPath: config.binaryPath || process.env["WINDMILL_BINARY"] || "",
features: allFeatures,
release: config.release ?? false,
workspace: config.workspace || "test",
username: config.username || "admin@windmill.dev",
password: config.password || "changeme",
timeout: config.timeout || 120000,
testConfigDir: config.testConfigDir || "",
verbose: config.verbose || false,
};
}
private findBackendDir(): string {
// Try to find backend directory relative to CLI
const cliTestDir = dirname(fileURLToPath(import.meta.url));
// Use resolve() for proper cross-platform path resolution
const candidates = [
resolve(cliTestDir, "..", "..", "backend"),
resolve(cliTestDir, "..", "..", "..", "backend"),
resolve(".", "backend"),
resolve("..", "backend"),
];
for (const candidate of candidates) {
try {
const cargoPath = resolve(candidate, "Cargo.toml");
const stat = statSync(cargoPath);
if (stat.isFile()) {
return candidate;
}
} catch {
// Continue searching
}
}
throw new Error("Could not find backend directory. Set backendDir in config.");
}
get baseUrl(): string {
return `http://localhost:${this.actualPort}`;
}
get workspace(): string {
return this.config.workspace;
}
get testConfigDir(): string {
return this.config.testConfigDir;
}
/**
* Start the backend server
*/
async start(): Promise<void> {
if (this.isRunning) {
return;
}
console.log("Starting Cargo-based Windmill backend...");
// Create test config directory
if (!this.config.testConfigDir) {
this.config.testConfigDir = await mkdtemp(join(tmpdir(), "wmill_test_config_"));
console.log(`Created test config directory: ${this.config.testConfigDir}`);
}
// Find a free port if not specified
if (this.actualPort === 0) {
this.actualPort = await this.findFreePort();
}
console.log(`Using port: ${this.actualPort}`);
// Create the test database
await this.createDatabase();
// Start the backend
await this.startBackendProcess();
// Wait for API to be ready
await this.waitForAPI();
// Initialize test data and authenticate
await this.initializeAndAuthenticate();
this.isRunning = true;
console.log("Cargo backend is ready!");
console.log(` Server: ${this.baseUrl}`);
console.log(` Database: ${this.dbName}`);
console.log(` Workspace: ${this.config.workspace}`);
}
/**
* Stop the backend server and cleanup
*/
async stop(): Promise<void> {
if (!this.isRunning) {
return;
}
console.log("Stopping Cargo backend...");
// Kill the backend process
if (this.process) {
try {
this.process.kill();
// Wait a bit for graceful shutdown
await Promise.race([
this.process.exited,
new Promise(resolve => setTimeout(resolve, 5000)),
]);
} catch {
// Process may already be dead
}
this.process = null;
}
// Drop the test database
await this.dropDatabase();
// Cleanup test config directory
if (this.config.testConfigDir?.includes("wmill_test_config_")) {
try {
await rm(this.config.testConfigDir, { recursive: true, force: true });
console.log(`Cleaned up test config directory`);
} catch {
// Ignore cleanup errors
}
}
this.isRunning = false;
console.log("Backend stopped");
}
/**
* Find a free port
*/
private async 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);
});
}
/**
* Parse PostgreSQL URL and return base URL (without database name)
* Handles both formats:
* - postgres://user:pass@host:port/database
* - postgres://user:pass@host:port (no database)
*/
private getBasePostgresUrl(): string {
const url = new URL(this.config.postgresUrl);
// Remove any existing database path and query params (e.g. ?sslmode=disable)
url.pathname = "";
url.search = "";
return url.toString().replace(/\/$/, ""); // Remove trailing slash
}
/**
* Create the test database
*/
private async createDatabase(): Promise<void> {
console.log(`Creating test database: ${this.dbName}`);
const baseUrl = this.getBasePostgresUrl();
const proc = Bun.spawn(["psql", `${baseUrl}/postgres`, "-c", `CREATE DATABASE "${this.dbName}";`], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`Failed to create database: ${stderr}`);
}
console.log("Test database created");
}
/**
* Drop the test database
*/
private async dropDatabase(): Promise<void> {
console.log(`Dropping test database: ${this.dbName}`);
const baseUrl = this.getBasePostgresUrl();
// Terminate existing connections
const terminateProc = Bun.spawn(["psql", `${baseUrl}/postgres`, "-c",
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${this.dbName}' AND pid <> pg_backend_pid();`], {
stdout: "pipe",
stderr: "pipe",
});
await Promise.all([
new Response(terminateProc.stdout).text(),
new Response(terminateProc.stderr).text(),
]);
await terminateProc.exited;
// Drop the database
const dropProc = Bun.spawn(["psql", `${baseUrl}/postgres`, "-c",
`DROP DATABASE IF EXISTS "${this.dbName}";`], {
stdout: "pipe",
stderr: "pipe",
});
const [, stderr] = await Promise.all([
new Response(dropProc.stdout).text(),
new Response(dropProc.stderr).text(),
]);
const exitCode = await dropProc.exited;
if (exitCode !== 0) {
console.warn(`Warning: Failed to drop database: ${stderr}`);
} else {
console.log("Test database dropped");
}
}
/**
* Start the backend process using cargo run
*/
private stderrChunks: Uint8Array[] = [];
private stdoutChunks: Uint8Array[] = [];
private async startBackendProcess(): Promise<void> {
const baseUrl = this.getBasePostgresUrl();
const databaseUrl = `${baseUrl}/${this.dbName}?sslmode=disable`;
const env: Record<string, string> = {
...process.env as Record<string, string>,
DATABASE_URL: databaseUrl,
PORT: String(this.actualPort),
MODE: "standalone", // Run server + worker in one process
RUST_LOG: "info",
DISABLE_TELEMETRY: "true",
METRICS_ENABLED: "false",
NUM_WORKERS: "1",
SLEEP_QUEUE: "50",
// Required for sqlx compile-time checks when using cargo run
SQLX_OFFLINE: "true",
// Disable embedding to speed up startup
DISABLE_EMBEDDING: "true",
// Create default admin user
CREATE_SUPERADMIN_IF_NOT_EXISTS: "1",
SUPERADMIN_EMAIL: this.config.username,
SUPERADMIN_PASSWORD: this.config.password,
};
// On Windows, ensure BUN_PATH and NODE_BIN_PATH are set for the worker.
// The Rust defaults (/usr/bin/bun, /usr/bin/node) don't exist on Windows.
if (process.platform === "win32") {
env.BUN_PATH = env.BUN_PATH || Bun.which("bun") || process.execPath;
env.NODE_BIN_PATH = env.NODE_BIN_PATH || Bun.which("node") || "node";
}
// Add license key if available
const licenseKey = process.env["EE_LICENSE_KEY"];
if (licenseKey) {
env.LICENSE_KEY = licenseKey;
}
if (this.config.binaryPath) {
// Use pre-built binary if explicitly specified
console.log(`Starting backend using binary: ${this.config.binaryPath}`);
console.log(` DATABASE_URL: ${databaseUrl}`);
this.process = Bun.spawn([this.config.binaryPath], {
env,
stdout: "pipe",
stderr: "pipe",
});
} else {
// Use cargo run with features
const cargoArgs = ["run"];
if (this.config.release) {
cargoArgs.push("--release");
}
if (this.config.features.length > 0) {
cargoArgs.push("--features", this.config.features.join(","));
}
console.log(`Starting backend via: cargo ${cargoArgs.join(" ")}`);
console.log(` DATABASE_URL: ${databaseUrl}`);
console.log(` Backend dir: ${this.config.backendDir}`);
this.process = Bun.spawn(["cargo", ...cargoArgs], {
cwd: this.config.backendDir,
env,
stdout: "pipe",
stderr: "pipe",
});
}
this.stderrChunks = [];
this.stdoutChunks = [];
// Capture output in background
this.captureProcessOutput();
console.log(`Backend process started (PID: ${this.process.pid})`);
}
/**
* Capture process output for debugging
*/
private captureProcessOutput(): void {
if (!this.process) return;
const stdout = this.process.stdout;
const stderr = this.process.stderr;
if (stdout) {
(async () => {
const reader = stdout.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
this.stdoutChunks.push(value);
if (this.config.verbose) {
process.stdout.write(value);
}
}
}
} catch {
// Process may have exited
}
})();
}
if (stderr) {
(async () => {
const reader = stderr.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
this.stderrChunks.push(value);
if (this.config.verbose) {
process.stderr.write(value);
}
}
}
} catch {
// Process may have exited
}
})();
}
}
/**
* Get captured stderr output
*/
private getStderr(): string {
const totalLength = this.stderrChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const combined = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of this.stderrChunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder().decode(combined);
}
/**
* Get captured stdout output
*/
private getStdout(): string {
const totalLength = this.stdoutChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const combined = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of this.stdoutChunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder().decode(combined);
}
/**
* Wait for the API to be responsive
*/
private async waitForAPI(): Promise<void> {
console.log("Waiting for API to be responsive (this may take a few minutes if compiling)...");
// Allow up to 10 minutes for cargo build + startup
const maxAttempts = 300; // 10 minutes with 2-second intervals
let attempts = 0;
let lastProgressLog = Date.now();
while (attempts < maxAttempts) {
try {
const response = await fetch(`${this.baseUrl}/api/version`, {
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
const version = await response.text();
console.log(`API ready (version: ${version.trim()})`);
return;
}
await response.text(); // Consume response
} catch {
// Continue trying
}
// Check if process died
if (this.process) {
try {
const status = await Promise.race([
this.process.exited,
new Promise<null>(resolve => setTimeout(() => resolve(null), 100)),
]);
if (status !== null) {
// Wait a bit for output to be captured
await new Promise(resolve => setTimeout(resolve, 500));
const stderr = this.getStderr();
const stdout = this.getStdout();
console.error("\nBackend process crashed!");
if (stdout) {
console.error("=== STDOUT ===\n" + stdout.slice(-2000));
}
if (stderr) {
console.error("=== STDERR ===\n" + stderr.slice(-2000));
}
throw new Error(`Backend process exited with code ${status}`);
}
} catch (e) {
if (e instanceof Error && e.message.includes("exited")) {
throw e;
}
}
}
attempts++;
// Log progress every 30 seconds
if (Date.now() - lastProgressLog > 30000) {
const elapsedMin = Math.floor((attempts * 2) / 60);
const elapsedSec = (attempts * 2) % 60;
console.log(` Still waiting... (${elapsedMin}m ${elapsedSec}s elapsed, compiling...)`);
lastProgressLog = Date.now();
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
throw new Error("API failed to respond within timeout (10 minutes)");
}
/**
* Initialize test data and authenticate
*/
private async initializeAndAuthenticate(): Promise<void> {
console.log("Initializing test workspace...");
// Create test workspace via API
await this.createWorkspace();
// Login to get token
await this.authenticate();
console.log("Test workspace initialized");
}
/**
* Create the test workspace
*/
private async createWorkspace(): Promise<void> {
// First login as superadmin to create workspace
const loginResponse = await fetch(`${this.baseUrl}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: this.config.username,
password: this.config.password,
}),
});
if (!loginResponse.ok) {
throw new Error(`Login failed: ${loginResponse.status}`);
}
const tempToken = await loginResponse.text();
// Create workspace
const createWsResponse = await fetch(`${this.baseUrl}/api/workspaces/create`, {
method: "POST",
headers: {
"Authorization": `Bearer ${tempToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: this.config.workspace,
name: "Test Workspace",
}),
});
if (!createWsResponse.ok) {
const error = await createWsResponse.text();
// Workspace may already exist
if (!error.includes("already exists") && !error.includes("duplicate")) {
console.warn(`Warning: Failed to create workspace: ${error}`);
}
} else {
await createWsResponse.text();
console.log(` Created workspace: ${this.config.workspace}`);
}
}
/**
* Authenticate and get token
*/
private async authenticate(): Promise<void> {
console.log("Authenticating...");
const loginResponse = await fetch(`${this.baseUrl}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: this.config.username,
password: this.config.password,
}),
});
if (!loginResponse.ok) {
throw new Error(`Authentication failed: ${loginResponse.status}`);
}
this.token = await loginResponse.text();
console.log("Authentication successful");
}
/**
* Get authentication token
*/
get authToken(): string {
return this.token;
}
/**
* Create CLI command with proper authentication
*/
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): { command: string, args: string[], cwd: string, env: Record<string, string> } {
const workspace = opts?.workspace || this.config.workspace;
const cliDir = join(dirname(fileURLToPath(import.meta.url)), "..");
const fullArgs = [
"--base-url", this.baseUrl,
"--workspace", workspace,
"--token", opts?.token || this.token,
"--config-dir", this.config.testConfigDir,
...args,
];
const useNode = process.env["TEST_CLI_RUNTIME"] === "node";
const runtime = useNode ? "node" : "bun";
const entrypoint = useNode
? join(cliDir, "npm", "esm", "main.js")
: join(cliDir, "src", "main.ts");
const runtimeArgs = useNode ? [entrypoint] : ["run", entrypoint];
console.log("CLI Command:", [runtime, ...runtimeArgs, ...fullArgs].join(" "));
return {
command: runtime,
args: [...runtimeArgs, ...fullArgs],
cwd: workingDir,
env: { ...process.env as Record<string, string> },
};
}
/**
* Run CLI command and return result
*/
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
stdout: string;
stderr: string;
code: number;
}> {
const cmd = this.createCLICommand(args, workingDir, opts);
const proc = Bun.spawn([cmd.command, ...cmd.args], {
cwd: cmd.cwd,
env: cmd.env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const code = await proc.exited;
return { stdout, stderr, code };
}
/**
* Make authenticated API request
*/
async apiRequest(path: string, options: RequestInit = {}): Promise<Response> {
const url = `${this.baseUrl}${path}`;
const headers = new Headers(options.headers);
headers.set("Authorization", `Bearer ${this.token}`);
return fetch(url, { ...options, headers });
}
/**
* Reset workspace to clean state
*/
async reset(): Promise<void> {
console.log("Resetting workspace...");
// Delete all content via API
await Promise.all([
this.deleteAll("scripts"),
this.deleteAll("flows"),
this.deleteAll("apps"),
this.deleteAll("resources"),
this.deleteAll("variables"),
this.deleteAll("folders"),
]);
console.log("Workspace reset complete");
}
private async deleteAll(resourceType: string): Promise<void> {
try {
const listResponse = await this.apiRequest(`/api/w/${this.config.workspace}/${resourceType}/list`);
if (!listResponse.ok) return;
const items = await listResponse.json();
for (const item of items) {
try {
const deletePath = resourceType === "scripts"
? `/api/w/${this.config.workspace}/${resourceType}/delete/p/${encodeURIComponent(item.path)}`
: `/api/w/${this.config.workspace}/${resourceType}/delete/${encodeURIComponent(item.path || item.name)}`;
const deleteResponse = await this.apiRequest(deletePath, { method: resourceType === "scripts" ? "POST" : "DELETE" });
await deleteResponse.text();
} catch {
// Ignore individual deletion failures
}
}
} catch {
// Ignore listing failures
}
}
}
// Global backend instance
let globalCargoBackend: CargoBackend | null = null;
/**
* Convenience function for tests with cargo backend
*/
export async function withCargoBackend<T>(
testFn: (backend: CargoBackend, tempDir: string) => Promise<T>,
config?: Partial<CargoBackendConfig>
): Promise<T> {
if (!globalCargoBackend) {
globalCargoBackend = new CargoBackend(config);
await globalCargoBackend.start();
}
const tempDir = await mkdtemp(join(tmpdir(), "windmill_cli_test_"));
try {
await globalCargoBackend.reset();
return await testFn(globalCargoBackend, tempDir);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
/**
* Cleanup function for test suites
*/
export async function cleanupCargoBackend(): Promise<void> {
if (globalCargoBackend) {
await globalCargoBackend.stop();
globalCargoBackend = null;
}
}
/**
* Check if EE-dependent tests should be skipped
*
* Returns true when:
* - CI_MINIMAL_FEATURES=true (CI mode with zip-only features)
* - EE_LICENSE_KEY is not set (EE features reject API calls without valid license)
*
* Use this in test definitions:
* test.skipIf(shouldSkipOnCI())("my EE test", ...)
*/
export function shouldSkipOnCI(): boolean {
return process.env["CI_MINIMAL_FEATURES"] === "true" || !process.env["EE_LICENSE_KEY"];
}