@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.
Files changed (88) hide show
  1. package/.claude-plugin/marketplace.json +52 -0
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/.mcp.json +2 -2
  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/LICENSE +190 -0
  19. package/OPENCODE.md +166 -0
  20. package/README.md +165 -104
  21. package/agents/test-creator.md +54 -1
  22. package/agents/test-improver.md +37 -0
  23. package/bin/cli.js +409 -16
  24. package/commands/capture.md +45 -0
  25. package/commands/create-test.md +16 -1
  26. package/opencode.json +11 -0
  27. package/package.json +7 -2
  28. package/scripts/setup-opencode.sh +113 -0
  29. package/skills/e2e-testing/SKILL.md +10 -3
  30. package/skills/e2e-testing/references/action-types.md +48 -5
  31. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  32. package/skills/e2e-testing/references/graphql.md +59 -0
  33. package/skills/e2e-testing/references/issue-verification.md +59 -0
  34. package/skills/e2e-testing/references/multi-pool.md +60 -0
  35. package/skills/e2e-testing/references/network-debugging.md +62 -0
  36. package/skills/e2e-testing/references/test-json-format.md +4 -0
  37. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  38. package/skills/e2e-testing/references/variables.md +41 -0
  39. package/skills/e2e-testing/references/visual-verification.md +89 -0
  40. package/src/actions.js +475 -2
  41. package/src/ai-generate.js +139 -8
  42. package/src/app-pool.js +339 -0
  43. package/src/config.js +266 -5
  44. package/src/dashboard.js +216 -17
  45. package/src/db.js +191 -7
  46. package/src/index.js +12 -9
  47. package/src/learner-sqlite.js +458 -0
  48. package/src/learner.js +78 -6
  49. package/src/mcp-tools.js +1348 -51
  50. package/src/module-resolver.js +37 -0
  51. package/src/narrate.js +65 -0
  52. package/src/pool-manager.js +229 -0
  53. package/src/pool.js +301 -31
  54. package/src/reporter.js +86 -2
  55. package/src/runner.js +480 -71
  56. package/src/sync/auth.js +354 -0
  57. package/src/sync/client.js +572 -0
  58. package/src/sync/hub-routes.js +816 -0
  59. package/src/sync/index.js +68 -0
  60. package/src/sync/middleware.js +347 -0
  61. package/src/sync/queue.js +209 -0
  62. package/src/sync/schema.js +540 -0
  63. package/src/verify.js +10 -7
  64. package/src/visual-diff.js +446 -0
  65. package/src/watch.js +384 -0
  66. package/templates/build-dashboard.js +47 -6
  67. package/templates/dashboard/js/api.js +62 -0
  68. package/templates/dashboard/js/init.js +13 -0
  69. package/templates/dashboard/js/keyboard.js +46 -0
  70. package/templates/dashboard/js/state.js +40 -0
  71. package/templates/dashboard/js/toast.js +41 -0
  72. package/templates/dashboard/js/utils.js +216 -0
  73. package/templates/dashboard/js/view-live.js +181 -0
  74. package/templates/dashboard/js/view-runs.js +676 -0
  75. package/templates/dashboard/js/view-tests.js +294 -0
  76. package/templates/dashboard/js/view-watch.js +242 -0
  77. package/templates/dashboard/js/websocket.js +116 -0
  78. package/templates/dashboard/styles/base.css +69 -0
  79. package/templates/dashboard/styles/components.css +117 -0
  80. package/templates/dashboard/styles/view-live.css +97 -0
  81. package/templates/dashboard/styles/view-runs.css +243 -0
  82. package/templates/dashboard/styles/view-tests.css +96 -0
  83. package/templates/dashboard/styles/view-watch.css +53 -0
  84. package/templates/dashboard/template.html +181 -100
  85. package/templates/dashboard.html +1614 -547
  86. package/templates/sample-test.json +0 -8
  87. package/templates/dashboard/app.js +0 -1152
  88. package/templates/dashboard/styles.css +0 -413
@@ -13,6 +13,7 @@
13
13
 
14
14
  import fs from 'fs';
15
15
  import path from 'path';
16
+ import { KNOWN_ACTION_TYPES } from './actions.js';
16
17
 
17
18
  /**
18
19
  * Loads all module definitions from a directory.
@@ -271,3 +272,39 @@ export function listModules(modulesDir) {
271
272
 
272
273
  return modules;
273
274
  }
275
+
276
+ /**
277
+ * Validates that all action types in a resolved test data structure are known.
278
+ * Call AFTER module resolution so $use references have been expanded.
279
+ * @param {object} data - { tests, hooks } with resolved actions
280
+ * @param {string} context - File/suite name for error messages
281
+ * @throws {Error} if any unknown action types are found
282
+ */
283
+ export function validateActionTypes(data, context) {
284
+ const unknown = [];
285
+
286
+ const check = (actions, location) => {
287
+ if (!Array.isArray(actions)) return;
288
+ for (const action of actions) {
289
+ if (action.$use) continue; // unresolved module ref — skip (shouldn't happen post-resolution)
290
+ if (action.type && !KNOWN_ACTION_TYPES.has(action.type)) {
291
+ unknown.push({ type: action.type, location });
292
+ }
293
+ }
294
+ };
295
+
296
+ // Check hooks
297
+ for (const [hookName, actions] of Object.entries(data.hooks || {})) {
298
+ check(actions, `hooks.${hookName}`);
299
+ }
300
+
301
+ // Check test actions
302
+ for (const test of data.tests || []) {
303
+ check(test.actions, `test "${test.name}"`);
304
+ }
305
+
306
+ if (unknown.length > 0) {
307
+ const details = unknown.map(u => `"${u.type}" in ${u.location}`).join(', ');
308
+ throw new Error(`Unknown action type(s) in ${context}: ${details}`);
309
+ }
310
+ }
package/src/narrate.js CHANGED
@@ -129,6 +129,33 @@ export function narrateAction(action, result) {
129
129
  case 'click_chip':
130
130
  return `Clicked chip "${text}"${time}`;
131
131
 
132
+ case 'set_storage': {
133
+ const storageType = selector === 'session' ? 'sessionStorage' : 'localStorage';
134
+ const eqIdx = value.indexOf('=');
135
+ const sKey = eqIdx === -1 ? value : value.slice(0, eqIdx);
136
+ const sVal = eqIdx === -1 ? '' : value.slice(eqIdx + 1);
137
+ const displayVal = isSensitive(sKey) ? '***' : (sVal.length > 30 ? sVal.slice(0, 27) + '...' : sVal);
138
+ return `Set ${storageType}.${sKey} = "${displayVal}"${time}`;
139
+ }
140
+
141
+ case 'assert_storage': {
142
+ const storageType = selector === 'session' ? 'sessionStorage' : 'localStorage';
143
+ const eqIdx = value.indexOf('=');
144
+ if (eqIdx === -1) {
145
+ return `Verified ${storageType} key "${value}" exists${time}`;
146
+ }
147
+ return `Verified ${storageType} key "${value.slice(0, eqIdx)}" has expected value${time}`;
148
+ }
149
+
150
+ case 'click_icon':
151
+ return `Clicked icon "${value}"${selector ? ` in "${selector}"` : ''}${time}`;
152
+
153
+ case 'click_menu_item':
154
+ return `Clicked menu item "${text}"${selector ? ` in "${selector}"` : ''}${time}`;
155
+
156
+ case 'click_in_context':
157
+ return `Clicked "${selector}" in container with text "${text}"${time}`;
158
+
132
159
  case 'evaluate': {
133
160
  const snippet = value.length > 80 ? value.slice(0, 77) + '...' : value;
134
161
  const evalResult = result.result?.value;
@@ -140,6 +167,28 @@ export function narrateAction(action, result) {
140
167
  return `Executed JS: ${snippet}${time}`;
141
168
  }
142
169
 
170
+ case 'assert_visual': {
171
+ const vr = result.result;
172
+ if (vr?.goldenCreated) return `Saved golden reference: ${value}${time}`;
173
+ const pct = vr?.diffPercentage != null ? (vr.diffPercentage * 100).toFixed(2) + '% diff' : '';
174
+ return `Visual comparison against "${value}": ${pct}${time}`;
175
+ }
176
+
177
+ case 'open_tab':
178
+ return `Opened new tab${text ? ` "${text}"` : ''} → ${value}${time}`;
179
+
180
+ case 'switch_tab':
181
+ return `Switched to tab "${value}"${time}`;
182
+
183
+ case 'close_tab':
184
+ return `Closed tab${value ? ` "${value}"` : ''}${time}`;
185
+
186
+ case 'assert_tab_count':
187
+ return `Verified ${value} tab(s) open${time}`;
188
+
189
+ case 'wait_for_tab':
190
+ return `Waited for new tab to open${text ? ` (labeled "${text}")` : ''}${time}`;
191
+
143
192
  default:
144
193
  return `Unknown action "${type}"${time}`;
145
194
  }
@@ -185,7 +234,23 @@ function describeIntent(action) {
185
234
  case 'click_option': return `Click option "${text}"`;
186
235
  case 'focus_autocomplete': return `Focus autocomplete "${text}"`;
187
236
  case 'click_chip': return `Click chip "${text}"`;
237
+ case 'set_storage': return `Set ${selector === 'session' ? 'sessionStorage' : 'localStorage'} key "${value?.split('=')[0] || value}"`;
238
+ case 'assert_storage': {
239
+ const eqIdx = value?.indexOf('=') ?? -1;
240
+ return eqIdx === -1
241
+ ? `Assert ${selector === 'session' ? 'sessionStorage' : 'localStorage'} key "${value}" exists`
242
+ : `Assert ${selector === 'session' ? 'sessionStorage' : 'localStorage'} key "${value.slice(0, eqIdx)}" value`;
243
+ }
244
+ case 'click_icon': return `Click icon "${value}"`;
245
+ case 'click_menu_item': return `Click menu item "${text}"`;
246
+ case 'click_in_context': return `Click "${selector}" in context of "${text}"`;
188
247
  case 'evaluate': return 'Execute JS';
248
+ case 'assert_visual': return `Visual compare against "${value}"`;
249
+ case 'open_tab': return `Open new tab → ${value}`;
250
+ case 'switch_tab': return `Switch to tab "${value}"`;
251
+ case 'close_tab': return `Close tab${value ? ` "${value}"` : ''}`;
252
+ case 'assert_tab_count': return `Assert ${value} tab(s) open`;
253
+ case 'wait_for_tab': return 'Wait for new tab';
189
254
  default: return `Action "${type}"`;
190
255
  }
191
256
  }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Pool Manager — multi-pool selection and distribution.
3
+ *
4
+ * Abstracts pool selection behind a least-pressure strategy.
5
+ * When multiple pools are configured, tests are distributed across
6
+ * all available Chrome capacity. Single-pool setups work identically.
7
+ *
8
+ * Uses a local pending counter to avoid "thundering herd" — when many
9
+ * workers call selectPool() simultaneously, the remote /pressure endpoint
10
+ * hasn't updated yet. The pending map tracks selections locally so
11
+ * subsequent calls factor in connections that are in-flight.
12
+ */
13
+
14
+ import { getPoolStatus, connectToPool } from './pool.js';
15
+ import { log, colors as C } from './logger.js';
16
+
17
+ function sleep(ms) {
18
+ return new Promise(resolve => setTimeout(resolve, ms));
19
+ }
20
+
21
+ /**
22
+ * Local pending counter — tracks connections selected but not yet
23
+ * reflected in the pool's /pressure endpoint. Prevents all workers
24
+ * from picking the same pool when they query simultaneously.
25
+ */
26
+ const pendingConnections = new Map();
27
+
28
+ export function trackPending(poolUrl) {
29
+ pendingConnections.set(poolUrl, (pendingConnections.get(poolUrl) || 0) + 1);
30
+ }
31
+
32
+ export function releasePending(poolUrl) {
33
+ const current = pendingConnections.get(poolUrl) || 0;
34
+ if (current > 1) {
35
+ pendingConnections.set(poolUrl, current - 1);
36
+ } else {
37
+ pendingConnections.delete(poolUrl);
38
+ }
39
+ }
40
+
41
+ function getPending(poolUrl) {
42
+ return pendingConnections.get(poolUrl) || 0;
43
+ }
44
+
45
+ /** Extracts pool driver options from config for passing to getPoolStatus. */
46
+ function driverOpts(config) {
47
+ if (!config) return {};
48
+ return { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
49
+ }
50
+
51
+ /** Returns the normalized pool URL array from config. Always an array, even for single pool. */
52
+ export function getPoolUrls(config) {
53
+ return config._poolUrls || [config.poolUrl];
54
+ }
55
+
56
+ /** Fetches status from all pools in parallel. Returns [{ url, status, error }]. */
57
+ export async function getAllPoolStatuses(poolUrls, options = {}) {
58
+ return Promise.all(poolUrls.map(async (url) => {
59
+ try {
60
+ const status = await getPoolStatus(url, options);
61
+ return { url, status, error: null };
62
+ } catch (error) {
63
+ return { url, status: null, error: error.message };
64
+ }
65
+ }));
66
+ }
67
+
68
+ /** Combined view across all pools: totalRunning, totalMaxConcurrent, per-pool details. */
69
+ export async function getAggregatedPoolStatus(poolUrls, options = {}) {
70
+ const results = await getAllPoolStatuses(poolUrls, options);
71
+
72
+ let totalRunning = 0;
73
+ let totalMaxConcurrent = 0;
74
+ let totalQueued = 0;
75
+ let availableCount = 0;
76
+
77
+ const pools = results.map(({ url, status, error }) => {
78
+ if (error || !status) {
79
+ return { url, available: false, error: error || 'unreachable', running: 0, maxConcurrent: 0, queued: 0, sessions: [] };
80
+ }
81
+ totalRunning += status.running;
82
+ totalMaxConcurrent += status.maxConcurrent;
83
+ totalQueued += status.queued;
84
+ if (status.available) availableCount++;
85
+ return { url, ...status };
86
+ });
87
+
88
+ return {
89
+ totalRunning,
90
+ totalMaxConcurrent,
91
+ totalQueued,
92
+ availableCount,
93
+ totalPools: poolUrls.length,
94
+ pools,
95
+ };
96
+ }
97
+
98
+ /** Blocks until at least one pool is reachable and available. */
99
+ export async function waitForAnyPool(poolUrls, maxWaitMs = 30000, options = {}) {
100
+ const start = Date.now();
101
+
102
+ while (Date.now() - start < maxWaitMs) {
103
+ const results = await getAllPoolStatuses(poolUrls, options);
104
+ const available = results.find(r => r.status?.available);
105
+ if (available) return available.status;
106
+
107
+ const reachable = results.filter(r => r.status && !r.error);
108
+ if (reachable.length > 0) {
109
+ log('⏳', `${C.dim}Pool(s) busy (${reachable.length}/${poolUrls.length} reachable), waiting...${C.reset}`);
110
+ } else {
111
+ log('⏳', `${C.dim}No pools reachable yet (0/${poolUrls.length}), waiting...${C.reset}`);
112
+ }
113
+
114
+ await sleep(2000);
115
+ }
116
+
117
+ throw new Error(`No Chrome Pool available after ${maxWaitMs / 1000}s. Verify containers are running.`);
118
+ }
119
+
120
+ /**
121
+ * Picks the pool with the lowest pressure ratio.
122
+ *
123
+ * Algorithm:
124
+ * 1. Query all pools' status in parallel (driver-aware)
125
+ * 2. Add local pending count to each pool's running total
126
+ * 3. Filter to reachable pools with (running + pending) < maxConcurrent
127
+ * 4. Sort by: lowest effective pressure → fewest queued → most free slots
128
+ * 5. Track selection in pending counter, return best candidate URL
129
+ * 6. If all full, poll every 2s up to 60s, then pick least-pressured anyway
130
+ */
131
+ export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60000, options = {}) {
132
+ // Fast path: single pool
133
+ if (poolUrls.length === 1) {
134
+ await waitForSlotOnPool(poolUrls[0], pollIntervalMs, maxWaitMs, options);
135
+ trackPending(poolUrls[0]);
136
+ return poolUrls[0];
137
+ }
138
+
139
+ const start = Date.now();
140
+
141
+ while (Date.now() - start < maxWaitMs) {
142
+ const results = await getAllPoolStatuses(poolUrls, options);
143
+ const candidates = results
144
+ .filter(r => r.status && !r.error && r.status.available)
145
+ .map(r => {
146
+ const pending = getPending(r.url);
147
+ const effectiveRunning = r.status.running + pending;
148
+ return {
149
+ url: r.url,
150
+ running: r.status.running,
151
+ pending,
152
+ effectiveRunning,
153
+ maxConcurrent: r.status.maxConcurrent,
154
+ queued: r.status.queued,
155
+ pressure: r.status.maxConcurrent > 0 ? effectiveRunning / r.status.maxConcurrent : 1,
156
+ freeSlots: r.status.maxConcurrent - effectiveRunning,
157
+ };
158
+ })
159
+ .filter(c => c.effectiveRunning < c.maxConcurrent);
160
+
161
+ if (candidates.length > 0) {
162
+ candidates.sort((a, b) => {
163
+ if (a.pressure !== b.pressure) return a.pressure - b.pressure;
164
+ if (a.queued !== b.queued) return a.queued - b.queued;
165
+ return b.freeSlots - a.freeSlots;
166
+ });
167
+ const chosen = candidates[0].url;
168
+ trackPending(chosen);
169
+ return chosen;
170
+ }
171
+
172
+ // All full — check if any are reachable
173
+ const reachable = results.filter(r => r.status && !r.error);
174
+ if (reachable.length > 0) {
175
+ log('⏳', `${C.dim}All pools at capacity (${reachable.length}/${poolUrls.length} reachable), waiting for slot...${C.reset}`);
176
+ }
177
+
178
+ await sleep(pollIntervalMs);
179
+ }
180
+
181
+ // Timeout — pick the least-pressured pool anyway (let connectToPool deal with it)
182
+ const results = await getAllPoolStatuses(poolUrls, options);
183
+ const reachable = results
184
+ .filter(r => r.status && !r.error)
185
+ .sort((a, b) => {
186
+ const pendA = getPending(a.url);
187
+ const pendB = getPending(b.url);
188
+ const pA = a.status.maxConcurrent > 0 ? (a.status.running + pendA) / a.status.maxConcurrent : 1;
189
+ const pB = b.status.maxConcurrent > 0 ? (b.status.running + pendB) / b.status.maxConcurrent : 1;
190
+ return pA - pB;
191
+ });
192
+
193
+ if (reachable.length > 0) {
194
+ log('⚠️', `${C.yellow}Waited ${maxWaitMs / 1000}s for pool slot, proceeding with least-pressured pool${C.reset}`);
195
+ const chosen = reachable[0].url;
196
+ trackPending(chosen);
197
+ return chosen;
198
+ }
199
+
200
+ // All unreachable — return first and let connectToPool error
201
+ return poolUrls[0];
202
+ }
203
+
204
+ /** Convenience: selectPool + connectToPool in one call. */
205
+ export async function selectAndConnect(config) {
206
+ const poolUrls = getPoolUrls(config);
207
+ const chosenUrl = await selectPool(poolUrls, 2000, 60000, driverOpts(config));
208
+ return connectToPool(chosenUrl, config.connectRetries, config.connectRetryDelay);
209
+ }
210
+
211
+ /** Waits until a single pool has capacity. */
212
+ async function waitForSlotOnPool(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000, options = {}) {
213
+ const start = Date.now();
214
+ while (Date.now() - start < maxWaitMs) {
215
+ try {
216
+ const status = await getPoolStatus(poolUrl, options);
217
+ if (status.available && status.running < status.maxConcurrent) {
218
+ return;
219
+ }
220
+ log('⏳', `${C.dim}Pool at capacity (${status.running}/${status.maxConcurrent}, ${status.queued} queued), waiting for slot...${C.reset}`);
221
+ } catch {
222
+ // Pool unreachable, let connectToPool handle the error
223
+ return;
224
+ }
225
+ await sleep(pollIntervalMs);
226
+ }
227
+ // Timeout — proceed anyway and let connectToPool deal with it
228
+ log('⚠️', `${C.yellow}Waited ${maxWaitMs / 1000}s for pool slot, proceeding anyway${C.reset}`);
229
+ }