* fix(cli): separate unit tests from integration tests and fix test cleanup - Rename 14 non-backend test files to *_unit.test.ts convention - Add UNIT_ONLY env var guard in setup.ts to skip cargo build/backend startup - Add test:unit and test:integration scripts to package.json - Use setsid on Linux for process group management so stop() kills both cargo and the windmill child process - Fix exit handler to kill process group instead of just the direct child - Add cleanupStaleTestResources() to drop orphaned windmill_test_* databases and kill orphaned backend processes on startup - Rewrite TESTING.md with current bun-based instructions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): fix process group approach - kill by db name instead of setsid The setsid approach didn't work because setsid forks, making the PID we get from Bun.spawn ephemeral. Instead, kill orphaned windmill child processes by matching our unique database name in /proc/pid/environ. Also add afterAll hook in setup.ts so full async cleanup (process kill + database drop) runs when all tests complete normally, not just on SIGINT/SIGTERM. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): address PR review feedback - Remove duplicate cleanupStaleTestResources() call in getTestBackend() (already called in setup.ts) - Add regex guard on database names before SQL interpolation - Extract shared killWindmillProcessesByEnvMatch() helper to deduplicate process-killing logic - Remove redundant test:integration script (test already runs everything) - Flip setup.ts to if/else pattern for readability Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
912 lines
28 KiB
TypeScript
912 lines
28 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, readFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { createServer } from "node:net";
|
|
import { Subprocess } from "bun";
|
|
|
|
const IS_LINUX = process.platform === "linux";
|
|
|
|
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, python)
|
|
// 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", "python"] : ["zip", "python"]);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Kill any child processes (e.g. the windmill binary spawned by cargo)
|
|
// by matching our unique database name in their environment
|
|
await this.killProcessesByDbName();
|
|
|
|
// 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");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kill any processes whose environment contains our unique database name.
|
|
* This catches child processes (e.g. the windmill binary spawned by cargo run)
|
|
* that survive after the direct child is killed.
|
|
*/
|
|
private async killProcessesByDbName(): Promise<void> {
|
|
await killWindmillProcessesByEnvMatch(this.dbName);
|
|
}
|
|
|
|
/**
|
|
* 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",
|
|
// Skip worker version check for workspace deps (workers need time to report version)
|
|
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: "1",
|
|
// 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"),
|
|
this.deleteAllWorkspaceDeps(),
|
|
]);
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
private async deleteAllWorkspaceDeps(): Promise<void> {
|
|
try {
|
|
const listResponse = await this.apiRequest(`/api/w/${this.config.workspace}/workspace_dependencies/list`);
|
|
if (!listResponse.ok) return;
|
|
|
|
const items = await listResponse.json() as { language: string; name?: string }[];
|
|
for (const item of items) {
|
|
try {
|
|
const nameParam = item.name ? `?name=${encodeURIComponent(item.name)}` : "";
|
|
await this.apiRequest(
|
|
`/api/w/${this.config.workspace}/workspace_dependencies/delete/${item.language}${nameParam}`,
|
|
{ method: "POST" }
|
|
);
|
|
} catch {
|
|
// Ignore individual deletion failures
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore failures
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kill windmill processes whose /proc/pid/environ contains the given pattern.
|
|
* Used by both per-test cleanup (match specific DB name) and stale cleanup (match any test DB).
|
|
*/
|
|
async function killWindmillProcessesByEnvMatch(pattern: string): Promise<void> {
|
|
if (!IS_LINUX) return;
|
|
try {
|
|
const pgrepProc = Bun.spawn(["pgrep", "-f", "target/(debug|release)/windmill"], {
|
|
stdout: "pipe", stderr: "pipe",
|
|
});
|
|
const output = await new Response(pgrepProc.stdout).text();
|
|
await new Response(pgrepProc.stderr).text();
|
|
await pgrepProc.exited;
|
|
|
|
for (const pidStr of output.trim().split("\n").filter(Boolean)) {
|
|
const pid = Number(pidStr);
|
|
if (isNaN(pid)) continue;
|
|
try {
|
|
const environ = await readFile(`/proc/${pid}/environ`, "utf-8");
|
|
if (environ.includes(pattern)) {
|
|
console.log(`Killing orphaned test backend process: ${pid}`);
|
|
process.kill(pid, "SIGKILL");
|
|
}
|
|
} catch {
|
|
// Process exited or we lack permissions
|
|
}
|
|
}
|
|
} catch {
|
|
// pgrep not available or no matches
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up stale test databases and orphaned backend processes from previous
|
|
* test runs that crashed or were killed without proper cleanup.
|
|
*
|
|
* Should be called before starting a new test backend.
|
|
*/
|
|
export async function cleanupStaleTestResources(postgresUrl?: string): Promise<void> {
|
|
const baseUrl = postgresUrl || process.env["DATABASE_URL"] || "postgres://postgres:changeme@localhost:5432";
|
|
const url = new URL(baseUrl);
|
|
url.pathname = "";
|
|
url.search = "";
|
|
const cleanBaseUrl = url.toString().replace(/\/$/, "");
|
|
|
|
// 1. Find and drop stale windmill_test_* databases
|
|
try {
|
|
const listProc = Bun.spawn(["psql", `${cleanBaseUrl}/postgres`, "-t", "-c",
|
|
`SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%';`
|
|
], { stdout: "pipe", stderr: "pipe" });
|
|
const output = await new Response(listProc.stdout).text();
|
|
await new Response(listProc.stderr).text();
|
|
await listProc.exited;
|
|
|
|
const staleDBs = output.trim().split("\n").map(s => s.trim()).filter(Boolean);
|
|
for (const db of staleDBs) {
|
|
// Only touch databases matching the expected naming pattern
|
|
if (!/^windmill_test_[a-z0-9_]+$/.test(db)) continue;
|
|
console.log(`Cleaning up stale test database: ${db}`);
|
|
const termProc = Bun.spawn(["psql", `${cleanBaseUrl}/postgres`, "-c",
|
|
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${db}' AND pid <> pg_backend_pid();`
|
|
], { stdout: "pipe", stderr: "pipe" });
|
|
await new Response(termProc.stdout).text();
|
|
await new Response(termProc.stderr).text();
|
|
await termProc.exited;
|
|
|
|
const dropProc = Bun.spawn(["psql", `${cleanBaseUrl}/postgres`, "-c",
|
|
`DROP DATABASE IF EXISTS "${db}";`
|
|
], { stdout: "pipe", stderr: "pipe" });
|
|
await new Response(dropProc.stdout).text();
|
|
await new Response(dropProc.stderr).text();
|
|
await dropProc.exited;
|
|
}
|
|
if (staleDBs.length > 0) {
|
|
console.log(`Cleaned up ${staleDBs.length} stale test database(s)`);
|
|
}
|
|
} catch (err) {
|
|
console.warn(`Warning: Failed to clean up stale databases: ${err}`);
|
|
}
|
|
|
|
// 2. Find and kill orphaned windmill processes from test runs
|
|
await killWindmillProcessesByEnvMatch("windmill_test_");
|
|
}
|
|
|
|
// 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"];
|
|
}
|