@matware/e2e-runner 1.3.1 → 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.
- package/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +110 -21
- package/agents/test-creator.md +4 -2
- package/agents/test-improver.md +5 -3
- package/bin/cli.js +80 -17
- package/package.json +3 -2
- package/skills/e2e-testing/SKILL.md +3 -2
- package/skills/e2e-testing/references/action-types.md +22 -4
- package/skills/e2e-testing/references/test-json-format.md +23 -0
- package/src/actions.js +170 -14
- package/src/config.js +6 -0
- package/src/dashboard.js +135 -4
- package/src/db.js +11 -0
- package/src/mcp-tools.js +8 -2
- package/src/module-analysis.js +247 -0
- package/src/module-resolver.js +35 -2
- package/src/narrate.js +14 -1
- package/src/pool-manager.js +46 -1
- package/src/pool.js +177 -20
- package/src/runner.js +77 -10
- package/src/visual-diff.js +69 -0
- package/src/websocket.js +14 -3
- package/src/wizard.js +184 -0
- package/templates/build-dashboard.js +3 -0
- package/templates/dashboard/js/api.js +60 -3
- package/templates/dashboard/js/init.js +46 -0
- package/templates/dashboard/js/keyboard.js +8 -7
- package/templates/dashboard/js/quicksearch.js +277 -0
- package/templates/dashboard/js/state.js +61 -7
- package/templates/dashboard/js/toast.js +1 -1
- package/templates/dashboard/js/view-live.js +235 -42
- package/templates/dashboard/js/view-runs.js +379 -37
- package/templates/dashboard/js/view-tests.js +157 -16
- package/templates/dashboard/js/view-tools.js +234 -0
- package/templates/dashboard/js/view-watch.js +2 -2
- package/templates/dashboard/js/websocket.js +33 -3
- package/templates/dashboard/styles/base.css +489 -53
- package/templates/dashboard/styles/components.css +719 -84
- package/templates/dashboard/styles/view-live.css +459 -78
- package/templates/dashboard/styles/view-runs.css +779 -177
- package/templates/dashboard/styles/view-tests.css +440 -77
- package/templates/dashboard/styles/view-tools.css +206 -0
- package/templates/dashboard/styles/view-watch.css +198 -41
- package/templates/dashboard/template.html +354 -56
- package/templates/dashboard.html +5173 -711
- 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
|
];
|
|
@@ -14,6 +14,10 @@ function triggerRun(suite,projectId){
|
|
|
14
14
|
function renderPool(d){
|
|
15
15
|
if(!d)return;
|
|
16
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;
|
|
17
21
|
if(d.pools&&d.pools.length>1){
|
|
18
22
|
var anyAvail=d.availableCount>0;
|
|
19
23
|
$('#poolDot').className='pool-dot '+(anyAvail?'on':'off');
|
|
@@ -29,34 +33,87 @@ function renderPool(d){
|
|
|
29
33
|
var sess=el('span',{className:'pool-sessions'},(p.running||0)+'/'+(p.maxConcurrent||0));
|
|
30
34
|
poolList.appendChild(el('div',{className:'pool-item'},[dot,name,status,sess]));
|
|
31
35
|
});
|
|
36
|
+
teleAvail=anyAvail;teleSessNow=d.totalRunning||0;teleSessMax=d.totalMaxConcurrent||0;
|
|
37
|
+
teleDriver=teleDriver||(d.pools.length+' pools');
|
|
32
38
|
}else if(d.pools&&d.pools.length===1){
|
|
33
39
|
var p=d.pools[0];
|
|
34
40
|
$('#poolDot').className='pool-dot '+(p.error||!p.available?'off':'on');
|
|
35
41
|
$('#poolLabel').textContent=p.error?'offline':p.available?'ready':'busy';
|
|
36
42
|
$('#poolSessions').textContent=(p.running||0)+'/'+(p.maxConcurrent||0);
|
|
37
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';
|
|
38
46
|
}else{
|
|
39
47
|
$('#poolDot').className='pool-dot '+(d.error||!d.available?'off':'on');
|
|
40
48
|
$('#poolLabel').textContent=d.error?'offline':d.available?'ready':'busy';
|
|
41
49
|
$('#poolSessions').textContent=(d.running||0)+'/'+(d.maxConcurrent||0);
|
|
42
50
|
poolList.style.display='none';
|
|
51
|
+
teleAvail=!d.error&&d.available;teleSessNow=d.running||0;teleSessMax=d.maxConcurrent||0;
|
|
43
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(){});
|
|
44
87
|
}
|
|
45
|
-
function refreshStatus(){api('/api/status').then(function(d){renderPool(d.pool)}).catch(function(){})}
|
|
46
88
|
|
|
47
89
|
/* ── Projects ── */
|
|
48
90
|
function refreshProjects(){
|
|
49
91
|
api('/api/db/projects').then(function(projects){
|
|
50
|
-
var sel=$('#projectSelect')
|
|
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;
|
|
51
97
|
while(sel.options.length>1)sel.remove(1);
|
|
52
98
|
if(Array.isArray(projects))projects.forEach(function(p){
|
|
53
99
|
var o=document.createElement('option');o.value=p.id;o.textContent=p.name;sel.appendChild(o);
|
|
54
100
|
});
|
|
55
|
-
|
|
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
|
+
}
|
|
56
112
|
}).catch(function(){});
|
|
57
113
|
}
|
|
58
114
|
$('#projectSelect').addEventListener('change',function(){
|
|
59
115
|
S.project=this.value?parseInt(this.value,10):null;
|
|
60
116
|
S.selectedRun=null;
|
|
117
|
+
try{S.project!=null?localStorage.setItem('e2e-project',String(S.project)):localStorage.removeItem('e2e-project')}catch(e){}
|
|
61
118
|
refreshRuns();refreshSuites();refreshScreenshots();refreshLearnings();refreshWatch();
|
|
62
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 (
|
|
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':'
|
|
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==='
|
|
27
|
-
else if(S.view==='
|
|
28
|
-
else if(S.view==='
|
|
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==='
|
|
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==='
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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
|
+
})();
|