mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
dcee9fe7b1
* fix(cli): prevent duplicate 'Using non-dotted paths' log message Add a flag to track whether the message has already been logged, so it only prints once even if setNonDottedPaths is called multiple times. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(cli): add --branch option to sync pull/push commands Add a --branch argument that allows overriding the current git branch for sync operations. This enables: - Using branch-specific settings even when not in a git repository - Overriding the detected git branch when needed The branch override is applied to: - getEffectiveSettings() for branch-specific config overrides - getSpecificItemsForCurrentBranch() for branch-specific items Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): correct log message for workspace fork branches Use rawGitBranch instead of currentBranch in the log message when showing the origin of a workspace fork branch. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(cli): extend --branch support to specificItems functionality Update getBranchSpecificPath and isCurrentBranchFile to accept optional branchOverride parameter. This ensures that branch-specific file handling (for variables, resources, triggers) works correctly with --branch flag. Updated functions: - getBranchSpecificPath(): now accepts branchOverride - isCurrentBranchFile(): now accepts branchOverride - elementsToMap(): now accepts branchOverride - compareDynFSElement(): now accepts branchOverride - prettyChanges(): now accepts branchOverride All call sites updated to pass opts.branch through the call chain. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): resolve TypeScript type errors - Fix Timeout type in dev.ts using ReturnType<typeof setTimeout> - Add proper type casts for unknown error types - Cast FlowModule to any to resolve generated type mismatch - Cast Uint8Array to BlobPart for Blob constructor compatibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(cli): add unit tests for branch detection and --branch override - Add specific_items.test.ts with 35 tests covering: - toBranchSpecificPath and fromBranchSpecificPath conversions - isSpecificItem pattern matching - isBranchSpecificFile detection - Round-trip path conversions - branchOverride parameter functionality - Add conf_branch_override.test.ts with 6 tests covering: - getEffectiveSettings with branchOverride parameter - Branch-specific overrides application - promotionOverrides precedence - Fallback to top-level settings - Fix containerized_backend.ts to use dynamic paths instead of hardcoded user home directories Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore(cli): add conf.ts barrel file for test imports Re-exports from src/core/conf.ts to support existing test imports. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * all * fix(cli): pass --branch override to workspace resolution Previously, the --branch flag was only used for config resolution but not for workspace resolution. This caused confusing log messages that showed the git branch (e.g., master) before showing the override branch. Changes: - Add branchOverride parameter to tryResolveBranchWorkspace() - Add branchOverride parameter to resolveWorkspace() - Pass opts.branch from sync pull/push to resolveWorkspace() - Log "Using branch override" early in workspace resolution - Remove duplicate log from getEffectiveSettings() Now when using --branch foobar, the logs show: Using branch override: foobar Applied settings for Git branch: foobar ... Instead of the confusing previous output that mentioned both master and foobar. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(cli): remove redundant branch detection in elementsToMap isCurrentBranchFile() already validates that a branch exists (via branchOverride or git detection) before returning true. No need to pre-compute currentBranch before calling it. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1366 lines
44 KiB
TypeScript
1366 lines
44 KiB
TypeScript
/**
|
|
* Containerized Backend Test Utilities
|
|
* Manages real Windmill EE backend containers for CLI testing
|
|
*/
|
|
|
|
export interface ContainerConfig {
|
|
composeFile?: string;
|
|
baseUrl?: string;
|
|
workspace?: string;
|
|
token?: string;
|
|
username?: string;
|
|
password?: string;
|
|
timeout?: number;
|
|
testConfigDir?: string;
|
|
}
|
|
|
|
export class ContainerizedBackend {
|
|
private config: Required<ContainerConfig>;
|
|
private isRunning = false;
|
|
|
|
constructor(config: Partial<ContainerConfig> = {}) {
|
|
this.config = {
|
|
composeFile: config.composeFile || new URL('./docker-compose.test.yml', import.meta.url).pathname,
|
|
baseUrl: config.baseUrl || 'http://localhost:8001',
|
|
workspace: config.workspace || 'test', // Use test workspace
|
|
token: config.token || '',
|
|
username: config.username || 'admin@windmill.dev',
|
|
password: config.password || 'changeme',
|
|
timeout: config.timeout || 120000, // 2 minutes
|
|
testConfigDir: config.testConfigDir || '' // Will be created if empty
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the base URL of the backend
|
|
*/
|
|
get baseUrl(): string {
|
|
return this.config.baseUrl;
|
|
}
|
|
|
|
/**
|
|
* Get the workspace ID
|
|
*/
|
|
get workspace(): string {
|
|
return this.config.workspace;
|
|
}
|
|
|
|
/**
|
|
* Get the authentication token
|
|
*/
|
|
get token(): string {
|
|
return this.config.token;
|
|
}
|
|
|
|
/**
|
|
* Get the test config directory
|
|
*/
|
|
get testConfigDir(): string {
|
|
return this.config.testConfigDir;
|
|
}
|
|
|
|
/**
|
|
* Start the containerized backend (database + server + worker)
|
|
*/
|
|
async start(): Promise<void> {
|
|
if (this.isRunning) {
|
|
return;
|
|
}
|
|
|
|
console.log('🚀 Starting containerized Windmill backend...');
|
|
|
|
// Create isolated test config directory if not provided
|
|
if (!this.config.testConfigDir) {
|
|
this.config.testConfigDir = await Deno.makeTempDir({ prefix: 'wmill_test_config_' });
|
|
console.log(`📁 Created test config directory: ${this.config.testConfigDir}`);
|
|
}
|
|
|
|
// Start containers with EE license key
|
|
const startCmd = new Deno.Command('docker', {
|
|
args: ['compose', '-f', this.config.composeFile, 'up', '-d'],
|
|
stdout: 'piped',
|
|
stderr: 'piped',
|
|
env: {
|
|
...Deno.env.toObject(),
|
|
...(Deno.env.get('EE_LICENSE_KEY') && { EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY')! })
|
|
}
|
|
});
|
|
|
|
const startResult = await startCmd.output();
|
|
if (startResult.code !== 0) {
|
|
const stderr = new TextDecoder().decode(startResult.stderr);
|
|
throw new Error(`Failed to start containers: ${stderr}`);
|
|
}
|
|
|
|
// Wait for services to be healthy
|
|
await this.waitForHealthy();
|
|
|
|
// Verify API is responsive
|
|
await this.waitForAPI();
|
|
|
|
// Authenticate and get a proper token
|
|
await this.authenticate();
|
|
|
|
this.isRunning = true;
|
|
console.log('✅ Containerized backend is ready!');
|
|
console.log(` Server: ${this.config.baseUrl}`);
|
|
console.log(` Workspace: ${this.config.workspace}`);
|
|
}
|
|
|
|
/**
|
|
* Stop the containerized backend
|
|
*/
|
|
async stop(): Promise<void> {
|
|
if (!this.isRunning) {
|
|
return;
|
|
}
|
|
|
|
console.log('🛑 Stopping containerized backend...');
|
|
|
|
const stopCmd = new Deno.Command('docker', {
|
|
args: ['compose', '-f', this.config.composeFile, 'down', '-v'],
|
|
stdout: 'piped',
|
|
stderr: 'piped',
|
|
env: {
|
|
...Deno.env.toObject(),
|
|
...(Deno.env.get('EE_LICENSE_KEY') && { EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY')! })
|
|
}
|
|
});
|
|
|
|
await stopCmd.output();
|
|
|
|
// Clean up test config directory if we created it
|
|
if (this.config.testConfigDir && this.config.testConfigDir.includes('wmill_test_config_')) {
|
|
try {
|
|
await Deno.remove(this.config.testConfigDir, { recursive: true });
|
|
console.log(`🗑️ Cleaned up test config directory: ${this.config.testConfigDir}`);
|
|
} catch (error) {
|
|
console.warn(`⚠️ Failed to clean up test config directory: ${error}`);
|
|
}
|
|
}
|
|
|
|
this.isRunning = false;
|
|
console.log('✅ Backend stopped');
|
|
}
|
|
|
|
/**
|
|
* Reset backend to clean state (clear workspace content, keep settings)
|
|
*/
|
|
async reset(): Promise<void> {
|
|
console.log('🔄 Fast API-based reset...');
|
|
|
|
try {
|
|
// Delete all workspace content via Windmill API
|
|
await Promise.all([
|
|
this.deleteAllScripts(),
|
|
this.deleteAllFlows(),
|
|
this.deleteAllApps(),
|
|
this.deleteAllResources(),
|
|
this.deleteAllVariables(),
|
|
this.deleteAllFolders()
|
|
]);
|
|
|
|
console.log('✅ Workspace reset complete');
|
|
} catch (error) {
|
|
console.error('Reset failed:', error instanceof Error ? error.message : String(error));
|
|
// Don't throw - tests can continue with existing state
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete all scripts in workspace
|
|
*/
|
|
private async deleteAllScripts(): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/list`, {
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.warn(`Failed to list scripts for deletion: ${response.status} ${response.statusText}`);
|
|
return;
|
|
}
|
|
|
|
const scripts = await response.json();
|
|
console.log(`Deleting ${scripts.length} scripts...`);
|
|
|
|
const deletionResults = [];
|
|
|
|
for (const script of scripts) {
|
|
try {
|
|
const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/delete/p/${encodeURIComponent(script.path)}`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
const responseText = await deleteResponse.text();
|
|
|
|
if (!deleteResponse.ok) {
|
|
console.warn(`Failed to delete script ${script.path}: ${deleteResponse.status} ${deleteResponse.statusText} - ${responseText}`);
|
|
deletionResults.push({ path: script.path, success: false, error: `${deleteResponse.status}: ${responseText}` });
|
|
} else {
|
|
console.log(`Successfully deleted script: ${script.path}`);
|
|
deletionResults.push({ path: script.path, success: true });
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Error deleting script ${script.path}:`, error instanceof Error ? error.message : String(error));
|
|
deletionResults.push({ path: script.path, success: false, error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
const successCount = deletionResults.filter(r => r.success).length;
|
|
const failureCount = deletionResults.filter(r => !r.success).length;
|
|
|
|
console.log(`Script deletion summary: ${successCount} successful, ${failureCount} failed`);
|
|
|
|
if (failureCount > 0) {
|
|
console.warn('Failed script deletions:', deletionResults.filter(r => !r.success));
|
|
}
|
|
|
|
// Verify deletion by checking if scripts still exist
|
|
await this.verifyScriptDeletion();
|
|
|
|
} catch (error) {
|
|
console.error('Failed to delete scripts:', error instanceof Error ? error.message : String(error));
|
|
throw error; // Don't silently ignore major failures
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify that scripts were actually deleted
|
|
*/
|
|
private async verifyScriptDeletion(): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/list`, {
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
if (response.ok) {
|
|
const remainingScripts = await response.json();
|
|
if (remainingScripts.length > 0) {
|
|
console.warn(`Warning: ${remainingScripts.length} scripts still exist after deletion attempt:`,
|
|
remainingScripts.map((s: any) => s.path));
|
|
|
|
// Attempt to delete remaining scripts with retry
|
|
for (const script of remainingScripts) {
|
|
await this.retryScriptDeletion(script.path);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Could not verify script deletion:', error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retry script deletion with exponential backoff
|
|
*/
|
|
private async retryScriptDeletion(scriptPath: string, maxRetries: number = 3): Promise<void> {
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/delete/${encodeURIComponent(scriptPath)}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
const responseText = await deleteResponse.text();
|
|
|
|
if (deleteResponse.ok) {
|
|
console.log(`Successfully deleted script on retry ${attempt}: ${scriptPath}`);
|
|
return;
|
|
} else if (deleteResponse.status === 404) {
|
|
console.log(`Script already deleted: ${scriptPath}`);
|
|
return;
|
|
} else {
|
|
console.warn(`Retry ${attempt} failed for script ${scriptPath}: ${deleteResponse.status} - ${responseText}`);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Retry ${attempt} error for script ${scriptPath}:`, error instanceof Error ? error.message : String(error));
|
|
}
|
|
|
|
if (attempt < maxRetries) {
|
|
const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
|
|
console.error(`Failed to delete script after ${maxRetries} retries: ${scriptPath}`);
|
|
}
|
|
|
|
/**
|
|
* Delete all flows in workspace
|
|
*/
|
|
private async deleteAllFlows(): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/flows/list`, {
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
if (!response.ok) return;
|
|
|
|
const flows = await response.json();
|
|
|
|
for (const flow of flows) {
|
|
try {
|
|
const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/flows/delete/${flow.path}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
await deleteResponse.text(); // Consume response
|
|
} catch {
|
|
// Ignore individual deletion failures
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore listing failures
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete all apps in workspace
|
|
*/
|
|
private async deleteAllApps(): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/apps/list`, {
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
if (!response.ok) return;
|
|
|
|
const apps = await response.json();
|
|
|
|
for (const app of apps) {
|
|
try {
|
|
const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/apps/delete/${app.path}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
await deleteResponse.text(); // Consume response
|
|
} catch {
|
|
// Ignore individual deletion failures
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore listing failures
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete all resources in workspace (except git repo)
|
|
*/
|
|
private async deleteAllResources(): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/resources/list`, {
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
if (!response.ok) return;
|
|
|
|
const resources = await response.json();
|
|
|
|
for (const resource of resources) {
|
|
// Keep git repo resource
|
|
if (resource.path === 'u/test/test_repo') continue;
|
|
|
|
try {
|
|
const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/resources/delete/${resource.path}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
await deleteResponse.text(); // Consume response
|
|
} catch {
|
|
// Ignore individual deletion failures
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore listing failures
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete all variables in workspace (except git repo)
|
|
*/
|
|
private async deleteAllVariables(): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/variables/list`, {
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
if (!response.ok) return;
|
|
|
|
const variables = await response.json();
|
|
|
|
for (const variable of variables) {
|
|
// Keep git repo resource
|
|
if (variable.path === 'u/test/test_repo') continue;
|
|
|
|
try {
|
|
const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/variables/delete/${variable.path}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
await deleteResponse.text(); // Consume response
|
|
} catch {
|
|
// Ignore individual deletion failures
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore listing failures
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete all folders in workspace
|
|
*/
|
|
private async deleteAllFolders(): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/folders/list`, {
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
if (!response.ok) return;
|
|
|
|
const folders = await response.json();
|
|
|
|
for (const folder of folders) {
|
|
try {
|
|
const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/folders/delete/${folder.name}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
await deleteResponse.text(); // Consume response
|
|
} catch {
|
|
// Ignore individual deletion failures
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore listing failures
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup test environment for CLI testing
|
|
*/
|
|
async seedTestData(): Promise<void> {
|
|
console.log('🌱 Setting up test environment...');
|
|
|
|
// Try to set up git sync settings if EE is available
|
|
try {
|
|
const gitSyncConfig = {
|
|
git_sync_settings: {
|
|
repositories: [{
|
|
settings: {
|
|
exclude_path: ["*.test.ts", "*.spec.ts"],
|
|
include_path: ["f/**"],
|
|
include_type: ["script", "flow", "app", "folder", "resource"],
|
|
extra_include_path: []
|
|
},
|
|
script_path: "f/**",
|
|
group_by_folder: false,
|
|
use_individual_branch: false,
|
|
git_repo_resource_path: "u/test/test_repo"
|
|
}]
|
|
}
|
|
};
|
|
|
|
await this.updateGitSyncConfig(gitSyncConfig);
|
|
console.log('✅ Git sync configuration set (EE features available)');
|
|
} catch (error) {
|
|
console.log('⚠️ Git sync not available (CE edition) - CLI tests will run without git sync');
|
|
}
|
|
|
|
// Create minimal test data (skip scripts/flows due to path validation issues)
|
|
console.log('📝 Creating test apps and resources...');
|
|
await this.createTestApp();
|
|
await this.createTestResources();
|
|
await this.createTestVariables();
|
|
await this.createTestGroups();
|
|
console.log('✅ Test data created');
|
|
}
|
|
|
|
/**
|
|
* Create a realistic test script
|
|
*/
|
|
private async createTestScript(): Promise<void> {
|
|
const scriptContent = `// Test script for CLI sync operations
|
|
import { Resource } from "https://deno.land/x/windmill@v1.85.0/mod.ts";
|
|
|
|
type PostgresqlResource = {
|
|
host: string;
|
|
port: number;
|
|
user: string;
|
|
dbname: string;
|
|
sslmode: string;
|
|
password: string;
|
|
root_certificate_pem: string;
|
|
};
|
|
|
|
export async function main(
|
|
database: Resource<"postgresql">,
|
|
query: string = "SELECT version();",
|
|
limit: number = 100
|
|
) {
|
|
// Simple database query example
|
|
const result = await database.query(query);
|
|
return {
|
|
query,
|
|
rows: result.rows.slice(0, limit),
|
|
rowCount: result.rows.length,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
}`;
|
|
|
|
const script = {
|
|
path: 'f/test_database_query',
|
|
summary: 'Test Database Query Script',
|
|
description: 'A realistic test script that demonstrates database operations for CLI sync testing',
|
|
content: scriptContent,
|
|
language: 'deno',
|
|
schema: {
|
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
type: 'object',
|
|
properties: {
|
|
database: {
|
|
type: 'object',
|
|
format: 'resource-postgresql',
|
|
description: 'PostgreSQL database connection'
|
|
},
|
|
query: {
|
|
type: 'string',
|
|
description: 'SQL query to execute',
|
|
default: 'SELECT version();'
|
|
},
|
|
limit: {
|
|
type: 'number',
|
|
description: 'Maximum number of rows to return',
|
|
default: 100,
|
|
minimum: 1,
|
|
maximum: 1000
|
|
}
|
|
},
|
|
required: ['database']
|
|
}
|
|
};
|
|
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/create`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.config.token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(script)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.warn(`Failed to create test script: ${response.status} - ${errorText}`);
|
|
} else {
|
|
await response.text(); // Consume response to avoid leak
|
|
console.log(' ✅ Created test script: f/test_database_query');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a realistic test flow
|
|
*/
|
|
private async createTestFlow(): Promise<void> {
|
|
const flow = {
|
|
path: 'f/test_data_processing',
|
|
summary: 'Test Data Processing Flow',
|
|
description: 'A multi-step flow that demonstrates data processing workflow for CLI sync testing',
|
|
value: {
|
|
modules: [
|
|
{
|
|
id: 'a',
|
|
value: {
|
|
type: 'rawscript',
|
|
content: `export async function main(input_data: string[] = ["test1", "test2", "test3"]) {
|
|
// Step 1: Validate and clean input data
|
|
const cleaned = input_data
|
|
.filter(item => item && item.trim().length > 0)
|
|
.map(item => item.trim().toLowerCase());
|
|
|
|
console.log(\`Processed \${cleaned.length} items\`);
|
|
return { cleaned_data: cleaned, count: cleaned.length };
|
|
}`,
|
|
language: 'deno'
|
|
}
|
|
},
|
|
{
|
|
id: 'b',
|
|
value: {
|
|
type: 'rawscript',
|
|
content: `export async function main(processed_data: { cleaned_data: string[], count: number }) {
|
|
// Step 2: Transform and enrich data
|
|
const enriched = processed_data.cleaned_data.map((item, index) => ({
|
|
id: index + 1,
|
|
value: item,
|
|
processed_at: new Date().toISOString(),
|
|
length: item.length
|
|
}));
|
|
|
|
return {
|
|
enriched_data: enriched,
|
|
total_items: processed_data.count,
|
|
avg_length: enriched.reduce((sum, item) => sum + item.length, 0) / enriched.length
|
|
};
|
|
}`,
|
|
language: 'deno'
|
|
}
|
|
},
|
|
{
|
|
id: 'c',
|
|
value: {
|
|
type: 'rawscript',
|
|
content: `export async function main(final_data: any) {
|
|
// Step 3: Generate summary report
|
|
const report = {
|
|
summary: "Data processing completed successfully",
|
|
processed_at: new Date().toISOString(),
|
|
total_records: final_data.total_items,
|
|
average_length: Math.round(final_data.avg_length * 100) / 100,
|
|
data_sample: final_data.enriched_data.slice(0, 3)
|
|
};
|
|
|
|
console.log("Processing report:", JSON.stringify(report, null, 2));
|
|
return report;
|
|
}`,
|
|
language: 'deno'
|
|
}
|
|
}
|
|
]
|
|
},
|
|
schema: {
|
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
type: 'object',
|
|
properties: {
|
|
input_data: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
description: 'Array of strings to process',
|
|
default: ['test1', 'test2', 'test3']
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/flows/create`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.config.token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(flow)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.warn(`Failed to create test flow: ${response.status} - ${errorText}`);
|
|
} else {
|
|
await response.text(); // Consume response to avoid leak
|
|
console.log(' ✅ Created test flow: f/test_data_processing');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a realistic test app
|
|
*/
|
|
private async createTestApp(): Promise<void> {
|
|
const app = {
|
|
path: 'f/test_dashboard',
|
|
summary: 'Test Dashboard App',
|
|
policy: {
|
|
execution_mode: 'viewer'
|
|
},
|
|
value: {
|
|
grid: [
|
|
{
|
|
"3": {
|
|
id: 'title',
|
|
data: {
|
|
type: 'displaycomponent',
|
|
configuration: {},
|
|
componentInput: {
|
|
type: 'static',
|
|
value: 'Test Dashboard'
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"12": {
|
|
id: 'table',
|
|
data: {
|
|
type: 'tablecomponent',
|
|
configuration: {},
|
|
componentInput: {
|
|
type: 'runnable',
|
|
runnable: {
|
|
type: 'runnableByName',
|
|
name: 'f/test_database_query'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
],
|
|
fullscreen: false,
|
|
unusedInlineScripts: []
|
|
}
|
|
};
|
|
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/apps/create`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.config.token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(app)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.warn(`Failed to create test app: ${response.status} - ${errorText}`);
|
|
} else {
|
|
await response.text(); // Consume response to avoid leak
|
|
console.log(' ✅ Created test app: f/test_dashboard');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create test resources
|
|
*/
|
|
private async createTestResources(): Promise<void> {
|
|
// Create PostgreSQL test database
|
|
const testDb = {
|
|
path: 'u/admin/test_database',
|
|
description: 'Test PostgreSQL database for CLI sync testing',
|
|
resource_type: 'postgresql',
|
|
value: {
|
|
host: 'localhost',
|
|
port: 5432,
|
|
user: 'testuser',
|
|
dbname: 'testdb',
|
|
sslmode: 'disable',
|
|
password: 'testpassword',
|
|
root_certificate_pem: ''
|
|
}
|
|
};
|
|
|
|
const dbResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/resources/create`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.config.token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(testDb)
|
|
});
|
|
|
|
if (!dbResponse.ok) {
|
|
const errorText = await dbResponse.text();
|
|
console.warn(`Failed to create test resource: ${dbResponse.status} - ${errorText}`);
|
|
} else {
|
|
await dbResponse.text(); // Consume response to avoid leak
|
|
console.log(' ✅ Created test resource: u/admin/test_database');
|
|
}
|
|
|
|
// Create only the primary git repository resource (u/test/test_repo)
|
|
// Multi-repo tests will create additional repositories as needed
|
|
const primaryGitRepo = {
|
|
path: 'u/test/test_repo',
|
|
description: 'Primary test git repository for backend code',
|
|
resource_type: 'git',
|
|
value: {
|
|
url: 'https://github.com/windmill-labs/windmill-test-repo.git',
|
|
branch: 'main',
|
|
token: ''
|
|
}
|
|
};
|
|
|
|
const gitResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/resources/create`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.config.token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(primaryGitRepo)
|
|
});
|
|
|
|
if (!gitResponse.ok) {
|
|
const errorText = await gitResponse.text();
|
|
console.warn(`Failed to create git resource ${primaryGitRepo.path}: ${gitResponse.status} - ${errorText}`);
|
|
} else {
|
|
await gitResponse.text(); // Consume response to avoid leak
|
|
console.log(` ✅ Created git resource: ${primaryGitRepo.path}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create additional git repository resource for multi-repo tests
|
|
*/
|
|
async createAdditionalGitRepo(repoPath: string, description: string): Promise<void> {
|
|
const gitRepo = {
|
|
path: repoPath,
|
|
description: description,
|
|
resource_type: 'git',
|
|
value: {
|
|
url: 'https://github.com/windmill-labs/windmill-frontend-test.git',
|
|
branch: 'main',
|
|
token: ''
|
|
}
|
|
};
|
|
|
|
const gitResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/resources/create`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.config.token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(gitRepo)
|
|
});
|
|
|
|
if (!gitResponse.ok) {
|
|
const errorText = await gitResponse.text();
|
|
console.warn(`Failed to create git resource ${gitRepo.path}: ${gitResponse.status} - ${errorText}`);
|
|
} else {
|
|
await gitResponse.text(); // Consume response to avoid leak
|
|
console.log(` ✅ Created additional git resource: ${gitRepo.path}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create test variables for sync testing
|
|
*/
|
|
private async createTestVariables(): Promise<void> {
|
|
// Create a regular variable
|
|
const testVariable = {
|
|
path: 'u/admin/test_config',
|
|
value: 'production',
|
|
description: 'Test configuration variable',
|
|
is_secret: false
|
|
};
|
|
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/variables/create`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.config.token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(testVariable)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.warn(`Failed to create test variable: ${response.status} - ${errorText}`);
|
|
} else {
|
|
await response.text(); // Consume response to avoid leak
|
|
console.log(' ✅ Created test variable: u/admin/test_config');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create test groups for sync testing
|
|
*/
|
|
private async createTestGroups(): Promise<void> {
|
|
// Create a test group for sync testing
|
|
const testGroup = {
|
|
name: 'test_group',
|
|
summary: 'Test group for CLI sync operations'
|
|
};
|
|
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/groups/create`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.config.token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(testGroup)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.warn(`Failed to create test group: ${response.status} - ${errorText}`);
|
|
} else {
|
|
await response.text(); // Consume response to avoid leak
|
|
console.log(' ✅ Created test group: test_group');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current workspace settings from backend
|
|
*/
|
|
async getWorkspaceSettings(): Promise<any> {
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/workspaces/get_settings`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${this.config.token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get workspace settings: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* List all scripts in workspace with full content
|
|
*/
|
|
async listAllScripts(): Promise<Array<{path: string, content: string, summary: string, description?: string}>> {
|
|
// First get the list of scripts
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/list`, {
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to list scripts: ${response.status}`);
|
|
}
|
|
|
|
const scriptList = await response.json();
|
|
|
|
// Then fetch full details (including content) for each script
|
|
const scriptsWithContent = [];
|
|
for (const script of scriptList) {
|
|
try {
|
|
const detailResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/get/p/${script.path}`, {
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
if (detailResponse.ok) {
|
|
const fullScript = await detailResponse.json();
|
|
scriptsWithContent.push(fullScript);
|
|
} else {
|
|
// If we can't get details, include the basic info without content
|
|
console.warn(`Could not fetch details for script ${script.path}: ${detailResponse.status}`);
|
|
scriptsWithContent.push(script);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Error fetching script ${script.path}:`, error);
|
|
scriptsWithContent.push(script);
|
|
}
|
|
}
|
|
|
|
return scriptsWithContent;
|
|
}
|
|
|
|
/**
|
|
* List all apps in workspace
|
|
*/
|
|
async listAllApps(): Promise<Array<{path: string, summary: string, value: any}>> {
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/apps/list`, {
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to list apps: ${response.status}`);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* List all resources in workspace
|
|
*/
|
|
async listAllResources(): Promise<Array<{path: string, description?: string, resource_type: string, value: any}>> {
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/resources/list`, {
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to list resources: ${response.status}`);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* List all variables in workspace
|
|
*/
|
|
async listAllVariables(): Promise<Array<{path: string, description?: string, value: any, is_secret: boolean}>> {
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/variables/list`, {
|
|
headers: { 'Authorization': `Bearer ${this.config.token}` }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to list variables: ${response.status}`);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Update git-sync configuration via API
|
|
*/
|
|
async updateGitSyncConfig(config: any): Promise<void> {
|
|
const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/workspaces/edit_git_sync_config`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.config.token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(config)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to update git-sync config: ${response.status} ${response.statusText} - ${errorText}`);
|
|
}
|
|
|
|
// Consume the response body to avoid resource leak
|
|
await response.text();
|
|
}
|
|
|
|
/**
|
|
* Create CLI command with proper authentication
|
|
*/
|
|
createCLICommand(args: string[], workingDir: string, workspaceName?: string): Deno.Command {
|
|
const workspace = workspaceName || this.config.workspace;
|
|
const fullArgs = [
|
|
'--base-url', this.config.baseUrl,
|
|
'--workspace', workspace,
|
|
'--token', this.config.token,
|
|
'--config-dir', this.config.testConfigDir,
|
|
...args
|
|
];
|
|
|
|
const denoPath = Deno.execPath();
|
|
const cliMainPath = new URL('../src/main.ts', import.meta.url).pathname;
|
|
|
|
console.log('🔧 CLI Command:', [denoPath, 'run', '-A', cliMainPath, ...fullArgs].join(' '));
|
|
|
|
return new Deno.Command(denoPath, {
|
|
args: ['run', '-A', cliMainPath, ...fullArgs],
|
|
cwd: workingDir,
|
|
stdout: 'piped',
|
|
stderr: 'piped',
|
|
env: {
|
|
'SKIP_DENO_DEPRECATION_WARNING': 'true'
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Run CLI command and return result
|
|
*/
|
|
async runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
|
|
stdout: string;
|
|
stderr: string;
|
|
code: number;
|
|
}> {
|
|
const cmd = this.createCLICommand(args, workingDir, workspaceName);
|
|
const result = await cmd.output();
|
|
|
|
return {
|
|
stdout: new TextDecoder().decode(result.stdout),
|
|
stderr: new TextDecoder().decode(result.stderr),
|
|
code: result.code
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Authenticate with the API and get a proper token
|
|
*/
|
|
private async authenticate(): Promise<void> {
|
|
console.log('🔑 Authenticating with backend...');
|
|
|
|
// First, initialize the database with our workspace and user
|
|
await this.initializeTestData();
|
|
|
|
// Step 1: Login to get session token
|
|
const loginResponse = await fetch(`${this.config.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} ${loginResponse.statusText}`);
|
|
}
|
|
|
|
// The session token is returned as plain text
|
|
const sessionToken = await loginResponse.text();
|
|
|
|
// Use the session token directly (API token creation has issues)
|
|
this.config.token = sessionToken;
|
|
|
|
// Step 2: Verify the session token works
|
|
const testResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/workspaces/get_settings`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${this.config.token}`
|
|
}
|
|
});
|
|
|
|
if (!testResponse.ok) {
|
|
const errorText = await testResponse.text();
|
|
throw new Error(`Token verification failed: ${testResponse.status} ${testResponse.statusText} - ${errorText}`);
|
|
}
|
|
|
|
// Consume response body to avoid leak
|
|
await testResponse.text();
|
|
|
|
console.log('✅ Authentication successful');
|
|
}
|
|
|
|
/**
|
|
* Initialize test workspace and user data via REST API
|
|
*/
|
|
private async initializeTestData(): Promise<void> {
|
|
console.log('🔧 Initializing test workspace...');
|
|
|
|
const initSQL = `
|
|
-- Create the test workspace if it doesn't exist (using INSERT ... WHERE NOT EXISTS)
|
|
INSERT INTO workspace (id, name, owner, deleted, premium)
|
|
SELECT '${this.config.workspace}', 'Test Workspace', 'admin@windmill.dev', false, false
|
|
WHERE NOT EXISTS (SELECT 1 FROM workspace WHERE id = '${this.config.workspace}');
|
|
|
|
-- Create admin user for the test workspace
|
|
INSERT INTO usr (workspace_id, email, username, is_admin, created_at)
|
|
SELECT '${this.config.workspace}', 'admin@windmill.dev', 'admin', true, NOW()
|
|
WHERE NOT EXISTS (SELECT 1 FROM usr WHERE workspace_id = '${this.config.workspace}' AND email = 'admin@windmill.dev');
|
|
|
|
-- Create a test token for CLI authentication
|
|
DELETE FROM token WHERE workspace_id = '${this.config.workspace}' AND owner = 'admin@windmill.dev' AND label = 'CLI Test Token';
|
|
INSERT INTO token (workspace_id, owner, label, token, created_at, expiration)
|
|
VALUES ('${this.config.workspace}', 'admin@windmill.dev', 'CLI Test Token', '${this.config.token}', NOW(), NOW() + INTERVAL '1 year');
|
|
|
|
-- Set up workspace settings with git-sync configuration for testing
|
|
INSERT INTO workspace_settings (workspace_id, git_sync)
|
|
VALUES ('${this.config.workspace}', '{
|
|
"include_path": ["f/**", "g/**"],
|
|
"include_type": ["script", "flow", "app", "folder", "resource"],
|
|
"repositories": [{
|
|
"exclude_types_override": [],
|
|
"script_path": "f/**",
|
|
"git_repo_resource_path": "u/test/test_repo",
|
|
"use_individual_branch": false,
|
|
"group_by_folder": false,
|
|
"settings": {
|
|
"include_path": ["f/**"],
|
|
"exclude_path": ["*.test.ts", "*.spec.ts"],
|
|
"extra_include_path": [],
|
|
"include_type": ["script", "flow", "app", "folder", "resource"]
|
|
}
|
|
}],
|
|
"exclude_path": ["*.test.ts", "*.spec.ts"],
|
|
"extra_include_path": []
|
|
}'::jsonb)
|
|
ON CONFLICT (workspace_id) DO UPDATE SET git_sync = EXCLUDED.git_sync;
|
|
|
|
-- Create test folders for proper structure
|
|
INSERT INTO folder (workspace_id, name, display_name, owners)
|
|
SELECT '${this.config.workspace}', 'f', 'Scripts and Flows', ARRAY['admin@windmill.dev']
|
|
WHERE NOT EXISTS (SELECT 1 FROM folder WHERE workspace_id = '${this.config.workspace}' AND name = 'f');
|
|
|
|
INSERT INTO folder (workspace_id, name, display_name, owners)
|
|
SELECT '${this.config.workspace}', 'g', 'General Scripts', ARRAY['admin@windmill.dev']
|
|
WHERE NOT EXISTS (SELECT 1 FROM folder WHERE workspace_id = '${this.config.workspace}' AND name = 'g');
|
|
|
|
INSERT INTO folder (workspace_id, name, display_name, owners)
|
|
SELECT '${this.config.workspace}', 'u', 'User Scripts', ARRAY['admin@windmill.dev']
|
|
WHERE NOT EXISTS (SELECT 1 FROM folder WHERE workspace_id = '${this.config.workspace}' AND name = 'u');
|
|
|
|
-- Create the 'all' group if it doesn't exist
|
|
INSERT INTO group_ (workspace_id, name, summary)
|
|
SELECT '${this.config.workspace}', 'all', 'All users group'
|
|
WHERE NOT EXISTS (SELECT 1 FROM group_ WHERE workspace_id = '${this.config.workspace}' AND name = 'all');
|
|
|
|
-- Grant all permissions to admin user
|
|
INSERT INTO usr_to_group (workspace_id, usr, group_)
|
|
SELECT '${this.config.workspace}', 'admin@windmill.dev', 'all'
|
|
WHERE NOT EXISTS (SELECT 1 FROM usr_to_group WHERE workspace_id = '${this.config.workspace}' AND usr = 'admin@windmill.dev' AND group_ = 'all');
|
|
|
|
-- Add test resource for git repository (mocked)
|
|
INSERT INTO resource (workspace_id, path, description, resource_type, value, extra_perms)
|
|
VALUES ('${this.config.workspace}', 'u/test/test_repo', 'Test Git Repository', 'git_repository', '{
|
|
"git_url": "https://github.com/test/test-repo.git",
|
|
"branch": "main",
|
|
"git_username": "test-user",
|
|
"git_token": "test-token-123"
|
|
}'::jsonb, '{}'::jsonb)
|
|
ON CONFLICT (workspace_id, path) DO UPDATE SET value = EXCLUDED.value;
|
|
|
|
-- EE license key is now handled via environment variable only
|
|
|
|
-- Create workspace encryption key for sync operations
|
|
INSERT INTO workspace_key (workspace_id, kind, key)
|
|
VALUES ('${this.config.workspace}', 'cloud', 'test_encryption_key_for_sync_operations_123456789abcdef')
|
|
ON CONFLICT (workspace_id, kind) DO UPDATE SET key = EXCLUDED.key;
|
|
`;
|
|
|
|
const execCmd = new Deno.Command('docker', {
|
|
args: ['compose', '-f', this.config.composeFile, 'exec', '-T', 'test_db',
|
|
'psql', '-U', 'postgres', '-d', 'windmill_test', '-c', initSQL],
|
|
stdout: 'piped',
|
|
stderr: 'piped',
|
|
env: {
|
|
...Deno.env.toObject(),
|
|
...(Deno.env.get('EE_LICENSE_KEY') && { EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY')! })
|
|
}
|
|
});
|
|
|
|
const result = await execCmd.output();
|
|
if (result.code !== 0) {
|
|
const stderr = new TextDecoder().decode(result.stderr);
|
|
throw new Error(`Failed to initialize test data: ${stderr}`);
|
|
}
|
|
|
|
console.log('✅ Test workspace initialized');
|
|
|
|
// Verify license key was stored
|
|
const checkLicenseCmd = new Deno.Command('docker', {
|
|
args: ['compose', '-f', this.config.composeFile, 'exec', '-T', 'test_db',
|
|
'psql', '-U', 'postgres', '-d', 'windmill_test', '-c',
|
|
"SELECT name, value FROM global_settings WHERE name = 'license_key';"],
|
|
stdout: 'piped',
|
|
stderr: 'piped',
|
|
env: {
|
|
...Deno.env.toObject(),
|
|
EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY') || 'REMOVED_HARDCODED_LICENSE'
|
|
}
|
|
});
|
|
|
|
const checkResult = await checkLicenseCmd.output();
|
|
if (checkResult.code === 0) {
|
|
const output = new TextDecoder().decode(checkResult.stdout);
|
|
console.log('🔍 License key in database:', output.trim());
|
|
}
|
|
}
|
|
|
|
// Private helper methods
|
|
|
|
private async waitForHealthy(): Promise<void> {
|
|
console.log('⏳ Waiting for containers to be healthy...');
|
|
|
|
const maxAttempts = 30;
|
|
let attempts = 0;
|
|
|
|
while (attempts < maxAttempts) {
|
|
const healthCmd = new Deno.Command('docker', {
|
|
args: ['compose', '-f', this.config.composeFile, 'ps', '--format', 'json'],
|
|
stdout: 'piped',
|
|
stderr: 'piped',
|
|
env: {
|
|
...Deno.env.toObject(),
|
|
...(Deno.env.get('EE_LICENSE_KEY') && { EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY')! })
|
|
}
|
|
});
|
|
|
|
const result = await healthCmd.output();
|
|
if (result.code === 0) {
|
|
const output = new TextDecoder().decode(result.stdout);
|
|
if (output.trim()) {
|
|
const containers = output.trim().split('\n').map(line => JSON.parse(line));
|
|
|
|
const allHealthy = containers.every(container =>
|
|
container.State === 'running' &&
|
|
(container.Health === 'healthy' || container.Health === '' || container.Health === undefined)
|
|
);
|
|
|
|
if (allHealthy) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
attempts++;
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
}
|
|
|
|
throw new Error('Containers failed to become healthy within timeout');
|
|
}
|
|
|
|
private async waitForAPI(): Promise<void> {
|
|
console.log('⏳ Waiting for API to be responsive...');
|
|
|
|
const maxAttempts = 30;
|
|
let attempts = 0;
|
|
|
|
while (attempts < maxAttempts) {
|
|
try {
|
|
const response = await fetch(`${this.config.baseUrl}/api/version`, {
|
|
signal: AbortSignal.timeout(5000)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const version = await response.text();
|
|
console.log(`📡 API ready (version: ${version})`);
|
|
return;
|
|
} else {
|
|
// Consume response body even on non-OK responses to avoid leak
|
|
await response.text();
|
|
}
|
|
} catch (error) {
|
|
// Continue trying
|
|
}
|
|
|
|
attempts++;
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
}
|
|
|
|
throw new Error('API failed to respond within timeout');
|
|
}
|
|
|
|
/**
|
|
* Quick health check for the backend
|
|
*/
|
|
async checkHealth(): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${this.config.baseUrl}/api/version`);
|
|
if (!response.ok) {
|
|
throw new Error(`Health check failed: ${response.status}`);
|
|
}
|
|
await response.text(); // Consume response to avoid leak
|
|
} catch (error) {
|
|
throw new Error(`Backend health check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// Global backend instance for persistent containers
|
|
let globalBackend: ContainerizedBackend | null = null;
|
|
|
|
// Convenience function for tests with persistent backend
|
|
export async function withContainerizedBackend<T>(
|
|
testFn: (backend: ContainerizedBackend, tempDir: string) => Promise<T>
|
|
): Promise<T> {
|
|
// Reuse global backend if it exists and is healthy
|
|
if (!globalBackend) {
|
|
globalBackend = new ContainerizedBackend();
|
|
await globalBackend.start();
|
|
} else {
|
|
// Quick health check
|
|
try {
|
|
await globalBackend.checkHealth();
|
|
} catch {
|
|
// Backend is unhealthy, restart it
|
|
await globalBackend.stop();
|
|
globalBackend = new ContainerizedBackend();
|
|
await globalBackend.start();
|
|
}
|
|
}
|
|
|
|
const tempDir = await Deno.makeTempDir({ prefix: 'windmill_cli_test_' });
|
|
|
|
try {
|
|
await globalBackend.reset();
|
|
await globalBackend.seedTestData();
|
|
|
|
return await testFn(globalBackend, tempDir);
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
// Cleanup function for test suites
|
|
export async function cleanupGlobalBackend(): Promise<void> {
|
|
if (globalBackend) {
|
|
await globalBackend.stop();
|
|
globalBackend = null;
|
|
}
|
|
} |