@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.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.mcp.json +2 -2
  3. package/.opencode/commands/create-test.md +63 -0
  4. package/.opencode/commands/run.md +50 -0
  5. package/.opencode/commands/verify-issue.md +62 -0
  6. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  7. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  8. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  9. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  10. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  12. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  13. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  14. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  15. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  16. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  17. package/OPENCODE.md +166 -0
  18. package/README.md +581 -55
  19. package/agents/test-creator.md +54 -1
  20. package/agents/test-improver.md +37 -0
  21. package/bin/cli.js +408 -16
  22. package/commands/create-test.md +16 -1
  23. package/opencode.json +11 -0
  24. package/package.json +7 -2
  25. package/scripts/setup-opencode.sh +113 -0
  26. package/skills/e2e-testing/SKILL.md +10 -3
  27. package/skills/e2e-testing/references/action-types.md +48 -5
  28. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  29. package/skills/e2e-testing/references/graphql.md +59 -0
  30. package/skills/e2e-testing/references/issue-verification.md +59 -0
  31. package/skills/e2e-testing/references/multi-pool.md +60 -0
  32. package/skills/e2e-testing/references/network-debugging.md +62 -0
  33. package/skills/e2e-testing/references/test-json-format.md +4 -0
  34. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  35. package/skills/e2e-testing/references/variables.md +41 -0
  36. package/skills/e2e-testing/references/visual-verification.md +89 -0
  37. package/src/actions.js +324 -2
  38. package/src/ai-generate.js +58 -8
  39. package/src/config.js +143 -0
  40. package/src/dashboard.js +145 -13
  41. package/src/db.js +130 -2
  42. package/src/index.js +7 -6
  43. package/src/learner-sqlite.js +304 -0
  44. package/src/learner.js +8 -3
  45. package/src/mcp-tools.js +1121 -43
  46. package/src/module-resolver.js +37 -0
  47. package/src/narrate.js +37 -0
  48. package/src/pool-manager.js +223 -0
  49. package/src/reporter.js +82 -1
  50. package/src/runner.js +157 -28
  51. package/src/sync/auth.js +354 -0
  52. package/src/sync/client.js +572 -0
  53. package/src/sync/hub-routes.js +816 -0
  54. package/src/sync/index.js +68 -0
  55. package/src/sync/middleware.js +347 -0
  56. package/src/sync/queue.js +209 -0
  57. package/src/sync/schema.js +540 -0
  58. package/src/verify.js +10 -7
  59. package/src/watch.js +384 -0
  60. package/templates/build-dashboard.js +47 -6
  61. package/templates/dashboard/js/api.js +60 -0
  62. package/templates/dashboard/js/init.js +13 -0
  63. package/templates/dashboard/js/keyboard.js +46 -0
  64. package/templates/dashboard/js/state.js +40 -0
  65. package/templates/dashboard/js/toast.js +41 -0
  66. package/templates/dashboard/js/utils.js +196 -0
  67. package/templates/dashboard/js/view-live.js +143 -0
  68. package/templates/dashboard/js/view-runs.js +572 -0
  69. package/templates/dashboard/js/view-tests.js +294 -0
  70. package/templates/dashboard/js/view-watch.js +242 -0
  71. package/templates/dashboard/js/websocket.js +110 -0
  72. package/templates/dashboard/styles/base.css +69 -0
  73. package/templates/dashboard/styles/components.css +110 -0
  74. package/templates/dashboard/styles/view-live.css +74 -0
  75. package/templates/dashboard/styles/view-runs.css +207 -0
  76. package/templates/dashboard/styles/view-tests.css +96 -0
  77. package/templates/dashboard/styles/view-watch.css +53 -0
  78. package/templates/dashboard/template.html +165 -99
  79. package/templates/dashboard.html +1596 -541
  80. package/templates/sample-test.json +0 -8
  81. package/templates/dashboard/app.js +0 -1152
  82. package/templates/dashboard/styles.css +0 -413
package/src/runner.js CHANGED
@@ -7,11 +7,15 @@
7
7
 
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
- import { connectToPool, waitForPool, getPoolStatus } from './pool.js';
10
+ import http from 'http';
11
+ import https from 'https';
12
+ import { connectToPool } from './pool.js';
13
+ import { getPoolUrls, selectPool, releasePending } from './pool-manager.js';
11
14
  import { executeAction } from './actions.js';
12
15
  import { narrateAction } from './narrate.js';
13
16
  import { log, colors as C } from './logger.js';
14
- import { resolveTestData } from './module-resolver.js';
17
+ import { resolveTestData, validateActionTypes } from './module-resolver.js';
18
+ import { ensureProject, getVariables } from './db.js';
15
19
 
16
20
  function sleep(ms) {
17
21
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -44,6 +48,46 @@ function mergeHooks(configHooks, suiteHooks) {
44
48
  };
45
49
  }
46
50
 
51
+ /** Replaces {{var.KEY}} and {{env.KEY}} in all string fields of an action object.
52
+ * Skips {{param}} patterns (no dot) — those are module params handled by module-resolver. */
53
+ function resolveVarsInAction(action, vars) {
54
+ const resolved = { ...action };
55
+ for (const key of Object.keys(resolved)) {
56
+ if (typeof resolved[key] !== 'string') continue;
57
+ resolved[key] = resolved[key].replace(/\{\{(var|env)\.([^}]+)\}\}/g, (match, ns, name) => {
58
+ if (ns === 'env') {
59
+ if (process.env[name] !== undefined) return process.env[name];
60
+ throw new Error(`Unresolved variable: {{env.${name}}} — environment variable not set`);
61
+ }
62
+ // ns === 'var'
63
+ if (vars[name] !== undefined) return vars[name];
64
+ throw new Error(`Unresolved variable: {{var.${name}}} — not found in project or suite variables`);
65
+ });
66
+ }
67
+ return resolved;
68
+ }
69
+
70
+ /** Loads merged variables for a test (project scope + suite scope overlay). */
71
+ function loadVarsForTest(config, suiteName) {
72
+ try {
73
+ const cwd = config._cwd || process.cwd();
74
+ const projectName = config.projectName || cwd.split('/').pop() || 'default';
75
+ const projectId = ensureProject(cwd, projectName, config.screenshotsDir, config.testsDir);
76
+ const projectVars = getVariables(projectId, 'project');
77
+ if (!suiteName) return projectVars;
78
+ const suiteVars = getVariables(projectId, suiteName);
79
+ return { ...projectVars, ...suiteVars };
80
+ } catch {
81
+ return {};
82
+ }
83
+ }
84
+
85
+ /** Resolves variables in an array of actions. */
86
+ function resolveVarsInActions(actions, vars) {
87
+ if (!actions || !actions.length) return actions;
88
+ return actions.map(a => resolveVarsInAction(a, vars));
89
+ }
90
+
47
91
  /** Executes an array of hook actions on a page */
48
92
  async function executeHookActions(page, actions, config) {
49
93
  for (const action of actions) {
@@ -65,24 +109,45 @@ function normalizeTestData(data) {
65
109
  return { tests: data.tests || [], hooks };
66
110
  }
67
111
 
68
- /** Waits until the pool has capacity before connecting */
69
- async function waitForSlot(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000) {
70
- const start = Date.now();
71
- while (Date.now() - start < maxWaitMs) {
72
- try {
73
- const status = await getPoolStatus(poolUrl);
74
- if (status.available && status.running < status.maxConcurrent) {
75
- return;
76
- }
77
- log('⏳', `${C.dim}Pool at capacity (${status.running}/${status.maxConcurrent}, ${status.queued} queued), waiting for slot...${C.reset}`);
78
- } catch {
79
- // Pool unreachable, let connectToPool handle the error
80
- return;
81
- }
82
- await sleep(pollIntervalMs);
83
- }
84
- // Timeout proceed anyway and let connectToPool deal with it
85
- log('⚠️', `${C.yellow}Waited ${maxWaitMs / 1000}s for pool slot, proceeding anyway${C.reset}`);
112
+ /** Extracts a value from an object using a dot-path (e.g. "data.token"). */
113
+ function getByPath(obj, dotPath) {
114
+ return dotPath.split('.').reduce((o, key) => o?.[key], obj);
115
+ }
116
+
117
+ /** Fetches an auth token by POSTing credentials to a login endpoint. */
118
+ function fetchAuthToken(endpoint, credentials, tokenPath) {
119
+ return new Promise((resolve, reject) => {
120
+ const url = new URL(endpoint);
121
+ const transport = url.protocol === 'https:' ? https : http;
122
+ const body = JSON.stringify(credentials);
123
+
124
+ const req = transport.request(url, {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
127
+ timeout: 15000,
128
+ }, (res) => {
129
+ let data = '';
130
+ res.on('data', (chunk) => { data += chunk; });
131
+ res.on('end', () => {
132
+ if (res.statusCode < 200 || res.statusCode >= 300) {
133
+ return reject(new Error(`Auth login failed: HTTP ${res.statusCode} from ${endpoint}`));
134
+ }
135
+ try {
136
+ const json = JSON.parse(data);
137
+ const token = getByPath(json, tokenPath);
138
+ if (!token) {
139
+ return reject(new Error(`Auth login: token not found at path "${tokenPath}" in response`));
140
+ }
141
+ resolve(token);
142
+ } catch (e) {
143
+ reject(new Error(`Auth login: failed to parse response from ${endpoint}: ${e.message}`));
144
+ }
145
+ });
146
+ });
147
+ req.on('error', (e) => reject(new Error(`Auth login request failed: ${e.message}`)));
148
+ req.on('timeout', () => { req.destroy(); reject(new Error(`Auth login request timed out: ${endpoint}`)); });
149
+ req.end(body);
150
+ });
86
151
  }
87
152
 
88
153
  /** Runs a single test end-to-end */
@@ -104,8 +169,15 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
104
169
  const pendingBodies = [];
105
170
 
106
171
  try {
107
- await waitForSlot(config.poolUrl);
108
- browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
172
+ const chosenPool = await selectPool(getPoolUrls(config));
173
+ result.poolUrl = chosenPool;
174
+ const poolLabel = chosenPool.replace('ws://', '').replace('wss://', '');
175
+ const isMultiPool = getPoolUrls(config).length > 1;
176
+ if (isMultiPool) {
177
+ log('🔗', `${C.cyan}${test.name}${C.reset} ${C.dim}→ ${poolLabel}${C.reset}`);
178
+ }
179
+ progressFn({ event: 'test:pool', name: test.name, poolUrl: chosenPool });
180
+ browser = await connectToPool(chosenPool, config.connectRetries, config.connectRetryDelay);
109
181
  // Use incognito context for cookie isolation between concurrent tests
110
182
  context = await browser.createBrowserContext();
111
183
  page = await context.newPage();
@@ -115,7 +187,10 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
115
187
  result.consoleLogs.push({ type: msg.type(), text: msg.text() });
116
188
  });
117
189
  page.on('requestfailed', (req) => {
118
- result.networkErrors.push({ url: req.url(), error: req.failure()?.errorText });
190
+ const url = req.url();
191
+ const ignoreDomains = config.networkIgnoreDomains || [];
192
+ if (ignoreDomains.length > 0 && ignoreDomains.some(d => url.includes(d))) return;
193
+ result.networkErrors.push({ url, error: req.failure()?.errorText });
119
194
  });
120
195
 
121
196
  const requestTimings = new Map();
@@ -149,11 +224,38 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
149
224
  }
150
225
  });
151
226
 
227
+ // Auto-inject auth token into localStorage (runs BEFORE beforeEach hooks)
228
+ if (config.authToken) {
229
+ const storageKey = config.authStorageKey || 'accessToken';
230
+ await page.goto(config.baseUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
231
+ await page.evaluate((key, token) => {
232
+ localStorage.setItem(key, token);
233
+ }, storageKey, config.authToken);
234
+ }
235
+
236
+ // Resolve {{var.X}} and {{env.X}} in test actions and hooks
237
+ const vars = loadVarsForTest(config, config._suiteName);
238
+ if (Object.keys(vars).length > 0 || /\{\{(var|env)\./.test(JSON.stringify(test.actions))) {
239
+ test = { ...test, actions: resolveVarsInActions(test.actions, vars) };
240
+ if (hooks.beforeEach?.length) hooks = { ...hooks, beforeEach: resolveVarsInActions(hooks.beforeEach, vars) };
241
+ if (hooks.afterEach?.length) hooks = { ...hooks, afterEach: resolveVarsInActions(hooks.afterEach, vars) };
242
+ }
243
+
152
244
  // Run beforeEach hook
153
245
  if (hooks.beforeEach?.length) {
154
246
  await executeHookActions(page, hooks.beforeEach, config);
155
247
  }
156
248
 
249
+ // Auto-capture baseline screenshot if test has "expect" (BEFORE actions)
250
+ if (test.expect && page) {
251
+ try {
252
+ const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
253
+ const baselinePath = path.join(config.screenshotsDir, `baseline-${safeName}-${Date.now()}.png`);
254
+ await page.screenshot({ path: baselinePath, fullPage: true });
255
+ result.baselineScreenshot = baselinePath;
256
+ } catch { /* page may not be ready */ }
257
+ }
258
+
157
259
  for (let i = 0; i < test.actions.length; i++) {
158
260
  const action = test.actions[i];
159
261
  const maxActionRetries = action.retries ?? config.actionRetries ?? 0;
@@ -263,6 +365,10 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
263
365
  if (browser) {
264
366
  try { browser.disconnect(); } catch { /* */ }
265
367
  }
368
+ // Release local pending counter so selectPool() knows this slot is free
369
+ if (result.poolUrl) {
370
+ releasePending(result.poolUrl);
371
+ }
266
372
  }
267
373
 
268
374
  return result;
@@ -283,7 +389,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
283
389
  log('🪝', `${C.dim}Running beforeAll hook...${C.reset}`);
284
390
  let browser = null;
285
391
  try {
286
- browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
392
+ const hookPool = await selectPool(getPoolUrls(config));
393
+ browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
287
394
  const page = await browser.newPage();
288
395
  await page.setViewport(config.viewport);
289
396
  await executeHookActions(page, hooks.beforeAll, config);
@@ -296,6 +403,22 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
296
403
  }
297
404
  }
298
405
 
406
+ // Auto-login: fetch auth token via API if configured and not already provided
407
+ if (config.authLoginEndpoint && !config.authToken && config.authCredentials) {
408
+ log('🔑', `${C.dim}Fetching auth token from ${config.authLoginEndpoint}...${C.reset}`);
409
+ try {
410
+ config.authToken = await fetchAuthToken(
411
+ config.authLoginEndpoint,
412
+ config.authCredentials,
413
+ config.authTokenPath || 'token'
414
+ );
415
+ log('✅', `${C.dim}Auth token acquired (${config.authToken.length} chars)${C.reset}`);
416
+ } catch (error) {
417
+ log('❌', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
418
+ throw error;
419
+ }
420
+ }
421
+
299
422
  const concurrency = config.concurrency || 3;
300
423
  const _runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
301
424
  const _proj = config.projectName || null;
@@ -363,7 +486,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
363
486
  activeCount--;
364
487
 
365
488
  const screenshots = result.actions.filter(a => a.result?.screenshot).map(a => a.result.screenshot);
366
- _progress({ event: 'test:complete', name: test.name, success: result.success, duration: timeDiff(result.startTime, result.endTime), error: result.error, consoleLogs: result.consoleLogs, networkErrors: result.networkErrors, networkLogs: result.networkLogs, errorScreenshot: result.errorScreenshot, screenshots });
489
+ _progress({ event: 'test:complete', name: test.name, success: result.success, duration: timeDiff(result.startTime, result.endTime), error: result.error, consoleLogs: result.consoleLogs, networkErrors: result.networkErrors, networkLogs: result.networkLogs, errorScreenshot: result.errorScreenshot, screenshots, poolUrl: result.poolUrl || null });
367
490
 
368
491
  if (result.success) {
369
492
  const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
@@ -401,7 +524,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
401
524
  log('🪝', `${C.dim}Running afterAll hook...${C.reset}`);
402
525
  let browser = null;
403
526
  try {
404
- browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
527
+ const hookPool = await selectPool(getPoolUrls(config));
528
+ browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
405
529
  const page = await browser.newPage();
406
530
  await page.setViewport(config.viewport);
407
531
  await executeHookActions(page, hooks.afterAll, config);
@@ -429,7 +553,9 @@ export function loadTestFile(filePath, modulesDir) {
429
553
  }
430
554
  const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
431
555
  const normalized = normalizeTestData(data);
432
- return modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
556
+ const resolved = modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
557
+ validateActionTypes(resolved, path.basename(filePath));
558
+ return resolved;
433
559
  }
434
560
 
435
561
  /** Loads a test suite by name — returns { tests, hooks } */
@@ -446,7 +572,9 @@ export function loadTestSuite(suiteName, testsDir, modulesDir) {
446
572
 
447
573
  const data = JSON.parse(fs.readFileSync(path.join(testsDir, match), 'utf-8'));
448
574
  const normalized = normalizeTestData(data);
449
- return modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
575
+ const resolved = modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
576
+ validateActionTypes(resolved, match);
577
+ return resolved;
450
578
  }
451
579
 
452
580
  /** Loads all test suites from the tests directory — returns { tests, hooks } */
@@ -466,6 +594,7 @@ export function loadAllSuites(testsDir, modulesDir, exclude = []) {
466
594
  if (modulesDir) {
467
595
  ({ tests, hooks } = resolveTestData({ tests, hooks }, modulesDir));
468
596
  }
597
+ validateActionTypes({ tests, hooks }, file);
469
598
  // Tag each test with its own suite's hooks so they're preserved
470
599
  for (const t of tests) {
471
600
  t._suiteHooks = hooks;
@@ -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
+ }