Files
windmill/cli/test/containerized_backend.ts
T
Ruben Fiszel dcee9fe7b1 feat: cli branch override (#7592)
* 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>
2026-01-16 20:55:01 +00:00

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;
}
}