wilfredwake 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +52 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +400 -0
- package/README.md +831 -0
- package/bin/cli.js +133 -0
- package/index.js +222 -0
- package/package.json +41 -0
- package/src/cli/commands/health.js +238 -0
- package/src/cli/commands/init.js +192 -0
- package/src/cli/commands/status.js +180 -0
- package/src/cli/commands/wake.js +182 -0
- package/src/cli/config.js +263 -0
- package/src/config/services.yaml +49 -0
- package/src/orchestrator/orchestrator.js +397 -0
- package/src/orchestrator/registry.js +283 -0
- package/src/orchestrator/server.js +402 -0
- package/src/shared/colors.js +154 -0
- package/src/shared/logger.js +224 -0
- package/tests/cli.test.js +328 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ╔═══════════════════════════════════════════════════════════════╗
|
|
3
|
+
* ║ COLORS & THEMES MODULE ║
|
|
4
|
+
* ║ Provides consistent color scheme for CLI output ║
|
|
5
|
+
* ║ Uses chalk for terminal color support ║
|
|
6
|
+
* ╚═══════════════════════════════════════════════════════════════╝
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Color scheme definitions
|
|
13
|
+
* Each color is assigned a semantic meaning for consistent UX
|
|
14
|
+
*/
|
|
15
|
+
export const colors = {
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════
|
|
17
|
+
// STATUS INDICATORS - Service states with visual distinction
|
|
18
|
+
// ═══════════════════════════════════════════════════════════════
|
|
19
|
+
status: {
|
|
20
|
+
// New architecture states
|
|
21
|
+
live: chalk.greenBright, // ✓ Service is responsive and healthy
|
|
22
|
+
dead: chalk.gray, // ⚫ Service is not responding/dormant
|
|
23
|
+
waking: chalk.yellowBright, // ⟳ Service is responding slowly
|
|
24
|
+
failed: chalk.redBright, // ✗ Service failed to respond
|
|
25
|
+
unknown: chalk.magentaBright, // ? Unknown state
|
|
26
|
+
|
|
27
|
+
// Legacy states (for backwards compatibility)
|
|
28
|
+
ready: chalk.greenBright, // ✓ Service is running and healthy
|
|
29
|
+
sleeping: chalk.gray, // ⚫ Service is in sleep/cold state
|
|
30
|
+
pending: chalk.blueBright, // ⏳ Service is pending action
|
|
31
|
+
error: chalk.red, // ⚠️ Error occurred
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
// ═══════════════════════════════════════════════════════════════
|
|
35
|
+
// ACTION MESSAGES - Operation feedback
|
|
36
|
+
// ═══════════════════════════════════════════════════════════════
|
|
37
|
+
action: {
|
|
38
|
+
success: chalk.greenBright, // Operation completed successfully
|
|
39
|
+
warning: chalk.yellowBright, // Warning/caution message
|
|
40
|
+
info: chalk.cyanBright, // Informational message
|
|
41
|
+
debug: chalk.gray, // Debug/verbose output
|
|
42
|
+
prompt: chalk.blueBright, // User input prompt
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
// ═══════════════════════════════════════════════════════════════
|
|
46
|
+
// EMPHASIS - UI elements and emphasis
|
|
47
|
+
// ═══════════════════════════════════════════════════════════════
|
|
48
|
+
emphasis: {
|
|
49
|
+
primary: chalk.cyan, // Primary/main heading
|
|
50
|
+
secondary: chalk.magenta, // Secondary heading
|
|
51
|
+
accent: chalk.yellow, // Accent elements
|
|
52
|
+
muted: chalk.gray, // Muted/secondary text
|
|
53
|
+
highlight: chalk.inverse, // Highlighted text
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// ═══════════════════════════════════════════════════════════════
|
|
57
|
+
// SPECIAL MARKERS - Icons and visual elements
|
|
58
|
+
// ═══════════════════════════════════════════════════════════════
|
|
59
|
+
markers: {
|
|
60
|
+
success: chalk.greenBright('✓'),
|
|
61
|
+
error: chalk.redBright('✗'),
|
|
62
|
+
warning: chalk.yellowBright('⚠'),
|
|
63
|
+
info: chalk.cyanBright('ℹ'),
|
|
64
|
+
arrow: chalk.dim('→'),
|
|
65
|
+
bullet: chalk.dim('•'),
|
|
66
|
+
spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Formatting utilities for structured output
|
|
72
|
+
*/
|
|
73
|
+
export const format = {
|
|
74
|
+
/**
|
|
75
|
+
* Format a service status line with color coding
|
|
76
|
+
* @param {string} serviceName - Name of the service
|
|
77
|
+
* @param {string} status - Current status (ready, sleeping, waking, etc)
|
|
78
|
+
* @param {string} message - Optional message to display
|
|
79
|
+
* @returns {string} Formatted status line
|
|
80
|
+
*/
|
|
81
|
+
serviceStatus(serviceName, status, message = '') {
|
|
82
|
+
const statusColor = colors.status[status] || colors.status.unknown;
|
|
83
|
+
const statusText = statusColor(status.toUpperCase().padEnd(10));
|
|
84
|
+
const msgText = message ? ` ${colors.emphasis.muted(`(${message})`)}` : '';
|
|
85
|
+
return ` ${statusText} ${chalk.cyan(serviceName)}${msgText}`;
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format a table header row
|
|
90
|
+
* @param {string[]} columns - Column names
|
|
91
|
+
* @returns {string} Formatted header
|
|
92
|
+
*/
|
|
93
|
+
tableHeader(columns) {
|
|
94
|
+
return chalk.inverse(
|
|
95
|
+
' ' + columns.map(col => col.padEnd(20)).join(' ') + ' '
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Format a table row
|
|
101
|
+
* @param {string[]} cells - Cell values
|
|
102
|
+
* @returns {string} Formatted row
|
|
103
|
+
*/
|
|
104
|
+
tableRow(cells) {
|
|
105
|
+
return ' ' + cells.map(cell => cell.padEnd(20)).join(' ');
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Format a section header
|
|
110
|
+
* @param {string} title - Section title
|
|
111
|
+
* @returns {string} Formatted header
|
|
112
|
+
*/
|
|
113
|
+
section(title) {
|
|
114
|
+
return chalk.cyanBright.bold(`\n▶ ${title}\n`);
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Format a success message
|
|
119
|
+
* @param {string} message - Message text
|
|
120
|
+
* @returns {string} Formatted message
|
|
121
|
+
*/
|
|
122
|
+
success(message) {
|
|
123
|
+
return `${colors.markers.success} ${chalk.greenBright(message)}`;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Format an error message
|
|
128
|
+
* @param {string} message - Message text
|
|
129
|
+
* @returns {string} Formatted message
|
|
130
|
+
*/
|
|
131
|
+
error(message) {
|
|
132
|
+
return `${colors.markers.error} ${chalk.redBright(message)}`;
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Format an info message
|
|
137
|
+
* @param {string} message - Message text
|
|
138
|
+
* @returns {string} Formatted message
|
|
139
|
+
*/
|
|
140
|
+
info(message) {
|
|
141
|
+
return `${colors.markers.info} ${chalk.cyanBright(message)}`;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Format a warning message
|
|
146
|
+
* @param {string} message - Message text
|
|
147
|
+
* @returns {string} Formatted message
|
|
148
|
+
*/
|
|
149
|
+
warning(message) {
|
|
150
|
+
return `${colors.markers.warning} ${chalk.yellowBright(message)}`;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export default { colors, format };
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ╔═══════════════════════════════════════════════════════════════╗
|
|
3
|
+
* ║ LOGGER & UTILITIES MODULE ║
|
|
4
|
+
* ║ Provides logging and utility functions ║
|
|
5
|
+
* ╚═══════════════════════════════════════════════════════════════╝
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { colors, format } from './colors.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Logger class for consistent output formatting
|
|
13
|
+
* Provides methods for different log levels with color coding
|
|
14
|
+
*/
|
|
15
|
+
export class Logger {
|
|
16
|
+
constructor(verbose = false) {
|
|
17
|
+
this.verbose = verbose;
|
|
18
|
+
this.spinnerState = 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Log a success message
|
|
23
|
+
* @param {string} message - Message to log
|
|
24
|
+
*/
|
|
25
|
+
success(message) {
|
|
26
|
+
console.log(format.success(message));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Log an error message
|
|
31
|
+
* @param {string} message - Message to log
|
|
32
|
+
*/
|
|
33
|
+
error(message) {
|
|
34
|
+
console.error(format.error(message));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Log an info message
|
|
39
|
+
* @param {string} message - Message to log
|
|
40
|
+
*/
|
|
41
|
+
info(message) {
|
|
42
|
+
console.log(format.info(message));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Log a warning message
|
|
47
|
+
* @param {string} message - Message to log
|
|
48
|
+
*/
|
|
49
|
+
warn(message) {
|
|
50
|
+
console.warn(format.warning(message));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Log a debug message (only if verbose mode enabled)
|
|
55
|
+
* @param {string} message - Message to log
|
|
56
|
+
*/
|
|
57
|
+
debug(message) {
|
|
58
|
+
if (this.verbose) {
|
|
59
|
+
console.log(colors.emphasis.muted(`[DEBUG] ${message}`));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Log a section header
|
|
65
|
+
* @param {string} title - Section title
|
|
66
|
+
*/
|
|
67
|
+
section(title) {
|
|
68
|
+
console.log(format.section(title));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Start a spinner animation
|
|
73
|
+
* @param {string} message - Message while spinning
|
|
74
|
+
* @returns {Function} Function to stop spinner
|
|
75
|
+
*/
|
|
76
|
+
spinner(message) {
|
|
77
|
+
const frames = colors.markers.spinner;
|
|
78
|
+
let index = 0;
|
|
79
|
+
|
|
80
|
+
const interval = setInterval(() => {
|
|
81
|
+
process.stdout.write(
|
|
82
|
+
`\r${chalk.blueBright(frames[index])} ${message}`
|
|
83
|
+
);
|
|
84
|
+
index = (index + 1) % frames.length;
|
|
85
|
+
}, 100);
|
|
86
|
+
|
|
87
|
+
return () => {
|
|
88
|
+
clearInterval(interval);
|
|
89
|
+
process.stdout.write('\r'); // Clear line
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Utility functions for common operations
|
|
96
|
+
*/
|
|
97
|
+
export const utils = {
|
|
98
|
+
/**
|
|
99
|
+
* Wait for a specified number of milliseconds
|
|
100
|
+
* @param {number} ms - Milliseconds to wait
|
|
101
|
+
* @returns {Promise<void>}
|
|
102
|
+
*/
|
|
103
|
+
async wait(ms) {
|
|
104
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Retry an async operation with exponential backoff
|
|
109
|
+
* @param {Function} fn - Async function to retry
|
|
110
|
+
* @param {number} maxAttempts - Maximum retry attempts
|
|
111
|
+
* @param {number} baseDelay - Base delay in milliseconds
|
|
112
|
+
* @returns {Promise<any>} Result from successful attempt
|
|
113
|
+
*/
|
|
114
|
+
async retry(fn, maxAttempts = 5, baseDelay = 1000) {
|
|
115
|
+
let lastError;
|
|
116
|
+
|
|
117
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
118
|
+
try {
|
|
119
|
+
return await fn();
|
|
120
|
+
} catch (error) {
|
|
121
|
+
lastError = error;
|
|
122
|
+
if (attempt < maxAttempts) {
|
|
123
|
+
const delay = baseDelay * Math.pow(2, attempt - 1);
|
|
124
|
+
await this.wait(delay);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
throw lastError;
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if a URL is reachable
|
|
134
|
+
* @param {string} url - URL to check
|
|
135
|
+
* @param {number} timeout - Timeout in milliseconds
|
|
136
|
+
* @returns {Promise<boolean>} True if reachable
|
|
137
|
+
*/
|
|
138
|
+
async isUrlReachable(url, timeout = 5000) {
|
|
139
|
+
try {
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
142
|
+
|
|
143
|
+
const response = await fetch(url, {
|
|
144
|
+
method: 'GET',
|
|
145
|
+
signal: controller.signal,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
clearTimeout(timeoutId);
|
|
149
|
+
return response.ok;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Format milliseconds to human-readable duration
|
|
157
|
+
* @param {number} ms - Milliseconds
|
|
158
|
+
* @returns {string} Formatted duration (e.g., "2.5s", "1m 30s")
|
|
159
|
+
*/
|
|
160
|
+
formatDuration(ms) {
|
|
161
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
162
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
163
|
+
|
|
164
|
+
const minutes = Math.floor(ms / 60000);
|
|
165
|
+
const seconds = Math.round((ms % 60000) / 1000);
|
|
166
|
+
return `${minutes}m ${seconds}s`;
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Parse duration string to milliseconds
|
|
171
|
+
* @param {string} duration - Duration string (e.g., "5m", "30s")
|
|
172
|
+
* @returns {number} Milliseconds
|
|
173
|
+
*/
|
|
174
|
+
parseDuration(duration) {
|
|
175
|
+
const match = duration.match(/^(\d+)([smh])$/);
|
|
176
|
+
if (!match) throw new Error(`Invalid duration format: ${duration}`);
|
|
177
|
+
|
|
178
|
+
const [, amount, unit] = match;
|
|
179
|
+
const multipliers = { s: 1000, m: 60000, h: 3600000 };
|
|
180
|
+
return parseInt(amount, 10) * multipliers[unit];
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Deep merge objects
|
|
185
|
+
* @param {Object} target - Target object
|
|
186
|
+
* @param {Object} source - Source object to merge
|
|
187
|
+
* @returns {Object} Merged object
|
|
188
|
+
*/
|
|
189
|
+
deepMerge(target, source) {
|
|
190
|
+
const result = { ...target };
|
|
191
|
+
|
|
192
|
+
for (const key in source) {
|
|
193
|
+
if (source.hasOwnProperty(key)) {
|
|
194
|
+
if (
|
|
195
|
+
typeof source[key] === 'object' &&
|
|
196
|
+
source[key] !== null &&
|
|
197
|
+
!Array.isArray(source[key])
|
|
198
|
+
) {
|
|
199
|
+
result[key] = this.deepMerge(result[key] || {}, source[key]);
|
|
200
|
+
} else {
|
|
201
|
+
result[key] = source[key];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return result;
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Validate URL format
|
|
211
|
+
* @param {string} url - URL to validate
|
|
212
|
+
* @returns {boolean} True if valid URL
|
|
213
|
+
*/
|
|
214
|
+
isValidUrl(url) {
|
|
215
|
+
try {
|
|
216
|
+
new URL(url);
|
|
217
|
+
return true;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export default { Logger, utils };
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ╔═══════════════════════════════════════════════════════════════╗
|
|
3
|
+
* ║ TEST FILE EXAMPLES ║
|
|
4
|
+
* ║ Unit and integration tests for wilfredwake ║
|
|
5
|
+
* ╚═══════════════════════════════════════════════════════════════╝
|
|
6
|
+
*
|
|
7
|
+
* Run tests with: npm test
|
|
8
|
+
* Watch mode: npm run test:watch
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import test from 'node:test';
|
|
12
|
+
import assert from 'node:assert';
|
|
13
|
+
import ServiceRegistry from '../src/orchestrator/registry.js';
|
|
14
|
+
import { Orchestrator, ServiceState } from '../src/orchestrator/orchestrator.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Test Suite: Service Registry
|
|
18
|
+
*/
|
|
19
|
+
test('ServiceRegistry - Load and validate YAML', async (t) => {
|
|
20
|
+
const registry = new ServiceRegistry();
|
|
21
|
+
|
|
22
|
+
const yaml = `
|
|
23
|
+
services:
|
|
24
|
+
dev:
|
|
25
|
+
auth:
|
|
26
|
+
url: https://auth.test
|
|
27
|
+
health: /health
|
|
28
|
+
wake: /wake
|
|
29
|
+
dependsOn: []
|
|
30
|
+
payment:
|
|
31
|
+
url: https://payment.test
|
|
32
|
+
health: /health
|
|
33
|
+
wake: /wake
|
|
34
|
+
dependsOn: [auth]
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
await registry.loadFromString(yaml, 'yaml');
|
|
38
|
+
const services = registry.getServices('dev');
|
|
39
|
+
|
|
40
|
+
assert.equal(services.length, 2, 'Should load 2 services');
|
|
41
|
+
assert.equal(services[0].name, 'auth', 'First service should be auth');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('ServiceRegistry - Resolve wake order with dependencies', async (t) => {
|
|
45
|
+
const registry = new ServiceRegistry();
|
|
46
|
+
|
|
47
|
+
const yaml = `
|
|
48
|
+
services:
|
|
49
|
+
dev:
|
|
50
|
+
auth:
|
|
51
|
+
url: https://auth.test
|
|
52
|
+
health: /health
|
|
53
|
+
wake: /wake
|
|
54
|
+
dependsOn: []
|
|
55
|
+
payment:
|
|
56
|
+
url: https://payment.test
|
|
57
|
+
health: /health
|
|
58
|
+
wake: /wake
|
|
59
|
+
dependsOn: [auth]
|
|
60
|
+
consumer:
|
|
61
|
+
url: https://consumer.test
|
|
62
|
+
health: /health
|
|
63
|
+
wake: /wake
|
|
64
|
+
dependsOn: [payment]
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
await registry.loadFromString(yaml, 'yaml');
|
|
68
|
+
const order = registry.resolveWakeOrder('all', 'dev');
|
|
69
|
+
|
|
70
|
+
assert.equal(order.length, 3, 'Should resolve 3 services');
|
|
71
|
+
assert.equal(order[0].name, 'auth', 'Auth should be first');
|
|
72
|
+
assert.equal(order[1].name, 'payment', 'Payment should be second');
|
|
73
|
+
assert.equal(order[2].name, 'consumer', 'Consumer should be third');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('ServiceRegistry - Detect circular dependencies', async (t) => {
|
|
77
|
+
const registry = new ServiceRegistry();
|
|
78
|
+
|
|
79
|
+
const yaml = `
|
|
80
|
+
services:
|
|
81
|
+
dev:
|
|
82
|
+
auth:
|
|
83
|
+
url: https://auth.test
|
|
84
|
+
health: /health
|
|
85
|
+
wake: /wake
|
|
86
|
+
dependsOn: [payment]
|
|
87
|
+
payment:
|
|
88
|
+
url: https://payment.test
|
|
89
|
+
health: /health
|
|
90
|
+
wake: /wake
|
|
91
|
+
dependsOn: [auth]
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
await registry.loadFromString(yaml, 'yaml');
|
|
95
|
+
|
|
96
|
+
assert.throws(
|
|
97
|
+
() => registry.resolveWakeOrder('all', 'dev'),
|
|
98
|
+
/Circular dependency/,
|
|
99
|
+
'Should detect circular dependency'
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('ServiceRegistry - Get service by name', async (t) => {
|
|
104
|
+
const registry = new ServiceRegistry();
|
|
105
|
+
|
|
106
|
+
const yaml = `
|
|
107
|
+
services:
|
|
108
|
+
dev:
|
|
109
|
+
auth:
|
|
110
|
+
url: https://auth.test
|
|
111
|
+
health: /health
|
|
112
|
+
wake: /wake
|
|
113
|
+
dependsOn: []
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
await registry.loadFromString(yaml, 'yaml');
|
|
117
|
+
const service = registry.getService('auth', 'dev');
|
|
118
|
+
|
|
119
|
+
assert.ok(service, 'Should find service');
|
|
120
|
+
assert.equal(service.name, 'auth', 'Service name should match');
|
|
121
|
+
assert.equal(service.url, 'https://auth.test', 'Service URL should match');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('ServiceRegistry - Get registry statistics', async (t) => {
|
|
125
|
+
const registry = new ServiceRegistry();
|
|
126
|
+
|
|
127
|
+
const yaml = `
|
|
128
|
+
services:
|
|
129
|
+
dev:
|
|
130
|
+
auth:
|
|
131
|
+
url: https://auth.test
|
|
132
|
+
health: /health
|
|
133
|
+
wake: /wake
|
|
134
|
+
dependsOn: []
|
|
135
|
+
payment:
|
|
136
|
+
url: https://payment.test
|
|
137
|
+
health: /health
|
|
138
|
+
wake: /wake
|
|
139
|
+
dependsOn: [auth]
|
|
140
|
+
staging:
|
|
141
|
+
auth:
|
|
142
|
+
url: https://auth-staging.test
|
|
143
|
+
health: /health
|
|
144
|
+
wake: /wake
|
|
145
|
+
dependsOn: []
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
await registry.loadFromString(yaml, 'yaml');
|
|
149
|
+
const stats = registry.getStats();
|
|
150
|
+
|
|
151
|
+
assert.equal(stats.totalServices, 3, 'Should count 3 total services');
|
|
152
|
+
assert.equal(stats.environments.length, 2, 'Should have 2 environments');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Test Suite: Orchestrator
|
|
157
|
+
*/
|
|
158
|
+
test('Orchestrator - Wake order respects dependencies', async (t) => {
|
|
159
|
+
const registry = new ServiceRegistry();
|
|
160
|
+
|
|
161
|
+
const yaml = `
|
|
162
|
+
services:
|
|
163
|
+
dev:
|
|
164
|
+
auth:
|
|
165
|
+
url: https://auth.test
|
|
166
|
+
health: /health
|
|
167
|
+
wake: /wake
|
|
168
|
+
dependsOn: []
|
|
169
|
+
payment:
|
|
170
|
+
url: https://payment.test
|
|
171
|
+
health: /health
|
|
172
|
+
wake: /wake
|
|
173
|
+
dependsOn: [auth]
|
|
174
|
+
`;
|
|
175
|
+
|
|
176
|
+
await registry.loadFromString(yaml, 'yaml');
|
|
177
|
+
const orchestrator = new Orchestrator(registry);
|
|
178
|
+
|
|
179
|
+
const order = registry.resolveWakeOrder('payment', 'dev');
|
|
180
|
+
|
|
181
|
+
assert.equal(order[0].name, 'auth', 'Auth should wake first');
|
|
182
|
+
assert.equal(order[1].name, 'payment', 'Payment should wake second');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('Orchestrator - Set and get service state', async (t) => {
|
|
186
|
+
const registry = new ServiceRegistry();
|
|
187
|
+
|
|
188
|
+
const yaml = `
|
|
189
|
+
services:
|
|
190
|
+
dev:
|
|
191
|
+
auth:
|
|
192
|
+
url: https://auth.test
|
|
193
|
+
health: /health
|
|
194
|
+
wake: /wake
|
|
195
|
+
dependsOn: []
|
|
196
|
+
`;
|
|
197
|
+
|
|
198
|
+
await registry.loadFromString(yaml, 'yaml');
|
|
199
|
+
const orchestrator = new Orchestrator(registry);
|
|
200
|
+
|
|
201
|
+
orchestrator._setServiceState('auth', ServiceState.LIVE);
|
|
202
|
+
|
|
203
|
+
// Note: In real implementation, would have a getter method
|
|
204
|
+
assert.ok(orchestrator.serviceStates.has('auth'), 'Should store service state');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Test Suite: Error Handling
|
|
209
|
+
*/
|
|
210
|
+
test('ServiceRegistry - Validate missing required fields', async (t) => {
|
|
211
|
+
const registry = new ServiceRegistry();
|
|
212
|
+
|
|
213
|
+
const invalidYaml = `
|
|
214
|
+
services:
|
|
215
|
+
dev:
|
|
216
|
+
auth:
|
|
217
|
+
url: https://auth.test
|
|
218
|
+
`;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
await registry.loadFromString(invalidYaml, 'yaml');
|
|
222
|
+
assert.fail('Should have thrown validation error');
|
|
223
|
+
} catch (error) {
|
|
224
|
+
assert.ok(error.message.includes('health'), 'Should error on missing health endpoint');
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('ServiceRegistry - Handle invalid YAML', async (t) => {
|
|
229
|
+
const registry = new ServiceRegistry();
|
|
230
|
+
|
|
231
|
+
const invalidYaml = `
|
|
232
|
+
services:
|
|
233
|
+
dev: [invalid]
|
|
234
|
+
`;
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
await registry.loadFromString(invalidYaml, 'yaml');
|
|
238
|
+
assert.fail('Should have thrown error');
|
|
239
|
+
} catch (error) {
|
|
240
|
+
assert.ok(error.message.includes('must be an object'), 'Should error on invalid structure');
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Test Suite: Configuration
|
|
246
|
+
*/
|
|
247
|
+
test('Config - Validate URL format', async (t) => {
|
|
248
|
+
const { utils } = await import('../src/shared/logger.js');
|
|
249
|
+
|
|
250
|
+
assert.ok(utils.isValidUrl('http://localhost:3000'), 'Valid URL');
|
|
251
|
+
assert.ok(utils.isValidUrl('https://example.com'), 'Valid HTTPS URL');
|
|
252
|
+
assert.ok(!utils.isValidUrl('not-a-url'), 'Invalid URL');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('Config - Parse duration strings', async (t) => {
|
|
256
|
+
const { utils } = await import('../src/shared/logger.js');
|
|
257
|
+
|
|
258
|
+
assert.equal(utils.parseDuration('5s'), 5000, 'Parse seconds');
|
|
259
|
+
assert.equal(utils.parseDuration('2m'), 120000, 'Parse minutes');
|
|
260
|
+
assert.equal(utils.parseDuration('1h'), 3600000, 'Parse hours');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('Config - Format duration to readable string', async (t) => {
|
|
264
|
+
const { utils } = await import('../src/shared/logger.js');
|
|
265
|
+
|
|
266
|
+
assert.equal(utils.formatDuration(500), '500ms', 'Format milliseconds');
|
|
267
|
+
assert.equal(utils.formatDuration(5000), '5.0s', 'Format seconds');
|
|
268
|
+
assert.equal(utils.formatDuration(65000), '1m 5s', 'Format minutes');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Test Suite: Deep Merge
|
|
273
|
+
*/
|
|
274
|
+
test('Utils - Deep merge objects', async (t) => {
|
|
275
|
+
const { utils } = await import('../src/shared/logger.js');
|
|
276
|
+
|
|
277
|
+
const target = { a: 1, b: { c: 2 } };
|
|
278
|
+
const source = { b: { d: 3 }, e: 4 };
|
|
279
|
+
const result = utils.deepMerge(target, source);
|
|
280
|
+
|
|
281
|
+
assert.equal(result.a, 1, 'Should preserve target properties');
|
|
282
|
+
assert.equal(result.b.c, 2, 'Should preserve nested target');
|
|
283
|
+
assert.equal(result.b.d, 3, 'Should add new nested properties');
|
|
284
|
+
assert.equal(result.e, 4, 'Should add new properties');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Test Suite: Retry Logic
|
|
289
|
+
*/
|
|
290
|
+
test('Utils - Retry with exponential backoff', async (t) => {
|
|
291
|
+
const { utils } = await import('../src/shared/logger.js');
|
|
292
|
+
|
|
293
|
+
let attempts = 0;
|
|
294
|
+
|
|
295
|
+
const result = await utils.retry(
|
|
296
|
+
async () => {
|
|
297
|
+
attempts++;
|
|
298
|
+
if (attempts < 3) {
|
|
299
|
+
throw new Error('Fail');
|
|
300
|
+
}
|
|
301
|
+
return 'success';
|
|
302
|
+
},
|
|
303
|
+
5,
|
|
304
|
+
100
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
assert.equal(result, 'success', 'Should succeed on retry');
|
|
308
|
+
assert.equal(attempts, 3, 'Should make 3 attempts');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('Utils - Retry timeout after max attempts', async (t) => {
|
|
312
|
+
const { utils } = await import('../src/shared/logger.js');
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
await utils.retry(
|
|
316
|
+
async () => {
|
|
317
|
+
throw new Error('Always fails');
|
|
318
|
+
},
|
|
319
|
+
3,
|
|
320
|
+
10
|
|
321
|
+
);
|
|
322
|
+
assert.fail('Should have thrown error');
|
|
323
|
+
} catch (error) {
|
|
324
|
+
assert.ok(error.message.includes('Always fails'), 'Should throw last error');
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
console.log('\n✓ All tests completed');
|