@matware/e2e-runner 1.2.1 → 1.3.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/.claude-plugin/marketplace.json +21 -0
- package/.mcp.json +2 -2
- package/.opencode/commands/create-test.md +63 -0
- package/.opencode/commands/run.md +50 -0
- package/.opencode/commands/verify-issue.md +62 -0
- package/.opencode/skills/e2e-testing/SKILL.md +181 -0
- package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
- package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
- package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
- package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
- package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
- package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
- package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/.opencode/skills/e2e-testing/references/variables.md +41 -0
- package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
- package/OPENCODE.md +166 -0
- package/README.md +581 -55
- package/agents/test-creator.md +54 -1
- package/agents/test-improver.md +37 -0
- package/bin/cli.js +408 -16
- package/commands/create-test.md +16 -1
- package/opencode.json +11 -0
- package/package.json +7 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +10 -3
- package/skills/e2e-testing/references/action-types.md +48 -5
- package/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/skills/e2e-testing/references/graphql.md +59 -0
- package/skills/e2e-testing/references/issue-verification.md +59 -0
- package/skills/e2e-testing/references/multi-pool.md +60 -0
- package/skills/e2e-testing/references/network-debugging.md +62 -0
- package/skills/e2e-testing/references/test-json-format.md +4 -0
- package/skills/e2e-testing/references/troubleshooting.md +44 -2
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +324 -2
- package/src/ai-generate.js +58 -8
- package/src/config.js +143 -0
- package/src/dashboard.js +145 -13
- package/src/db.js +130 -2
- package/src/index.js +7 -6
- package/src/learner-sqlite.js +304 -0
- package/src/learner.js +8 -3
- package/src/mcp-tools.js +1121 -43
- package/src/module-resolver.js +37 -0
- package/src/narrate.js +37 -0
- package/src/pool-manager.js +223 -0
- package/src/reporter.js +82 -1
- package/src/runner.js +157 -28
- package/src/sync/auth.js +354 -0
- package/src/sync/client.js +572 -0
- package/src/sync/hub-routes.js +816 -0
- package/src/sync/index.js +68 -0
- package/src/sync/middleware.js +347 -0
- package/src/sync/queue.js +209 -0
- package/src/sync/schema.js +540 -0
- package/src/verify.js +10 -7
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +47 -6
- package/templates/dashboard/js/api.js +60 -0
- package/templates/dashboard/js/init.js +13 -0
- package/templates/dashboard/js/keyboard.js +46 -0
- package/templates/dashboard/js/state.js +40 -0
- package/templates/dashboard/js/toast.js +41 -0
- package/templates/dashboard/js/utils.js +196 -0
- package/templates/dashboard/js/view-live.js +143 -0
- package/templates/dashboard/js/view-runs.js +572 -0
- package/templates/dashboard/js/view-tests.js +294 -0
- package/templates/dashboard/js/view-watch.js +242 -0
- package/templates/dashboard/js/websocket.js +110 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +110 -0
- package/templates/dashboard/styles/view-live.css +74 -0
- package/templates/dashboard/styles/view-runs.css +207 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +165 -99
- package/templates/dashboard.html +1596 -541
- package/templates/sample-test.json +0 -8
- package/templates/dashboard/app.js +0 -1152
- package/templates/dashboard/styles.css +0 -413
package/src/watch.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch Engine — 24/7 test scheduler with interval, git-polling, and webhook triggers.
|
|
3
|
+
*
|
|
4
|
+
* Communicates with the dashboard via HTTP (POST /api/run, GET /api/status)
|
|
5
|
+
* to reuse the existing 409 guard and run persistence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import http from 'http';
|
|
9
|
+
import https from 'https';
|
|
10
|
+
import { execFileSync } from 'child_process';
|
|
11
|
+
import { startDashboard } from './dashboard.js';
|
|
12
|
+
import { log, colors as C } from './logger.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse human-readable interval string to milliseconds.
|
|
16
|
+
* Supports: '15m', '1h', '30s', '2h30m', or raw ms number.
|
|
17
|
+
*/
|
|
18
|
+
export function parseInterval(value) {
|
|
19
|
+
if (value == null) return null;
|
|
20
|
+
if (typeof value === 'number') return value;
|
|
21
|
+
const str = String(value).trim();
|
|
22
|
+
if (/^\d+$/.test(str)) return parseInt(str, 10);
|
|
23
|
+
|
|
24
|
+
let total = 0;
|
|
25
|
+
const regex = /(\d+)\s*(h|m|s)/gi;
|
|
26
|
+
let match;
|
|
27
|
+
while ((match = regex.exec(str)) !== null) {
|
|
28
|
+
const n = parseInt(match[1], 10);
|
|
29
|
+
switch (match[2].toLowerCase()) {
|
|
30
|
+
case 'h': total += n * 3600000; break;
|
|
31
|
+
case 'm': total += n * 60000; break;
|
|
32
|
+
case 's': total += n * 1000; break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (total === 0) throw new Error(`Invalid interval: "${value}". Use format like 15m, 1h, 30s`);
|
|
36
|
+
return total;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Get the current git commit hash for a directory and optional branch. */
|
|
40
|
+
function getGitCommitHash(cwd, branch) {
|
|
41
|
+
const ref = branch || 'HEAD';
|
|
42
|
+
try {
|
|
43
|
+
return execFileSync('git', ['rev-parse', ref], { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Format milliseconds as a human-readable string. */
|
|
50
|
+
function formatMs(ms) {
|
|
51
|
+
if (ms >= 3600000) return `${(ms / 3600000).toFixed(1)}h`;
|
|
52
|
+
if (ms >= 60000) return `${(ms / 60000).toFixed(0)}m`;
|
|
53
|
+
return `${(ms / 1000).toFixed(0)}s`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** HTTP POST JSON to a URL using built-in http/https. Returns a promise. */
|
|
57
|
+
function httpPostJson(url, body) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const parsed = new URL(url);
|
|
60
|
+
const mod = parsed.protocol === 'https:' ? https : http;
|
|
61
|
+
const data = JSON.stringify(body);
|
|
62
|
+
const req = mod.request({
|
|
63
|
+
hostname: parsed.hostname,
|
|
64
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
65
|
+
path: parsed.pathname + parsed.search,
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
|
68
|
+
timeout: 15000,
|
|
69
|
+
}, (res) => {
|
|
70
|
+
let buf = '';
|
|
71
|
+
res.on('data', (chunk) => { buf += chunk; });
|
|
72
|
+
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
|
|
73
|
+
});
|
|
74
|
+
req.on('error', reject);
|
|
75
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
|
|
76
|
+
req.end(data);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** HTTP GET JSON from a localhost URL. */
|
|
81
|
+
function httpGetJson(url) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const parsed = new URL(url);
|
|
84
|
+
const mod = parsed.protocol === 'https:' ? https : http;
|
|
85
|
+
const req = mod.request({
|
|
86
|
+
hostname: parsed.hostname,
|
|
87
|
+
port: parsed.port,
|
|
88
|
+
path: parsed.pathname + parsed.search,
|
|
89
|
+
method: 'GET',
|
|
90
|
+
timeout: 10000,
|
|
91
|
+
}, (res) => {
|
|
92
|
+
let buf = '';
|
|
93
|
+
res.on('data', (chunk) => { buf += chunk; });
|
|
94
|
+
res.on('end', () => {
|
|
95
|
+
try { resolve(JSON.parse(buf)); } catch { resolve(null); }
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
req.on('error', reject);
|
|
99
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
|
|
100
|
+
req.end();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Send a webhook notification. */
|
|
105
|
+
async function sendWebhook(url, payload) {
|
|
106
|
+
try {
|
|
107
|
+
const resp = await httpPostJson(url, payload);
|
|
108
|
+
if (resp.status >= 400) {
|
|
109
|
+
log('⚠️', `${C.yellow}Webhook returned ${resp.status}${C.reset}`);
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
log('⚠️', `${C.yellow}Webhook failed: ${err.message}${C.reset}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Trigger a run via the dashboard API and wait for completion. */
|
|
117
|
+
async function triggerRun(state, port) {
|
|
118
|
+
if (state.running) return null;
|
|
119
|
+
state.running = true;
|
|
120
|
+
|
|
121
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
122
|
+
try {
|
|
123
|
+
const body = state.projectId ? { projectId: state.projectId } : {};
|
|
124
|
+
const resp = await httpPostJson(`${baseUrl}/api/run`, body);
|
|
125
|
+
|
|
126
|
+
if (resp.status === 409) {
|
|
127
|
+
log('⏭️', `${C.dim}${state.name}: skipped (run already in progress)${C.reset}`);
|
|
128
|
+
state.running = false;
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (resp.status !== 200) {
|
|
133
|
+
log('⚠️', `${C.yellow}${state.name}: run trigger failed (HTTP ${resp.status})${C.reset}`);
|
|
134
|
+
state.running = false;
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Poll until complete
|
|
139
|
+
const result = await waitForRunComplete(baseUrl, state.config.testTimeout * 10 || 600000);
|
|
140
|
+
state.running = false;
|
|
141
|
+
return result;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
log('⚠️', `${C.yellow}${state.name}: run error — ${err.message}${C.reset}`);
|
|
144
|
+
state.running = false;
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Poll GET /api/status until dashboard.running is false. */
|
|
150
|
+
async function waitForRunComplete(baseUrl, timeoutMs) {
|
|
151
|
+
const start = Date.now();
|
|
152
|
+
while (Date.now() - start < timeoutMs) {
|
|
153
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
154
|
+
try {
|
|
155
|
+
const status = await httpGetJson(`${baseUrl}/api/status`);
|
|
156
|
+
if (status && !status.dashboard?.running) {
|
|
157
|
+
// Fetch latest report
|
|
158
|
+
try {
|
|
159
|
+
return await httpGetJson(`${baseUrl}/api/report/latest`);
|
|
160
|
+
} catch { return null; }
|
|
161
|
+
}
|
|
162
|
+
} catch { /* dashboard may be briefly unavailable */ }
|
|
163
|
+
}
|
|
164
|
+
log('⚠️', `${C.yellow}Run timed out after ${formatMs(timeoutMs)}${C.reset}`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Evaluate a run result and send webhook if needed. */
|
|
169
|
+
async function evaluateResult(state, report, config) {
|
|
170
|
+
if (!report?.summary) return;
|
|
171
|
+
|
|
172
|
+
const prevResult = state.lastResult;
|
|
173
|
+
const failed = report.summary.failed > 0;
|
|
174
|
+
state.lastResult = failed ? 'fail' : 'pass';
|
|
175
|
+
|
|
176
|
+
const wasRecovery = prevResult === 'fail' && !failed;
|
|
177
|
+
const statusIcon = failed ? '❌' : wasRecovery ? '🟢' : '✅';
|
|
178
|
+
const statusText = failed
|
|
179
|
+
? `${C.red}${report.summary.failed}/${report.summary.total} failed${C.reset}`
|
|
180
|
+
: wasRecovery
|
|
181
|
+
? `${C.green}RECOVERED — all ${report.summary.total} passed${C.reset}`
|
|
182
|
+
: `${C.green}all ${report.summary.total} passed${C.reset}`;
|
|
183
|
+
|
|
184
|
+
log(statusIcon, `${C.bold}${state.name}${C.reset}: ${statusText}`);
|
|
185
|
+
|
|
186
|
+
// Determine if webhook should fire
|
|
187
|
+
const webhookUrl = state.config.watchWebhookUrl || config.watchWebhookUrl;
|
|
188
|
+
if (!webhookUrl) return;
|
|
189
|
+
|
|
190
|
+
const events = (state.config.watchWebhookEvents || config.watchWebhookEvents || 'failure').toLowerCase();
|
|
191
|
+
const shouldSend =
|
|
192
|
+
events === 'always' ||
|
|
193
|
+
(events === 'failure' && failed) ||
|
|
194
|
+
(events === 'recovery' && wasRecovery) ||
|
|
195
|
+
(events.includes('failure') && failed) ||
|
|
196
|
+
(events.includes('recovery') && wasRecovery);
|
|
197
|
+
|
|
198
|
+
if (!shouldSend) return;
|
|
199
|
+
|
|
200
|
+
const passRate = report.summary.total > 0
|
|
201
|
+
? ((report.summary.passed / report.summary.total) * 100).toFixed(1) + '%'
|
|
202
|
+
: '0%';
|
|
203
|
+
|
|
204
|
+
const event = wasRecovery ? 'test:recovery' : failed ? 'test:failure' : 'test:pass';
|
|
205
|
+
const emoji = wasRecovery ? '🟢' : failed ? '❌' : '✅';
|
|
206
|
+
const text = wasRecovery
|
|
207
|
+
? `${emoji} ${state.name}: Recovered — all ${report.summary.total} tests passing`
|
|
208
|
+
: failed
|
|
209
|
+
? `${emoji} ${state.name}: ${report.summary.failed}/${report.summary.total} tests failed`
|
|
210
|
+
: `${emoji} ${state.name}: All ${report.summary.total} tests passed`;
|
|
211
|
+
|
|
212
|
+
await sendWebhook(webhookUrl, {
|
|
213
|
+
event,
|
|
214
|
+
project: state.name,
|
|
215
|
+
timestamp: new Date().toISOString(),
|
|
216
|
+
summary: {
|
|
217
|
+
total: report.summary.total,
|
|
218
|
+
passed: report.summary.passed,
|
|
219
|
+
failed: report.summary.failed,
|
|
220
|
+
passRate,
|
|
221
|
+
},
|
|
222
|
+
wasRecovery,
|
|
223
|
+
dashboardUrl: `http://localhost:${state.dashboardPort}`,
|
|
224
|
+
text,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Poll git for new commits and trigger a run on change. */
|
|
229
|
+
async function pollGit(state, port, config) {
|
|
230
|
+
const hash = getGitCommitHash(state.cwd, state.config.watchGitBranch || config.watchGitBranch);
|
|
231
|
+
if (!hash) return;
|
|
232
|
+
|
|
233
|
+
if (state.lastCommit && hash !== state.lastCommit) {
|
|
234
|
+
log('🔄', `${C.cyan}${state.name}${C.reset}: new commit ${C.dim}${hash.slice(0, 8)}${C.reset}`);
|
|
235
|
+
const report = await triggerRun(state, port);
|
|
236
|
+
if (report) await evaluateResult(state, report, config);
|
|
237
|
+
}
|
|
238
|
+
state.lastCommit = hash;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Start a watch job for a single project. Returns cleanup function. */
|
|
242
|
+
function startJob(projectDef, config, dashPort) {
|
|
243
|
+
const state = {
|
|
244
|
+
cwd: projectDef.cwd || config._cwd,
|
|
245
|
+
name: projectDef.name || config.projectName || 'default',
|
|
246
|
+
config: { ...config, ...projectDef },
|
|
247
|
+
running: false,
|
|
248
|
+
lastResult: null,
|
|
249
|
+
lastCommit: null,
|
|
250
|
+
nextRunAt: null,
|
|
251
|
+
dashboardPort: dashPort,
|
|
252
|
+
projectId: projectDef.projectId || null,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const timers = [];
|
|
256
|
+
|
|
257
|
+
// Interval-based trigger
|
|
258
|
+
const interval = projectDef.watchInterval || config.watchInterval;
|
|
259
|
+
if (interval) {
|
|
260
|
+
const ms = parseInterval(interval);
|
|
261
|
+
log('⏱️', `${C.bold}${state.name}${C.reset}: scheduled every ${C.cyan}${formatMs(ms)}${C.reset}`);
|
|
262
|
+
|
|
263
|
+
const runAndSchedule = async () => {
|
|
264
|
+
state.nextRunAt = new Date(Date.now() + ms).toISOString();
|
|
265
|
+
const report = await triggerRun(state, dashPort);
|
|
266
|
+
if (report) await evaluateResult(state, report, config);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Run on start if configured
|
|
270
|
+
const runOnStart = projectDef.watchRunOnStart !== undefined ? projectDef.watchRunOnStart : config.watchRunOnStart;
|
|
271
|
+
if (runOnStart) {
|
|
272
|
+
state.nextRunAt = new Date().toISOString();
|
|
273
|
+
setTimeout(() => runAndSchedule(), 1000);
|
|
274
|
+
} else {
|
|
275
|
+
state.nextRunAt = new Date(Date.now() + ms).toISOString();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
timers.push(setInterval(runAndSchedule, ms));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Git polling trigger
|
|
282
|
+
const gitPoll = projectDef.watchGitPoll !== undefined ? projectDef.watchGitPoll : config.watchGitPoll;
|
|
283
|
+
if (gitPoll) {
|
|
284
|
+
const gitMs = parseInterval(projectDef.watchGitInterval || config.watchGitInterval || '30s');
|
|
285
|
+
log('🔍', `${C.bold}${state.name}${C.reset}: git polling every ${C.cyan}${formatMs(gitMs)}${C.reset}`);
|
|
286
|
+
|
|
287
|
+
// Seed initial commit hash
|
|
288
|
+
state.lastCommit = getGitCommitHash(state.cwd, projectDef.watchGitBranch || config.watchGitBranch);
|
|
289
|
+
|
|
290
|
+
timers.push(setInterval(() => pollGit(state, dashPort, config), gitMs));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
state,
|
|
295
|
+
stop() {
|
|
296
|
+
timers.forEach(t => clearInterval(t));
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Start the watch engine.
|
|
303
|
+
*
|
|
304
|
+
* @param {object} config - Loaded config object
|
|
305
|
+
* @returns {Promise<{ stop: Function }>}
|
|
306
|
+
*/
|
|
307
|
+
export async function startWatch(config) {
|
|
308
|
+
// Start dashboard
|
|
309
|
+
const dashHandle = await startDashboard(config);
|
|
310
|
+
const port = dashHandle.port;
|
|
311
|
+
|
|
312
|
+
// Register /api/watch/status endpoint on the existing server
|
|
313
|
+
const jobs = [];
|
|
314
|
+
|
|
315
|
+
const originalListeners = dashHandle.server.listeners('request');
|
|
316
|
+
dashHandle.server.removeAllListeners('request');
|
|
317
|
+
dashHandle.server.on('request', (req, res) => {
|
|
318
|
+
if (req.url === '/api/watch/status' && req.method === 'GET') {
|
|
319
|
+
const data = jobs.map(j => ({
|
|
320
|
+
name: j.state.name,
|
|
321
|
+
cwd: j.state.cwd,
|
|
322
|
+
running: j.state.running,
|
|
323
|
+
lastResult: j.state.lastResult,
|
|
324
|
+
nextRunAt: j.state.nextRunAt,
|
|
325
|
+
lastCommit: j.state.lastCommit,
|
|
326
|
+
triggers: {
|
|
327
|
+
interval: j.state.config.watchInterval || config.watchInterval || null,
|
|
328
|
+
gitPoll: j.state.config.watchGitPoll || config.watchGitPoll || false,
|
|
329
|
+
},
|
|
330
|
+
}));
|
|
331
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
332
|
+
res.end(JSON.stringify(data));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// Fall through to original dashboard handler
|
|
336
|
+
for (const listener of originalListeners) {
|
|
337
|
+
listener.call(dashHandle.server, req, res);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Build project list
|
|
342
|
+
const projects = [];
|
|
343
|
+
if (config.watchProjects && Array.isArray(config.watchProjects)) {
|
|
344
|
+
for (const p of config.watchProjects) {
|
|
345
|
+
projects.push(p);
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
// Single-project mode
|
|
349
|
+
projects.push({
|
|
350
|
+
cwd: config._cwd,
|
|
351
|
+
name: config.projectName,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Validate: must have at least one trigger
|
|
356
|
+
const hasInterval = config.watchInterval;
|
|
357
|
+
const hasGit = config.watchGitPoll;
|
|
358
|
+
const anyProjectHasTrigger = projects.some(p => p.watchInterval || p.watchGitPoll);
|
|
359
|
+
|
|
360
|
+
if (!hasInterval && !hasGit && !anyProjectHasTrigger) {
|
|
361
|
+
log('⚠️', `${C.yellow}No triggers configured. Use --interval or --git to schedule runs.${C.reset}`);
|
|
362
|
+
log('', `${C.dim}Dashboard is running at http://0.0.0.0:${port} — you can trigger runs manually.${C.reset}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Start jobs
|
|
366
|
+
for (const p of projects) {
|
|
367
|
+
const job = startJob(p, config, port);
|
|
368
|
+
jobs.push(job);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
console.log('');
|
|
372
|
+
log('👁️', `${C.bold}Watch mode active${C.reset} — ${C.dim}${jobs.length} project${jobs.length !== 1 ? 's' : ''}${C.reset}`);
|
|
373
|
+
if (config.watchWebhookUrl) {
|
|
374
|
+
log('🔔', `Webhook: ${C.dim}${config.watchWebhookUrl}${C.reset} (${config.watchWebhookEvents})`);
|
|
375
|
+
}
|
|
376
|
+
console.log('');
|
|
377
|
+
|
|
378
|
+
const stop = () => {
|
|
379
|
+
jobs.forEach(j => j.stop());
|
|
380
|
+
dashHandle.close();
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
return { stop, jobs, dashHandle };
|
|
384
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Build script —
|
|
4
|
-
*
|
|
3
|
+
* Build script — reads modular styles/ and js/ directories,
|
|
4
|
+
* concatenates them in explicit order, wraps JS in an IIFE,
|
|
5
|
+
* and injects into template.html to produce dashboard.html.
|
|
5
6
|
*
|
|
6
7
|
* Usage: node templates/build-dashboard.js
|
|
7
8
|
*/
|
|
@@ -12,17 +13,57 @@ import { fileURLToPath } from 'url';
|
|
|
12
13
|
|
|
13
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const dashDir = path.join(__dirname, 'dashboard');
|
|
16
|
+
const stylesDir = path.join(dashDir, 'styles');
|
|
17
|
+
const jsDir = path.join(dashDir, 'js');
|
|
18
|
+
|
|
19
|
+
// Explicit file order — dependencies must come first
|
|
20
|
+
const CSS_ORDER = [
|
|
21
|
+
'base.css',
|
|
22
|
+
'components.css',
|
|
23
|
+
'view-watch.css',
|
|
24
|
+
'view-tests.css',
|
|
25
|
+
'view-runs.css',
|
|
26
|
+
'view-live.css',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const JS_ORDER = [
|
|
30
|
+
'utils.js',
|
|
31
|
+
'state.js',
|
|
32
|
+
'toast.js',
|
|
33
|
+
'api.js',
|
|
34
|
+
'websocket.js',
|
|
35
|
+
'view-watch.js',
|
|
36
|
+
'view-tests.js',
|
|
37
|
+
'view-runs.js',
|
|
38
|
+
'view-live.js',
|
|
39
|
+
'keyboard.js',
|
|
40
|
+
'init.js',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function readOrdered(dir, files) {
|
|
44
|
+
return files.map(f => {
|
|
45
|
+
const fp = path.join(dir, f);
|
|
46
|
+
if (!fs.existsSync(fp)) {
|
|
47
|
+
console.error(`Missing: ${fp}`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
return `/* ── ${f} ── */\n` + fs.readFileSync(fp, 'utf-8');
|
|
51
|
+
}).join('\n\n');
|
|
52
|
+
}
|
|
15
53
|
|
|
16
54
|
const template = fs.readFileSync(path.join(dashDir, 'template.html'), 'utf-8');
|
|
17
|
-
const styles =
|
|
18
|
-
const
|
|
55
|
+
const styles = readOrdered(stylesDir, CSS_ORDER);
|
|
56
|
+
const scripts = readOrdered(jsDir, JS_ORDER);
|
|
57
|
+
const wrappedScript = `(function(){\n'use strict';\n${scripts}\n})();`;
|
|
19
58
|
|
|
20
59
|
const output = template
|
|
21
60
|
.replace('/* __STYLES__ */', () => styles)
|
|
22
|
-
.replace('/* __SCRIPT__ */', () =>
|
|
61
|
+
.replace('/* __SCRIPT__ */', () => wrappedScript);
|
|
23
62
|
|
|
24
63
|
const outPath = path.join(__dirname, 'dashboard.html');
|
|
25
64
|
fs.writeFileSync(outPath, output);
|
|
26
65
|
|
|
27
66
|
const lines = output.split('\n').length;
|
|
28
|
-
|
|
67
|
+
const cssCount = CSS_ORDER.length;
|
|
68
|
+
const jsCount = JS_ORDER.length;
|
|
69
|
+
console.log(`Built ${outPath} (${lines} lines from ${cssCount} CSS + ${jsCount} JS files)`);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/* ── API & Pool ── */
|
|
2
|
+
function api(p){return fetch(p).then(function(r){return r.json()})}
|
|
3
|
+
function triggerRun(suite,projectId){
|
|
4
|
+
if(anyLiveRunning())return;
|
|
5
|
+
var body={};
|
|
6
|
+
if(suite)body.suite=suite;
|
|
7
|
+
if(projectId)body.projectId=projectId;
|
|
8
|
+
else if(S.project)body.projectId=S.project;
|
|
9
|
+
fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function renderPool(d){
|
|
13
|
+
if(!d)return;
|
|
14
|
+
var poolList=$('#poolList');
|
|
15
|
+
if(d.pools&&d.pools.length>1){
|
|
16
|
+
var anyAvail=d.availableCount>0;
|
|
17
|
+
$('#poolDot').className='pool-dot '+(anyAvail?'on':'off');
|
|
18
|
+
$('#poolLabel').textContent=anyAvail?d.availableCount+'/'+d.totalPools+' ready':'all busy';
|
|
19
|
+
$('#poolSessions').textContent=(d.totalRunning||0)+'/'+(d.totalMaxConcurrent||0);
|
|
20
|
+
poolList.textContent='';poolList.style.display='';
|
|
21
|
+
d.pools.forEach(function(p){
|
|
22
|
+
var label=(p.url||'').replace('ws://','').replace('wss://','');
|
|
23
|
+
var ok=!p.error&&p.available;
|
|
24
|
+
var dot=el('span',{className:'pool-dot '+(ok?'on':'off')});
|
|
25
|
+
var name=el('strong',{},label);
|
|
26
|
+
var status=el('span',{},p.error?'offline':p.available?'ready':'busy');
|
|
27
|
+
var sess=el('span',{className:'pool-sessions'},(p.running||0)+'/'+(p.maxConcurrent||0));
|
|
28
|
+
poolList.appendChild(el('div',{className:'pool-item'},[dot,name,status,sess]));
|
|
29
|
+
});
|
|
30
|
+
}else if(d.pools&&d.pools.length===1){
|
|
31
|
+
var p=d.pools[0];
|
|
32
|
+
$('#poolDot').className='pool-dot '+(p.error||!p.available?'off':'on');
|
|
33
|
+
$('#poolLabel').textContent=p.error?'offline':p.available?'ready':'busy';
|
|
34
|
+
$('#poolSessions').textContent=(p.running||0)+'/'+(p.maxConcurrent||0);
|
|
35
|
+
poolList.style.display='none';
|
|
36
|
+
}else{
|
|
37
|
+
$('#poolDot').className='pool-dot '+(d.error||!d.available?'off':'on');
|
|
38
|
+
$('#poolLabel').textContent=d.error?'offline':d.available?'ready':'busy';
|
|
39
|
+
$('#poolSessions').textContent=(d.running||0)+'/'+(d.maxConcurrent||0);
|
|
40
|
+
poolList.style.display='none';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function refreshStatus(){api('/api/status').then(function(d){renderPool(d.pool)}).catch(function(){})}
|
|
44
|
+
|
|
45
|
+
/* ── Projects ── */
|
|
46
|
+
function refreshProjects(){
|
|
47
|
+
api('/api/db/projects').then(function(projects){
|
|
48
|
+
var sel=$('#projectSelect'),prev=sel.value;
|
|
49
|
+
while(sel.options.length>1)sel.remove(1);
|
|
50
|
+
if(Array.isArray(projects))projects.forEach(function(p){
|
|
51
|
+
var o=document.createElement('option');o.value=p.id;o.textContent=p.name;sel.appendChild(o);
|
|
52
|
+
});
|
|
53
|
+
sel.value=prev||'';
|
|
54
|
+
}).catch(function(){});
|
|
55
|
+
}
|
|
56
|
+
$('#projectSelect').addEventListener('change',function(){
|
|
57
|
+
S.project=this.value?parseInt(this.value,10):null;
|
|
58
|
+
S.selectedRun=null;
|
|
59
|
+
refreshRuns();refreshSuites();refreshScreenshots();refreshLearnings();refreshWatch();
|
|
60
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* ══════════════════════════════════════════════════════════════════
|
|
2
|
+
Init — startup sequence
|
|
3
|
+
══════════════════════════════════════════════════════════════════ */
|
|
4
|
+
initTabs();
|
|
5
|
+
connectWS();
|
|
6
|
+
refreshStatus();
|
|
7
|
+
refreshProjects();
|
|
8
|
+
refreshSuites();
|
|
9
|
+
refreshRuns();
|
|
10
|
+
refreshScreenshots();
|
|
11
|
+
refreshLearnings();
|
|
12
|
+
refreshVariables();
|
|
13
|
+
startWatchPolling();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* ══════════════════════════════════════════════════════════════════
|
|
2
|
+
Keyboard Shortcuts (Updated: 1=Watch, 2=Tests, 3=Runs, 4=Live)
|
|
3
|
+
══════════════════════════════════════════════════════════════════ */
|
|
4
|
+
document.addEventListener('keydown',function(e){
|
|
5
|
+
var tag=document.activeElement.tagName;
|
|
6
|
+
if(tag==='INPUT'||tag==='SELECT'||tag==='TEXTAREA')return;
|
|
7
|
+
if(e.key==='Escape'){
|
|
8
|
+
if($('#kbModal').classList.contains('open')){$('#kbModal').classList.remove('open');return}
|
|
9
|
+
if($('#modal').classList.contains('open')){$('#modal').classList.remove('open');return}
|
|
10
|
+
if($('#suiteModalOverlay').classList.contains('open')){$('#suiteModalOverlay').classList.remove('open');return}
|
|
11
|
+
if(S.selectedRun!==null){
|
|
12
|
+
var expanded=document.querySelector('#runsBody tr.expanded');
|
|
13
|
+
if(expanded){
|
|
14
|
+
var next=expanded.nextElementSibling;
|
|
15
|
+
if(next&&next.classList.contains('run-detail-row')){var w=next.querySelector('.rd-wrap');if(w)w.classList.remove('open');expanded.classList.remove('expanded');setTimeout(function(){if(next.parentNode)next.parentNode.removeChild(next)},350)}
|
|
16
|
+
S.selectedRun=null;
|
|
17
|
+
}
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if(e.key==='?'){$('#kbModal').classList.toggle('open');return}
|
|
23
|
+
var viewMap={'1':'watch','2':'tests','3':'runs','4':'live'};
|
|
24
|
+
if(viewMap[e.key]){showView(viewMap[e.key]);return}
|
|
25
|
+
if(e.key==='r'){
|
|
26
|
+
if(S.view==='watch')refreshWatch();
|
|
27
|
+
else if(S.view==='tests'){refreshSuites();refreshVariables()}
|
|
28
|
+
else if(S.view==='runs'){refreshRuns();refreshScreenshots();refreshLearnings()}
|
|
29
|
+
else if(S.view==='live')renderLive();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if(S.view==='runs'&&(e.key==='j'||e.key==='k')){
|
|
33
|
+
var visible=_allRunRows.filter(function(item){return item.tr.style.display!=='none'});
|
|
34
|
+
if(!visible.length)return;
|
|
35
|
+
if(e.key==='j')S.highlightedRunIdx=Math.min(S.highlightedRunIdx+1,visible.length-1);
|
|
36
|
+
if(e.key==='k')S.highlightedRunIdx=Math.max(S.highlightedRunIdx-1,0);
|
|
37
|
+
visible.forEach(function(item,i){if(i===S.highlightedRunIdx){item.tr.classList.add('selected');item.tr.scrollIntoView({block:'nearest'})}else item.tr.classList.remove('selected')});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if(S.view==='runs'&&e.key==='Enter'){
|
|
41
|
+
var visible2=_allRunRows.filter(function(item){return item.tr.style.display!=='none'});
|
|
42
|
+
if(S.highlightedRunIdx>=0&&S.highlightedRunIdx<visible2.length){visible2[S.highlightedRunIdx].tr.click()}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
$('#kbModal').addEventListener('click',function(e){if(e.target===$('#kbModal'))$('#kbModal').classList.remove('open')});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* ── Global State ── */
|
|
2
|
+
var S={
|
|
3
|
+
ws:null,project:null,view:'watch',selectedRun:null,
|
|
4
|
+
liveRuns:{},liveCollapsed:new Set(),liveSSOpen:new Set(),
|
|
5
|
+
runFilter:{status:'all',search:''},
|
|
6
|
+
lastLearningsData:null,
|
|
7
|
+
highlightedRunIdx:-1
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/* ── Navigation ── */
|
|
11
|
+
$$('.nav-item').forEach(function(n){
|
|
12
|
+
n.addEventListener('click',function(){
|
|
13
|
+
showView(n.dataset.view);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
function showView(v){
|
|
17
|
+
S.view=v;
|
|
18
|
+
$$('.nav-item').forEach(function(n){n.classList.toggle('active',n.dataset.view===v)});
|
|
19
|
+
$$('.view').forEach(function(x){x.classList.remove('active')});
|
|
20
|
+
var viewEl=$('#view-'+v);
|
|
21
|
+
if(viewEl)viewEl.classList.add('active');
|
|
22
|
+
if(v==='watch'&&typeof startWatchPolling==='function')startWatchPolling();
|
|
23
|
+
else if(typeof stopWatchPolling==='function')stopWatchPolling();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* ── Inner Tabs ── */
|
|
27
|
+
function initTabs(){
|
|
28
|
+
$$('.tab-bar').forEach(function(bar){
|
|
29
|
+
var container=bar.parentElement;
|
|
30
|
+
bar.querySelectorAll('.tab-btn').forEach(function(btn){
|
|
31
|
+
btn.addEventListener('click',function(){
|
|
32
|
+
bar.querySelectorAll('.tab-btn').forEach(function(b){b.classList.remove('active')});
|
|
33
|
+
btn.classList.add('active');
|
|
34
|
+
container.querySelectorAll('.tab-pane').forEach(function(p){p.classList.remove('active')});
|
|
35
|
+
var pane=container.querySelector('#'+btn.dataset.tab);
|
|
36
|
+
if(pane)pane.classList.add('active');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* ── Toast Notifications ── */
|
|
2
|
+
function showToast(message,type,timeout){
|
|
3
|
+
type=type||'info';
|
|
4
|
+
timeout=timeout||5000;
|
|
5
|
+
var container=$('#toastContainer');
|
|
6
|
+
var icons={success:'\u2714',error:'\u2718',info:'\u2139'};
|
|
7
|
+
var t=el('div',{className:'toast '+type},[
|
|
8
|
+
el('span',null,icons[type]||''),
|
|
9
|
+
el('span',null,message)
|
|
10
|
+
]);
|
|
11
|
+
container.appendChild(t);
|
|
12
|
+
setTimeout(function(){
|
|
13
|
+
t.classList.add('fade-out');
|
|
14
|
+
setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t)},300);
|
|
15
|
+
},timeout);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function showEnrichedToast(message,type){
|
|
19
|
+
var container=$('#toastContainer');
|
|
20
|
+
var icons={success:'\u2714',error:'\u2718',info:'\u2139'};
|
|
21
|
+
var t=el('div',{className:'toast clickable '+type,onclick:function(){showView('runs');var lb=$('#runsTabLearnings');if(lb)lb.click()}},[
|
|
22
|
+
el('span',null,icons[type]||''),
|
|
23
|
+
el('span',null,message)
|
|
24
|
+
]);
|
|
25
|
+
container.appendChild(t);
|
|
26
|
+
setTimeout(function(){
|
|
27
|
+
t.classList.add('fade-out');
|
|
28
|
+
setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t)},300);
|
|
29
|
+
},7000);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* ── Download helper ── */
|
|
33
|
+
function downloadFile(filename,content,mimeType){
|
|
34
|
+
var blob=new Blob([content],{type:mimeType||'text/plain'});
|
|
35
|
+
var url=URL.createObjectURL(blob);
|
|
36
|
+
var a=document.createElement('a');
|
|
37
|
+
a.href=url;a.download=filename;
|
|
38
|
+
document.body.appendChild(a);a.click();
|
|
39
|
+
document.body.removeChild(a);
|
|
40
|
+
URL.revokeObjectURL(url);
|
|
41
|
+
}
|