@matware/e2e-runner 1.1.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.
Files changed (89) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/.mcp.json +9 -0
  4. package/.opencode/commands/create-test.md +63 -0
  5. package/.opencode/commands/run.md +50 -0
  6. package/.opencode/commands/verify-issue.md +62 -0
  7. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  8. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  9. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  10. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  12. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  13. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  14. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  15. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  16. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  17. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  18. package/OPENCODE.md +166 -0
  19. package/README.md +990 -296
  20. package/agents/test-analyzer.md +81 -0
  21. package/agents/test-creator.md +155 -0
  22. package/agents/test-improver.md +177 -0
  23. package/bin/cli.js +602 -22
  24. package/commands/create-test.md +65 -0
  25. package/commands/run.md +49 -0
  26. package/commands/verify-issue.md +63 -0
  27. package/opencode.json +11 -0
  28. package/package.json +15 -2
  29. package/scripts/setup-opencode.sh +113 -0
  30. package/skills/e2e-testing/SKILL.md +173 -0
  31. package/skills/e2e-testing/references/action-types.md +143 -0
  32. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  33. package/skills/e2e-testing/references/graphql.md +59 -0
  34. package/skills/e2e-testing/references/issue-verification.md +59 -0
  35. package/skills/e2e-testing/references/multi-pool.md +60 -0
  36. package/skills/e2e-testing/references/network-debugging.md +62 -0
  37. package/skills/e2e-testing/references/test-json-format.md +163 -0
  38. package/skills/e2e-testing/references/troubleshooting.md +224 -0
  39. package/skills/e2e-testing/references/variables.md +41 -0
  40. package/skills/e2e-testing/references/visual-verification.md +89 -0
  41. package/src/actions.js +597 -20
  42. package/src/ai-generate.js +142 -12
  43. package/src/config.js +171 -0
  44. package/src/dashboard.js +299 -17
  45. package/src/db.js +335 -13
  46. package/src/index.js +15 -8
  47. package/src/learner-markdown.js +177 -0
  48. package/src/learner-neo4j.js +255 -0
  49. package/src/learner-sqlite.js +658 -0
  50. package/src/learner.js +418 -0
  51. package/src/mcp-tools.js +1558 -50
  52. package/src/module-resolver.js +310 -0
  53. package/src/narrate.js +262 -0
  54. package/src/neo4j-pool.js +124 -0
  55. package/src/pool-manager.js +223 -0
  56. package/src/reporter.js +117 -3
  57. package/src/runner.js +274 -71
  58. package/src/sync/auth.js +354 -0
  59. package/src/sync/client.js +572 -0
  60. package/src/sync/hub-routes.js +816 -0
  61. package/src/sync/index.js +68 -0
  62. package/src/sync/middleware.js +347 -0
  63. package/src/sync/queue.js +209 -0
  64. package/src/sync/schema.js +540 -0
  65. package/src/verify.js +14 -9
  66. package/src/watch.js +384 -0
  67. package/templates/build-dashboard.js +69 -0
  68. package/templates/dashboard/js/api.js +60 -0
  69. package/templates/dashboard/js/init.js +13 -0
  70. package/templates/dashboard/js/keyboard.js +46 -0
  71. package/templates/dashboard/js/state.js +40 -0
  72. package/templates/dashboard/js/toast.js +41 -0
  73. package/templates/dashboard/js/utils.js +196 -0
  74. package/templates/dashboard/js/view-live.js +143 -0
  75. package/templates/dashboard/js/view-runs.js +572 -0
  76. package/templates/dashboard/js/view-tests.js +294 -0
  77. package/templates/dashboard/js/view-watch.js +242 -0
  78. package/templates/dashboard/js/websocket.js +110 -0
  79. package/templates/dashboard/styles/base.css +69 -0
  80. package/templates/dashboard/styles/components.css +110 -0
  81. package/templates/dashboard/styles/view-live.css +74 -0
  82. package/templates/dashboard/styles/view-runs.css +207 -0
  83. package/templates/dashboard/styles/view-tests.css +96 -0
  84. package/templates/dashboard/styles/view-watch.css +53 -0
  85. package/templates/dashboard/template.html +267 -0
  86. package/templates/dashboard.html +2171 -530
  87. package/templates/docker-compose-neo4j.yml +19 -0
  88. package/templates/e2e.config.js +3 -0
  89. package/templates/sample-test.json +0 -8
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Sync Client - Agent Mode HTTP Client
3
+ *
4
+ * Connects to a hub instance to push/pull test results.
5
+ * Features:
6
+ * - Automatic token refresh
7
+ * - Retry with exponential backoff
8
+ * - Offline queue support
9
+ * - TLS/mTLS support
10
+ */
11
+
12
+ import https from 'https';
13
+ import http from 'http';
14
+ import fs from 'fs';
15
+ import crypto from 'crypto';
16
+ import { generateTotpCode } from './auth.js';
17
+ import {
18
+ getHubConnection,
19
+ saveHubConnection,
20
+ updateHubConnectionStatus,
21
+ updateLastPush,
22
+ updateLastPull,
23
+ migrateSyncSchema,
24
+ } from './schema.js';
25
+ import { enqueueSync, getQueuedItems, completeQueueItem, failQueueItem } from './schema.js';
26
+
27
+ // ═══════════════════════════════════════════════════════════════════════════
28
+ // SYNC CLIENT CLASS
29
+ // ═══════════════════════════════════════════════════════════════════════════
30
+
31
+ export class SyncClient {
32
+ constructor(config) {
33
+ this.config = config;
34
+ this.syncConfig = config.sync?.agent || {};
35
+ this.hubUrl = this.syncConfig.hubUrl;
36
+ this.instanceId = this.syncConfig.instanceId;
37
+ this.displayName = this.syncConfig.displayName || this.instanceId;
38
+
39
+ // Credentials from env vars
40
+ this.apiKey = process.env[this.syncConfig.apiKeyEnv || 'E2E_SYNC_API_KEY'];
41
+ this.totpSecret = process.env[this.syncConfig.totpSecretEnv || 'E2E_SYNC_TOTP'];
42
+
43
+ // Token state
44
+ this.accessToken = null;
45
+ this.refreshToken = null;
46
+ this.tokenExpires = null;
47
+
48
+ // TLS config
49
+ this.tlsOptions = this._buildTlsOptions();
50
+
51
+ // Queue processing
52
+ this.queueProcessing = false;
53
+ this.queueInterval = null;
54
+ }
55
+
56
+ /**
57
+ * Build TLS options for HTTPS requests.
58
+ */
59
+ _buildTlsOptions() {
60
+ const tls = this.syncConfig.tls || {};
61
+ const options = {};
62
+
63
+ if (tls.certPath && fs.existsSync(tls.certPath)) {
64
+ options.cert = fs.readFileSync(tls.certPath);
65
+ }
66
+ if (tls.keyPath && fs.existsSync(tls.keyPath)) {
67
+ options.key = fs.readFileSync(tls.keyPath);
68
+ }
69
+ if (tls.caPath && fs.existsSync(tls.caPath)) {
70
+ options.ca = fs.readFileSync(tls.caPath);
71
+ }
72
+
73
+ return options;
74
+ }
75
+
76
+ /**
77
+ * Initialize the client - load saved connection state.
78
+ */
79
+ async init() {
80
+ migrateSyncSchema();
81
+
82
+ const saved = getHubConnection();
83
+ if (saved && saved.hub_url === this.hubUrl && saved.instance_id === this.instanceId) {
84
+ this.accessToken = saved.jwt_token;
85
+ this.refreshToken = saved.refresh_token;
86
+ this.tokenExpires = saved.token_expires ? new Date(saved.token_expires + 'Z') : null;
87
+ }
88
+
89
+ // Start queue processor if enabled
90
+ if (this.syncConfig.offlineQueue !== false) {
91
+ this.startQueueProcessor();
92
+ }
93
+
94
+ return this;
95
+ }
96
+
97
+ /**
98
+ * Check if we have valid credentials configured.
99
+ */
100
+ isConfigured() {
101
+ return !!(this.hubUrl && this.instanceId && this.apiKey && this.totpSecret);
102
+ }
103
+
104
+ /**
105
+ * Check if access token is valid (not expired).
106
+ */
107
+ hasValidToken() {
108
+ if (!this.accessToken || !this.tokenExpires) return false;
109
+ // Refresh 5 minutes before expiry
110
+ return this.tokenExpires.getTime() > Date.now() + 5 * 60 * 1000;
111
+ }
112
+
113
+ /**
114
+ * Authenticate with the hub.
115
+ */
116
+ async authenticate() {
117
+ if (!this.isConfigured()) {
118
+ throw new Error('Sync client not configured. Set hubUrl, instanceId, apiKey, and totpSecret.');
119
+ }
120
+
121
+ const totpCode = generateTotpCode(this.totpSecret);
122
+ const timestamp = Date.now();
123
+ const nonce = crypto.randomBytes(16).toString('hex');
124
+
125
+ const response = await this._request('POST', '/api/sync/auth', {
126
+ instanceId: this.instanceId,
127
+ apiKey: this.apiKey,
128
+ totpCode,
129
+ timestamp,
130
+ nonce,
131
+ }, false); // Don't use auth for auth request
132
+
133
+ this.accessToken = response.accessToken;
134
+ this.refreshToken = response.refreshToken;
135
+ this.tokenExpires = new Date(Date.now() + response.expiresIn * 1000);
136
+
137
+ // Save connection state
138
+ saveHubConnection({
139
+ hubUrl: this.hubUrl,
140
+ instanceId: this.instanceId,
141
+ displayName: this.displayName,
142
+ jwtToken: this.accessToken,
143
+ refreshToken: this.refreshToken,
144
+ tokenExpires: this.tokenExpires.toISOString(),
145
+ status: 'connected',
146
+ });
147
+
148
+ return response;
149
+ }
150
+
151
+ /**
152
+ * Ensure we have a valid token, refreshing if needed.
153
+ */
154
+ async ensureAuth() {
155
+ if (this.hasValidToken()) return;
156
+
157
+ // Try refresh first
158
+ if (this.refreshToken) {
159
+ try {
160
+ await this._refreshToken();
161
+ return;
162
+ } catch (err) {
163
+ // Refresh failed, do full auth
164
+ console.error('[sync] Token refresh failed, re-authenticating');
165
+ }
166
+ }
167
+
168
+ await this.authenticate();
169
+ }
170
+
171
+ /**
172
+ * Refresh the access token.
173
+ */
174
+ async _refreshToken() {
175
+ const response = await this._request('POST', '/api/sync/auth/refresh', {
176
+ refreshToken: this.refreshToken,
177
+ }, false);
178
+
179
+ this.accessToken = response.accessToken;
180
+ if (response.refreshToken) {
181
+ this.refreshToken = response.refreshToken;
182
+ }
183
+ this.tokenExpires = new Date(Date.now() + response.expiresIn * 1000);
184
+
185
+ saveHubConnection({
186
+ hubUrl: this.hubUrl,
187
+ instanceId: this.instanceId,
188
+ displayName: this.displayName,
189
+ jwtToken: this.accessToken,
190
+ refreshToken: this.refreshToken,
191
+ tokenExpires: this.tokenExpires.toISOString(),
192
+ status: 'connected',
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Get hub status.
198
+ */
199
+ async getStatus() {
200
+ await this.ensureAuth();
201
+ return this._request('GET', '/api/sync/status');
202
+ }
203
+
204
+ /**
205
+ * Push a run to the hub.
206
+ */
207
+ async pushRun(project, run, testResults, screenshots = []) {
208
+ await this.ensureAuth();
209
+
210
+ const payload = {
211
+ project: {
212
+ name: project.name,
213
+ slug: project.slug || project.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
214
+ },
215
+ runs: [run],
216
+ testResults,
217
+ screenshots,
218
+ };
219
+
220
+ const response = await this._request('POST', '/api/sync/push', payload);
221
+ updateLastPush();
222
+ return response;
223
+ }
224
+
225
+ /**
226
+ * Push a run with offline queue fallback.
227
+ */
228
+ async pushRunWithQueue(project, run, testResults, screenshots = []) {
229
+ try {
230
+ return await this.pushRun(project, run, testResults, screenshots);
231
+ } catch (err) {
232
+ if (this.syncConfig.offlineQueue !== false) {
233
+ console.error(`[sync] Push failed, queuing for retry: ${err.message}`);
234
+ enqueueSync({
235
+ operation: 'push_run',
236
+ resourceType: 'run',
237
+ resourceId: run.runId,
238
+ payload: { project, run, testResults, screenshots },
239
+ priority: 0,
240
+ });
241
+ return { queued: true, error: err.message };
242
+ }
243
+ throw err;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Pull runs from other instances.
249
+ */
250
+ async pullRuns(options = {}) {
251
+ await this.ensureAuth();
252
+
253
+ const params = new URLSearchParams();
254
+ if (options.since) params.append('since', options.since);
255
+ if (options.project) params.append('project', options.project);
256
+ if (options.limit) params.append('limit', options.limit);
257
+
258
+ const query = params.toString();
259
+ const path = `/api/sync/pull${query ? '?' + query : ''}`;
260
+
261
+ const response = await this._request('GET', path);
262
+ updateLastPull();
263
+ return response;
264
+ }
265
+
266
+ /**
267
+ * List instances on the hub.
268
+ */
269
+ async listInstances(status = null) {
270
+ await this.ensureAuth();
271
+ const path = status ? `/api/sync/instances?status=${status}` : '/api/sync/instances';
272
+ return this._request('GET', path);
273
+ }
274
+
275
+ /**
276
+ * Get a screenshot from the hub.
277
+ */
278
+ async getScreenshot(hash) {
279
+ await this.ensureAuth();
280
+ return this._requestRaw('GET', `/api/sync/screenshots/${hash}`);
281
+ }
282
+
283
+ /**
284
+ * Upload a screenshot to the hub.
285
+ */
286
+ async uploadScreenshot(hash, data) {
287
+ await this.ensureAuth();
288
+ return this._request('POST', '/api/sync/screenshots', { hash, data });
289
+ }
290
+
291
+ /**
292
+ * Make an HTTP request to the hub.
293
+ */
294
+ async _request(method, path, body = null, useAuth = true) {
295
+ const url = new URL(path, this.hubUrl);
296
+ const isHttps = url.protocol === 'https:';
297
+ const lib = isHttps ? https : http;
298
+
299
+ const options = {
300
+ method,
301
+ hostname: url.hostname,
302
+ port: url.port || (isHttps ? 443 : 80),
303
+ path: url.pathname + url.search,
304
+ headers: {
305
+ 'Content-Type': 'application/json',
306
+ 'Accept': 'application/json',
307
+ 'User-Agent': `e2e-runner-sync/${this.instanceId}`,
308
+ },
309
+ ...this.tlsOptions,
310
+ };
311
+
312
+ if (useAuth && this.accessToken) {
313
+ options.headers['Authorization'] = `Bearer ${this.accessToken}`;
314
+ }
315
+
316
+ return new Promise((resolve, reject) => {
317
+ const req = lib.request(options, (res) => {
318
+ let data = '';
319
+ res.on('data', chunk => data += chunk);
320
+ res.on('end', () => {
321
+ try {
322
+ const json = JSON.parse(data);
323
+ if (res.statusCode >= 400) {
324
+ const err = new Error(json.error || `HTTP ${res.statusCode}`);
325
+ err.statusCode = res.statusCode;
326
+ err.response = json;
327
+ reject(err);
328
+ } else {
329
+ resolve(json);
330
+ }
331
+ } catch (e) {
332
+ reject(new Error(`Invalid JSON response: ${data.slice(0, 100)}`));
333
+ }
334
+ });
335
+ });
336
+
337
+ req.on('error', reject);
338
+ req.setTimeout(30000, () => {
339
+ req.destroy();
340
+ reject(new Error('Request timeout'));
341
+ });
342
+
343
+ if (body) {
344
+ req.write(JSON.stringify(body));
345
+ }
346
+ req.end();
347
+ });
348
+ }
349
+
350
+ /**
351
+ * Make a raw HTTP request (for binary data like screenshots).
352
+ */
353
+ async _requestRaw(method, path) {
354
+ const url = new URL(path, this.hubUrl);
355
+ const isHttps = url.protocol === 'https:';
356
+ const lib = isHttps ? https : http;
357
+
358
+ const options = {
359
+ method,
360
+ hostname: url.hostname,
361
+ port: url.port || (isHttps ? 443 : 80),
362
+ path: url.pathname + url.search,
363
+ headers: {
364
+ 'Authorization': `Bearer ${this.accessToken}`,
365
+ 'User-Agent': `e2e-runner-sync/${this.instanceId}`,
366
+ },
367
+ ...this.tlsOptions,
368
+ };
369
+
370
+ return new Promise((resolve, reject) => {
371
+ const req = lib.request(options, (res) => {
372
+ const chunks = [];
373
+ res.on('data', chunk => chunks.push(chunk));
374
+ res.on('end', () => {
375
+ const buffer = Buffer.concat(chunks);
376
+ if (res.statusCode >= 400) {
377
+ reject(new Error(`HTTP ${res.statusCode}`));
378
+ } else {
379
+ resolve({
380
+ data: buffer,
381
+ contentType: res.headers['content-type'],
382
+ });
383
+ }
384
+ });
385
+ });
386
+
387
+ req.on('error', reject);
388
+ req.setTimeout(30000, () => {
389
+ req.destroy();
390
+ reject(new Error('Request timeout'));
391
+ });
392
+ req.end();
393
+ });
394
+ }
395
+
396
+ /**
397
+ * Start the queue processor for offline sync.
398
+ */
399
+ startQueueProcessor() {
400
+ if (this.queueInterval) return;
401
+
402
+ const interval = (this.syncConfig.queueRetryInterval || 60) * 1000;
403
+
404
+ this.queueInterval = setInterval(() => {
405
+ this.processQueue().catch(err => {
406
+ console.error('[sync] Queue processing error:', err.message);
407
+ });
408
+ }, interval);
409
+
410
+ // Process immediately on start
411
+ this.processQueue().catch(() => {});
412
+ }
413
+
414
+ /**
415
+ * Stop the queue processor.
416
+ */
417
+ stopQueueProcessor() {
418
+ if (this.queueInterval) {
419
+ clearInterval(this.queueInterval);
420
+ this.queueInterval = null;
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Process queued sync items.
426
+ */
427
+ async processQueue() {
428
+ if (this.queueProcessing) return;
429
+ this.queueProcessing = true;
430
+
431
+ try {
432
+ const items = getQueuedItems(10);
433
+
434
+ for (const item of items) {
435
+ try {
436
+ const payload = JSON.parse(item.payload);
437
+
438
+ if (item.operation === 'push_run') {
439
+ await this.pushRun(
440
+ payload.project,
441
+ payload.run,
442
+ payload.testResults,
443
+ payload.screenshots
444
+ );
445
+ }
446
+
447
+ completeQueueItem(item.id);
448
+ } catch (err) {
449
+ failQueueItem(item.id, err.message);
450
+ }
451
+ }
452
+ } finally {
453
+ this.queueProcessing = false;
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Disconnect and cleanup.
459
+ */
460
+ disconnect() {
461
+ this.stopQueueProcessor();
462
+ updateHubConnectionStatus('disconnected');
463
+ this.accessToken = null;
464
+ this.refreshToken = null;
465
+ this.tokenExpires = null;
466
+ }
467
+ }
468
+
469
+ // ═══════════════════════════════════════════════════════════════════════════
470
+ // SINGLETON CLIENT INSTANCE
471
+ // ═══════════════════════════════════════════════════════════════════════════
472
+
473
+ let clientInstance = null;
474
+
475
+ /**
476
+ * Get or create the sync client singleton.
477
+ */
478
+ export async function getSyncClient(config) {
479
+ if (!clientInstance) {
480
+ clientInstance = new SyncClient(config);
481
+ await clientInstance.init();
482
+ }
483
+ return clientInstance;
484
+ }
485
+
486
+ /**
487
+ * Reset the sync client (for testing).
488
+ */
489
+ export function resetSyncClient() {
490
+ if (clientInstance) {
491
+ clientInstance.disconnect();
492
+ clientInstance = null;
493
+ }
494
+ }
495
+
496
+ // ═══════════════════════════════════════════════════════════════════════════
497
+ // CONVENIENCE FUNCTIONS
498
+ // ═══════════════════════════════════════════════════════════════════════════
499
+
500
+ /**
501
+ * Push a run to the hub (convenience function).
502
+ */
503
+ export async function pushRun(config, project, report) {
504
+ if (config.sync?.mode !== 'agent') return null;
505
+
506
+ const client = await getSyncClient(config);
507
+ if (!client.isConfigured()) {
508
+ console.error('[sync] Agent mode enabled but credentials not configured');
509
+ return null;
510
+ }
511
+
512
+ const run = {
513
+ runId: report.runId,
514
+ total: report.summary.total,
515
+ passed: report.summary.passed,
516
+ failed: report.summary.failed,
517
+ passRate: report.summary.passRate,
518
+ duration: report.summary.duration,
519
+ generatedAt: report.generatedAt,
520
+ suiteName: report.suiteName,
521
+ triggeredBy: report.triggeredBy,
522
+ };
523
+
524
+ const testResults = report.results.map(r => ({
525
+ runId: report.runId,
526
+ name: r.name,
527
+ success: r.success,
528
+ error: r.error,
529
+ durationMs: r.endTime && r.startTime
530
+ ? new Date(r.endTime) - new Date(r.startTime)
531
+ : null,
532
+ attempt: r.attempt,
533
+ maxAttempts: r.maxAttempts,
534
+ errorScreenshot: r.errorScreenshot,
535
+ consoleLogs: r.consoleLogs,
536
+ networkErrors: r.networkErrors,
537
+ }));
538
+
539
+ // Collect screenshots to sync
540
+ const screenshots = [];
541
+ for (const r of report.results) {
542
+ if (r.errorScreenshot && fs.existsSync(r.errorScreenshot)) {
543
+ const data = fs.readFileSync(r.errorScreenshot);
544
+ const hash = crypto.createHash('sha256').update(data).digest('hex').slice(0, 8);
545
+ screenshots.push({ hash, data: data.toString('base64') });
546
+ }
547
+ }
548
+
549
+ try {
550
+ return await client.pushRunWithQueue(project, run, testResults, screenshots);
551
+ } catch (err) {
552
+ console.error(`[sync] Failed to push run: ${err.message}`);
553
+ return null;
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Pull runs from the hub (convenience function).
559
+ */
560
+ export async function pullRuns(config, options = {}) {
561
+ if (config.sync?.mode !== 'agent') return null;
562
+
563
+ const client = await getSyncClient(config);
564
+ if (!client.isConfigured()) return null;
565
+
566
+ try {
567
+ return await client.pullRuns(options);
568
+ } catch (err) {
569
+ console.error(`[sync] Failed to pull runs: ${err.message}`);
570
+ return null;
571
+ }
572
+ }