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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* update skills

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

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

* cleaning

* cleaning

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

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

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

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

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

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

* own workspace

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

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

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

---------

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

1360 lines
44 KiB
TypeScript

/**
* Containerized Backend Test Utilities
* Manages real Windmill EE backend containers for CLI testing
*/
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
async function runCommand(cmd: string, args: string[], opts?: { cwd?: string, env?: Record<string, string> }): Promise<{ code: number, stdout: string, stderr: string }> {
const proc = Bun.spawn([cmd, ...args], {
stdout: 'pipe',
stderr: 'pipe',
cwd: opts?.cwd,
env: { ...process.env, ...opts?.env },
});
const [code, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
return { code, stdout, stderr };
}
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 mkdtemp(join(tmpdir(), 'wmill_test_config_'));
console.log(`📁 Created test config directory: ${this.config.testConfigDir}`);
}
// Start containers with EE license key
const startResult = await runCommand('docker', ['compose', '-f', this.config.composeFile, 'up', '-d'], {
env: {
...(process.env.EE_LICENSE_KEY && { EE_LICENSE_KEY: process.env.EE_LICENSE_KEY })
}
});
if (startResult.code !== 0) {
throw new Error(`Failed to start containers: ${startResult.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...');
await runCommand('docker', ['compose', '-f', this.config.composeFile, 'down', '-v'], {
env: {
...(process.env.EE_LICENSE_KEY && { EE_LICENSE_KEY: process.env.EE_LICENSE_KEY })
}
});
// Clean up test config directory if we created it
if (this.config.testConfigDir && this.config.testConfigDir.includes('wmill_test_config_')) {
try {
await rm(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, opts?: { workspace?: string; token?: string }): { cmd: string[], cwd: string } {
const workspace = opts?.workspace || this.config.workspace;
const fullArgs = [
'--base-url', this.config.baseUrl,
'--workspace', workspace,
'--token', opts?.token || this.config.token,
'--config-dir', this.config.testConfigDir,
...args
];
const useNode = process.env["TEST_CLI_RUNTIME"] === "node";
const cliDir = new URL('..', import.meta.url).pathname;
const entrypoint = useNode
? new URL('../npm/esm/main.js', import.meta.url).pathname
: new URL('../src/main.ts', import.meta.url).pathname;
const runtime = useNode ? 'node' : 'bun';
const runtimeArgs = useNode ? [entrypoint] : ['run', entrypoint];
console.log('CLI Command:', [runtime, ...runtimeArgs, ...fullArgs].join(' '));
return {
cmd: [runtime, ...runtimeArgs, ...fullArgs],
cwd: workingDir,
};
}
/**
* 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, cwd } = this.createCLICommand(args, workingDir, opts);
const proc = Bun.spawn(cmd, {
stdout: 'pipe',
stderr: 'pipe',
cwd,
});
const [code, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
return { stdout, stderr, 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 result = await runCommand('docker', ['compose', '-f', this.config.composeFile, 'exec', '-T', 'test_db',
'psql', '-U', 'postgres', '-d', 'windmill_test', '-c', initSQL], {
env: {
...(process.env.EE_LICENSE_KEY && { EE_LICENSE_KEY: process.env.EE_LICENSE_KEY })
}
});
if (result.code !== 0) {
throw new Error(`Failed to initialize test data: ${result.stderr}`);
}
console.log('✅ Test workspace initialized');
// Verify license key was stored
const checkResult = await runCommand('docker', ['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';"], {
env: {
EE_LICENSE_KEY: process.env.EE_LICENSE_KEY || 'REMOVED_HARDCODED_LICENSE'
}
});
if (checkResult.code === 0) {
console.log('License key in database:', checkResult.stdout.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 result = await runCommand('docker', ['compose', '-f', this.config.composeFile, 'ps', '--format', 'json'], {
env: {
...(process.env.EE_LICENSE_KEY && { EE_LICENSE_KEY: process.env.EE_LICENSE_KEY })
}
});
if (result.code === 0) {
const output = 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 mkdtemp(join(tmpdir(), 'windmill_cli_test_'));
try {
await globalBackend.reset();
await globalBackend.seedTestData();
return await testFn(globalBackend, tempDir);
} finally {
await rm(tempDir, { recursive: true });
}
}
// Cleanup function for test suites
export async function cleanupGlobalBackend(): Promise<void> {
if (globalBackend) {
await globalBackend.stop();
globalBackend = null;
}
}