@matware/e2e-runner 1.2.1 → 1.3.1
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 +52 -0
- package/.claude-plugin/plugin.json +17 -3
- 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/LICENSE +190 -0
- package/OPENCODE.md +166 -0
- package/README.md +165 -104
- package/agents/test-creator.md +54 -1
- package/agents/test-improver.md +37 -0
- package/bin/cli.js +409 -16
- package/commands/capture.md +45 -0
- 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 +475 -2
- package/src/ai-generate.js +139 -8
- package/src/app-pool.js +339 -0
- package/src/config.js +266 -5
- package/src/dashboard.js +216 -17
- package/src/db.js +191 -7
- package/src/index.js +12 -9
- package/src/learner-sqlite.js +458 -0
- package/src/learner.js +78 -6
- package/src/mcp-tools.js +1348 -51
- package/src/module-resolver.js +37 -0
- package/src/narrate.js +65 -0
- package/src/pool-manager.js +229 -0
- package/src/pool.js +301 -31
- package/src/reporter.js +86 -2
- package/src/runner.js +480 -71
- 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/visual-diff.js +446 -0
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +47 -6
- package/templates/dashboard/js/api.js +62 -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 +216 -0
- package/templates/dashboard/js/view-live.js +181 -0
- package/templates/dashboard/js/view-runs.js +676 -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 +116 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +117 -0
- package/templates/dashboard/styles/view-live.css +97 -0
- package/templates/dashboard/styles/view-runs.css +243 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +181 -100
- package/templates/dashboard.html +1614 -547
- package/templates/sample-test.json +0 -8
- package/templates/dashboard/app.js +0 -1152
- package/templates/dashboard/styles.css +0 -413
|
@@ -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
|
+
}
|