@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
package/src/sync/auth.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Authentication Module
|
|
3
|
+
*
|
|
4
|
+
* Provides cryptographic utilities for multi-instance sync:
|
|
5
|
+
* - API Key generation and validation
|
|
6
|
+
* - TOTP (Time-based One-Time Password) RFC 6238
|
|
7
|
+
* - JWT token signing and verification
|
|
8
|
+
* - Request signature generation
|
|
9
|
+
*
|
|
10
|
+
* Zero external dependencies - uses Node.js crypto only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import crypto from 'crypto';
|
|
14
|
+
|
|
15
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
16
|
+
// API KEY MANAGEMENT
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate a secure API key (256-bit random).
|
|
21
|
+
* Format: sk_<base64url encoded 32 bytes>
|
|
22
|
+
*/
|
|
23
|
+
export function generateApiKey() {
|
|
24
|
+
const bytes = crypto.randomBytes(32);
|
|
25
|
+
return 'sk_' + bytes.toString('base64url');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Hash an API key for storage (never store plaintext).
|
|
30
|
+
*/
|
|
31
|
+
export function hashApiKey(apiKey) {
|
|
32
|
+
return crypto.createHash('sha256').update(apiKey).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Verify an API key against its stored hash.
|
|
37
|
+
*/
|
|
38
|
+
export function verifyApiKey(apiKey, storedHash) {
|
|
39
|
+
const hash = hashApiKey(apiKey);
|
|
40
|
+
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(storedHash));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
44
|
+
// TOTP (TIME-BASED ONE-TIME PASSWORD)
|
|
45
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate a TOTP secret (20 bytes = 160 bits, per RFC 6238).
|
|
49
|
+
* Returns base32-encoded string for compatibility with authenticator apps.
|
|
50
|
+
*/
|
|
51
|
+
export function generateTotpSecret() {
|
|
52
|
+
const bytes = crypto.randomBytes(20);
|
|
53
|
+
return base32Encode(bytes);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Generate TOTP code for a given secret and time step.
|
|
58
|
+
* @param {string} secret - Base32-encoded secret
|
|
59
|
+
* @param {number} timeStep - Time step (default: current)
|
|
60
|
+
* @returns {string} 6-digit TOTP code
|
|
61
|
+
*/
|
|
62
|
+
export function generateTotpCode(secret, timeStep = null) {
|
|
63
|
+
if (timeStep === null) {
|
|
64
|
+
timeStep = Math.floor(Date.now() / 1000 / 30);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const secretBytes = base32Decode(secret);
|
|
68
|
+
const timeBuffer = Buffer.alloc(8);
|
|
69
|
+
timeBuffer.writeBigInt64BE(BigInt(timeStep));
|
|
70
|
+
|
|
71
|
+
const hmac = crypto.createHmac('sha1', secretBytes);
|
|
72
|
+
hmac.update(timeBuffer);
|
|
73
|
+
const hash = hmac.digest();
|
|
74
|
+
|
|
75
|
+
const offset = hash[hash.length - 1] & 0x0f;
|
|
76
|
+
const code = (
|
|
77
|
+
((hash[offset] & 0x7f) << 24) |
|
|
78
|
+
((hash[offset + 1] & 0xff) << 16) |
|
|
79
|
+
((hash[offset + 2] & 0xff) << 8) |
|
|
80
|
+
(hash[offset + 3] & 0xff)
|
|
81
|
+
) % 1000000;
|
|
82
|
+
|
|
83
|
+
return code.toString().padStart(6, '0');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate a TOTP code with a tolerance window of ±1 step (±30 seconds).
|
|
88
|
+
* @param {string} secret - Base32-encoded secret
|
|
89
|
+
* @param {string} code - 6-digit code to validate
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
export function validateTotp(secret, code) {
|
|
93
|
+
const now = Math.floor(Date.now() / 1000 / 30);
|
|
94
|
+
|
|
95
|
+
for (const offset of [0, -1, 1]) {
|
|
96
|
+
const expected = generateTotpCode(secret, now + offset);
|
|
97
|
+
if (crypto.timingSafeEqual(Buffer.from(code), Buffer.from(expected))) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Generate TOTP URI for authenticator apps (Google Authenticator, etc.).
|
|
107
|
+
*/
|
|
108
|
+
export function generateTotpUri(secret, instanceId, issuer = 'e2e-runner') {
|
|
109
|
+
const encodedIssuer = encodeURIComponent(issuer);
|
|
110
|
+
const encodedLabel = encodeURIComponent(`${issuer}:${instanceId}`);
|
|
111
|
+
return `otpauth://totp/${encodedLabel}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
115
|
+
// JWT (JSON WEB TOKENS)
|
|
116
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Sign a JWT token (HS256).
|
|
120
|
+
* @param {object} payload - Claims to include
|
|
121
|
+
* @param {string} secret - Signing secret (256-bit recommended)
|
|
122
|
+
* @param {number} expiresIn - Expiration in seconds (default: 1 hour)
|
|
123
|
+
* @returns {string} JWT token
|
|
124
|
+
*/
|
|
125
|
+
export function signJwt(payload, secret, expiresIn = 3600) {
|
|
126
|
+
const header = { alg: 'HS256', typ: 'JWT' };
|
|
127
|
+
const now = Math.floor(Date.now() / 1000);
|
|
128
|
+
|
|
129
|
+
const claims = {
|
|
130
|
+
...payload,
|
|
131
|
+
iat: now,
|
|
132
|
+
exp: now + expiresIn,
|
|
133
|
+
jti: crypto.randomBytes(16).toString('hex'),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
137
|
+
const unsigned = `${b64url(header)}.${b64url(claims)}`;
|
|
138
|
+
const signature = crypto.createHmac('sha256', secret).update(unsigned).digest('base64url');
|
|
139
|
+
|
|
140
|
+
return `${unsigned}.${signature}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Verify and decode a JWT token.
|
|
145
|
+
* @param {string} token - JWT token
|
|
146
|
+
* @param {string} secret - Signing secret
|
|
147
|
+
* @returns {object} Decoded payload
|
|
148
|
+
* @throws {Error} If token is invalid or expired
|
|
149
|
+
*/
|
|
150
|
+
export function verifyJwt(token, secret) {
|
|
151
|
+
const parts = token.split('.');
|
|
152
|
+
if (parts.length !== 3) {
|
|
153
|
+
throw new Error('Invalid token format');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const [headerB64, payloadB64, signature] = parts;
|
|
157
|
+
const unsigned = `${headerB64}.${payloadB64}`;
|
|
158
|
+
const expectedSig = crypto.createHmac('sha256', secret).update(unsigned).digest('base64url');
|
|
159
|
+
|
|
160
|
+
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSig))) {
|
|
161
|
+
throw new Error('Invalid signature');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
165
|
+
|
|
166
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
|
167
|
+
throw new Error('Token expired');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return payload;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Decode JWT without verification (for debugging only).
|
|
175
|
+
*/
|
|
176
|
+
export function decodeJwt(token) {
|
|
177
|
+
const parts = token.split('.');
|
|
178
|
+
if (parts.length !== 3) return null;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
188
|
+
// REQUEST SIGNING
|
|
189
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Generate a signature for a request payload.
|
|
193
|
+
* Used for additional integrity verification on sensitive operations.
|
|
194
|
+
*/
|
|
195
|
+
export function signRequest(payload, secret) {
|
|
196
|
+
const canonical = JSON.stringify(payload, Object.keys(payload).sort());
|
|
197
|
+
return crypto.createHmac('sha512', secret).update(canonical).digest('hex');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Verify a request signature.
|
|
202
|
+
*/
|
|
203
|
+
export function verifyRequestSignature(payload, signature, secret) {
|
|
204
|
+
const expected = signRequest(payload, secret);
|
|
205
|
+
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
209
|
+
// ENCRYPTION (for storing secrets in DB)
|
|
210
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Encrypt a value using AES-256-GCM.
|
|
214
|
+
* @param {string} plaintext - Value to encrypt
|
|
215
|
+
* @param {string} masterKey - 32-byte hex-encoded master key
|
|
216
|
+
* @returns {string} Encrypted value (iv:ciphertext:tag in hex)
|
|
217
|
+
*/
|
|
218
|
+
export function encrypt(plaintext, masterKey) {
|
|
219
|
+
const key = Buffer.from(masterKey, 'hex');
|
|
220
|
+
if (key.length !== 32) {
|
|
221
|
+
throw new Error('Master key must be 32 bytes (64 hex chars)');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const iv = crypto.randomBytes(12);
|
|
225
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
226
|
+
|
|
227
|
+
const encrypted = Buffer.concat([
|
|
228
|
+
cipher.update(plaintext, 'utf8'),
|
|
229
|
+
cipher.final(),
|
|
230
|
+
]);
|
|
231
|
+
const tag = cipher.getAuthTag();
|
|
232
|
+
|
|
233
|
+
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Decrypt a value encrypted with encrypt().
|
|
238
|
+
* @param {string} ciphertext - Encrypted value (iv:ciphertext:tag)
|
|
239
|
+
* @param {string} masterKey - 32-byte hex-encoded master key
|
|
240
|
+
* @returns {string} Decrypted plaintext
|
|
241
|
+
*/
|
|
242
|
+
export function decrypt(ciphertext, masterKey) {
|
|
243
|
+
const key = Buffer.from(masterKey, 'hex');
|
|
244
|
+
if (key.length !== 32) {
|
|
245
|
+
throw new Error('Master key must be 32 bytes (64 hex chars)');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const [ivHex, encryptedHex, tagHex] = ciphertext.split(':');
|
|
249
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
250
|
+
const encrypted = Buffer.from(encryptedHex, 'hex');
|
|
251
|
+
const tag = Buffer.from(tagHex, 'hex');
|
|
252
|
+
|
|
253
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
254
|
+
decipher.setAuthTag(tag);
|
|
255
|
+
|
|
256
|
+
return decipher.update(encrypted) + decipher.final('utf8');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Generate a master key for encryption.
|
|
261
|
+
* Store this securely (env var, secrets manager).
|
|
262
|
+
*/
|
|
263
|
+
export function generateMasterKey() {
|
|
264
|
+
return crypto.randomBytes(32).toString('hex');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
268
|
+
// NONCE & TIMESTAMP VALIDATION
|
|
269
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Generate a nonce for request freshness.
|
|
273
|
+
*/
|
|
274
|
+
export function generateNonce() {
|
|
275
|
+
return crypto.randomBytes(16).toString('hex');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Check if a timestamp is within acceptable range (±30 seconds).
|
|
280
|
+
*/
|
|
281
|
+
export function isTimestampValid(timestamp, toleranceMs = 30000) {
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
return Math.abs(now - timestamp) <= toleranceMs;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
287
|
+
// BASE32 ENCODING (for TOTP compatibility)
|
|
288
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
289
|
+
|
|
290
|
+
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
291
|
+
|
|
292
|
+
function base32Encode(buffer) {
|
|
293
|
+
let result = '';
|
|
294
|
+
let bits = 0;
|
|
295
|
+
let value = 0;
|
|
296
|
+
|
|
297
|
+
for (const byte of buffer) {
|
|
298
|
+
value = (value << 8) | byte;
|
|
299
|
+
bits += 8;
|
|
300
|
+
|
|
301
|
+
while (bits >= 5) {
|
|
302
|
+
bits -= 5;
|
|
303
|
+
result += BASE32_ALPHABET[(value >>> bits) & 0x1f];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (bits > 0) {
|
|
308
|
+
result += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function base32Decode(str) {
|
|
315
|
+
str = str.toUpperCase().replace(/=+$/, '');
|
|
316
|
+
const bytes = [];
|
|
317
|
+
let bits = 0;
|
|
318
|
+
let value = 0;
|
|
319
|
+
|
|
320
|
+
for (const char of str) {
|
|
321
|
+
const idx = BASE32_ALPHABET.indexOf(char);
|
|
322
|
+
if (idx === -1) continue;
|
|
323
|
+
|
|
324
|
+
value = (value << 5) | idx;
|
|
325
|
+
bits += 5;
|
|
326
|
+
|
|
327
|
+
if (bits >= 8) {
|
|
328
|
+
bits -= 8;
|
|
329
|
+
bytes.push((value >>> bits) & 0xff);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return Buffer.from(bytes);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
337
|
+
// INSTANCE ID GENERATION
|
|
338
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Generate a unique instance ID.
|
|
342
|
+
* Format: <prefix>-<random 4 chars>
|
|
343
|
+
*/
|
|
344
|
+
export function generateInstanceId(prefix = 'instance') {
|
|
345
|
+
const suffix = crypto.randomBytes(2).toString('hex');
|
|
346
|
+
return `${prefix}-${suffix}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Validate instance ID format.
|
|
351
|
+
*/
|
|
352
|
+
export function isValidInstanceId(id) {
|
|
353
|
+
return /^[a-z0-9][a-z0-9-]{2,48}[a-z0-9]$/i.test(id);
|
|
354
|
+
}
|