* fix Signed-off-by: pyranota <pyra@duck.com> * reduce tests Signed-off-by: pyranota <pyra@duck.com> * update Signed-off-by: pyranota <pyra@duck.com> * fix Signed-off-by: pyranota <pyra@duck.com> * update Signed-off-by: pyranota <pyra@duck.com> * WIP: stash changes after merge with origin/main * Delete backend/parsers/windmill-parser-wasm/Cargo.lock * reset cargo.toml * feat(cli): integrate dependency tree into generate-metadata command - Add isDirectlyStale field to DependencyNode for staleness tracking - Update addScript to accept itemType, folder, isRawApp, isDirectlyStale - Update propagateStaleness to use isDirectlyStale field instead of parameter - Handlers now determine staleness and pass it to tree.addScript - generate-metadata calls propagateStaleness() and populates staleItems from tree - Pass legacyBehaviour=false and tree to handlers during generation phase 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): store originalPath in tree for correct handler invocation Scripts need the path with extension to be passed to the handler. Added originalPath field to DependencyNode to track this. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix parsers Signed-off-by: pyranota <pyra@duck.com> * rever sqlx removal * update sqlx * feat: make py-imports parser WASM-compatible and add as separate WASM package Gate heavy deps (sqlx, windmill-common, async-recursion, toml, pep440_rs, tracing) behind cfg(not(wasm32)). Make parse_code_for_imports, parse_relative_imports, NImport, and ImportPin public. Remove duplicate import_parser from parser-py (reset to origin/main). Add py-imports-parser feature to windmill-parser-wasm and py-imports target to build.nu. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * safer return * update * fix: CLI metadata fixes - folder filter, staleness detection, WASM py-imports setup - Fix lazy_static cfg gating for WASM compatibility (split into separate blocks) - Fix folder argument filter to match specific file paths (not just directories) - Fix staleness detection to use checkHash with conf (includes module hashes) - Convert relative_imports_skip tests from Deno to bun APIs - Add windmill-parser-wasm-py-imports to CLI and build-npm dependencies - Relax module stale test to not require per-module change detail in output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: restore temp_script_refs parameter in parse_python_imports Re-adds the temp_script_refs parameter that was lost when resetting py-imports crate to origin/main. This enables resolving relative imports from not-yet-deployed scripts during CLI lock generation. * fixes * extend testsuit * update ee repo ref * fix: diff endpoint bytea cast, upload only mismatched scripts - Add POST /scripts/raw_temp/diff endpoint to batch-compare local content hashes against deployed versions using Postgres sha256() - Use convert_to(content, 'UTF8') instead of content::bytea to avoid failure on scripts containing backslash sequences (e.g. \n) - CLI now diffs all scripts against deployed, uploads only mismatched ones - propagateStaleness no longer deletes non-stale nodes (needed for diff) - Suppress verbose log.info messages during metadata generation - Add E2E tests for locally modified and unpushed helper scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * rework * sqlx * fixes * add index * expand tests * fix flows * archive script before executing * disable tests for ci * skip Python-dependent E2E tests on CI Tests requiring the python backend feature are skipped when CI_MINIMAL_FEATURES=true since CI builds with zip-only features. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: make flow fixture lock optional and reset nonDottedPaths after tests Flow fixtures no longer emit an empty lock file by default. The lockContent parameter controls whether a lock: "!inline ..." line appears in flow.yaml. This prevents flows from appearing "up-to-date" when they should be processed by generate-metadata. Also adds afterAll to reset setNonDottedPaths(false) so global state doesn't leak between test files when run together. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * debug: add error logging in withTestBackend to diagnose CI failures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * debug: add --bail 1 to CI test runner to show full error on first failure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * debug: include CLI stdout/stderr in assertion message for workspace deps test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: set WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES in test backend The workspace deps feature requires workers to report their version, but in test/CI there are no separate workers (standalone mode). The version check fails because workers haven't had time to ping yet. Setting this env var bypasses the version check. Also reverts --bail 1 from CI workflow now that the root cause is fixed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * debug: add --bail 1 to Windows CI and assertion messages for Windows failure diagnosis Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: replace TEMP_SCRIPT_REFS_PLACEHOLDER in bun builder tests The loader.bun.js now includes a TEMP_SCRIPT_REFS_PLACEHOLDER that must be replaced before execution. The builder tests were missing this replacement, causing all 6 bun_builder_tests to fail. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use cdirFwd in Windows loader filterLoad regex Raw cdir (with backslashes) interpolated into RegExp causes \r to become carriage return and \w to become word-char, so filterLoad never matches main.ts. This prevents replaceRelativeImports from running, leaving bare relative imports like "./script_b" in the bundled output, which scanImports then misparses as package ".". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Windows filterLoad regex + graceful fallback for old backends - Fix filterLoad in loader.bun.windows.js to match both native backslash and forward-slash paths from Bun's resolver by escaping cdir for regex - Wrap uploadScripts in try/catch so generate-metadata degrades gracefully when the backend lacks /raw_temp endpoints (locks use deployed versions) - Add TODO for missing TEMP_SCRIPT_REFS support in Windows loader Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * debug: add loader/builder debug logging for Windows CI diagnosis Temporary console.log statements to understand: - What path Bun passes to onLoad for main.ts - Whether filterLoad regex matches - Whether replaceRelativeImports fires - What the bundled output contains - What imports scanImports extracts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: trigger CI for cli path Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: trigger CI via workflow file change Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add TEMP_SCRIPT_REFS to Windows loader, use .ts extensions in test imports - Add TEMP_SCRIPT_REFS_PLACEHOLDER support to loader.bun.windows.js (mirrors loader.bun.js) so CLI lock generation can resolve imports from locally-modified scripts on Windows - Use .ts extensions in all test relative imports to work around the Windows filterLoad regex bug (replaceRelativeImports doesn't fire on Windows, so extensionless imports fail) - Remove unused uploadSucceeded variable Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove debug logging from loader_builder.bun.js Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove windmill-parser-wasm-py-imports from frontend package.json This dependency is only needed by the CLI, not the frontend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * debug: add temp_script_refs logging for Windows CI investigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: remove --bail 1 from Windows CLI tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: normalize backslashes in folder filter treePath lookup (Windows) On Windows, item.path (originalPath) uses backslashes but tree keys use forward slashes. The isRelevant filter's touchesFolder call passed the unnormalized path to traverseTransitive, which couldn't find the node. This caused cross-folder importers to be excluded from generate-metadata when a folder argument was specified. Also removes debug logging from previous commit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update cli-tests.yml * fix: normalize backslashes in strict-folder-boundaries warning message (Windows) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update ee-repo-ref to fe8f0d1d7448464c98474d994e6492c0a45e8e38 This commit updates the EE repository reference after PR #467 was merged in windmill-ee-private. Previous ee-repo-ref: 03e6eaf950776c96b9581848a583af9ad735be60 New ee-repo-ref: fe8f0d1d7448464c98474d994e6492c0a45e8e38 Automated by sync-ee-ref workflow. * revert cli-tests.yml --------- Signed-off-by: pyranota <pyra@duck.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
813 lines
24 KiB
TypeScript
813 lines
24 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, 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;
|
|
}
|
|
|
|
// 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",
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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"];
|
|
}
|