@vizzly-testing/cli 0.25.0 → 0.26.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/dist/cli.js +14 -3
- package/dist/commands/finalize.js +42 -2
- package/dist/commands/preview.js +56 -7
- package/dist/commands/tdd-daemon.js +195 -22
- package/dist/reporter/reporter-bundle.iife.js +2 -2
- package/dist/tdd/server-registry.js +245 -0
- package/dist/utils/ci-env.js +1 -1
- package/dist/utils/global-config.js +26 -0
- package/package.json +1 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Manages a global registry of running TDD servers at ~/.vizzly/servers.json
|
|
9
|
+
* Enables the menubar app to discover and manage multiple concurrent servers.
|
|
10
|
+
*/
|
|
11
|
+
export class ServerRegistry {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.vizzlyHome = process.env.VIZZLY_HOME || join(homedir(), '.vizzly');
|
|
14
|
+
this.registryPath = join(this.vizzlyHome, 'servers.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Ensure the registry directory exists
|
|
19
|
+
*/
|
|
20
|
+
ensureDirectory() {
|
|
21
|
+
if (!existsSync(this.vizzlyHome)) {
|
|
22
|
+
mkdirSync(this.vizzlyHome, {
|
|
23
|
+
recursive: true
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the current registry, returning empty if it doesn't exist
|
|
30
|
+
*/
|
|
31
|
+
read() {
|
|
32
|
+
try {
|
|
33
|
+
if (existsSync(this.registryPath)) {
|
|
34
|
+
let data = JSON.parse(readFileSync(this.registryPath, 'utf8'));
|
|
35
|
+
return {
|
|
36
|
+
version: data.version || 1,
|
|
37
|
+
servers: data.servers || []
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
} catch (_err) {
|
|
41
|
+
// Corrupted file, start fresh
|
|
42
|
+
console.warn('Warning: Could not read server registry, starting fresh');
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
version: 1,
|
|
46
|
+
servers: []
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write the registry to disk
|
|
52
|
+
*/
|
|
53
|
+
write(registry) {
|
|
54
|
+
this.ensureDirectory();
|
|
55
|
+
writeFileSync(this.registryPath, JSON.stringify(registry, null, 2));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Register a new server in the registry
|
|
60
|
+
*/
|
|
61
|
+
register(serverInfo) {
|
|
62
|
+
// Validate required fields
|
|
63
|
+
if (!serverInfo.pid || !serverInfo.port || !serverInfo.directory) {
|
|
64
|
+
throw new Error('Missing required fields: pid, port, directory');
|
|
65
|
+
}
|
|
66
|
+
let port = Number(serverInfo.port);
|
|
67
|
+
let pid = Number(serverInfo.pid);
|
|
68
|
+
if (Number.isNaN(port) || Number.isNaN(pid)) {
|
|
69
|
+
throw new Error('Invalid port or pid - must be numbers');
|
|
70
|
+
}
|
|
71
|
+
let registry = this.read();
|
|
72
|
+
|
|
73
|
+
// Remove any existing entry for this port or directory (shouldn't happen, but be safe)
|
|
74
|
+
registry.servers = registry.servers.filter(s => s.port !== port && s.directory !== serverInfo.directory);
|
|
75
|
+
|
|
76
|
+
// Add the new server
|
|
77
|
+
registry.servers.push({
|
|
78
|
+
id: serverInfo.id || randomBytes(8).toString('hex'),
|
|
79
|
+
port,
|
|
80
|
+
pid,
|
|
81
|
+
directory: serverInfo.directory,
|
|
82
|
+
startedAt: serverInfo.startedAt || new Date().toISOString(),
|
|
83
|
+
configPath: serverInfo.configPath || null,
|
|
84
|
+
name: serverInfo.name || null,
|
|
85
|
+
logFile: serverInfo.logFile || null
|
|
86
|
+
});
|
|
87
|
+
this.write(registry);
|
|
88
|
+
this.notifyMenubar();
|
|
89
|
+
return registry;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Unregister a server by port and/or directory
|
|
94
|
+
* When both are provided, matches servers with BOTH criteria (AND logic)
|
|
95
|
+
* When only one is provided, matches servers with that criteria
|
|
96
|
+
*/
|
|
97
|
+
unregister({
|
|
98
|
+
port,
|
|
99
|
+
directory
|
|
100
|
+
}) {
|
|
101
|
+
let registry = this.read();
|
|
102
|
+
let initialCount = registry.servers.length;
|
|
103
|
+
if (port && directory) {
|
|
104
|
+
// Both specified - match servers with both port AND directory
|
|
105
|
+
registry.servers = registry.servers.filter(s => !(s.port === port && s.directory === directory));
|
|
106
|
+
} else if (port) {
|
|
107
|
+
registry.servers = registry.servers.filter(s => s.port !== port);
|
|
108
|
+
} else if (directory) {
|
|
109
|
+
registry.servers = registry.servers.filter(s => s.directory !== directory);
|
|
110
|
+
}
|
|
111
|
+
if (registry.servers.length !== initialCount) {
|
|
112
|
+
this.write(registry);
|
|
113
|
+
this.notifyMenubar();
|
|
114
|
+
}
|
|
115
|
+
return registry;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Find a server by port or directory
|
|
120
|
+
*/
|
|
121
|
+
find({
|
|
122
|
+
port,
|
|
123
|
+
directory
|
|
124
|
+
}) {
|
|
125
|
+
let registry = this.read();
|
|
126
|
+
if (port) {
|
|
127
|
+
return registry.servers.find(s => s.port === port);
|
|
128
|
+
}
|
|
129
|
+
if (directory) {
|
|
130
|
+
return registry.servers.find(s => s.directory === directory);
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get all registered servers
|
|
137
|
+
*/
|
|
138
|
+
list() {
|
|
139
|
+
return this.read().servers;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Remove servers whose PIDs no longer exist (stale entries)
|
|
144
|
+
*/
|
|
145
|
+
cleanupStale() {
|
|
146
|
+
let registry = this.read();
|
|
147
|
+
let initialCount = registry.servers.length;
|
|
148
|
+
registry.servers = registry.servers.filter(server => {
|
|
149
|
+
try {
|
|
150
|
+
// Signal 0 doesn't kill, just checks if process exists
|
|
151
|
+
process.kill(server.pid, 0);
|
|
152
|
+
return true;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// ESRCH = process doesn't exist, EPERM = exists but no permission (still valid)
|
|
155
|
+
return err.code === 'EPERM';
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
if (registry.servers.length !== initialCount) {
|
|
159
|
+
this.write(registry);
|
|
160
|
+
this.notifyMenubar();
|
|
161
|
+
return initialCount - registry.servers.length;
|
|
162
|
+
}
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Notify the menubar app that the registry changed
|
|
168
|
+
*
|
|
169
|
+
* NOTE: The menubar app primarily uses FSEvents file watching on servers.json.
|
|
170
|
+
* This method is a placeholder for future notification mechanisms (e.g., XPC).
|
|
171
|
+
* For now, file watching provides reliable, immediate updates.
|
|
172
|
+
*/
|
|
173
|
+
notifyMenubar() {
|
|
174
|
+
// File watching on servers.json is the primary notification mechanism.
|
|
175
|
+
// This method exists for future enhancements (XPC, etc.) but is currently a no-op.
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get all ports currently in use by registered servers
|
|
180
|
+
* @returns {Set<number>} Set of ports in use
|
|
181
|
+
*/
|
|
182
|
+
getUsedPorts() {
|
|
183
|
+
let registry = this.read();
|
|
184
|
+
return new Set(registry.servers.map(s => s.port));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Find an available port starting from the default
|
|
189
|
+
* @param {number} startPort - Port to start searching from (default: 47392)
|
|
190
|
+
* @param {number} maxAttempts - Maximum ports to try (default: 100)
|
|
191
|
+
* @returns {Promise<number>} Available port
|
|
192
|
+
*/
|
|
193
|
+
async findAvailablePort(startPort = 47392, maxAttempts = 100) {
|
|
194
|
+
// Clean up stale entries first
|
|
195
|
+
this.cleanupStale();
|
|
196
|
+
let usedPorts = this.getUsedPorts();
|
|
197
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
198
|
+
let port = startPort + i;
|
|
199
|
+
|
|
200
|
+
// Skip if registered in our registry
|
|
201
|
+
if (usedPorts.has(port)) continue;
|
|
202
|
+
|
|
203
|
+
// Check if port is actually free (not used by other apps)
|
|
204
|
+
let isFree = await isPortFree(port);
|
|
205
|
+
if (isFree) {
|
|
206
|
+
return port;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Fallback to default if nothing found (will fail later with clear error)
|
|
211
|
+
return startPort;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Check if a port is free (not in use by any process)
|
|
217
|
+
* @param {number} port - Port to check
|
|
218
|
+
* @returns {Promise<boolean>} True if port is free
|
|
219
|
+
*/
|
|
220
|
+
async function isPortFree(port) {
|
|
221
|
+
return new Promise(resolve => {
|
|
222
|
+
let server = createServer();
|
|
223
|
+
server.once('error', err => {
|
|
224
|
+
if (err.code === 'EADDRINUSE') {
|
|
225
|
+
resolve(false);
|
|
226
|
+
} else {
|
|
227
|
+
// Other errors - assume port is free
|
|
228
|
+
resolve(true);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
server.once('listening', () => {
|
|
232
|
+
server.close(() => resolve(true));
|
|
233
|
+
});
|
|
234
|
+
server.listen(port, '127.0.0.1');
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Singleton instance
|
|
239
|
+
let registryInstance = null;
|
|
240
|
+
export function getServerRegistry() {
|
|
241
|
+
if (!registryInstance) {
|
|
242
|
+
registryInstance = new ServerRegistry();
|
|
243
|
+
}
|
|
244
|
+
return registryInstance;
|
|
245
|
+
}
|
package/dist/utils/ci-env.js
CHANGED
|
@@ -100,6 +100,32 @@ export async function clearGlobalConfig() {
|
|
|
100
100
|
await saveGlobalConfig({});
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Save user's PATH for menubar app to use
|
|
105
|
+
* This auto-configures the menubar app so it can find npx/node
|
|
106
|
+
* @returns {Promise<void>}
|
|
107
|
+
*/
|
|
108
|
+
export async function saveUserPath() {
|
|
109
|
+
let config = await loadGlobalConfig();
|
|
110
|
+
let userPath = process.env.PATH;
|
|
111
|
+
|
|
112
|
+
// Only update if PATH has changed
|
|
113
|
+
if (config.userPath === userPath) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
config.userPath = userPath;
|
|
117
|
+
await saveGlobalConfig(config);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get stored user PATH for external tools (like menubar app)
|
|
122
|
+
* @returns {Promise<string|null>} PATH string or null if not configured
|
|
123
|
+
*/
|
|
124
|
+
export async function getUserPath() {
|
|
125
|
+
let config = await loadGlobalConfig();
|
|
126
|
+
return config.userPath || null;
|
|
127
|
+
}
|
|
128
|
+
|
|
103
129
|
/**
|
|
104
130
|
* Get authentication tokens from global config
|
|
105
131
|
* @returns {Promise<Object|null>} Token object with accessToken, refreshToken, expiresAt, user, or null if not found
|