Add proxmox_create_vm tool, comprehensive test suite, and documentation improvements
New Features: - Add proxmox_create_vm tool for QEMU VM creation - Add test-basic-tools.js for validating basic operations (7/7 tests) - Add test-workflows.js for lifecycle workflow testing (19/22 tests) - Add TEST-WORKFLOWS.md with comprehensive test documentation Bug Fixes: - Fix regex patterns in test scripts for MCP formatted output - Increase snapshot wait times and add retry logic - Handle markdown formatting in tool responses Documentation: - Update tool count from 54 to 55 - Add Claude Desktop integration with OS-specific paths - Document proxmox_create_vm with full parameters and examples - Add testing section with usage examples Other: - Update .gitignore for Node.js (node_modules, package-lock.json)
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -57,5 +57,14 @@ coverage.xml
|
|||||||
# UV
|
# UV
|
||||||
.uv/
|
.uv/
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.npm
|
||||||
|
|
||||||
# Local configuration
|
# Local configuration
|
||||||
config/config.json
|
config/config.json
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
283
TEST-WORKFLOWS.md
Normal file
283
TEST-WORKFLOWS.md
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
# Proxmox MCP Workflow Test Scripts
|
||||||
|
|
||||||
|
This directory contains comprehensive test scripts for validating Proxmox MCP server functionality.
|
||||||
|
|
||||||
|
## Available Test Scripts
|
||||||
|
|
||||||
|
### 1. test-basic-tools.js
|
||||||
|
Tests all basic (non-elevated) read-only tools:
|
||||||
|
- `proxmox_get_nodes` - List cluster nodes
|
||||||
|
- `proxmox_get_cluster_status` - Cluster status
|
||||||
|
- `proxmox_get_vms` - List VMs/containers
|
||||||
|
- `proxmox_get_vm_status` - VM details
|
||||||
|
- `proxmox_get_storage` - Storage information
|
||||||
|
- `proxmox_get_next_vmid` - Get next available VMID
|
||||||
|
- `proxmox_list_templates` - List LXC templates
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
node test-basic-tools.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Valid `.env` configuration
|
||||||
|
- Working Proxmox connection
|
||||||
|
- Does NOT require `PROXMOX_ALLOW_ELEVATED=true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. test-workflows.js
|
||||||
|
Comprehensive workflow tests for complete lifecycle operations.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
# Run all workflows
|
||||||
|
node test-workflows.js
|
||||||
|
|
||||||
|
# Run specific workflow
|
||||||
|
node test-workflows.js --workflow=lxc
|
||||||
|
node test-workflows.js --workflow=vm
|
||||||
|
node test-workflows.js --workflow=network
|
||||||
|
node test-workflows.js --workflow=disk
|
||||||
|
node test-workflows.js --workflow=snapshot
|
||||||
|
node test-workflows.js --workflow=backup
|
||||||
|
|
||||||
|
# Dry-run mode (show what would be done)
|
||||||
|
node test-workflows.js --dry-run
|
||||||
|
|
||||||
|
# Interactive mode (confirm before destructive operations)
|
||||||
|
node test-workflows.js --interactive
|
||||||
|
|
||||||
|
# Skip cleanup (keep test resources)
|
||||||
|
node test-workflows.js --no-cleanup
|
||||||
|
|
||||||
|
# Combine options
|
||||||
|
node test-workflows.js --workflow=lxc --interactive --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Valid `.env` configuration
|
||||||
|
- `PROXMOX_ALLOW_ELEVATED=true` in `.env`
|
||||||
|
- API token with appropriate permissions:
|
||||||
|
- `VM.Allocate` - Create VMs/containers
|
||||||
|
- `VM.Config.Disk` - Disk management
|
||||||
|
- `VM.Config.Network` - Network management
|
||||||
|
- `VM.PowerMgmt` - Start/stop/reboot
|
||||||
|
- `VM.Snapshot` - Snapshot operations
|
||||||
|
- `VM.Backup` - Backup operations
|
||||||
|
- `Datastore.Allocate` - Storage allocation
|
||||||
|
|
||||||
|
## Workflows Tested
|
||||||
|
|
||||||
|
### LXC Container Complete Workflow
|
||||||
|
1. Get next available VMID
|
||||||
|
2. List available templates
|
||||||
|
3. Create new LXC container
|
||||||
|
4. Start container
|
||||||
|
5. Check status
|
||||||
|
6. Create snapshot
|
||||||
|
7. List snapshots
|
||||||
|
8. Stop container
|
||||||
|
9. Delete snapshot
|
||||||
|
10. Delete container (cleanup)
|
||||||
|
|
||||||
|
**What it validates:**
|
||||||
|
- Container creation with templates
|
||||||
|
- Lifecycle management (start/stop)
|
||||||
|
- Snapshot operations
|
||||||
|
- Status monitoring
|
||||||
|
- Resource cleanup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### VM Lifecycle Operations
|
||||||
|
1. Find existing VM
|
||||||
|
2. Detect VM type (QEMU/LXC)
|
||||||
|
3. Get detailed status
|
||||||
|
4. Start (if stopped) or reboot (if running)
|
||||||
|
5. Verify operations
|
||||||
|
|
||||||
|
**What it validates:**
|
||||||
|
- VM discovery
|
||||||
|
- Power management operations
|
||||||
|
- Status checking
|
||||||
|
- Type detection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Network Management Workflow
|
||||||
|
1. Find stopped VM
|
||||||
|
2. Add network interface (net1, bridge vmbr0)
|
||||||
|
3. Update interface (add rate limit)
|
||||||
|
4. Remove interface
|
||||||
|
|
||||||
|
**What it validates:**
|
||||||
|
- Network interface addition
|
||||||
|
- Configuration updates
|
||||||
|
- Interface removal
|
||||||
|
- Bridge configuration
|
||||||
|
|
||||||
|
**Note:** VM must be stopped for network changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Disk Management Workflow
|
||||||
|
1. Find QEMU VM
|
||||||
|
2. Add new disk (10G on local-lvm)
|
||||||
|
3. Resize disk (+2G)
|
||||||
|
4. Remove disk
|
||||||
|
|
||||||
|
**What it validates:**
|
||||||
|
- Disk allocation
|
||||||
|
- Disk resizing
|
||||||
|
- Disk removal
|
||||||
|
- Storage integration
|
||||||
|
|
||||||
|
**Note:** Primarily tests QEMU VMs for reliability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Snapshot Workflow
|
||||||
|
1. Find VM
|
||||||
|
2. Create snapshot
|
||||||
|
3. List snapshots
|
||||||
|
4. Delete snapshot
|
||||||
|
|
||||||
|
**What it validates:**
|
||||||
|
- Snapshot creation
|
||||||
|
- Snapshot listing
|
||||||
|
- Snapshot deletion
|
||||||
|
- Snapshot naming
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Backup Workflow
|
||||||
|
1. Find VM
|
||||||
|
2. Create backup (to local storage)
|
||||||
|
3. Wait for background job
|
||||||
|
4. List backups
|
||||||
|
|
||||||
|
**What it validates:**
|
||||||
|
- Backup job creation
|
||||||
|
- Background job handling
|
||||||
|
- Backup listing
|
||||||
|
- Storage integration
|
||||||
|
|
||||||
|
**Note:** Backup restoration is not tested automatically (destructive operation)
|
||||||
|
|
||||||
|
## Exit Codes
|
||||||
|
|
||||||
|
- `0` - All tests passed
|
||||||
|
- `1` - One or more tests failed or error occurred
|
||||||
|
|
||||||
|
## Test Output
|
||||||
|
|
||||||
|
The scripts provide detailed, color-coded output:
|
||||||
|
- 🟢 **Green** - Success
|
||||||
|
- 🔴 **Red** - Failure
|
||||||
|
- 🟡 **Yellow** - Warning
|
||||||
|
- 🔵 **Blue** - Information
|
||||||
|
- 🟣 **Magenta** - Headers
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
```
|
||||||
|
═══════════════════════════════════════════════════════════════════
|
||||||
|
LXC Container Complete Workflow
|
||||||
|
═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[12:34:56] ▶ Step 1: Get next available VMID
|
||||||
|
✓ Got VMID: 105
|
||||||
|
|
||||||
|
[12:34:57] ▶ Step 2: List available templates
|
||||||
|
Node: pve1
|
||||||
|
✓ Templates listed successfully
|
||||||
|
✓ Found template: local:vztmpl/debian-12-standard_12.2-1_amd64.tar.gz
|
||||||
|
|
||||||
|
[12:34:58] ▶ Step 3: Create LXC container
|
||||||
|
VMID: 105, Template: local:vztmpl/debian-12-standard_12.2-1_amd64.tar.gz
|
||||||
|
✓ Create Container: Container 105 created
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
By default, `test-workflows.js` automatically cleans up test resources (deletes created VMs/containers) at the end.
|
||||||
|
|
||||||
|
**To keep test resources:**
|
||||||
|
```bash
|
||||||
|
node test-workflows.js --no-cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual cleanup:**
|
||||||
|
If a test fails or is interrupted, you may need to manually delete test resources:
|
||||||
|
- Container hostname: `test-mcp-{vmid}`
|
||||||
|
- Snapshots: `test-snapshot` or `test-snap-{timestamp}`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Elevated permissions required"
|
||||||
|
**Solution:** Set `PROXMOX_ALLOW_ELEVATED=true` in your `.env` file
|
||||||
|
|
||||||
|
### "No templates found"
|
||||||
|
**Solution:** Download LXC templates via Proxmox UI: Storage → local → CT Templates → Download
|
||||||
|
|
||||||
|
### "No stopped VMs found" (network workflow)
|
||||||
|
**Solution:** Stop a VM first, or skip network workflow: `--workflow=lxc`
|
||||||
|
|
||||||
|
### "Failed to connect to Proxmox"
|
||||||
|
**Solution:**
|
||||||
|
- Check `.env` configuration
|
||||||
|
- Verify Proxmox API is accessible
|
||||||
|
- Check API token permissions
|
||||||
|
- Ensure firewall allows connection
|
||||||
|
|
||||||
|
### Tests timing out
|
||||||
|
**Solution:**
|
||||||
|
- Increase timeout values in script
|
||||||
|
- Check Proxmox performance
|
||||||
|
- Reduce concurrent operations
|
||||||
|
|
||||||
|
## Safety Features
|
||||||
|
|
||||||
|
1. **Dry-run mode** - Preview operations without executing
|
||||||
|
2. **Interactive mode** - Confirm before destructive operations
|
||||||
|
3. **Resource tracking** - Automatic cleanup of created resources
|
||||||
|
4. **Error handling** - Graceful failure with detailed messages
|
||||||
|
5. **Status validation** - Verify operations completed successfully
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Start with dry-run:** `node test-workflows.js --dry-run`
|
||||||
|
2. **Test basic tools first:** `node test-basic-tools.js`
|
||||||
|
3. **Run one workflow at a time initially:** `--workflow=lxc`
|
||||||
|
4. **Use interactive mode for production:** `--interactive`
|
||||||
|
5. **Review cleanup:** Don't use `--no-cleanup` unless debugging
|
||||||
|
6. **Check permissions:** Ensure API token has all required permissions
|
||||||
|
7. **Monitor Proxmox:** Watch for resource usage during tests
|
||||||
|
|
||||||
|
## Integration with CI/CD
|
||||||
|
|
||||||
|
These scripts can be integrated into CI/CD pipelines:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic connectivity test
|
||||||
|
node test-basic-tools.js || exit 1
|
||||||
|
|
||||||
|
# Full workflow validation (non-interactive)
|
||||||
|
node test-workflows.js || exit 1
|
||||||
|
|
||||||
|
# Or specific workflow
|
||||||
|
node test-workflows.js --workflow=snapshot || exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When adding new workflows:
|
||||||
|
1. Follow existing pattern (logStep, recordResult)
|
||||||
|
2. Add cleanup tracking for new resources
|
||||||
|
3. Include error handling
|
||||||
|
4. Add to workflow list in main()
|
||||||
|
5. Document in this README
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Same as parent project (MIT)
|
||||||
304
test-basic-tools.js
Executable file
304
test-basic-tools.js
Executable file
@@ -0,0 +1,304 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test script for basic (non-elevated) Proxmox MCP tools
|
||||||
|
* This tests all tools that should work without PROXMOX_ALLOW_ELEVATED=true
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// ANSI color codes
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
cyan: '\x1b[36m'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test results tracker
|
||||||
|
const results = {
|
||||||
|
passed: [],
|
||||||
|
failed: [],
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call a tool and return the result
|
||||||
|
async function callTool(toolName, args = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: Date.now(),
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: toolName,
|
||||||
|
arguments: args
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverProcess = spawn('node', [path.join(__dirname, 'index.js')], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
serverProcess.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.on('close', (code) => {
|
||||||
|
try {
|
||||||
|
// Find JSON response in stdout
|
||||||
|
const lines = stdout.split('\n');
|
||||||
|
let response = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim().startsWith('{')) {
|
||||||
|
try {
|
||||||
|
response = JSON.parse(line);
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
// Not JSON, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
resolve({ response, stderr, code });
|
||||||
|
} else {
|
||||||
|
reject(new Error(`No JSON response found. stdout: ${stdout}, stderr: ${stderr}`));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.on('error', reject);
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
serverProcess.stdin.write(JSON.stringify(request) + '\n');
|
||||||
|
serverProcess.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print test header
|
||||||
|
function printHeader(message) {
|
||||||
|
console.log(`\n${colors.cyan}${'='.repeat(60)}${colors.reset}`);
|
||||||
|
console.log(`${colors.cyan}${message}${colors.reset}`);
|
||||||
|
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print test result
|
||||||
|
function printResult(toolName, success, message, details = null) {
|
||||||
|
const icon = success ? '✓' : '✗';
|
||||||
|
const color = success ? colors.green : colors.red;
|
||||||
|
|
||||||
|
console.log(`${color}${icon} ${toolName}${colors.reset}`);
|
||||||
|
console.log(` ${message}`);
|
||||||
|
|
||||||
|
if (details) {
|
||||||
|
console.log(` ${colors.yellow}Details:${colors.reset} ${details}`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
results.passed.push({ tool: toolName, message });
|
||||||
|
} else {
|
||||||
|
results.failed.push({ tool: toolName, message, details });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test a tool
|
||||||
|
async function testTool(toolName, args = {}, validator = null) {
|
||||||
|
try {
|
||||||
|
console.log(`${colors.blue}Testing: ${toolName}${colors.reset}`);
|
||||||
|
|
||||||
|
const { response } = await callTool(toolName, args);
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
printResult(toolName, false, `Error: ${response.error.message}`, response.error.code);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.result || !response.result.content || response.result.content.length === 0) {
|
||||||
|
printResult(toolName, false, 'No content returned', JSON.stringify(response.result));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = response.result.content[0];
|
||||||
|
|
||||||
|
// Check if it's an error message about permissions
|
||||||
|
if (content.text && content.text.includes('Requires Elevated Permissions')) {
|
||||||
|
printResult(toolName, false, 'Incorrectly requires elevated permissions', content.text.substring(0, 100));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run custom validator if provided
|
||||||
|
if (validator) {
|
||||||
|
const validationResult = validator(content);
|
||||||
|
if (!validationResult.success) {
|
||||||
|
printResult(toolName, false, validationResult.message, validationResult.details);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printResult(toolName, true, 'Success', `Returned ${content.text ? content.text.length : 0} characters`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
printResult(toolName, false, `Exception: ${error.message}`, error.stack);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main test suite
|
||||||
|
async function runTests() {
|
||||||
|
printHeader('Testing Basic Proxmox MCP Tools');
|
||||||
|
|
||||||
|
console.log(`${colors.yellow}Note: These tests require a working Proxmox connection.${colors.reset}`);
|
||||||
|
console.log(`${colors.yellow}Ensure .env is configured with valid credentials.${colors.reset}\n`);
|
||||||
|
|
||||||
|
// Test 1: proxmox_get_nodes
|
||||||
|
await testTool('proxmox_get_nodes', {}, (content) => {
|
||||||
|
if (!content.text || content.text.length === 0) {
|
||||||
|
return { success: false, message: 'Empty response' };
|
||||||
|
}
|
||||||
|
if (!content.text.includes('Node') && !content.text.includes('node')) {
|
||||||
|
return { success: false, message: 'Response does not appear to contain node information' };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: proxmox_get_cluster_status
|
||||||
|
await testTool('proxmox_get_cluster_status', {}, (content) => {
|
||||||
|
if (!content.text || content.text.length === 0) {
|
||||||
|
return { success: false, message: 'Empty response' };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: proxmox_get_vms
|
||||||
|
await testTool('proxmox_get_vms', {}, (content) => {
|
||||||
|
if (!content.text || content.text.length === 0) {
|
||||||
|
return { success: false, message: 'Empty response' };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: proxmox_get_storage
|
||||||
|
await testTool('proxmox_get_storage', {}, (content) => {
|
||||||
|
if (!content.text || content.text.length === 0) {
|
||||||
|
return { success: false, message: 'Empty response' };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: proxmox_get_next_vmid
|
||||||
|
await testTool('proxmox_get_next_vmid', {}, (content) => {
|
||||||
|
if (!content.text || content.text.length === 0) {
|
||||||
|
return { success: false, message: 'Empty response' };
|
||||||
|
}
|
||||||
|
// Check if response contains a number
|
||||||
|
if (!/\d{3,}/.test(content.text)) {
|
||||||
|
return { success: false, message: 'Response does not contain a valid VMID number', details: content.text };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: proxmox_list_templates (requires node parameter)
|
||||||
|
// First we need to get a node name from proxmox_get_nodes
|
||||||
|
console.log(`${colors.blue}Testing: proxmox_list_templates (requires node name)${colors.reset}`);
|
||||||
|
console.log(`${colors.yellow} Getting node name first...${colors.reset}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { response: nodesResponse } = await callTool('proxmox_get_nodes', {});
|
||||||
|
if (nodesResponse.result && nodesResponse.result.content && nodesResponse.result.content[0]) {
|
||||||
|
const nodesText = nodesResponse.result.content[0].text;
|
||||||
|
// Try to extract first node name
|
||||||
|
const nodeMatch = nodesText.match(/(?:Node|node):\s*(\S+)/i) ||
|
||||||
|
nodesText.match(/^(\S+)/m);
|
||||||
|
|
||||||
|
if (nodeMatch && nodeMatch[1]) {
|
||||||
|
const nodeName = nodeMatch[1].replace(/[^a-zA-Z0-9-]/g, '');
|
||||||
|
console.log(`${colors.yellow} Using node: ${nodeName}${colors.reset}`);
|
||||||
|
|
||||||
|
await testTool('proxmox_list_templates', { node: nodeName }, (content) => {
|
||||||
|
if (!content.text || content.text.length === 0) {
|
||||||
|
return { success: false, message: 'Empty response' };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
printResult('proxmox_list_templates', false, 'Could not extract node name from nodes list', nodesText.substring(0, 100));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
printResult('proxmox_list_templates', false, 'Could not get nodes list to find node name');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
printResult('proxmox_list_templates', false, `Failed to get node for testing: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: proxmox_get_vm_status (requires node and vmid)
|
||||||
|
console.log(`${colors.blue}Testing: proxmox_get_vm_status (requires node and vmid)${colors.reset}`);
|
||||||
|
console.log(`${colors.yellow} Getting VM info first...${colors.reset}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { response: vmsResponse } = await callTool('proxmox_get_vms', {});
|
||||||
|
if (vmsResponse.result && vmsResponse.result.content && vmsResponse.result.content[0]) {
|
||||||
|
const vmsText = vmsResponse.result.content[0].text;
|
||||||
|
// Try to extract first VM info - handles format: (ID: 100) ... • Node: pve1
|
||||||
|
const vmMatch = vmsText.match(/\(ID:\s*(\d+)\).*?[•\s]*Node:\s*(\S+)/is);
|
||||||
|
|
||||||
|
if (vmMatch && vmMatch[1] && vmMatch[2]) {
|
||||||
|
const vmid = vmMatch[1];
|
||||||
|
const nodeName = vmMatch[2].replace(/[^a-zA-Z0-9-]/g, '');
|
||||||
|
console.log(`${colors.yellow} Using VM: ${vmid} on node: ${nodeName}${colors.reset}`);
|
||||||
|
|
||||||
|
await testTool('proxmox_get_vm_status', { node: nodeName, vmid: vmid }, (content) => {
|
||||||
|
if (!content.text || content.text.length === 0) {
|
||||||
|
return { success: false, message: 'Empty response' };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
printResult('proxmox_get_vm_status', false, 'Could not extract VM info from VMs list', vmsText.substring(0, 100));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
printResult('proxmox_get_vm_status', false, 'Could not get VMs list to find VM for testing');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
printResult('proxmox_get_vm_status', false, `Failed to get VM info for testing: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
printHeader('Test Summary');
|
||||||
|
|
||||||
|
console.log(`${colors.green}Passed: ${results.passed.length}${colors.reset}`);
|
||||||
|
results.passed.forEach(r => console.log(` ✓ ${r.tool}`));
|
||||||
|
|
||||||
|
if (results.failed.length > 0) {
|
||||||
|
console.log(`\n${colors.red}Failed: ${results.failed.length}${colors.reset}`);
|
||||||
|
results.failed.forEach(r => console.log(` ✗ ${r.tool}: ${r.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${colors.cyan}Total: ${results.passed.length}/${results.passed.length + results.failed.length} passed${colors.reset}\n`);
|
||||||
|
|
||||||
|
// Exit with error code if any tests failed
|
||||||
|
process.exit(results.failed.length > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the tests
|
||||||
|
runTests().catch(error => {
|
||||||
|
console.error(`${colors.red}Fatal error: ${error.message}${colors.reset}`);
|
||||||
|
console.error(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
948
test-workflows.js
Executable file
948
test-workflows.js
Executable file
@@ -0,0 +1,948 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprehensive workflow test script for Proxmox MCP Server
|
||||||
|
* Tests complete lifecycle workflows for VMs, containers, networking, disks, snapshots, and backups
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node test-workflows.js [options]
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --dry-run Show what would be done without executing
|
||||||
|
* --workflow=NAME Run specific workflow (lxc, vm, network, disk, snapshot, backup, all)
|
||||||
|
* --no-cleanup Skip cleanup after tests
|
||||||
|
* --interactive Prompt before each destructive operation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import readline from 'readline';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// ANSI color codes
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
bright: '\x1b[1m',
|
||||||
|
dim: '\x1b[2m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
magenta: '\x1b[35m'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const config = {
|
||||||
|
dryRun: process.argv.includes('--dry-run'),
|
||||||
|
interactive: process.argv.includes('--interactive'),
|
||||||
|
noCleanup: process.argv.includes('--no-cleanup'),
|
||||||
|
workflow: process.argv.find(arg => arg.startsWith('--workflow='))?.split('=')[1] || 'all',
|
||||||
|
testNode: null, // Will be auto-detected
|
||||||
|
testVmid: null, // Will be generated
|
||||||
|
testResources: [] // Track resources for cleanup
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test results
|
||||||
|
const results = {
|
||||||
|
passed: [],
|
||||||
|
failed: [],
|
||||||
|
skipped: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create readline interface for interactive prompts
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function log(message, color = 'reset') {
|
||||||
|
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
||||||
|
console.log(`${colors.dim}[${timestamp}]${colors.reset} ${colors[color]}${message}${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSection(title) {
|
||||||
|
console.log(`\n${colors.cyan}${'═'.repeat(70)}${colors.reset}`);
|
||||||
|
console.log(`${colors.cyan}${colors.bright} ${title}${colors.reset}`);
|
||||||
|
console.log(`${colors.cyan}${'═'.repeat(70)}${colors.reset}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logStep(step, detail = '') {
|
||||||
|
console.log(`${colors.blue}▶${colors.reset} ${colors.bright}${step}${colors.reset}`);
|
||||||
|
if (detail) console.log(` ${colors.dim}${detail}${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSuccess(message) {
|
||||||
|
console.log(` ${colors.green}✓ ${message}${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logError(message) {
|
||||||
|
console.log(` ${colors.red}✗ ${message}${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logWarning(message) {
|
||||||
|
console.log(` ${colors.yellow}⚠ ${message}${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prompt(question) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(`${colors.yellow}${question} (y/n): ${colors.reset}`, (answer) => {
|
||||||
|
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call MCP tool
|
||||||
|
async function callTool(toolName, args = {}) {
|
||||||
|
if (config.dryRun) {
|
||||||
|
log(`[DRY-RUN] Would call: ${toolName} with ${JSON.stringify(args)}`, 'dim');
|
||||||
|
return { success: true, dryRun: true, content: 'Dry run - no actual call made' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: Date.now(),
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: toolName,
|
||||||
|
arguments: args
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverProcess = spawn('node', [path.join(__dirname, 'index.js')], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
serverProcess.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.on('close', (code) => {
|
||||||
|
try {
|
||||||
|
const lines = stdout.split('\n');
|
||||||
|
let response = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim().startsWith('{')) {
|
||||||
|
try {
|
||||||
|
response = JSON.parse(line);
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
// Not JSON, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
if (response.error) {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: response.error.message,
|
||||||
|
errorCode: response.error.code
|
||||||
|
});
|
||||||
|
} else if (response.result && response.result.content) {
|
||||||
|
const content = response.result.content[0];
|
||||||
|
// Check if it's an error message about permissions
|
||||||
|
if (content.text && content.text.includes('Requires Elevated Permissions')) {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: 'Elevated permissions required',
|
||||||
|
content: content.text
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
content: content.text || '',
|
||||||
|
isError: response.result.isError || false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: 'No content in response'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error(`No JSON response. stdout: ${stdout.substring(0, 200)}`));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.on('error', reject);
|
||||||
|
serverProcess.stdin.write(JSON.stringify(request) + '\n');
|
||||||
|
serverProcess.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track resource for cleanup
|
||||||
|
function trackResource(type, node, vmid) {
|
||||||
|
config.testResources.push({ type, node, vmid });
|
||||||
|
log(`Tracking resource for cleanup: ${type} ${vmid} on ${node}`, 'dim');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record test result
|
||||||
|
function recordResult(workflow, testName, success, message, details = null) {
|
||||||
|
const result = { workflow, test: testName, message, details };
|
||||||
|
if (success) {
|
||||||
|
results.passed.push(result);
|
||||||
|
logSuccess(`${testName}: ${message}`);
|
||||||
|
} else {
|
||||||
|
results.failed.push(result);
|
||||||
|
logError(`${testName}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WORKFLOW TESTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function testLXCWorkflow() {
|
||||||
|
logSection('LXC Container Complete Workflow');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Get next available VMID
|
||||||
|
logStep('Step 1: Get next available VMID');
|
||||||
|
const vmidResult = await callTool('proxmox_get_next_vmid');
|
||||||
|
if (!vmidResult.success) {
|
||||||
|
recordResult('lxc', 'Get VMID', false, vmidResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vmidMatch = vmidResult.content.match(/\d{3,}/);
|
||||||
|
if (!vmidMatch) {
|
||||||
|
recordResult('lxc', 'Get VMID', false, 'Could not parse VMID from response');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
config.testVmid = vmidMatch[0];
|
||||||
|
logSuccess(`Got VMID: ${config.testVmid}`);
|
||||||
|
|
||||||
|
// Step 2: List available templates
|
||||||
|
logStep('Step 2: List available templates', `Node: ${config.testNode}`);
|
||||||
|
const templatesResult = await callTool('proxmox_list_templates', {
|
||||||
|
node: config.testNode,
|
||||||
|
storage: 'local'
|
||||||
|
});
|
||||||
|
if (!templatesResult.success) {
|
||||||
|
recordResult('lxc', 'List Templates', false, templatesResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logSuccess('Templates listed successfully');
|
||||||
|
|
||||||
|
// Try to find a Debian template - handles markdown format: **local:vztmpl/debian-...**
|
||||||
|
const templateMatch = templatesResult.content.match(/\*\*local:vztmpl\/(debian-\d+-standard[^\s*]+)\*\*/i) ||
|
||||||
|
templatesResult.content.match(/\*\*local:vztmpl\/([^\s*]+\.tar\.[gxz]+)\*\*/i) ||
|
||||||
|
templatesResult.content.match(/\*\*local:vztmpl\/(debian[^\s*]+)\*\*/i) ||
|
||||||
|
templatesResult.content.match(/\*\*local:vztmpl\/([^\s*]+)\*\*/i);
|
||||||
|
|
||||||
|
if (!templateMatch) {
|
||||||
|
recordResult('lxc', 'Find Template', false, 'No suitable template found', templatesResult.content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const template = `local:vztmpl/${templateMatch[1]}`;
|
||||||
|
logSuccess(`Found template: ${template}`);
|
||||||
|
|
||||||
|
// Step 3: Create LXC container
|
||||||
|
if (config.interactive) {
|
||||||
|
const proceed = await prompt(`Create LXC container ${config.testVmid}?`);
|
||||||
|
if (!proceed) {
|
||||||
|
recordResult('lxc', 'Create Container', false, 'Skipped by user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep('Step 3: Create LXC container', `VMID: ${config.testVmid}, Template: ${template}`);
|
||||||
|
const createResult = await callTool('proxmox_create_lxc', {
|
||||||
|
node: config.testNode,
|
||||||
|
vmid: config.testVmid,
|
||||||
|
ostemplate: template,
|
||||||
|
hostname: `test-mcp-${config.testVmid}`,
|
||||||
|
password: 'Test123!@#',
|
||||||
|
memory: 512,
|
||||||
|
storage: 'local-lvm',
|
||||||
|
rootfs: '4'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResult.success) {
|
||||||
|
recordResult('lxc', 'Create Container', false, createResult.error || 'Creation failed', createResult.content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackResource('lxc', config.testNode, config.testVmid);
|
||||||
|
recordResult('lxc', 'Create Container', true, `Container ${config.testVmid} created`);
|
||||||
|
await sleep(2000); // Wait for creation to complete
|
||||||
|
|
||||||
|
// Step 4: Start container
|
||||||
|
logStep('Step 4: Start LXC container', `VMID: ${config.testVmid}`);
|
||||||
|
const startResult = await callTool('proxmox_start_lxc', {
|
||||||
|
node: config.testNode,
|
||||||
|
vmid: config.testVmid
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!startResult.success) {
|
||||||
|
recordResult('lxc', 'Start Container', false, startResult.error);
|
||||||
|
} else {
|
||||||
|
recordResult('lxc', 'Start Container', true, `Container ${config.testVmid} started`);
|
||||||
|
await sleep(3000); // Wait for startup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Check status
|
||||||
|
logStep('Step 5: Check container status');
|
||||||
|
const statusResult = await callTool('proxmox_get_vm_status', {
|
||||||
|
node: config.testNode,
|
||||||
|
vmid: config.testVmid,
|
||||||
|
type: 'lxc'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!statusResult.success) {
|
||||||
|
recordResult('lxc', 'Check Status', false, statusResult.error);
|
||||||
|
} else {
|
||||||
|
const isRunning = statusResult.content.toLowerCase().includes('running');
|
||||||
|
recordResult('lxc', 'Check Status', isRunning,
|
||||||
|
isRunning ? 'Container is running' : 'Container is not running',
|
||||||
|
statusResult.content.substring(0, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Create snapshot
|
||||||
|
logStep('Step 6: Create snapshot', `Name: test-snapshot`);
|
||||||
|
const snapshotResult = await callTool('proxmox_create_snapshot_lxc', {
|
||||||
|
node: config.testNode,
|
||||||
|
vmid: config.testVmid,
|
||||||
|
snapname: 'test-snapshot'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!snapshotResult.success) {
|
||||||
|
recordResult('lxc', 'Create Snapshot', false, snapshotResult.error);
|
||||||
|
} else {
|
||||||
|
recordResult('lxc', 'Create Snapshot', true, 'Snapshot created');
|
||||||
|
await sleep(5000); // Increased wait time for Proxmox to register snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: List snapshots (with retry logic)
|
||||||
|
logStep('Step 7: List snapshots');
|
||||||
|
|
||||||
|
let hasSnapshot = false;
|
||||||
|
let listSuccess = false;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
const listSnapshotsResult = await callTool('proxmox_list_snapshots_lxc', {
|
||||||
|
node: config.testNode,
|
||||||
|
vmid: config.testVmid
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!listSnapshotsResult.success) {
|
||||||
|
if (attempt === 3) {
|
||||||
|
recordResult('lxc', 'List Snapshots', false, listSnapshotsResult.error);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSnapshot = listSnapshotsResult.content.includes('test-snapshot');
|
||||||
|
if (hasSnapshot) {
|
||||||
|
listSuccess = true;
|
||||||
|
recordResult('lxc', 'List Snapshots', true, 'Snapshot found in list');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < 3) {
|
||||||
|
logWarning(`Snapshot not found yet, retrying in 2 seconds... (attempt ${attempt}/3)`);
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listSuccess === false && hasSnapshot === false) {
|
||||||
|
recordResult('lxc', 'List Snapshots', false, 'Snapshot not found in list after 3 attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: Stop container
|
||||||
|
logStep('Step 8: Stop LXC container');
|
||||||
|
const stopResult = await callTool('proxmox_stop_lxc', {
|
||||||
|
node: config.testNode,
|
||||||
|
vmid: config.testVmid
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stopResult.success) {
|
||||||
|
recordResult('lxc', 'Stop Container', false, stopResult.error);
|
||||||
|
} else {
|
||||||
|
recordResult('lxc', 'Stop Container', true, `Container ${config.testVmid} stopped`);
|
||||||
|
await sleep(3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 9: Delete snapshot
|
||||||
|
logStep('Step 9: Delete snapshot');
|
||||||
|
const deleteSnapshotResult = await callTool('proxmox_delete_snapshot_lxc', {
|
||||||
|
node: config.testNode,
|
||||||
|
vmid: config.testVmid,
|
||||||
|
snapname: 'test-snapshot'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deleteSnapshotResult.success) {
|
||||||
|
recordResult('lxc', 'Delete Snapshot', false, deleteSnapshotResult.error);
|
||||||
|
} else {
|
||||||
|
recordResult('lxc', 'Delete Snapshot', true, 'Snapshot deleted');
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
recordResult('lxc', 'Workflow', false, `Exception: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testVMLifecycleWorkflow() {
|
||||||
|
logSection('VM Lifecycle Operations Workflow');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find an existing VM to test with
|
||||||
|
logStep('Step 1: Find existing VM for testing');
|
||||||
|
const vmsResult = await callTool('proxmox_get_vms');
|
||||||
|
|
||||||
|
if (!vmsResult.success) {
|
||||||
|
recordResult('vm-lifecycle', 'Find VM', false, vmsResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find a stopped VM, or any VM - handles format: (ID: 100) ... • Node: pve1 ... • Status: running
|
||||||
|
const vmMatch = vmsResult.content.match(/\(ID:\s*(\d+)\).*?[•\s]*Node:\s*(\S+).*?[•\s]*Status:\s*(\S+)/is);
|
||||||
|
|
||||||
|
if (!vmMatch) {
|
||||||
|
recordResult('vm-lifecycle', 'Find VM', false, 'No VMs found for testing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingVmid = vmMatch[1];
|
||||||
|
const existingNode = vmMatch[2];
|
||||||
|
const existingStatus = vmMatch[3];
|
||||||
|
|
||||||
|
logSuccess(`Found VM ${existingVmid} on ${existingNode} (status: ${existingStatus})`);
|
||||||
|
recordResult('vm-lifecycle', 'Find VM', true, `Testing with VM ${existingVmid}`);
|
||||||
|
|
||||||
|
// Determine VM type
|
||||||
|
logStep('Step 2: Detect VM type');
|
||||||
|
let vmType = 'lxc';
|
||||||
|
if (vmsResult.content.includes(`ID: ${existingVmid}`) && vmsResult.content.includes('QEMU')) {
|
||||||
|
vmType = 'qemu';
|
||||||
|
}
|
||||||
|
logSuccess(`VM type: ${vmType}`);
|
||||||
|
|
||||||
|
// Step 3: Get detailed status
|
||||||
|
logStep('Step 3: Get VM status');
|
||||||
|
const statusResult = await callTool('proxmox_get_vm_status', {
|
||||||
|
node: existingNode,
|
||||||
|
vmid: existingVmid,
|
||||||
|
type: vmType
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!statusResult.success) {
|
||||||
|
recordResult('vm-lifecycle', 'Get Status', false, statusResult.error);
|
||||||
|
} else {
|
||||||
|
recordResult('vm-lifecycle', 'Get Status', true, 'Status retrieved successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test start/stop based on current state
|
||||||
|
if (existingStatus.toLowerCase().includes('stopped')) {
|
||||||
|
logStep('Step 4: Start VM (currently stopped)');
|
||||||
|
const startTool = vmType === 'lxc' ? 'proxmox_start_lxc' : 'proxmox_start_vm';
|
||||||
|
const startResult = await callTool(startTool, {
|
||||||
|
node: existingNode,
|
||||||
|
vmid: existingVmid
|
||||||
|
});
|
||||||
|
|
||||||
|
recordResult('vm-lifecycle', 'Start VM', startResult.success,
|
||||||
|
startResult.success ? 'VM started' : startResult.error);
|
||||||
|
|
||||||
|
if (startResult.success) {
|
||||||
|
await sleep(3000);
|
||||||
|
|
||||||
|
// Now test reboot
|
||||||
|
logStep('Step 5: Reboot VM');
|
||||||
|
const rebootTool = vmType === 'lxc' ? 'proxmox_reboot_lxc' : 'proxmox_reboot_vm';
|
||||||
|
const rebootResult = await callTool(rebootTool, {
|
||||||
|
node: existingNode,
|
||||||
|
vmid: existingVmid
|
||||||
|
});
|
||||||
|
|
||||||
|
recordResult('vm-lifecycle', 'Reboot VM', rebootResult.success,
|
||||||
|
rebootResult.success ? 'VM rebooted' : rebootResult.error);
|
||||||
|
}
|
||||||
|
} else if (existingStatus.toLowerCase().includes('running')) {
|
||||||
|
logStep('Step 4: VM already running, testing reboot');
|
||||||
|
const rebootTool = vmType === 'lxc' ? 'proxmox_reboot_lxc' : 'proxmox_reboot_vm';
|
||||||
|
const rebootResult = await callTool(rebootTool, {
|
||||||
|
node: existingNode,
|
||||||
|
vmid: existingVmid
|
||||||
|
});
|
||||||
|
|
||||||
|
recordResult('vm-lifecycle', 'Reboot VM', rebootResult.success,
|
||||||
|
rebootResult.success ? 'VM rebooted' : rebootResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
recordResult('vm-lifecycle', 'Workflow', false, `Exception: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testNetworkWorkflow() {
|
||||||
|
logSection('Network Management Workflow');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find an existing stopped VM to test network operations
|
||||||
|
logStep('Step 1: Find stopped VM for network testing');
|
||||||
|
const vmsResult = await callTool('proxmox_get_vms');
|
||||||
|
|
||||||
|
if (!vmsResult.success) {
|
||||||
|
recordResult('network', 'Find VM', false, vmsResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmMatch = vmsResult.content.match(/\(ID:\s*(\d+)\).*?[•\s]*Node:\s*(\S+).*?[•\s]*Status:\s*stopped/is);
|
||||||
|
|
||||||
|
if (!vmMatch) {
|
||||||
|
logWarning('No stopped VMs found, skipping network workflow (VM must be stopped)');
|
||||||
|
recordResult('network', 'Find VM', false, 'No stopped VMs available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmid = vmMatch[1];
|
||||||
|
const node = vmMatch[2];
|
||||||
|
|
||||||
|
logSuccess(`Using VM ${vmid} on ${node}`);
|
||||||
|
|
||||||
|
// Determine VM type
|
||||||
|
let vmType = vmsResult.content.includes('QEMU') ? 'qemu' : 'lxc';
|
||||||
|
|
||||||
|
// Step 2: Add network interface
|
||||||
|
logStep('Step 2: Add network interface', 'Bridge: vmbr0, Interface: net1');
|
||||||
|
const addTool = vmType === 'qemu' ? 'proxmox_add_network_vm' : 'proxmox_add_network_lxc';
|
||||||
|
const addResult = await callTool(addTool, {
|
||||||
|
node: node,
|
||||||
|
vmid: vmid,
|
||||||
|
net: 'net1',
|
||||||
|
bridge: 'vmbr0',
|
||||||
|
firewall: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!addResult.success) {
|
||||||
|
recordResult('network', 'Add Interface', false, addResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordResult('network', 'Add Interface', true, 'Network interface net1 added');
|
||||||
|
|
||||||
|
// Step 3: Update network interface
|
||||||
|
logStep('Step 3: Update network interface', 'Add rate limit');
|
||||||
|
const updateTool = vmType === 'qemu' ? 'proxmox_update_network_vm' : 'proxmox_update_network_lxc';
|
||||||
|
const updateResult = await callTool(updateTool, {
|
||||||
|
node: node,
|
||||||
|
vmid: vmid,
|
||||||
|
net: 'net1',
|
||||||
|
rate: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
recordResult('network', 'Update Interface', updateResult.success,
|
||||||
|
updateResult.success ? 'Network interface updated' : updateResult.error);
|
||||||
|
|
||||||
|
// Step 4: Remove network interface
|
||||||
|
logStep('Step 4: Remove network interface');
|
||||||
|
const removeTool = vmType === 'qemu' ? 'proxmox_remove_network_vm' : 'proxmox_remove_network_lxc';
|
||||||
|
const removeResult = await callTool(removeTool, {
|
||||||
|
node: node,
|
||||||
|
vmid: vmid,
|
||||||
|
net: 'net1'
|
||||||
|
});
|
||||||
|
|
||||||
|
recordResult('network', 'Remove Interface', removeResult.success,
|
||||||
|
removeResult.success ? 'Network interface removed' : removeResult.error);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
recordResult('network', 'Workflow', false, `Exception: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testDiskWorkflow() {
|
||||||
|
logSection('Disk Management Workflow');
|
||||||
|
|
||||||
|
try {
|
||||||
|
logStep('Step 1: Find VM for disk testing');
|
||||||
|
const vmsResult = await callTool('proxmox_get_vms');
|
||||||
|
|
||||||
|
if (!vmsResult.success) {
|
||||||
|
recordResult('disk', 'Find VM', false, vmsResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a QEMU VM (disk operations are more reliable on QEMU) - handles format: (ID: 100) ... • Node: pve1 ... • Type: QEMU
|
||||||
|
const vmMatch = vmsResult.content.match(/\(ID:\s*(\d+)\).*?[•\s]*Node:\s*(\S+).*?[•\s]*Type:\s*QEMU/is);
|
||||||
|
|
||||||
|
if (!vmMatch) {
|
||||||
|
logWarning('No QEMU VMs found, skipping disk workflow');
|
||||||
|
recordResult('disk', 'Find VM', false, 'No QEMU VMs available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmid = vmMatch[1];
|
||||||
|
const node = vmMatch[2];
|
||||||
|
|
||||||
|
logSuccess(`Using VM ${vmid} on ${node}`);
|
||||||
|
|
||||||
|
// Step 2: Add disk
|
||||||
|
logStep('Step 2: Add disk to VM', 'Size: 10G, Storage: local-lvm');
|
||||||
|
const addResult = await callTool('proxmox_add_disk_vm', {
|
||||||
|
node: node,
|
||||||
|
vmid: vmid,
|
||||||
|
disk: 'scsi1',
|
||||||
|
size: '10G',
|
||||||
|
storage: 'local-lvm'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!addResult.success) {
|
||||||
|
recordResult('disk', 'Add Disk', false, addResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordResult('disk', 'Add Disk', true, 'Disk added successfully');
|
||||||
|
|
||||||
|
// Step 3: Resize disk
|
||||||
|
logStep('Step 3: Resize disk', 'New size: +2G');
|
||||||
|
const resizeResult = await callTool('proxmox_resize_disk_vm', {
|
||||||
|
node: node,
|
||||||
|
vmid: vmid,
|
||||||
|
disk: 'scsi1',
|
||||||
|
size: '+2G'
|
||||||
|
});
|
||||||
|
|
||||||
|
recordResult('disk', 'Resize Disk', resizeResult.success,
|
||||||
|
resizeResult.success ? 'Disk resized' : resizeResult.error);
|
||||||
|
|
||||||
|
// Step 4: Remove disk (cleanup)
|
||||||
|
logStep('Step 4: Remove disk');
|
||||||
|
const removeResult = await callTool('proxmox_remove_disk_vm', {
|
||||||
|
node: node,
|
||||||
|
vmid: vmid,
|
||||||
|
disk: 'scsi1'
|
||||||
|
});
|
||||||
|
|
||||||
|
recordResult('disk', 'Remove Disk', removeResult.success,
|
||||||
|
removeResult.success ? 'Disk removed' : removeResult.error);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
recordResult('disk', 'Workflow', false, `Exception: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testSnapshotWorkflow() {
|
||||||
|
logSection('Snapshot Workflow');
|
||||||
|
|
||||||
|
try {
|
||||||
|
logStep('Step 1: Find VM for snapshot testing');
|
||||||
|
const vmsResult = await callTool('proxmox_get_vms');
|
||||||
|
|
||||||
|
if (!vmsResult.success) {
|
||||||
|
recordResult('snapshot', 'Find VM', false, vmsResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmMatch = vmsResult.content.match(/\(ID:\s*(\d+)\).*?[•\s]*Node:\s*(\S+)/is);
|
||||||
|
|
||||||
|
if (!vmMatch) {
|
||||||
|
recordResult('snapshot', 'Find VM', false, 'No VMs found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmid = vmMatch[1];
|
||||||
|
const node = vmMatch[2];
|
||||||
|
const vmType = vmsResult.content.includes('QEMU') ? 'qemu' : 'lxc';
|
||||||
|
|
||||||
|
logSuccess(`Using ${vmType} ${vmid} on ${node}`);
|
||||||
|
|
||||||
|
const snapname = `test-snap-${Date.now()}`;
|
||||||
|
|
||||||
|
// Step 2: Create snapshot
|
||||||
|
logStep('Step 2: Create snapshot', `Name: ${snapname}`);
|
||||||
|
const createTool = vmType === 'qemu' ? 'proxmox_create_snapshot_vm' : 'proxmox_create_snapshot_lxc';
|
||||||
|
const createResult = await callTool(createTool, {
|
||||||
|
node: node,
|
||||||
|
vmid: vmid,
|
||||||
|
snapname: snapname
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResult.success) {
|
||||||
|
recordResult('snapshot', 'Create Snapshot', false, createResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordResult('snapshot', 'Create Snapshot', true, `Snapshot ${snapname} created`);
|
||||||
|
await sleep(5000); // Increased wait time for Proxmox to register snapshot
|
||||||
|
|
||||||
|
// Step 3: List snapshots (with retry logic)
|
||||||
|
logStep('Step 3: List snapshots');
|
||||||
|
const listTool = vmType === 'qemu' ? 'proxmox_list_snapshots_vm' : 'proxmox_list_snapshots_lxc';
|
||||||
|
|
||||||
|
let hasSnapshot = false;
|
||||||
|
let listSuccess = false;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
const listResult = await callTool(listTool, {
|
||||||
|
node: node,
|
||||||
|
vmid: vmid
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!listResult.success) {
|
||||||
|
if (attempt === 3) {
|
||||||
|
recordResult('snapshot', 'List Snapshots', false, listResult.error);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSnapshot = listResult.content.includes(snapname);
|
||||||
|
if (hasSnapshot) {
|
||||||
|
listSuccess = true;
|
||||||
|
recordResult('snapshot', 'List Snapshots', true, `Snapshot ${snapname} found`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < 3) {
|
||||||
|
logWarning(`Snapshot not found yet, retrying in 2 seconds... (attempt ${attempt}/3)`);
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listSuccess === false && hasSnapshot === false) {
|
||||||
|
recordResult('snapshot', 'List Snapshots', false, 'Snapshot not found in list after 3 attempts');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Delete snapshot
|
||||||
|
logStep('Step 4: Delete snapshot');
|
||||||
|
const deleteTool = vmType === 'qemu' ? 'proxmox_delete_snapshot_vm' : 'proxmox_delete_snapshot_lxc';
|
||||||
|
const deleteResult = await callTool(deleteTool, {
|
||||||
|
node: node,
|
||||||
|
vmid: vmid,
|
||||||
|
snapname: snapname
|
||||||
|
});
|
||||||
|
|
||||||
|
recordResult('snapshot', 'Delete Snapshot', deleteResult.success,
|
||||||
|
deleteResult.success ? 'Snapshot deleted' : deleteResult.error);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
recordResult('snapshot', 'Workflow', false, `Exception: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testBackupWorkflow() {
|
||||||
|
logSection('Backup Workflow');
|
||||||
|
|
||||||
|
try {
|
||||||
|
logStep('Step 1: Find VM for backup testing');
|
||||||
|
const vmsResult = await callTool('proxmox_get_vms');
|
||||||
|
|
||||||
|
if (!vmsResult.success) {
|
||||||
|
recordResult('backup', 'Find VM', false, vmsResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmMatch = vmsResult.content.match(/\(ID:\s*(\d+)\).*?[•\s]*Node:\s*(\S+)/is);
|
||||||
|
|
||||||
|
if (!vmMatch) {
|
||||||
|
recordResult('backup', 'Find VM', false, 'No VMs found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmid = vmMatch[1];
|
||||||
|
const node = vmMatch[2];
|
||||||
|
const vmType = vmsResult.content.includes('QEMU') ? 'qemu' : 'lxc';
|
||||||
|
|
||||||
|
logSuccess(`Using ${vmType} ${vmid} on ${node}`);
|
||||||
|
|
||||||
|
// Step 2: Create backup
|
||||||
|
logStep('Step 2: Create backup', 'Storage: local');
|
||||||
|
const createTool = vmType === 'qemu' ? 'proxmox_create_backup_vm' : 'proxmox_create_backup_lxc';
|
||||||
|
const createResult = await callTool(createTool, {
|
||||||
|
node: node,
|
||||||
|
vmid: vmid,
|
||||||
|
storage: 'local'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResult.success) {
|
||||||
|
recordResult('backup', 'Create Backup', false, createResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordResult('backup', 'Create Backup', true, 'Backup job started');
|
||||||
|
|
||||||
|
logWarning('Backup runs in background. Waiting 5 seconds before listing...');
|
||||||
|
await sleep(5000);
|
||||||
|
|
||||||
|
// Step 3: List backups
|
||||||
|
logStep('Step 3: List backups');
|
||||||
|
const listResult = await callTool('proxmox_list_backups', {
|
||||||
|
node: node
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!listResult.success) {
|
||||||
|
recordResult('backup', 'List Backups', false, listResult.error);
|
||||||
|
} else {
|
||||||
|
recordResult('backup', 'List Backups', true, 'Backups listed successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
logWarning('Note: Backup deletion requires the backup filename from the list above');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
recordResult('backup', 'Workflow', false, `Exception: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CLEANUP
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
if (config.noCleanup || config.dryRun) {
|
||||||
|
logWarning('Cleanup skipped');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.testResources.length === 0) {
|
||||||
|
log('No resources to clean up', 'dim');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSection('Cleanup');
|
||||||
|
|
||||||
|
for (const resource of config.testResources) {
|
||||||
|
logStep(`Deleting ${resource.type} ${resource.vmid} on ${resource.node}`);
|
||||||
|
|
||||||
|
const deleteTool = resource.type === 'lxc' ? 'proxmox_delete_lxc' : 'proxmox_delete_vm';
|
||||||
|
const deleteResult = await callTool(deleteTool, {
|
||||||
|
node: resource.node,
|
||||||
|
vmid: resource.vmid
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleteResult.success) {
|
||||||
|
logSuccess(`Deleted ${resource.type} ${resource.vmid}`);
|
||||||
|
} else {
|
||||||
|
logError(`Failed to delete ${resource.type} ${resource.vmid}: ${deleteResult.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAIN
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`${colors.bright}${colors.magenta}`);
|
||||||
|
console.log('╔═══════════════════════════════════════════════════════════════════╗');
|
||||||
|
console.log('║ Proxmox MCP Server - Workflow Test Suite ║');
|
||||||
|
console.log('╚═══════════════════════════════════════════════════════════════════╝');
|
||||||
|
console.log(colors.reset);
|
||||||
|
|
||||||
|
// Check for elevated permissions
|
||||||
|
log('Checking environment...', 'cyan');
|
||||||
|
|
||||||
|
if (config.dryRun) {
|
||||||
|
logWarning('DRY-RUN MODE: No actual operations will be performed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.interactive) {
|
||||||
|
log('Interactive mode enabled', 'yellow');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get test node
|
||||||
|
log('Auto-detecting node...', 'cyan');
|
||||||
|
const nodesResult = await callTool('proxmox_get_nodes');
|
||||||
|
if (!nodesResult.success) {
|
||||||
|
logError('Failed to get nodes. Ensure .env is configured and server is accessible.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle node name format: 🟢 **pve1**
|
||||||
|
const nodeMatch = nodesResult.content.match(/\*\*([a-zA-Z0-9-]+)\*\*/i) ||
|
||||||
|
nodesResult.content.match(/Node:\s*(\S+)/i) ||
|
||||||
|
nodesResult.content.match(/^([a-zA-Z0-9-]+)/m);
|
||||||
|
|
||||||
|
if (!nodeMatch) {
|
||||||
|
logError('Could not detect node name');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.testNode = nodeMatch[1].replace(/[^a-zA-Z0-9-]/g, '');
|
||||||
|
logSuccess(`Using node: ${config.testNode}`);
|
||||||
|
|
||||||
|
// Run workflows
|
||||||
|
const workflows = {
|
||||||
|
lxc: testLXCWorkflow,
|
||||||
|
vm: testVMLifecycleWorkflow,
|
||||||
|
network: testNetworkWorkflow,
|
||||||
|
disk: testDiskWorkflow,
|
||||||
|
snapshot: testSnapshotWorkflow,
|
||||||
|
backup: testBackupWorkflow
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.workflow === 'all') {
|
||||||
|
for (const [name, func] of Object.entries(workflows)) {
|
||||||
|
await func();
|
||||||
|
}
|
||||||
|
} else if (workflows[config.workflow]) {
|
||||||
|
await workflows[config.workflow]();
|
||||||
|
} else {
|
||||||
|
logError(`Unknown workflow: ${config.workflow}`);
|
||||||
|
log(`Available workflows: ${Object.keys(workflows).join(', ')}, all`, 'yellow');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
logSection('Test Summary');
|
||||||
|
|
||||||
|
console.log(`${colors.green}✓ Passed: ${results.passed.length}${colors.reset}`);
|
||||||
|
results.passed.forEach(r => {
|
||||||
|
console.log(` ${colors.dim}[${r.workflow}]${colors.reset} ${r.test}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.failed.length > 0) {
|
||||||
|
console.log(`\n${colors.red}✗ Failed: ${results.failed.length}${colors.reset}`);
|
||||||
|
results.failed.forEach(r => {
|
||||||
|
console.log(` ${colors.dim}[${r.workflow}]${colors.reset} ${r.test}: ${r.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.skipped.length > 0) {
|
||||||
|
console.log(`\n${colors.yellow}⊘ Skipped: ${results.skipped.length}${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = results.passed.length + results.failed.length;
|
||||||
|
const passRate = total > 0 ? ((results.passed.length / total) * 100).toFixed(1) : 0;
|
||||||
|
|
||||||
|
console.log(`\n${colors.cyan}${colors.bright}Total: ${results.passed.length}/${total} passed (${passRate}%)${colors.reset}\n`);
|
||||||
|
|
||||||
|
rl.close();
|
||||||
|
process.exit(results.failed.length > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
process.on('unhandledRejection', (error) => {
|
||||||
|
logError(`Unhandled error: ${error.message}`);
|
||||||
|
console.error(error);
|
||||||
|
rl.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run
|
||||||
|
main().catch(error => {
|
||||||
|
logError(`Fatal error: ${error.message}`);
|
||||||
|
console.error(error);
|
||||||
|
rl.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user