@matware/e2e-runner 1.3.0 → 1.5.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 (56) hide show
  1. package/.claude-plugin/marketplace.json +37 -6
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/LICENSE +190 -0
  4. package/README.md +151 -527
  5. package/agents/test-creator.md +4 -2
  6. package/agents/test-improver.md +5 -3
  7. package/bin/cli.js +84 -20
  8. package/commands/capture.md +45 -0
  9. package/package.json +3 -2
  10. package/skills/e2e-testing/SKILL.md +3 -2
  11. package/skills/e2e-testing/references/action-types.md +22 -4
  12. package/skills/e2e-testing/references/test-json-format.md +23 -0
  13. package/src/actions.js +321 -14
  14. package/src/ai-generate.js +81 -0
  15. package/src/app-pool.js +339 -0
  16. package/src/config.js +131 -7
  17. package/src/dashboard.js +209 -11
  18. package/src/db.js +74 -7
  19. package/src/index.js +6 -4
  20. package/src/learner-sqlite.js +154 -0
  21. package/src/learner.js +70 -3
  22. package/src/mcp-tools.js +259 -34
  23. package/src/module-analysis.js +247 -0
  24. package/src/module-resolver.js +35 -2
  25. package/src/narrate.js +42 -1
  26. package/src/pool-manager.js +68 -17
  27. package/src/pool.js +464 -37
  28. package/src/reporter.js +4 -1
  29. package/src/runner.js +410 -63
  30. package/src/visual-diff.js +515 -0
  31. package/src/websocket.js +14 -3
  32. package/src/wizard.js +184 -0
  33. package/templates/build-dashboard.js +3 -0
  34. package/templates/dashboard/js/api.js +62 -3
  35. package/templates/dashboard/js/init.js +46 -0
  36. package/templates/dashboard/js/keyboard.js +8 -7
  37. package/templates/dashboard/js/quicksearch.js +277 -0
  38. package/templates/dashboard/js/state.js +61 -7
  39. package/templates/dashboard/js/toast.js +1 -1
  40. package/templates/dashboard/js/utils.js +20 -0
  41. package/templates/dashboard/js/view-live.js +240 -9
  42. package/templates/dashboard/js/view-runs.js +540 -94
  43. package/templates/dashboard/js/view-tests.js +157 -16
  44. package/templates/dashboard/js/view-tools.js +234 -0
  45. package/templates/dashboard/js/view-watch.js +2 -2
  46. package/templates/dashboard/js/websocket.js +36 -0
  47. package/templates/dashboard/styles/base.css +489 -53
  48. package/templates/dashboard/styles/components.css +719 -77
  49. package/templates/dashboard/styles/view-live.css +463 -59
  50. package/templates/dashboard/styles/view-runs.css +793 -155
  51. package/templates/dashboard/styles/view-tests.css +440 -77
  52. package/templates/dashboard/styles/view-tools.css +206 -0
  53. package/templates/dashboard/styles/view-watch.css +198 -41
  54. package/templates/dashboard/template.html +369 -56
  55. package/templates/dashboard.html +5375 -901
  56. package/templates/docker-compose-lightpanda.yml +7 -0
package/src/wizard.js ADDED
@@ -0,0 +1,184 @@
1
+ import readline from 'node:readline/promises';
2
+ import { stdin as input, stdout as output } from 'node:process';
3
+ import path from 'node:path';
4
+ import { colors as C } from './logger.js';
5
+
6
+ const DRIVERS = ['browserless', 'cdp', 'steel', 'lightpanda'];
7
+ const OUTPUT_FORMATS = ['json', 'junit', 'both'];
8
+
9
+ function isInteractive() {
10
+ return Boolean(input.isTTY && output.isTTY);
11
+ }
12
+
13
+ function defaultAnswers(cwd) {
14
+ return {
15
+ projectName: path.basename(cwd),
16
+ baseUrl: 'http://host.docker.internal:3000',
17
+ driver: 'browserless',
18
+ poolPort: 3333,
19
+ concurrency: 3,
20
+ maxSessions: 5,
21
+ outputFormat: 'json',
22
+ includeSampleTest: true,
23
+ };
24
+ }
25
+
26
+ export function getDefaultAnswers(cwd) {
27
+ return defaultAnswers(cwd);
28
+ }
29
+
30
+ async function ask(rl, question, fallback, validate) {
31
+ const hint = fallback === '' ? '' : ` ${C.dim}(${fallback})${C.reset}`;
32
+ for (;;) {
33
+ const raw = (await rl.question(`${C.cyan}?${C.reset} ${question}${hint} `)).trim();
34
+ const value = raw === '' ? fallback : raw;
35
+ if (validate) {
36
+ const err = validate(value);
37
+ if (err) {
38
+ console.log(` ${C.red}${err}${C.reset}`);
39
+ continue;
40
+ }
41
+ }
42
+ return value;
43
+ }
44
+ }
45
+
46
+ async function askChoice(rl, question, choices, fallback) {
47
+ const list = choices.map((c, i) => `${i + 1}) ${c}${c === fallback ? ' [default]' : ''}`).join(' ');
48
+ for (;;) {
49
+ const raw = (await rl.question(`${C.cyan}?${C.reset} ${question}\n ${C.dim}${list}${C.reset}\n `)).trim();
50
+ if (raw === '') return fallback;
51
+ const asNum = parseInt(raw, 10);
52
+ if (!Number.isNaN(asNum) && asNum >= 1 && asNum <= choices.length) return choices[asNum - 1];
53
+ if (choices.includes(raw)) return raw;
54
+ console.log(` ${C.red}Choose 1-${choices.length} or one of: ${choices.join(', ')}${C.reset}`);
55
+ }
56
+ }
57
+
58
+ async function askYesNo(rl, question, fallback = true) {
59
+ const hint = fallback ? 'Y/n' : 'y/N';
60
+ const raw = (await rl.question(`${C.cyan}?${C.reset} ${question} ${C.dim}(${hint})${C.reset} `)).trim().toLowerCase();
61
+ if (raw === '') return fallback;
62
+ return raw === 'y' || raw === 'yes' || raw === 's' || raw === 'si';
63
+ }
64
+
65
+ function validateUrl(value) {
66
+ try {
67
+ const u = new URL(value);
68
+ if (!['http:', 'https:'].includes(u.protocol)) return 'Use http:// or https://';
69
+ return null;
70
+ } catch {
71
+ return 'Not a valid URL';
72
+ }
73
+ }
74
+
75
+ function validatePositiveInt(value) {
76
+ const n = Number(value);
77
+ if (!Number.isInteger(n) || n <= 0) return 'Must be a positive integer';
78
+ return null;
79
+ }
80
+
81
+ export async function runInitWizard(cwd, overrides = {}) {
82
+ const defaults = { ...defaultAnswers(cwd), ...overrides };
83
+
84
+ if (!isInteractive()) return defaults;
85
+
86
+ console.log(`\n${C.bold}${C.cyan}@matware/e2e-runner — init wizard${C.reset}`);
87
+ console.log(`${C.dim}Press Enter to accept the default in parentheses.${C.reset}\n`);
88
+
89
+ const rl = readline.createInterface({ input, output });
90
+
91
+ try {
92
+ const projectName = await ask(rl, 'Project name', defaults.projectName);
93
+ const baseUrl = await ask(rl, 'App base URL', defaults.baseUrl, validateUrl);
94
+ const driver = await askChoice(rl, 'Browser driver', DRIVERS, defaults.driver);
95
+ const poolPort = parseInt(await ask(rl, 'Chrome pool port', String(defaults.poolPort), validatePositiveInt), 10);
96
+ const concurrency = parseInt(await ask(rl, 'Parallel test workers', String(defaults.concurrency), validatePositiveInt), 10);
97
+ const maxSessions = parseInt(await ask(rl, 'Max concurrent pool sessions', String(defaults.maxSessions), validatePositiveInt), 10);
98
+ const outputFormat = await askChoice(rl, 'Report output format', OUTPUT_FORMATS, defaults.outputFormat);
99
+ const includeSampleTest = await askYesNo(rl, 'Include a sample test?', defaults.includeSampleTest);
100
+
101
+ return {
102
+ projectName,
103
+ baseUrl,
104
+ driver,
105
+ poolPort,
106
+ concurrency,
107
+ maxSessions,
108
+ outputFormat,
109
+ includeSampleTest,
110
+ };
111
+ } finally {
112
+ rl.close();
113
+ }
114
+ }
115
+
116
+ export function renderConfig(answers) {
117
+ const { projectName, baseUrl, driver, poolPort, concurrency, maxSessions, outputFormat } = answers;
118
+ const driverLine = driver === 'browserless'
119
+ ? ''
120
+ : `\n // Browser driver: 'browserless' | 'cdp' | 'steel' | 'lightpanda'\n driver: '${driver}',\n`;
121
+
122
+ return `export default {
123
+ // Display name shown in the dashboard
124
+ projectName: '${projectName}',
125
+
126
+ // App URL (from inside Docker, use host.docker.internal to reach the host)
127
+ baseUrl: '${baseUrl}',
128
+ ${driverLine}
129
+ // Chrome Pool WebSocket URL
130
+ poolUrl: 'ws://localhost:${poolPort}',
131
+
132
+ // Chrome Pool port (for pool start/stop)
133
+ poolPort: ${poolPort},
134
+
135
+ // Directory containing JSON test files
136
+ testsDir: 'e2e/tests',
137
+
138
+ // Directory for reusable modules (referenced via $use in tests)
139
+ // modulesDir: 'e2e/modules',
140
+
141
+ // Directory for screenshots and reports
142
+ screenshotsDir: 'e2e/screenshots',
143
+
144
+ // Parallel test workers
145
+ concurrency: ${concurrency},
146
+
147
+ // Max concurrent pool sessions
148
+ maxSessions: ${maxSessions},
149
+
150
+ // Browser viewport
151
+ viewport: { width: 1280, height: 720 },
152
+
153
+ // Timeout per action (ms)
154
+ defaultTimeout: 10000,
155
+
156
+ // Per-test timeout (ms) — kills the test if it exceeds this
157
+ testTimeout: 60000,
158
+
159
+ // Retry failed tests N times (0 = no retries)
160
+ retries: 0,
161
+
162
+ // Delay between retries (ms)
163
+ retryDelay: 1000,
164
+
165
+ // Report output format: 'json', 'junit', or 'both'
166
+ outputFormat: '${outputFormat}',
167
+
168
+ // Global hooks — run actions before/after all tests or each test
169
+ // hooks: {
170
+ // beforeAll: [{ type: 'goto', value: '/login' }],
171
+ // afterAll: [],
172
+ // beforeEach: [{ type: 'goto', value: '/' }],
173
+ // afterEach: [],
174
+ // },
175
+
176
+ // Environment profiles — override any config key per environment
177
+ // Use with --env <name> or E2E_ENV=<name>
178
+ // environments: {
179
+ // staging: { baseUrl: 'https://staging.example.com' },
180
+ // production: { baseUrl: 'https://example.com', concurrency: 5 },
181
+ // },
182
+ };
183
+ `;
184
+ }
@@ -24,6 +24,7 @@ const CSS_ORDER = [
24
24
  'view-tests.css',
25
25
  'view-runs.css',
26
26
  'view-live.css',
27
+ 'view-tools.css',
27
28
  ];
28
29
 
29
30
  const JS_ORDER = [
@@ -36,6 +37,8 @@ const JS_ORDER = [
36
37
  'view-tests.js',
37
38
  'view-runs.js',
38
39
  'view-live.js',
40
+ 'view-tools.js',
41
+ 'quicksearch.js',
39
42
  'keyboard.js',
40
43
  'init.js',
41
44
  ];
@@ -6,12 +6,18 @@ function triggerRun(suite,projectId){
6
6
  if(suite)body.suite=suite;
7
7
  if(projectId)body.projectId=projectId;
8
8
  else if(S.project)body.projectId=S.project;
9
+ var scToggle=$('#screencastToggle');
10
+ if(scToggle&&scToggle.checked)body.screencast=true;
9
11
  fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
10
12
  }
11
13
 
12
14
  function renderPool(d){
13
15
  if(!d)return;
14
16
  var poolList=$('#poolList');
17
+ // Telemetry strip — driver + sessions
18
+ var teleDriver=(d.pools&&d.pools[0]&&d.pools[0].driver)||d.driver||'';
19
+ var teleAvail=false;
20
+ var teleSessNow=0,teleSessMax=0;
15
21
  if(d.pools&&d.pools.length>1){
16
22
  var anyAvail=d.availableCount>0;
17
23
  $('#poolDot').className='pool-dot '+(anyAvail?'on':'off');
@@ -27,34 +33,87 @@ function renderPool(d){
27
33
  var sess=el('span',{className:'pool-sessions'},(p.running||0)+'/'+(p.maxConcurrent||0));
28
34
  poolList.appendChild(el('div',{className:'pool-item'},[dot,name,status,sess]));
29
35
  });
36
+ teleAvail=anyAvail;teleSessNow=d.totalRunning||0;teleSessMax=d.totalMaxConcurrent||0;
37
+ teleDriver=teleDriver||(d.pools.length+' pools');
30
38
  }else if(d.pools&&d.pools.length===1){
31
39
  var p=d.pools[0];
32
40
  $('#poolDot').className='pool-dot '+(p.error||!p.available?'off':'on');
33
41
  $('#poolLabel').textContent=p.error?'offline':p.available?'ready':'busy';
34
42
  $('#poolSessions').textContent=(p.running||0)+'/'+(p.maxConcurrent||0);
35
43
  poolList.style.display='none';
44
+ teleAvail=!p.error&&p.available;teleSessNow=p.running||0;teleSessMax=p.maxConcurrent||0;
45
+ teleDriver=teleDriver||p.driver||'cdp';
36
46
  }else{
37
47
  $('#poolDot').className='pool-dot '+(d.error||!d.available?'off':'on');
38
48
  $('#poolLabel').textContent=d.error?'offline':d.available?'ready':'busy';
39
49
  $('#poolSessions').textContent=(d.running||0)+'/'+(d.maxConcurrent||0);
40
50
  poolList.style.display='none';
51
+ teleAvail=!d.error&&d.available;teleSessNow=d.running||0;teleSessMax=d.maxConcurrent||0;
41
52
  }
53
+ // Telemetry pills (best-effort — elements may not exist on older templates)
54
+ var dotEl=$('#telePoolDot');if(dotEl)dotEl.className='tele-pill-dot '+(teleAvail?'on':'off');
55
+ var valEl=$('#telePoolValue');if(valEl)valEl.textContent=teleDriver||'--';
56
+ var sessEl=$('#teleSessionsValue');if(sessEl)sessEl.textContent=teleSessNow+'/'+teleSessMax;
57
+ }
58
+ function renderRunningTelemetry(n){
59
+ var v=$('#teleRunningValue');if(!v)return;
60
+ v.textContent=String(n||0);
61
+ var pill=$('#teleRunning');if(pill)pill.classList.toggle('has-running',(n||0)>0);
62
+ }
63
+ function refreshTodayTelemetry(){
64
+ var v=$('#teleTodayValue');if(!v)return;
65
+ var url=S.project?'/api/db/projects/'+S.project+'/runs':'/api/db/runs';
66
+ api(url).then(function(rows){
67
+ if(!Array.isArray(rows))return;
68
+ var today=new Date();today.setHours(0,0,0,0);var t=today.getTime();
69
+ var c=0;
70
+ rows.forEach(function(r){
71
+ var d=r.generated_at||r.started_at||r.created_at;
72
+ if(!d)return;
73
+ var ts=new Date(d).getTime();
74
+ if(ts>=t)c++;
75
+ });
76
+ v.textContent=String(c);
77
+ }).catch(function(){});
78
+ }
79
+ function refreshStatus(){
80
+ api('/api/status').then(function(d){
81
+ renderPool(d.pool);
82
+ // Telemetry: running count from dashboard.running flag fallback
83
+ if(d.dashboard&&typeof d.dashboard.runningTests==='number'){
84
+ renderRunningTelemetry(d.dashboard.runningTests);
85
+ }
86
+ }).catch(function(){});
42
87
  }
43
- function refreshStatus(){api('/api/status').then(function(d){renderPool(d.pool)}).catch(function(){})}
44
88
 
45
89
  /* ── Projects ── */
46
90
  function refreshProjects(){
47
91
  api('/api/db/projects').then(function(projects){
48
- var sel=$('#projectSelect'),prev=sel.value;
92
+ var sel=$('#projectSelect');
93
+ // Prefer in-memory state, then the persisted selection, then whatever the
94
+ // browser restored into the <select> on reload.
95
+ var saved=null;try{saved=localStorage.getItem('e2e-project')}catch(e){}
96
+ var prev=(S.project!=null?String(S.project):'')||saved||sel.value;
49
97
  while(sel.options.length>1)sel.remove(1);
50
98
  if(Array.isArray(projects))projects.forEach(function(p){
51
99
  var o=document.createElement('option');o.value=p.id;o.textContent=p.name;sel.appendChild(o);
52
100
  });
53
- sel.value=prev||'';
101
+ // Only keep the previous value if it still maps to a real option.
102
+ var valid=prev&&Array.prototype.some.call(sel.options,function(o){return o.value===prev});
103
+ sel.value=valid?prev:'';
104
+ // Sync app state to the restored <select> value. The browser restores the
105
+ // dropdown's visual value across reloads, but never fires a 'change' event,
106
+ // so S.project would otherwise stay null and views render "select a project".
107
+ var resolved=sel.value?parseInt(sel.value,10):null;
108
+ if(resolved!==S.project){
109
+ S.project=resolved;
110
+ refreshRuns();refreshSuites();refreshScreenshots();refreshLearnings();refreshWatch();
111
+ }
54
112
  }).catch(function(){});
55
113
  }
56
114
  $('#projectSelect').addEventListener('change',function(){
57
115
  S.project=this.value?parseInt(this.value,10):null;
58
116
  S.selectedRun=null;
117
+ try{S.project!=null?localStorage.setItem('e2e-project',String(S.project)):localStorage.removeItem('e2e-project')}catch(e){}
59
118
  refreshRuns();refreshSuites();refreshScreenshots();refreshLearnings();refreshWatch();
60
119
  });
@@ -11,3 +11,49 @@ refreshScreenshots();
11
11
  refreshLearnings();
12
12
  refreshVariables();
13
13
  startWatchPolling();
14
+ updateBreadcrumb();
15
+ syncTopbarLive(false,0,0);
16
+ if(typeof refreshTodayTelemetry==='function'){
17
+ refreshTodayTelemetry();
18
+ setInterval(refreshTodayTelemetry,30000);
19
+ }
20
+ // Keep pool telemetry fresh independent of the WS pool stream
21
+ setInterval(refreshStatus,8000);
22
+
23
+ /* ── Top bar handlers ── */
24
+ (function(){
25
+ var liveBtn=$('#topbarLive');
26
+ if(liveBtn)liveBtn.addEventListener('click',function(){showView('live')});
27
+ var runBtn=$('#topbarRunBtn');
28
+ if(runBtn)runBtn.addEventListener('click',function(){
29
+ if(typeof triggerRun==='function')triggerRun();
30
+ });
31
+ })();
32
+
33
+ /* ── Screencast toggle persistence (default ON) ── */
34
+ (function(){
35
+ var sc=$('#screencastToggle');
36
+ if(!sc)return;
37
+ var saved=null;try{saved=localStorage.getItem('e2e-screencast')}catch(e){}
38
+ sc.checked=saved===null?true:saved==='1';
39
+ sc.addEventListener('change',function(){try{localStorage.setItem('e2e-screencast',sc.checked?'1':'0')}catch(e){}});
40
+ })();
41
+
42
+ /* ── Theme toggle ── */
43
+ (function(){
44
+ var btn=$('#themeToggle');
45
+ var lbl=$('#themeToggleLabel');
46
+ if(!btn)return;
47
+ function syncLabel(){
48
+ var t=document.documentElement.getAttribute('data-theme')||'dark';
49
+ if(lbl)lbl.textContent=(t==='dark'?'Light':'Dark');
50
+ }
51
+ syncLabel();
52
+ btn.addEventListener('click',function(){
53
+ var cur=document.documentElement.getAttribute('data-theme')||'dark';
54
+ var next=cur==='dark'?'light':'dark';
55
+ document.documentElement.setAttribute('data-theme',next);
56
+ try{localStorage.setItem('e2e-theme',next)}catch(e){}
57
+ syncLabel();
58
+ });
59
+ })();
@@ -1,5 +1,5 @@
1
1
  /* ══════════════════════════════════════════════════════════════════
2
- Keyboard Shortcuts (Updated: 1=Watch, 2=Tests, 3=Runs, 4=Live)
2
+ Keyboard Shortcuts (1=Overview, 2=Live, 3=Run, 4=Investigate, 5=Insights)
3
3
  ══════════════════════════════════════════════════════════════════ */
4
4
  document.addEventListener('keydown',function(e){
5
5
  var tag=document.activeElement.tagName;
@@ -20,16 +20,17 @@ document.addEventListener('keydown',function(e){
20
20
  return;
21
21
  }
22
22
  if(e.key==='?'){$('#kbModal').classList.toggle('open');return}
23
- var viewMap={'1':'watch','2':'tests','3':'runs','4':'live'};
23
+ var viewMap={'1':'overview','2':'live','3':'run','4':'investigate','5':'insights'};
24
24
  if(viewMap[e.key]){showView(viewMap[e.key]);return}
25
25
  if(e.key==='r'){
26
- if(S.view==='watch')refreshWatch();
27
- else if(S.view==='tests'){refreshSuites();refreshVariables()}
28
- else if(S.view==='runs'){refreshRuns();refreshScreenshots();refreshLearnings()}
26
+ if(S.view==='overview')refreshWatch();
27
+ else if(S.view==='run'){refreshSuites();refreshVariables()}
28
+ else if(S.view==='investigate'){refreshRuns();refreshScreenshots();if(typeof refreshNetwork==='function')refreshNetwork()}
29
+ else if(S.view==='insights')refreshLearnings();
29
30
  else if(S.view==='live')renderLive();
30
31
  return;
31
32
  }
32
- if(S.view==='runs'&&(e.key==='j'||e.key==='k')){
33
+ if(S.view==='investigate'&&(e.key==='j'||e.key==='k')){
33
34
  var visible=_allRunRows.filter(function(item){return item.tr.style.display!=='none'});
34
35
  if(!visible.length)return;
35
36
  if(e.key==='j')S.highlightedRunIdx=Math.min(S.highlightedRunIdx+1,visible.length-1);
@@ -37,7 +38,7 @@ document.addEventListener('keydown',function(e){
37
38
  visible.forEach(function(item,i){if(i===S.highlightedRunIdx){item.tr.classList.add('selected');item.tr.scrollIntoView({block:'nearest'})}else item.tr.classList.remove('selected')});
38
39
  return;
39
40
  }
40
- if(S.view==='runs'&&e.key==='Enter'){
41
+ if(S.view==='investigate'&&e.key==='Enter'){
41
42
  var visible2=_allRunRows.filter(function(item){return item.tr.style.display!=='none'});
42
43
  if(S.highlightedRunIdx>=0&&S.highlightedRunIdx<visible2.length){visible2[S.highlightedRunIdx].tr.click()}
43
44
  return;
@@ -0,0 +1,277 @@
1
+ /* ══════════════════════════════════════════════════════════════════
2
+ Quick Search palette — Ctrl/⌘+K (or /) to open, searches across
3
+ suites, tests within suites, and reusable modules. Jumps to the
4
+ right view + tab on Enter / click.
5
+ ══════════════════════════════════════════════════════════════════ */
6
+
7
+ var QS = { index: [], filtered: [], active: 0, lastFetch: 0 };
8
+
9
+ function qsModalEl(){return document.getElementById('qsModal')}
10
+ function qsOpen(){
11
+ var m=qsModalEl();if(!m)return;
12
+ m.classList.add('open');m.setAttribute('aria-hidden','false');
13
+ var inp=document.getElementById('qsInput');
14
+ if(inp){inp.value='';inp.focus()}
15
+ // Refresh index opportunistically (cached for 20s)
16
+ if(Date.now()-QS.lastFetch>20000)qsBuildIndex();
17
+ else qsRender('');
18
+ }
19
+ function qsClose(){
20
+ var m=qsModalEl();if(!m)return;
21
+ m.classList.remove('open');m.setAttribute('aria-hidden','true');
22
+ }
23
+
24
+ /* Build a flat index of suites, modules and tests across all projects. */
25
+ function qsBuildIndex(){
26
+ var empty=document.getElementById('qsEmpty');
27
+ if(empty)empty.textContent='Loading index...';
28
+ api('/api/db/projects').then(function(projects){
29
+ if(!Array.isArray(projects))projects=[];
30
+ var pending=projects.length*2;
31
+ if(pending===0){QS.index=[];QS.lastFetch=Date.now();qsRender('');return}
32
+ var idx=[];
33
+ projects.forEach(function(proj){
34
+ api('/api/db/projects/'+proj.id+'/suites').then(function(suites){
35
+ if(Array.isArray(suites)){
36
+ suites.forEach(function(s){
37
+ idx.push({
38
+ kind:'suite',
39
+ name:s.name,
40
+ sub:proj.name,
41
+ meta:(s.testCount||0)+' tests',
42
+ project:proj,
43
+ suite:s,
44
+ });
45
+ (s.tests||[]).forEach(function(t){
46
+ idx.push({
47
+ kind:'test',
48
+ name:t.name||'(unnamed test)',
49
+ sub:proj.name+' › '+s.name,
50
+ meta:(t.actionCount||(t.actions&&t.actions.length)||0)+' steps',
51
+ project:proj,
52
+ suite:s,
53
+ test:t,
54
+ });
55
+ });
56
+ });
57
+ }
58
+ }).catch(function(){}).then(function(){
59
+ pending--;if(pending===0){QS.index=idx;QS.lastFetch=Date.now();qsRender('')}
60
+ });
61
+ api('/api/db/projects/'+proj.id+'/modules').then(function(modules){
62
+ if(Array.isArray(modules)){
63
+ modules.forEach(function(m){
64
+ idx.push({
65
+ kind:'module',
66
+ name:m.name,
67
+ sub:proj.name+(m.description?' — '+m.description:''),
68
+ meta:(m.actionCount||0)+' actions'+(m.params&&m.params.length?' · '+m.params.length+' params':''),
69
+ project:proj,
70
+ module:m,
71
+ });
72
+ });
73
+ }
74
+ }).catch(function(){}).then(function(){
75
+ pending--;if(pending===0){QS.index=idx;QS.lastFetch=Date.now();qsRender('')}
76
+ });
77
+ });
78
+ }).catch(function(){
79
+ QS.index=[];QS.lastFetch=Date.now();qsRender('');
80
+ });
81
+ }
82
+
83
+ /* Fuzzy-ish scoring: subsequence match + bonuses for word starts and exact. */
84
+ function qsScore(text,q){
85
+ if(!q)return 0;
86
+ text=(text||'').toLowerCase();q=q.toLowerCase();
87
+ if(text===q)return 1000;
88
+ if(text.indexOf(q)===0)return 700;
89
+ var i=text.indexOf(q);
90
+ if(i>=0){
91
+ // Word-boundary bonus
92
+ var prev=i>0?text.charAt(i-1):'';
93
+ var wb=prev===' '||prev==='-'||prev==='_'||prev==='.'||prev==='/'||prev===':';
94
+ return 400+(wb?80:0);
95
+ }
96
+ // Subsequence fallback
97
+ var ti=0,qi=0;
98
+ while(ti<text.length&&qi<q.length){
99
+ if(text.charAt(ti)===q.charAt(qi))qi++;
100
+ ti++;
101
+ }
102
+ return qi===q.length?100:0;
103
+ }
104
+
105
+ function qsEscape(s){
106
+ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
107
+ }
108
+ function qsHighlight(name,q){
109
+ if(!q)return name;
110
+ var lc=name.toLowerCase();var lq=q.toLowerCase();
111
+ var i=lc.indexOf(lq);
112
+ if(i<0)return name;
113
+ return name.slice(0,i)+'<mark>'+name.slice(i,i+q.length)+'</mark>'+name.slice(i+q.length);
114
+ }
115
+
116
+ function qsRender(q){
117
+ var results=document.getElementById('qsResults');
118
+ var empty=document.getElementById('qsEmpty');
119
+ var modal=qsModalEl();if(!results||!modal)return;
120
+ results.textContent='';
121
+ if(!QS.index.length){
122
+ modal.classList.remove('has-results');
123
+ if(empty)empty.textContent=QS.lastFetch?'No suites or modules indexed yet.':'Loading index...';
124
+ QS.filtered=[];QS.active=0;return;
125
+ }
126
+ var query=(q||'').trim();
127
+ var scored=QS.index.map(function(it){
128
+ return {item:it,score:query?qsScore(it.name,query)+0.5*qsScore(it.sub||'',query):1};
129
+ }).filter(function(s){return s.score>0});
130
+ scored.sort(function(a,b){return b.score-a.score});
131
+ var top=scored.slice(0,40);
132
+ if(!top.length){
133
+ modal.classList.remove('has-results');
134
+ if(empty)empty.textContent='No matches for "'+query+'"';
135
+ QS.filtered=[];QS.active=0;return;
136
+ }
137
+ modal.classList.add('has-results');
138
+ QS.filtered=top.map(function(s){return s.item});
139
+ QS.active=0;
140
+ // Group by kind in display
141
+ var groups={suite:[],test:[],module:[]};
142
+ QS.filtered.forEach(function(it,idx){groups[it.kind].push({item:it,idx:idx})});
143
+ var labelMap={suite:'Suites',test:'Tests',module:'Modules'};
144
+ ['suite','test','module'].forEach(function(k){
145
+ if(!groups[k].length)return;
146
+ results.appendChild(el('div',{className:'qs-group-label'},labelMap[k]));
147
+ groups[k].forEach(function(entry){
148
+ var it=entry.item;var idx=entry.idx;
149
+ var nameEl=el('div',{className:'qs-item-name'});
150
+ nameEl.innerHTML=qsHighlight(qsEscape(it.name),qsEscape(query));
151
+ var row=el('div',{className:'qs-item',dataIdx:String(idx)},[
152
+ el('span',{className:'qs-item-kind '+it.kind},it.kind),
153
+ el('div',{className:'qs-item-main'},[
154
+ nameEl,
155
+ el('div',{className:'qs-item-sub'},it.sub||'')
156
+ ]),
157
+ el('span',{className:'qs-item-meta'},it.meta||'')
158
+ ]);
159
+ row.addEventListener('click',function(){qsJump(it)});
160
+ results.appendChild(row);
161
+ });
162
+ });
163
+ qsUpdateActive();
164
+ }
165
+
166
+ function qsUpdateActive(){
167
+ var nodes=document.querySelectorAll('.qs-item');
168
+ nodes.forEach(function(n,i){
169
+ n.classList.toggle('active',i===QS.active);
170
+ });
171
+ var act=nodes[QS.active];
172
+ if(act&&act.scrollIntoView)act.scrollIntoView({block:'nearest'});
173
+ }
174
+
175
+ function qsMove(delta){
176
+ if(!QS.filtered.length)return;
177
+ QS.active=(QS.active+delta+QS.filtered.length)%QS.filtered.length;
178
+ qsUpdateActive();
179
+ }
180
+
181
+ function qsJump(it){
182
+ if(!it)return;
183
+ qsClose();
184
+ // Set project selector if needed
185
+ if(it.project&&S.project!==it.project.id){
186
+ var sel=document.getElementById('projectSelect');
187
+ if(sel){
188
+ sel.value=String(it.project.id);
189
+ S.project=it.project.id;
190
+ if(typeof S.selectedRun!=='undefined')S.selectedRun=null;
191
+ // Trigger refresh chain
192
+ if(typeof refreshSuites==='function')refreshSuites();
193
+ if(typeof refreshRuns==='function')refreshRuns();
194
+ if(typeof refreshScreenshots==='function')refreshScreenshots();
195
+ if(typeof refreshLearnings==='function')refreshLearnings();
196
+ if(typeof refreshVariables==='function')refreshVariables();
197
+ }
198
+ }
199
+ // Route to the correct view + tab
200
+ if(it.kind==='suite'||it.kind==='test'){
201
+ showView('run','testsTabSuites');
202
+ setTimeout(function(){qsScrollToSuite(it)},250);
203
+ }else if(it.kind==='module'){
204
+ showView('run','testsTabModules');
205
+ setTimeout(function(){qsScrollToModule(it)},250);
206
+ }
207
+ }
208
+
209
+ function qsScrollToSuite(it){
210
+ if(!it||!it.suite)return;
211
+ var name=(it.suite.name||'').toLowerCase();
212
+ var cards=document.querySelectorAll('.suite-card');
213
+ for(var i=0;i<cards.length;i++){
214
+ var n=(cards[i].dataset.suiteName||cards[i].textContent||'').toLowerCase();
215
+ if(n.indexOf(name)>=0){
216
+ cards[i].scrollIntoView({behavior:'smooth',block:'center'});
217
+ cards[i].classList.add('qs-flash');
218
+ setTimeout(function(c){return function(){c.classList.remove('qs-flash')}}(cards[i]),1500);
219
+ // If user wanted a test, also click the suite card to open its modal
220
+ if(it.kind==='test'&&cards[i].click)cards[i].click();
221
+ return;
222
+ }
223
+ }
224
+ }
225
+ function qsScrollToModule(it){
226
+ if(!it||!it.module)return;
227
+ var name=(it.module.name||'').toLowerCase();
228
+ var cards=document.querySelectorAll('.module-card');
229
+ for(var i=0;i<cards.length;i++){
230
+ var n=(cards[i].textContent||'').toLowerCase();
231
+ if(n.indexOf(name)===0||(' '+n).indexOf(' '+name)>=0){
232
+ cards[i].scrollIntoView({behavior:'smooth',block:'center'});
233
+ cards[i].classList.add('qs-flash');
234
+ setTimeout(function(c){return function(){c.classList.remove('qs-flash')}}(cards[i]),1500);
235
+ return;
236
+ }
237
+ }
238
+ }
239
+
240
+ /* Wire up triggers + keyboard */
241
+ (function(){
242
+ var inp=document.getElementById('qsInput');
243
+ if(inp){
244
+ inp.addEventListener('input',function(){qsRender(inp.value)});
245
+ inp.addEventListener('keydown',function(e){
246
+ if(e.key==='ArrowDown'){e.preventDefault();qsMove(1)}
247
+ else if(e.key==='ArrowUp'){e.preventDefault();qsMove(-1)}
248
+ else if(e.key==='Enter'){
249
+ e.preventDefault();
250
+ var it=QS.filtered[QS.active];if(it)qsJump(it);
251
+ }
252
+ else if(e.key==='Escape'){qsClose()}
253
+ });
254
+ }
255
+ var trigger=document.getElementById('topbarSearchTrigger');
256
+ if(trigger)trigger.addEventListener('click',qsOpen);
257
+ // Backdrop close
258
+ var modal=qsModalEl();
259
+ if(modal){
260
+ modal.addEventListener('click',function(e){
261
+ if(e.target===modal)qsClose();
262
+ });
263
+ }
264
+ // Global keyboard binding
265
+ document.addEventListener('keydown',function(e){
266
+ var typingHere=document.activeElement&&(document.activeElement.tagName==='INPUT'||document.activeElement.tagName==='TEXTAREA'||document.activeElement.isContentEditable);
267
+ var open=modal&&modal.classList.contains('open');
268
+ if((e.metaKey||e.ctrlKey)&&e.key==='k'){
269
+ e.preventDefault();
270
+ if(open)qsClose();else qsOpen();
271
+ return;
272
+ }
273
+ if(!typingHere&&!open&&e.key==='/'){
274
+ e.preventDefault();qsOpen();
275
+ }
276
+ });
277
+ })();