specbook 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/app/controllers/specbook/application_controller.rb +4 -0
- data/app/controllers/specbook/viewer_controller.rb +179 -0
- data/app/views/specbook/viewer/_app_js.html.erb +1335 -0
- data/app/views/specbook/viewer/_screenshots_sidebar.html.erb +24 -0
- data/app/views/specbook/viewer/_styles.html.erb +178 -0
- data/app/views/specbook/viewer/_top_bar.html.erb +13 -0
- data/app/views/specbook/viewer/_traces_sidebar.html.erb +15 -0
- data/app/views/specbook/viewer/_viewer_panel.html.erb +44 -0
- data/app/views/specbook/viewer/show.html.erb +27 -0
- data/config/routes.rb +6 -0
- data/lib/generators/specbook/install/USAGE +25 -0
- data/lib/generators/specbook/install/install_generator.rb +19 -0
- data/lib/generators/specbook/install/templates/README +22 -0
- data/lib/generators/specbook/install/templates/specbook.rb +45 -0
- data/lib/specbook/configuration.rb +50 -0
- data/lib/specbook/engine.rb +7 -0
- data/lib/specbook/recorders/playwright_trace.rb +91 -0
- data/lib/specbook/recorders/screenshot.rb +713 -0
- data/lib/specbook/rspec.rb +15 -0
- data/lib/specbook/version.rb +3 -0
- data/lib/specbook.rb +13 -0
- metadata +150 -0
|
@@ -0,0 +1,1335 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
const traces = <%= raw @traces.to_json %>;
|
|
3
|
+
const traceBase = "<%= @traces.present? ? specbook.trace_path(filename: '__PH__').sub('__PH__', '') : '' %>";
|
|
4
|
+
|
|
5
|
+
function switchTab(tab) {
|
|
6
|
+
var tSidebar = document.getElementById('traces-sidebar');
|
|
7
|
+
var sSidebar = document.getElementById('screenshots-sidebar');
|
|
8
|
+
var tViewer = document.getElementById('traces-viewer');
|
|
9
|
+
var sViewer = document.getElementById('screenshots-viewer');
|
|
10
|
+
var tBtn = document.getElementById('tab-traces');
|
|
11
|
+
var sBtn = document.getElementById('tab-screenshots');
|
|
12
|
+
if (tab === 'traces') {
|
|
13
|
+
tSidebar.style.display = ''; sSidebar.style.display = 'none';
|
|
14
|
+
tViewer.style.display = ''; sViewer.style.display = 'none';
|
|
15
|
+
tBtn.style.background = '#334155'; tBtn.style.color = '#e2e8f0';
|
|
16
|
+
sBtn.style.background = 'transparent'; sBtn.style.color = '#94a3b8';
|
|
17
|
+
} else {
|
|
18
|
+
tSidebar.style.display = 'none'; sSidebar.style.display = '';
|
|
19
|
+
tViewer.style.display = 'none'; sViewer.style.display = '';
|
|
20
|
+
tBtn.style.background = 'transparent'; tBtn.style.color = '#94a3b8';
|
|
21
|
+
sBtn.style.background = '#334155'; sBtn.style.color = '#e2e8f0';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function selectTrace(index) {
|
|
26
|
+
var items = document.querySelectorAll('.trace-item');
|
|
27
|
+
items.forEach(function(el) { el.classList.remove('active'); });
|
|
28
|
+
items[index].classList.add('active');
|
|
29
|
+
|
|
30
|
+
var t = traces[index];
|
|
31
|
+
var detail = document.getElementById('trace-detail');
|
|
32
|
+
detail.innerHTML =
|
|
33
|
+
'<div style="max-width:500px; text-align:left;">' +
|
|
34
|
+
'<h2 style="font-size:1.1rem; margin-bottom:0.3rem; color:#e2e8f0;">' + t.name + '</h2>' +
|
|
35
|
+
'<div style="font-size:0.8rem; color:#64748b; margin-bottom:1.5rem;">' + t.file + ':' + t.line + '</div>' +
|
|
36
|
+
'<button onclick="openTrace(\'' + t.trace + '\', this)" style="padding:0.75rem 2rem; background:#1d4ed8; color:white; border:none; border-radius:8px; font-size:1rem; cursor:pointer; font-weight:600;">Open in Trace Viewer</button>' +
|
|
37
|
+
'<span id="trace-status" style="margin-left:1rem; font-size:0.85rem; color:#4ade80;"></span>' +
|
|
38
|
+
'</div>';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function openTrace(filename, btn) {
|
|
42
|
+
var status = document.getElementById('trace-status');
|
|
43
|
+
status.textContent = 'Launching...';
|
|
44
|
+
btn.disabled = true;
|
|
45
|
+
|
|
46
|
+
var csrfToken = document.querySelector('meta[name="csrf-token"]');
|
|
47
|
+
fetch(traceBase + filename + '/view', {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'X-CSRF-Token': csrfToken ? csrfToken.content : '' }
|
|
50
|
+
}).then(function(r) { return r.json(); })
|
|
51
|
+
.then(function(data) {
|
|
52
|
+
status.textContent = '';
|
|
53
|
+
btn.disabled = false;
|
|
54
|
+
window.open(data.url, '_blank');
|
|
55
|
+
}).catch(function(e) {
|
|
56
|
+
status.textContent = 'Error: ' + e.message;
|
|
57
|
+
status.style.color = '#f87171';
|
|
58
|
+
btn.disabled = false;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Trace search filter
|
|
63
|
+
var traceSearch = document.getElementById('trace-search');
|
|
64
|
+
if (traceSearch) {
|
|
65
|
+
traceSearch.addEventListener('input', function() {
|
|
66
|
+
var q = this.value.toLowerCase();
|
|
67
|
+
document.querySelectorAll('.trace-item').forEach(function(el) {
|
|
68
|
+
el.style.display = el.dataset.name.includes(q) ? '' : 'none';
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const manifest = <%= raw @manifest.to_json %>;
|
|
74
|
+
const features = <%= raw @features.to_json %>;
|
|
75
|
+
const screenshotBase = "<%= specbook.screenshot_path(filename: '__PLACEHOLDER__').sub('__PLACEHOLDER__', '') %>";
|
|
76
|
+
|
|
77
|
+
let currentExampleIdx = 0;
|
|
78
|
+
let currentStepIdx = 0;
|
|
79
|
+
let currentGherkinIdx = 0; // For gherkin features: index into bg+scenario steps
|
|
80
|
+
let autoplayTimer = null;
|
|
81
|
+
let filter = 'all';
|
|
82
|
+
|
|
83
|
+
// Don't navigate on click if user is selecting text
|
|
84
|
+
function hasTextSelection() {
|
|
85
|
+
var sel = window.getSelection();
|
|
86
|
+
return sel && sel.toString().length > 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isGherkinExample(ex) {
|
|
90
|
+
return ex && ex.gherkin && ex.gherkin.scenario;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function gherkinStepCount(ex) {
|
|
94
|
+
if (!isGherkinExample(ex)) return 0;
|
|
95
|
+
// +2 for intro and end cards
|
|
96
|
+
return (ex.gherkin.background || []).length + ex.gherkin.scenario.length + 2;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function filteredManifest() {
|
|
100
|
+
if (filter === 'all') return manifest;
|
|
101
|
+
return manifest.filter(ex => ex.type === filter);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stepSummary(steps) {
|
|
105
|
+
var parts = [];
|
|
106
|
+
steps.forEach(function(s) {
|
|
107
|
+
var a = s.action || 'navigate';
|
|
108
|
+
var desc = s.description || '';
|
|
109
|
+
if (a === 'navigate') parts.push('→ ' + desc.replace('Navigate to ', ''));
|
|
110
|
+
else if (a === 'click') parts.push('🖱 ' + desc.replace(/^Click (button |link )?/, ''));
|
|
111
|
+
else if (a === 'fill') parts.push('⌨ ' + desc.replace(/^Type /, ''));
|
|
112
|
+
else if (a === 'select') parts.push('▼ ' + desc.replace(/^Select /, ''));
|
|
113
|
+
else if (a === 'assert') {
|
|
114
|
+
var count = (s.assertions || []).length;
|
|
115
|
+
parts.push('✓ ' + count + ' assertion' + (count !== 1 ? 's' : ''));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return parts.slice(0, 4).join(' › ') + (parts.length > 4 ? ' …' : '');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function highlightQuotes(text) {
|
|
122
|
+
return text.replace(/"([^"]*)"/g, '"<span class="quoted">$1</span>"');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
var featureCollapsed = {};
|
|
126
|
+
|
|
127
|
+
function renderFeatureSidebar(featureFile, activeScenarioName) {
|
|
128
|
+
var featureList = document.getElementById('feature-list');
|
|
129
|
+
var exampleList = document.getElementById('example-list');
|
|
130
|
+
exampleList.style.display = 'none';
|
|
131
|
+
featureList.style.display = '';
|
|
132
|
+
|
|
133
|
+
var filtered = filteredManifest();
|
|
134
|
+
|
|
135
|
+
// Build map of all feature files → their scenarios in manifest
|
|
136
|
+
var allFeatureFiles = [];
|
|
137
|
+
var fileScenarios = {};
|
|
138
|
+
filtered.forEach(function(ex, i) {
|
|
139
|
+
if (!ex.file.endsWith('.feature') || !features[ex.file]) return;
|
|
140
|
+
if (!fileScenarios[ex.file]) { fileScenarios[ex.file] = []; allFeatureFiles.push(ex.file); }
|
|
141
|
+
var sName = (ex.groups || []).length >= 2 ? ex.groups[ex.groups.length - 1] : ex.description;
|
|
142
|
+
fileScenarios[ex.file].push({ name: sName, idx: i, status: ex.status });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Group by top-level category (UI Features vs non-UI), then by domain
|
|
146
|
+
var uiDomains = (window.Specbook && window.Specbook.config && window.Specbook.config.uiDomains) || [];
|
|
147
|
+
var categories = { 'UI Features': {}, 'Services': {}, 'Models': {}, 'Jobs': {}, 'Policies': {}, 'Other': {} };
|
|
148
|
+
var categoryOrder = ['UI Features', 'Services', 'Models', 'Jobs', 'Policies', 'Other'];
|
|
149
|
+
var catDomainOrder = {};
|
|
150
|
+
categoryOrder.forEach(function(c) { catDomainOrder[c] = []; });
|
|
151
|
+
|
|
152
|
+
allFeatureFiles.forEach(function(f) {
|
|
153
|
+
var parts = f.replace(/^spec\/features\//, '').split('/');
|
|
154
|
+
var topDir = parts[0];
|
|
155
|
+
var cat, domain;
|
|
156
|
+
if (uiDomains.indexOf(topDir) >= 0) {
|
|
157
|
+
cat = 'UI Features';
|
|
158
|
+
domain = titleCase(topDir);
|
|
159
|
+
} else if (topDir === 'services') {
|
|
160
|
+
cat = 'Services';
|
|
161
|
+
domain = parts.length > 2 ? titleCase(parts[1]) : 'General';
|
|
162
|
+
} else if (topDir === 'models') {
|
|
163
|
+
cat = 'Models';
|
|
164
|
+
domain = 'Models';
|
|
165
|
+
} else if (topDir === 'jobs') {
|
|
166
|
+
cat = 'Jobs';
|
|
167
|
+
domain = 'Jobs';
|
|
168
|
+
} else if (topDir === 'policies') {
|
|
169
|
+
cat = 'Policies';
|
|
170
|
+
domain = 'Policies';
|
|
171
|
+
} else {
|
|
172
|
+
cat = 'Other';
|
|
173
|
+
domain = titleCase(topDir);
|
|
174
|
+
}
|
|
175
|
+
if (!categories[cat][domain]) { categories[cat][domain] = []; catDomainOrder[cat].push(domain); }
|
|
176
|
+
categories[cat][domain].push(f);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
function chevron(key) { return featureCollapsed[key] ? '▶' : '▼'; }
|
|
180
|
+
|
|
181
|
+
var html = '';
|
|
182
|
+
|
|
183
|
+
categoryOrder.forEach(function(cat) {
|
|
184
|
+
var catDomains = catDomainOrder[cat];
|
|
185
|
+
if (catDomains.length === 0) return;
|
|
186
|
+
var catKey = 'cat_' + cat;
|
|
187
|
+
if (featureCollapsed[catKey] === undefined) featureCollapsed[catKey] = false;
|
|
188
|
+
var catCount = 0;
|
|
189
|
+
catDomains.forEach(function(d) { catCount += categories[cat][d].reduce(function(sum, f) { return sum + (fileScenarios[f] || []).length; }, 0); });
|
|
190
|
+
|
|
191
|
+
html += '<div class="group-header" data-ftoggle="' + catKey + '" style="font-size:1rem;"><span class="chevron">' + chevron(catKey) + '</span>' + cat + '<span class="count">' + catCount + '</span></div>';
|
|
192
|
+
html += '<div class="group-body' + (featureCollapsed[catKey] ? ' collapsed' : '') + '" data-fbody="' + catKey + '">';
|
|
193
|
+
|
|
194
|
+
catDomains.forEach(function(domain) {
|
|
195
|
+
var domKey = 'domain_' + cat + '_' + domain;
|
|
196
|
+
if (featureCollapsed[domKey] === undefined) featureCollapsed[domKey] = (cat !== 'UI Features');
|
|
197
|
+
var domFiles = categories[cat][domain];
|
|
198
|
+
var domCount = domFiles.reduce(function(sum, f) { return sum + (fileScenarios[f] || []).length; }, 0);
|
|
199
|
+
|
|
200
|
+
// Only show domain sub-header if category has multiple domains
|
|
201
|
+
if (catDomains.length > 1) {
|
|
202
|
+
html += '<div class="group-header" data-ftoggle="' + domKey + '" style="padding-left:1.8rem;font-size:0.95rem;"><span class="chevron">' + chevron(domKey) + '</span>' + domain + '<span class="count">' + domCount + '</span></div>';
|
|
203
|
+
html += '<div class="group-body' + (featureCollapsed[domKey] ? ' collapsed' : '') + '" data-fbody="' + domKey + '">';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
domFiles.forEach(function(f) {
|
|
207
|
+
var feat = features[f];
|
|
208
|
+
var isCurrentFeature = f === featureFile;
|
|
209
|
+
var fKey = 'feat_' + f;
|
|
210
|
+
if (featureCollapsed[fKey] === undefined) featureCollapsed[fKey] = !isCurrentFeature;
|
|
211
|
+
|
|
212
|
+
var scenarios = fileScenarios[f] || [];
|
|
213
|
+
var passCount = scenarios.filter(function(s) { return s.status !== 'failed'; }).length;
|
|
214
|
+
var failCount = scenarios.length - passCount;
|
|
215
|
+
var statusSummary = failCount > 0 ? '<span style="color:#f87171;">' + failCount + '✗</span>' : '<span style="color:#4ade80;">' + passCount + '✓</span>';
|
|
216
|
+
|
|
217
|
+
html += '<div class="subgroup-header' + (isCurrentFeature ? ' has-active' : '') + '" data-ftoggle="' + fKey + '" style="padding-left:2.2rem;"><span class="chevron">' + chevron(fKey) + '</span>' + feat.name + '<span class="count">' + statusSummary + '</span></div>';
|
|
218
|
+
html += '<div class="subgroup-body' + (featureCollapsed[fKey] ? ' collapsed' : '') + '" data-fbody="' + fKey + '">';
|
|
219
|
+
|
|
220
|
+
// Feature description
|
|
221
|
+
if (feat.description && feat.description.length > 0) {
|
|
222
|
+
feat.description.forEach(function(line) {
|
|
223
|
+
html += '<div style="padding:0.1rem 1.25rem 0.1rem 3.4rem;color:#94a3b8;font-style:italic;font-size:0.72rem;">' + line + '</div>';
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
scenarios.forEach(function(s) {
|
|
228
|
+
var isActive = isCurrentFeature && s.name === activeScenarioName;
|
|
229
|
+
var cls = 'scenario-block' + (isActive ? ' active' : '');
|
|
230
|
+
var icon = s.status === 'failed' ? '<span style="color:#f87171;">✗</span>' : '<span style="color:#4ade80;">✓</span>';
|
|
231
|
+
var ex = filtered[s.idx];
|
|
232
|
+
var screenshotCount = ex && ex.steps ? ex.steps.filter(function(st) { return st.file; }).length : 0;
|
|
233
|
+
var gherkinSteps = ex && ex.gherkin ? (ex.gherkin.background || []).length + (ex.gherkin.scenario || []).length : 0;
|
|
234
|
+
var stepCount = gherkinSteps || (ex && ex.steps ? ex.steps.length : 0);
|
|
235
|
+
var nameColor = s.status === 'failed' ? 'color:#f87171;' : 'color:#4ade80;';
|
|
236
|
+
var meta = '<div style="color:#94a3b8;font-size:0.8em;margin-top:0.15em;">';
|
|
237
|
+
meta += stepCount + ' steps';
|
|
238
|
+
if (screenshotCount > 0) meta += ' · 📷 ' + screenshotCount;
|
|
239
|
+
meta += '</div>';
|
|
240
|
+
html += '<div class="' + cls + '" data-scenario-idx="' + s.idx + '" style="padding-left:3.4rem;"><div class="scenario-header"><div class="scenario-name" style="' + nameColor + '">' + s.name + '</div>' + meta + '</div></div>';
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
html += '</div>';
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (catDomains.length > 1) {
|
|
247
|
+
html += '</div>'; // close domain group-body
|
|
248
|
+
}
|
|
249
|
+
}); // end domain loop
|
|
250
|
+
|
|
251
|
+
html += '</div>'; // close category group-body
|
|
252
|
+
}); // end category loop
|
|
253
|
+
|
|
254
|
+
featureList.innerHTML = html;
|
|
255
|
+
|
|
256
|
+
// Toggle handlers
|
|
257
|
+
featureList.querySelectorAll('[data-ftoggle]').forEach(function(el) {
|
|
258
|
+
el.addEventListener('click', function() {
|
|
259
|
+
var key = el.dataset.ftoggle;
|
|
260
|
+
featureCollapsed[key] = !featureCollapsed[key];
|
|
261
|
+
var body = featureList.querySelector('[data-fbody="' + key + '"]');
|
|
262
|
+
var chev = el.querySelector('.chevron');
|
|
263
|
+
if (featureCollapsed[key]) { body.classList.add('collapsed'); chev.innerHTML = '▶'; }
|
|
264
|
+
else { body.classList.remove('collapsed'); chev.innerHTML = '▼'; }
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Scenario click handlers
|
|
269
|
+
featureList.querySelectorAll('[data-scenario-idx]').forEach(function(el) {
|
|
270
|
+
el.addEventListener('click', function() {
|
|
271
|
+
if (hasTextSelection()) return;
|
|
272
|
+
selectExample(parseInt(el.dataset.scenarioIdx));
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Scroll active into view
|
|
277
|
+
var activeBlock = featureList.querySelector('.scenario-block.active');
|
|
278
|
+
if (activeBlock) activeBlock.scrollIntoView({ block: 'nearest' });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function titleCase(s) { return s.split('_').map(function(w) { return w.charAt(0).toUpperCase() + w.slice(1); }).join(' '); }
|
|
282
|
+
|
|
283
|
+
function domainFromFile(file) {
|
|
284
|
+
// Returns [topLevel, subArea] e.g. ["Admin", "Devices"] or ["Mobile", null]
|
|
285
|
+
var path = file.replace(/^spec\/(system|features)\//, '');
|
|
286
|
+
var parts = path.split('/');
|
|
287
|
+
if (parts.length >= 3) return [titleCase(parts[0]), titleCase(parts[1])];
|
|
288
|
+
return [titleCase(parts[0]), null];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Track collapsed state for groups and subgroups
|
|
292
|
+
var collapsed = {};
|
|
293
|
+
|
|
294
|
+
function renderExampleList() {
|
|
295
|
+
const list = document.getElementById('example-list');
|
|
296
|
+
const filtered = filteredManifest();
|
|
297
|
+
|
|
298
|
+
// Build 3-level tree: domain → subArea → describeGroup → items
|
|
299
|
+
var domains = {};
|
|
300
|
+
var domainOrder = [];
|
|
301
|
+
filtered.forEach(function(ex, i) {
|
|
302
|
+
var d = domainFromFile(ex.file);
|
|
303
|
+
var domain = d[0], subArea = d[1] || '(root)';
|
|
304
|
+
if (!domains[domain]) { domains[domain] = { subAreas: {}, subOrder: [], total: 0 }; domainOrder.push(domain); }
|
|
305
|
+
if (!domains[domain].subAreas[subArea]) { domains[domain].subAreas[subArea] = { groups: {}, groupOrder: [], items: [], total: 0 }; domains[domain].subOrder.push(subArea); }
|
|
306
|
+
domains[domain].total++;
|
|
307
|
+
domains[domain].subAreas[subArea].total++;
|
|
308
|
+
|
|
309
|
+
var descGroups = (ex.groups || []).slice(1);
|
|
310
|
+
var groupKey = descGroups.length > 0 ? descGroups.join(' › ') : '';
|
|
311
|
+
var groupLabel = descGroups.length > 0 ? descGroups[descGroups.length - 1] : '';
|
|
312
|
+
var sa = domains[domain].subAreas[subArea];
|
|
313
|
+
if (groupKey) {
|
|
314
|
+
if (!sa.groups[groupKey]) { sa.groups[groupKey] = { label: groupLabel, items: [] }; sa.groupOrder.push(groupKey); }
|
|
315
|
+
sa.groups[groupKey].items.push({ ex: ex, idx: i });
|
|
316
|
+
} else {
|
|
317
|
+
sa.items.push({ ex: ex, idx: i });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Active tracking
|
|
322
|
+
var activeEx = filtered[currentExampleIdx];
|
|
323
|
+
var activeDomain = activeEx ? domainFromFile(activeEx.file)[0] : null;
|
|
324
|
+
var activeSubArea = activeEx ? (domainFromFile(activeEx.file)[1] || '(root)') : null;
|
|
325
|
+
|
|
326
|
+
// Auto-expand: domains/subAreas with failures or active item
|
|
327
|
+
var failDomains = {}, failSubAreas = {};
|
|
328
|
+
filtered.forEach(function(ex) {
|
|
329
|
+
if (ex.status === 'failed') {
|
|
330
|
+
var d = domainFromFile(ex.file);
|
|
331
|
+
failDomains[d[0]] = true;
|
|
332
|
+
failSubAreas[d[0] + '::' + (d[1] || '(root)')] = true;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
domainOrder.forEach(function(dom) {
|
|
336
|
+
if (collapsed[dom] === undefined) collapsed[dom] = !(dom === activeDomain || failDomains[dom]);
|
|
337
|
+
domains[dom].subOrder.forEach(function(sa) {
|
|
338
|
+
var k = dom + '::' + sa;
|
|
339
|
+
if (collapsed[k] === undefined) collapsed[k] = !(sa === activeSubArea && dom === activeDomain) && !failSubAreas[k];
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
function renderItem(it) {
|
|
344
|
+
var ex = it.ex;
|
|
345
|
+
var displayName = ex.description || ex.name;
|
|
346
|
+
if (displayName.length > 0) displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1);
|
|
347
|
+
if (ex.type === 'adversarial') displayName += ' <span style="color:#f87171;font-size:0.7rem;">(adversarial)</span>';
|
|
348
|
+
var active = it.idx === currentExampleIdx ? 'active' : '';
|
|
349
|
+
var statusColor = ex.status === 'failed' ? 'color:#f87171' : 'color:#4ade80';
|
|
350
|
+
var editorBase = (window.Specbook && window.Specbook.config && window.Specbook.config.editorBase) || null;
|
|
351
|
+
var openLink = editorBase
|
|
352
|
+
? '<a href="' + editorBase + '/' + ex.file + ':' + ex.line + '" style="color:inherit;text-decoration:underline;" onclick="event.stopPropagation()">open</a>'
|
|
353
|
+
: '<span style="color:inherit;">' + ex.file + ':' + ex.line + '</span>';
|
|
354
|
+
return '<div class="example-item ' + active + '" style="' + statusColor + '" data-idx="' + it.idx + '">' +
|
|
355
|
+
displayName + '<div class="example-file">' + ex.steps.length + ' steps · ' + openLink + '</div></div>';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function chevron(key) { return collapsed[key] ? '▶' : '▼'; }
|
|
359
|
+
function bodyClass(key) { return collapsed[key] ? ' collapsed' : ''; }
|
|
360
|
+
|
|
361
|
+
var html = '';
|
|
362
|
+
domainOrder.forEach(function(dom) {
|
|
363
|
+
var d = domains[dom];
|
|
364
|
+
var domCls = 'group-header' + (dom === activeDomain ? ' has-active' : '');
|
|
365
|
+
html += '<div class="' + domCls + '" data-toggle="' + dom + '"><span class="chevron">' + chevron(dom) + '</span>' + dom + '<span class="count">' + d.total + '</span></div>';
|
|
366
|
+
html += '<div class="group-body' + bodyClass(dom) + '" data-body="' + dom + '">';
|
|
367
|
+
|
|
368
|
+
d.subOrder.forEach(function(sa) {
|
|
369
|
+
var sub = d.subAreas[sa];
|
|
370
|
+
var saKey = dom + '::' + sa;
|
|
371
|
+
if (sa === '(root)') {
|
|
372
|
+
// No sub-area header, render items directly
|
|
373
|
+
sub.items.forEach(function(it) { html += renderItem(it); });
|
|
374
|
+
sub.groupOrder.forEach(function(gk) { var g = sub.groups[gk]; g.items.forEach(function(it) { html += renderItem(it); }); });
|
|
375
|
+
} else {
|
|
376
|
+
html += '<div class="subgroup-header" data-toggle="' + saKey + '"><span class="chevron">' + chevron(saKey) + '</span>' + sa + '<span class="count">' + sub.total + '</span></div>';
|
|
377
|
+
html += '<div class="subgroup-body' + bodyClass(saKey) + '" data-body="' + saKey + '">';
|
|
378
|
+
|
|
379
|
+
sub.items.forEach(function(it) { html += renderItem(it); });
|
|
380
|
+
var lastGroup = '';
|
|
381
|
+
sub.groupOrder.forEach(function(gk) {
|
|
382
|
+
var g = sub.groups[gk];
|
|
383
|
+
if (g.label !== lastGroup) {
|
|
384
|
+
var gKey = saKey + '::' + g.label;
|
|
385
|
+
if (collapsed[gKey] === undefined) collapsed[gKey] = false;
|
|
386
|
+
html += '<div class="subgroup-header" style="padding-left:3.4rem;" data-toggle="' + gKey + '"><span class="chevron">' + chevron(gKey) + '</span>' + g.label + '<span class="count">' + g.items.length + '</span></div>';
|
|
387
|
+
html += '<div class="subgroup-body depth-3' + bodyClass(gKey) + '" data-body="' + gKey + '">';
|
|
388
|
+
lastGroup = g.label;
|
|
389
|
+
}
|
|
390
|
+
g.items.forEach(function(it) { html += renderItem(it); });
|
|
391
|
+
// Check if next group is different — close the div
|
|
392
|
+
var nextGk = sub.groupOrder[sub.groupOrder.indexOf(gk) + 1];
|
|
393
|
+
var nextLabel = nextGk ? sub.groups[nextGk].label : null;
|
|
394
|
+
if (nextLabel !== g.label) {
|
|
395
|
+
html += '</div>';
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
html += '</div>';
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
html += '</div>';
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
list.innerHTML = html;
|
|
405
|
+
|
|
406
|
+
// Summary counts
|
|
407
|
+
var passCount = filtered.filter(function(ex) { return ex.status !== 'failed'; }).length;
|
|
408
|
+
var failCount = filtered.filter(function(ex) { return ex.status === 'failed'; }).length;
|
|
409
|
+
document.getElementById('total-count').textContent = filtered.length + ' specs';
|
|
410
|
+
document.getElementById('pass-count').textContent = passCount + ' passing';
|
|
411
|
+
document.getElementById('fail-count').textContent = failCount > 0 ? failCount + ' failing' : '';
|
|
412
|
+
|
|
413
|
+
// Click handlers for items
|
|
414
|
+
list.querySelectorAll('.example-item').forEach(function(el) {
|
|
415
|
+
el.addEventListener('click', function() { if (hasTextSelection()) return; selectExample(parseInt(el.dataset.idx)); });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Click handlers for all toggle headers (groups + subgroups)
|
|
419
|
+
list.querySelectorAll('[data-toggle]').forEach(function(el) {
|
|
420
|
+
el.addEventListener('click', function() {
|
|
421
|
+
var key = el.dataset.toggle;
|
|
422
|
+
collapsed[key] = !collapsed[key];
|
|
423
|
+
var body = list.querySelector('[data-body="' + key + '"]');
|
|
424
|
+
var chevron = el.querySelector('.chevron');
|
|
425
|
+
if (collapsed[key]) {
|
|
426
|
+
body.classList.add('collapsed');
|
|
427
|
+
chevron.innerHTML = '▶';
|
|
428
|
+
} else {
|
|
429
|
+
body.classList.remove('collapsed');
|
|
430
|
+
chevron.innerHTML = '▼';
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Filter state — two orthogonal radio groups + search
|
|
438
|
+
var statusFilter = 'all'; // all | passing | failing
|
|
439
|
+
var typeFilter = 'all'; // all | visual | code
|
|
440
|
+
var searchQuery = '';
|
|
441
|
+
|
|
442
|
+
function scenarioVisible(ex) {
|
|
443
|
+
// Status filter
|
|
444
|
+
if (statusFilter === 'passing' && ex.status === 'failed') return false;
|
|
445
|
+
if (statusFilter === 'failing' && ex.status !== 'failed') return false;
|
|
446
|
+
// Type filter
|
|
447
|
+
var hasScreenshots = ex.steps && ex.steps.some(function(st) { return st.file; });
|
|
448
|
+
if (typeFilter === 'visual' && !hasScreenshots) return false;
|
|
449
|
+
if (typeFilter === 'code' && hasScreenshots) return false;
|
|
450
|
+
// Search filter
|
|
451
|
+
if (searchQuery) {
|
|
452
|
+
var q = searchQuery.toLowerCase();
|
|
453
|
+
var searchText = (ex.name || '') + ' ' + (ex.description || '') + ' ' + (ex.file || '');
|
|
454
|
+
// Include gherkin steps in search
|
|
455
|
+
if (ex.gherkin) {
|
|
456
|
+
(ex.gherkin.background || []).forEach(function(s) { searchText += ' ' + s.keyword + ' ' + s.text; });
|
|
457
|
+
(ex.gherkin.scenario || []).forEach(function(s) { searchText += ' ' + s.keyword + ' ' + s.text; });
|
|
458
|
+
}
|
|
459
|
+
// Include feature description from features object
|
|
460
|
+
var feat = typeof features !== 'undefined' && features[ex.file];
|
|
461
|
+
if (feat) {
|
|
462
|
+
searchText += ' ' + (feat.name || '');
|
|
463
|
+
(feat.description || []).forEach(function(d) { searchText += ' ' + d; });
|
|
464
|
+
}
|
|
465
|
+
if (searchText.toLowerCase().indexOf(q) === -1) return false;
|
|
466
|
+
}
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function applyScenarioFilter() {
|
|
471
|
+
var flist = document.getElementById('feature-list');
|
|
472
|
+
|
|
473
|
+
flist.querySelectorAll('[data-scenario-idx]').forEach(function(el) {
|
|
474
|
+
var idx = parseInt(el.dataset.scenarioIdx);
|
|
475
|
+
var ex = manifest[idx];
|
|
476
|
+
el.style.display = (ex && scenarioVisible(ex)) ? '' : 'none';
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// Update counts and hide empty headers
|
|
480
|
+
var allBodies = Array.from(flist.querySelectorAll('[data-fbody]'));
|
|
481
|
+
allBodies.forEach(function(body) {
|
|
482
|
+
var key = body.dataset.fbody;
|
|
483
|
+
var header = flist.querySelector('[data-ftoggle="' + key + '"]');
|
|
484
|
+
if (!header) return;
|
|
485
|
+
var visibleCount = 0;
|
|
486
|
+
body.querySelectorAll('[data-scenario-idx]').forEach(function(el) {
|
|
487
|
+
if (el.style.display !== 'none') visibleCount++;
|
|
488
|
+
});
|
|
489
|
+
var countEl = header.querySelector('.count');
|
|
490
|
+
if (countEl) countEl.textContent = visibleCount;
|
|
491
|
+
header.style.display = visibleCount === 0 ? 'none' : '';
|
|
492
|
+
body.style.display = visibleCount === 0 ? 'none' : '';
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Update top-level stats
|
|
496
|
+
var visibleTotal = 0, visiblePassing = 0;
|
|
497
|
+
manifest.forEach(function(ex) {
|
|
498
|
+
if (scenarioVisible(ex)) {
|
|
499
|
+
visibleTotal++;
|
|
500
|
+
if (ex.status !== 'failed') visiblePassing++;
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
var failCount = visibleTotal - visiblePassing;
|
|
504
|
+
document.getElementById('total-count').textContent = visibleTotal + ' specs';
|
|
505
|
+
document.getElementById('pass-count').textContent = visiblePassing + ' passing';
|
|
506
|
+
document.getElementById('fail-count').textContent = failCount > 0 ? failCount + ' failing' : '';
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Attach filter listeners — two radio groups
|
|
510
|
+
(function attachFilterListeners() {
|
|
511
|
+
var statusBtns = document.querySelectorAll('[data-status-filter]');
|
|
512
|
+
var typeBtns = document.querySelectorAll('[data-type-filter]');
|
|
513
|
+
if (statusBtns.length === 0) { setTimeout(attachFilterListeners, 50); return; }
|
|
514
|
+
|
|
515
|
+
statusBtns.forEach(function(btn) {
|
|
516
|
+
btn.addEventListener('click', function() {
|
|
517
|
+
statusBtns.forEach(function(b) { b.classList.remove('active'); });
|
|
518
|
+
btn.classList.add('active');
|
|
519
|
+
statusFilter = btn.dataset.statusFilter;
|
|
520
|
+
setTimeout(applyScenarioFilter, 10);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
typeBtns.forEach(function(btn) {
|
|
525
|
+
btn.addEventListener('click', function() {
|
|
526
|
+
typeBtns.forEach(function(b) { b.classList.remove('active'); });
|
|
527
|
+
btn.classList.add('active');
|
|
528
|
+
typeFilter = btn.dataset.typeFilter;
|
|
529
|
+
setTimeout(applyScenarioFilter, 10);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
})();
|
|
533
|
+
|
|
534
|
+
// Search input — debounced, filters + auto-expands matching sections
|
|
535
|
+
(function() {
|
|
536
|
+
var searchInput = document.getElementById('spec-search');
|
|
537
|
+
var debounceTimer;
|
|
538
|
+
if (searchInput) {
|
|
539
|
+
searchInput.addEventListener('input', function() {
|
|
540
|
+
clearTimeout(debounceTimer);
|
|
541
|
+
debounceTimer = setTimeout(function() {
|
|
542
|
+
searchQuery = searchInput.value.trim();
|
|
543
|
+
applyScenarioFilter();
|
|
544
|
+
// Auto-expand all sections when searching, collapse when cleared
|
|
545
|
+
if (searchQuery) setAllCollapsed(false);
|
|
546
|
+
}, 200);
|
|
547
|
+
});
|
|
548
|
+
// Clear on Escape
|
|
549
|
+
searchInput.addEventListener('keydown', function(e) {
|
|
550
|
+
if (e.key === 'Escape') {
|
|
551
|
+
searchInput.value = '';
|
|
552
|
+
searchQuery = '';
|
|
553
|
+
applyScenarioFilter();
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
})();
|
|
558
|
+
|
|
559
|
+
// Expand/collapse all — works for both sidebar types
|
|
560
|
+
function setAllCollapsed(state) {
|
|
561
|
+
var flist = document.getElementById('feature-list');
|
|
562
|
+
flist.querySelectorAll('[data-ftoggle]').forEach(function(el) {
|
|
563
|
+
var key = el.dataset.ftoggle;
|
|
564
|
+
featureCollapsed[key] = state;
|
|
565
|
+
var body = flist.querySelector('[data-fbody="' + key + '"]');
|
|
566
|
+
var chevron = el.querySelector('.chevron');
|
|
567
|
+
if (body) {
|
|
568
|
+
if (state) { body.classList.add('collapsed'); if (chevron) chevron.innerHTML = '▶'; }
|
|
569
|
+
else { body.classList.remove('collapsed'); if (chevron) chevron.innerHTML = '▼'; }
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
var list = document.getElementById('example-list');
|
|
573
|
+
list.querySelectorAll('[data-toggle]').forEach(function(el) {
|
|
574
|
+
var key = el.dataset.toggle;
|
|
575
|
+
var body = list.querySelector('[data-body="' + key + '"]');
|
|
576
|
+
var chevron = el.querySelector('.chevron');
|
|
577
|
+
if (body) {
|
|
578
|
+
if (state) { body.classList.add('collapsed'); if (chevron) chevron.innerHTML = '▶'; }
|
|
579
|
+
else { body.classList.remove('collapsed'); if (chevron) chevron.innerHTML = '▼'; }
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
document.getElementById('expand-all').onclick = function(e) { e.preventDefault(); setAllCollapsed(false); };
|
|
584
|
+
document.getElementById('collapse-all').onclick = function(e) { e.preventDefault(); setAllCollapsed(true); };
|
|
585
|
+
|
|
586
|
+
function selectExample(idx) {
|
|
587
|
+
const filtered = filteredManifest();
|
|
588
|
+
if (idx < 0 || idx >= filtered.length) return;
|
|
589
|
+
currentExampleIdx = idx;
|
|
590
|
+
currentStepIdx = 0;
|
|
591
|
+
currentGherkinIdx = 0;
|
|
592
|
+
|
|
593
|
+
var ex = filtered[idx];
|
|
594
|
+
if (ex.file.endsWith('.feature') && features[ex.file]) {
|
|
595
|
+
var scenarioName = (ex.groups || []).length >= 2 ? ex.groups[ex.groups.length - 1] : ex.description;
|
|
596
|
+
renderFeatureSidebar(ex.file, scenarioName);
|
|
597
|
+
renderGherkinAtIndex(ex);
|
|
598
|
+
} else {
|
|
599
|
+
document.getElementById('feature-list').style.display = 'none';
|
|
600
|
+
document.getElementById('example-list').style.display = '';
|
|
601
|
+
renderExampleList();
|
|
602
|
+
renderStep();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Calculate image position/scale relative to its container
|
|
607
|
+
function getImageMetrics() {
|
|
608
|
+
const img = document.getElementById('screenshot-img');
|
|
609
|
+
const wrapper = document.getElementById('screenshot-wrapper');
|
|
610
|
+
const imgRect = img.getBoundingClientRect();
|
|
611
|
+
const wrapperRect = wrapper.getBoundingClientRect();
|
|
612
|
+
return {
|
|
613
|
+
scaleX: imgRect.width / (img.naturalWidth || 1400),
|
|
614
|
+
scaleY: imgRect.height / (img.naturalHeight || 900),
|
|
615
|
+
offsetX: imgRect.left - wrapperRect.left,
|
|
616
|
+
offsetY: imgRect.top - wrapperRect.top,
|
|
617
|
+
imgWidth: imgRect.width,
|
|
618
|
+
imgHeight: imgRect.height
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
var stepIcons = { navigate: '→', click: '🖱', fill: '⌨', select: '▼', check: '☑', assert: '✓', confirm: '⚠' };
|
|
623
|
+
|
|
624
|
+
function showOverlayCard(content) {
|
|
625
|
+
var wrapper = document.getElementById('screenshot-wrapper');
|
|
626
|
+
var img = document.getElementById('screenshot-img');
|
|
627
|
+
var overlays = document.getElementById('overlays');
|
|
628
|
+
img.style.display = 'none';
|
|
629
|
+
overlays.innerHTML = '';
|
|
630
|
+
var oldOverlay = wrapper.querySelector('.setup-overlay');
|
|
631
|
+
if (oldOverlay) oldOverlay.remove();
|
|
632
|
+
|
|
633
|
+
var div = document.createElement('div');
|
|
634
|
+
div.className = 'setup-overlay';
|
|
635
|
+
div.innerHTML = content;
|
|
636
|
+
wrapper.appendChild(div);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function showIntroOverlay(scenarioName, status) {
|
|
640
|
+
var statusText = status === 'failed' ? '<span style="color:#f87171;">✗ Failed</span>' : '<span style="color:#4ade80;">✓ Passed</span>';
|
|
641
|
+
showOverlayCard(
|
|
642
|
+
'<div style="font-size:0.8rem;color:#64748b;text-transform:uppercase;letter-spacing:0.15em;">Scenario</div>' +
|
|
643
|
+
'<div style="color:#e2e8f0;font-size:1.5rem;font-weight:600;max-width:600px;line-height:1.4;margin:0.5rem 0;">' + scenarioName + '</div>' +
|
|
644
|
+
'<div style="margin-top:1rem;">' + statusText + '</div>'
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function showEndOverlay(scenarioName, status) {
|
|
649
|
+
var icon = status === 'failed' ? '✗' : '✓';
|
|
650
|
+
var color = status === 'failed' ? '#f87171' : '#4ade80';
|
|
651
|
+
var label = status === 'failed' ? 'Scenario Failed' : 'Scenario Passed';
|
|
652
|
+
showOverlayCard(
|
|
653
|
+
'<div style="font-size:3rem;color:' + color + ';">' + icon + '</div>' +
|
|
654
|
+
'<div style="color:' + color + ';font-size:1.2rem;font-weight:600;margin:0.5rem 0;">' + label + '</div>' +
|
|
655
|
+
'<div style="color:#94a3b8;font-size:0.9rem;">' + scenarioName + '</div>'
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function showSetupOverlay(gi, bgSteps, scSteps, bgLen) {
|
|
660
|
+
var s = gi < bgLen ? bgSteps[gi] : scSteps[gi - bgLen];
|
|
661
|
+
if (!s) return;
|
|
662
|
+
|
|
663
|
+
var text = s.text;
|
|
664
|
+
var icon, note;
|
|
665
|
+
|
|
666
|
+
// Login step — show key icon with actor colour and full name
|
|
667
|
+
var loginActorPattern = actorNames.length > 0
|
|
668
|
+
? new RegExp('^(' + actorNames.map(function(n) { return n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|') + ') logs in$')
|
|
669
|
+
: null;
|
|
670
|
+
var loginMatch = loginActorPattern ? text.match(loginActorPattern) : null;
|
|
671
|
+
if (loginMatch) {
|
|
672
|
+
var actor = loginMatch[1];
|
|
673
|
+
var color = actorColors[actor] || '#e2e8f0';
|
|
674
|
+
// Find full name from users
|
|
675
|
+
var fullName = text;
|
|
676
|
+
if (typeof manifest !== 'undefined') {
|
|
677
|
+
// Look up from background steps
|
|
678
|
+
var bgSteps = (manifest[0] || {}).gherkin ? manifest[0].gherkin.background : [];
|
|
679
|
+
bgSteps.forEach(function(bs) {
|
|
680
|
+
var m = bs.text.match(new RegExp('(contractor|foreman|admin).*"(' + actor + '[^"]*)"', 'i'));
|
|
681
|
+
if (m) fullName = m[2];
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
showOverlayCard(
|
|
685
|
+
'<div class="setup-icon">🔑</div>' +
|
|
686
|
+
'<div class="setup-keyword" style="color:' + color + ';">' + s.keyword.toUpperCase() + '</div>' +
|
|
687
|
+
'<div class="setup-text"><span style="color:' + color + ';font-weight:700;">' + fullName + '</span> logs in</div>' +
|
|
688
|
+
'<div class="setup-note">Authentication — switching user session</div>'
|
|
689
|
+
);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Host-configured overlay rules — first match wins
|
|
694
|
+
var rules = (window.Specbook && window.Specbook.config && window.Specbook.config.setupOverlayRules) || [];
|
|
695
|
+
var matched = false;
|
|
696
|
+
for (var ri = 0; ri < rules.length; ri++) {
|
|
697
|
+
var rule = rules[ri];
|
|
698
|
+
try {
|
|
699
|
+
var re = new RegExp(rule.pattern, rule.flags || '');
|
|
700
|
+
if (text.match(re)) {
|
|
701
|
+
icon = rule.icon;
|
|
702
|
+
note = rule.note;
|
|
703
|
+
matched = true;
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
} catch (e) {
|
|
707
|
+
// Skip invalid pattern
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (!matched) {
|
|
711
|
+
icon = '⚙️';
|
|
712
|
+
note = 'No browser interaction';
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
var displayText = text.replace(/"([^"]*)"/g, function(match, inner) {
|
|
716
|
+
for (var name in actorColors) {
|
|
717
|
+
if (inner.indexOf(name) === 0) {
|
|
718
|
+
return '"<span style="color:' + actorColors[name] + ';font-weight:700;">' + inner + '</span>"';
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return '"<span class="quoted">' + inner + '</span>"';
|
|
722
|
+
});
|
|
723
|
+
var sourceHtml = s.source ? renderStepSource(s.source) : '';
|
|
724
|
+
showOverlayCard(
|
|
725
|
+
'<div class="setup-icon">' + icon + '</div>' +
|
|
726
|
+
'<div class="setup-keyword">' + s.keyword + '</div>' +
|
|
727
|
+
'<div class="setup-text">' + displayText + '</div>' +
|
|
728
|
+
'<div class="setup-note">' + note + '</div>' +
|
|
729
|
+
sourceHtml
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function highlightGherkinStep(stepList, activeGi) {
|
|
734
|
+
stepList.querySelectorAll('li[data-gi]').forEach(function(el) {
|
|
735
|
+
var gi = parseInt(el.dataset.gi);
|
|
736
|
+
el.classList.toggle('active', gi === activeGi);
|
|
737
|
+
el.style.opacity = gi > activeGi ? '0.35' : '';
|
|
738
|
+
});
|
|
739
|
+
var activeLi = stepList.querySelector('li.active');
|
|
740
|
+
if (activeLi) activeLi.scrollIntoView({ block: 'nearest' });
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Actor colours — host-configured via Specbook.config.actorColors
|
|
744
|
+
var actorColors = (window.Specbook && window.Specbook.config && window.Specbook.config.actorColors) || {};
|
|
745
|
+
var actorNames = Object.keys(actorColors);
|
|
746
|
+
var actorPattern = actorNames.length > 0
|
|
747
|
+
? new RegExp('^(' + actorNames.map(function(n) { return n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|') + ')\\b')
|
|
748
|
+
: null;
|
|
749
|
+
|
|
750
|
+
function actorFromText(text) {
|
|
751
|
+
if (!actorPattern) return null;
|
|
752
|
+
var m = text.match(actorPattern);
|
|
753
|
+
return m ? m[1] : null;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function colorizeStep(rawText) {
|
|
757
|
+
// Highlight quoted strings, but colour actor names within quotes
|
|
758
|
+
var text = rawText.replace(/"([^"]*)"/g, function(match, inner) {
|
|
759
|
+
// Check if the quoted string contains an actor name
|
|
760
|
+
for (var name in actorColors) {
|
|
761
|
+
if (inner.indexOf(name) === 0) {
|
|
762
|
+
return '“<span style=color:' + actorColors[name] + ';font-weight:600>' + inner + '</span>”';
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return '“<span style=color:#4ade80>' + inner + '</span>”';
|
|
766
|
+
});
|
|
767
|
+
// Colour actor names at start of step text (named-actor style)
|
|
768
|
+
var actor = actorFromText(rawText);
|
|
769
|
+
if (actor && actorColors[actor]) {
|
|
770
|
+
text = text.replace(new RegExp('^' + actor + '\\b'), '<span style=color:' + actorColors[actor] + ';font-weight:600>' + actor + '</span>');
|
|
771
|
+
}
|
|
772
|
+
return text;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function renderStepSource(src) {
|
|
776
|
+
if (!src || !src.body) return '';
|
|
777
|
+
// Body is already stripped of `step '...' do` / `end` by the recorder.
|
|
778
|
+
// Just clean up any stray end lines from older recordings.
|
|
779
|
+
var lines = src.body.split('\n');
|
|
780
|
+
if (lines[0] && lines[0].match(/^\s*step\s/)) lines.shift();
|
|
781
|
+
while (lines.length > 0 && lines[lines.length - 1].match(/^\s*end\s*$/)) lines.pop();
|
|
782
|
+
// Dedent
|
|
783
|
+
var minIndent = 999;
|
|
784
|
+
lines.forEach(function(l) { if (l.trim().length > 0) { var m = l.match(/^(\s*)/); if (m[1].length < minIndent) minIndent = m[1].length; }});
|
|
785
|
+
var body = lines.map(function(l) { return l.substring(minIndent); }).join('\n').trim();
|
|
786
|
+
if (!body) return '';
|
|
787
|
+
// HTML escape
|
|
788
|
+
body = body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
789
|
+
|
|
790
|
+
// Tokenize then reassemble to avoid nested replacements
|
|
791
|
+
// 1. Extract strings and comments into placeholders
|
|
792
|
+
var tokens = [];
|
|
793
|
+
body = body.replace(/#[^\n]*/g, function(m) { tokens.push('<span class="src-comment">' + m + '</span>'); return '__TK' + (tokens.length - 1) + '%%'; });
|
|
794
|
+
body = body.replace(/"[^"]*"/g, function(m) { tokens.push('<span class="src-string">' + m + '</span>'); return '__TK' + (tokens.length - 1) + '%%'; });
|
|
795
|
+
body = body.replace(/'[^']*'/g, function(m) { tokens.push('<span class="src-string">' + m + '</span>'); return '__TK' + (tokens.length - 1) + '%%'; });
|
|
796
|
+
|
|
797
|
+
// 2. Ruby keywords
|
|
798
|
+
body = body.replace(/\b(def|do|end|if|unless|else|elsif|return|nil|true|false|rescue|begin|yield|raise|class|module|require|require_relative|include|extend|prepend|attr_accessor|attr_reader|new|self)\b/g, '<span class="src-keyword">$1</span>');
|
|
799
|
+
|
|
800
|
+
// 3. RSpec/test keywords
|
|
801
|
+
body = body.replace(/\b(expect|allow|receive|have_received|to|not_to|eq|be_nil|be_present|be_a|have_css|have_text|have_link|have_button|have_field|described_class|subject|let!?|before|after|context|describe|it)\b/g, '<span style="color:#7dd3fc;">$1</span>');
|
|
802
|
+
|
|
803
|
+
// 4. Symbols
|
|
804
|
+
body = body.replace(/:(\w+)/g, '<span style="color:#fbbf24;">:$1</span>');
|
|
805
|
+
|
|
806
|
+
// 5. Instance variables
|
|
807
|
+
body = body.replace(/@\w+/g, '<span style="color:#f9a8d4;">$&</span>');
|
|
808
|
+
|
|
809
|
+
// 6. Constants (PascalCase)
|
|
810
|
+
body = body.replace(/\b([A-Z][a-zA-Z0-9]*(::[A-Z][a-zA-Z0-9]*)*)\b/g, '<span style="color:#c4b5fd;">$1</span>');
|
|
811
|
+
|
|
812
|
+
// 7. Method calls after dot
|
|
813
|
+
body = body.replace(/\.(\w+[!?]?)/g, '.<span style="color:#93c5fd;">$1</span>');
|
|
814
|
+
|
|
815
|
+
// 8. Numbers
|
|
816
|
+
body = body.replace(/\b(\d+)\b/g, '<span style="color:#fbbf24;">$1</span>');
|
|
817
|
+
|
|
818
|
+
// Restore tokens
|
|
819
|
+
body = body.replace(/__TK(\d+)%%/g, function(_, i) { return tokens[parseInt(i)]; });
|
|
820
|
+
|
|
821
|
+
var file = src.file + ':' + src.line;
|
|
822
|
+
var editorBase = (window.Specbook && window.Specbook.config && window.Specbook.config.editorBase) || null;
|
|
823
|
+
var fileHtml = editorBase
|
|
824
|
+
? '<a href="' + editorBase + '/' + src.file + ':' + src.line + '" class="src-file">' + file + '</a>'
|
|
825
|
+
: '<span class="src-file">' + file + '</span>';
|
|
826
|
+
return '<div class="step-source">' + fileHtml + body + '</div>';
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function renderStep() {
|
|
830
|
+
const filtered = filteredManifest();
|
|
831
|
+
if (filtered.length === 0) return;
|
|
832
|
+
const example = filtered[currentExampleIdx];
|
|
833
|
+
const step = example.steps[currentStepIdx];
|
|
834
|
+
|
|
835
|
+
const img = document.getElementById('screenshot-img');
|
|
836
|
+
const wrapper = document.getElementById('screenshot-wrapper');
|
|
837
|
+
// Remove any previous overlay
|
|
838
|
+
var oldOverlay = wrapper.querySelector('.setup-overlay');
|
|
839
|
+
if (oldOverlay) oldOverlay.remove();
|
|
840
|
+
var oldCard = wrapper.querySelector('.gherkin-card');
|
|
841
|
+
if (oldCard) oldCard.remove();
|
|
842
|
+
|
|
843
|
+
// No-screenshot specs (services, models): show gherkin steps as main content
|
|
844
|
+
if (!step && example.gherkin) {
|
|
845
|
+
img.style.display = 'none';
|
|
846
|
+
var card = document.createElement('div');
|
|
847
|
+
card.className = 'gherkin-card';
|
|
848
|
+
var scenarioName = example.description || example.name;
|
|
849
|
+
var html = '<div class="gherkin-title">Scenario</div>';
|
|
850
|
+
html += '<div class="gherkin-scenario-name">' + scenarioName + '</div>';
|
|
851
|
+
html += '<ul class="gherkin-steps-list">';
|
|
852
|
+
var bg = example.gherkin.background || [];
|
|
853
|
+
var sc = example.gherkin.scenario || [];
|
|
854
|
+
if (bg.length > 0) {
|
|
855
|
+
html += '<li class="bg-label">Background</li>';
|
|
856
|
+
bg.forEach(function(s) {
|
|
857
|
+
html += '<li><span class="kw">' + s.keyword + '</span> ' + colorizeStep(s.text) + '</li>';
|
|
858
|
+
if (s.source) html += renderStepSource(s.source);
|
|
859
|
+
});
|
|
860
|
+
html += '<li class="bg-label">Scenario</li>';
|
|
861
|
+
}
|
|
862
|
+
sc.forEach(function(s) {
|
|
863
|
+
var kwClass = s.keyword === 'Then' ? 'kw kw-then' : 'kw';
|
|
864
|
+
html += '<li><span class="' + kwClass + '">' + s.keyword + '</span> ' + colorizeStep(s.text) + '</li>';
|
|
865
|
+
if (s.source) html += renderStepSource(s.source);
|
|
866
|
+
});
|
|
867
|
+
html += '</ul>';
|
|
868
|
+
var statusClass = example.status === 'failed' ? 'failed' : 'passed';
|
|
869
|
+
var statusIcon = example.status === 'failed' ? '✗ Failed' : '✓ Passed';
|
|
870
|
+
html += '<div class="gherkin-status ' + statusClass + '">' + statusIcon + '</div>';
|
|
871
|
+
card.innerHTML = html;
|
|
872
|
+
wrapper.appendChild(card);
|
|
873
|
+
|
|
874
|
+
// Clear the step list pane
|
|
875
|
+
document.getElementById('step-list').innerHTML = '';
|
|
876
|
+
document.getElementById('progress-bar').style.width = '100%';
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (!step) return;
|
|
881
|
+
|
|
882
|
+
img.style.display = '';
|
|
883
|
+
|
|
884
|
+
if (step.file) {
|
|
885
|
+
img.src = screenshotBase + step.file;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Build step list — Gherkin steps for features, low-level steps for system specs
|
|
889
|
+
const stepList = document.getElementById('step-list');
|
|
890
|
+
if (example.gherkin && example.gherkin.scenario) {
|
|
891
|
+
var bgSteps = example.gherkin.background || [];
|
|
892
|
+
var scSteps = example.gherkin.scenario;
|
|
893
|
+
// Current screenshot's gherkin index (gi field, offset by background length)
|
|
894
|
+
var currentGi = (step.gi !== undefined) ? step.gi : -1;
|
|
895
|
+
var bgLen = bgSteps.length;
|
|
896
|
+
|
|
897
|
+
// Build map: for each gherkin index, find the first screenshot step
|
|
898
|
+
var giToScreenshot = {};
|
|
899
|
+
example.steps.forEach(function(s, si) {
|
|
900
|
+
if (s.gi !== undefined) {
|
|
901
|
+
// Prefer steps with bbox (visible element) over those without
|
|
902
|
+
if (giToScreenshot[s.gi] === undefined || (s.bbox && !example.steps[giToScreenshot[s.gi]].bbox)) giToScreenshot[s.gi] = si;
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
function renderGherkinStep(s, gi, label) {
|
|
907
|
+
var isCurrent = gi === currentGi;
|
|
908
|
+
var isTodo = gi > currentGi;
|
|
909
|
+
var cls = isCurrent ? 'active' : '';
|
|
910
|
+
var opacity = isTodo ? 'opacity:0.35;' : '';
|
|
911
|
+
var kwColor = s.keyword === 'Then' ? '#4ade80' : '#60a5fa';
|
|
912
|
+
var text = colorizeStep(s.text);
|
|
913
|
+
return '<li class="' + cls + '" data-gi="' + gi + '" style="' + opacity + '"><span class="step-icon" style="color:' + kwColor + ';min-width:3.5em;display:inline-block;font-weight:600;">' + s.keyword + '</span> ' + text + '</li>';
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
var html = '';
|
|
917
|
+
// Background steps
|
|
918
|
+
if (bgLen > 0) {
|
|
919
|
+
html += '<li style="color:#64748b;font-size:0.7rem;padding:0.15rem 0.5rem;font-style:italic;">Background</li>';
|
|
920
|
+
bgSteps.forEach(function(s, i) { html += renderGherkinStep(s, i, 'bg'); });
|
|
921
|
+
html += '<li style="border-bottom:1px solid #334155;padding:0;margin:0.25rem 0;"></li>';
|
|
922
|
+
}
|
|
923
|
+
// Scenario label + steps
|
|
924
|
+
html += '<li style="color:#c084fc;font-size:0.72rem;padding:0.3rem 0.5rem 0.15rem;font-weight:600;letter-spacing:0.03em;border-bottom:1px solid #334155;margin-bottom:0.2rem;"><span style="opacity:0.6;">Scenario:</span> ' + (example.description || '') + '</li>';
|
|
925
|
+
scSteps.forEach(function(s, i) { html += renderGherkinStep(s, bgLen + i, 'sc'); });
|
|
926
|
+
|
|
927
|
+
stepList.innerHTML = html;
|
|
928
|
+
|
|
929
|
+
stepList.querySelectorAll('li[data-gi]').forEach(function(el) {
|
|
930
|
+
el.addEventListener('click', function() {
|
|
931
|
+
if (hasTextSelection()) return;
|
|
932
|
+
currentGherkinIdx = parseInt(el.dataset.gi) + 1; // +1 for intro offset
|
|
933
|
+
renderGherkinAtIndex(example);
|
|
934
|
+
});
|
|
935
|
+
});
|
|
936
|
+
} else {
|
|
937
|
+
stepList.innerHTML = example.steps.map((s, i) => {
|
|
938
|
+
var icon = stepIcons[s.action] || '·';
|
|
939
|
+
var desc = s.description || s.action;
|
|
940
|
+
var cls = i === currentStepIdx ? 'active' : '';
|
|
941
|
+
return `<li class="${cls}" data-step="${i}"><span class="step-icon">${icon}</span>${desc}</li>`;
|
|
942
|
+
}).join('');
|
|
943
|
+
|
|
944
|
+
stepList.querySelectorAll('li[data-step]').forEach(el => {
|
|
945
|
+
el.addEventListener('click', () => { if (hasTextSelection()) return; currentStepIdx = parseInt(el.dataset.step); renderStep(); });
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Scroll active step into view
|
|
950
|
+
var activeLi = stepList.querySelector('li.active');
|
|
951
|
+
if (activeLi) activeLi.scrollIntoView({ block: 'nearest' });
|
|
952
|
+
|
|
953
|
+
if (isGherkinExample(example)) {
|
|
954
|
+
// Sync gherkin index from screenshot's gi (+1 for intro offset)
|
|
955
|
+
if (step.gi !== undefined) currentGherkinIdx = step.gi + 1;
|
|
956
|
+
var total = gherkinStepCount(example);
|
|
957
|
+
var pct = ((currentGherkinIdx + 1) / total * 100).toFixed(1);
|
|
958
|
+
document.getElementById('progress-fill').style.width = pct + '%';
|
|
959
|
+
document.getElementById('btn-prev').disabled = (currentGherkinIdx === 0 && currentExampleIdx === 0);
|
|
960
|
+
document.getElementById('btn-next').disabled = (currentGherkinIdx === total - 1 && currentExampleIdx === filtered.length - 1);
|
|
961
|
+
} else {
|
|
962
|
+
var pct = ((currentStepIdx + 1) / example.steps.length * 100).toFixed(1);
|
|
963
|
+
document.getElementById('progress-fill').style.width = pct + '%';
|
|
964
|
+
document.getElementById('btn-prev').disabled = (currentStepIdx === 0 && currentExampleIdx === 0);
|
|
965
|
+
document.getElementById('btn-next').disabled = (currentStepIdx === example.steps.length - 1 && currentExampleIdx === filtered.length - 1);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Draw overlays after image loads
|
|
969
|
+
const drawOverlays = () => updateOverlays(step);
|
|
970
|
+
if (img.complete && img.naturalWidth) {
|
|
971
|
+
drawOverlays();
|
|
972
|
+
} else {
|
|
973
|
+
img.addEventListener('load', drawOverlays, { once: true });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function updateOverlays(step) {
|
|
978
|
+
const container = document.getElementById('overlays');
|
|
979
|
+
container.innerHTML = '';
|
|
980
|
+
|
|
981
|
+
const m = getImageMetrics();
|
|
982
|
+
const action = step.action || 'navigate';
|
|
983
|
+
const actionLabels = { click: '🖱 Click', fill: '⌨ Type', select: '▼ Select', check: '☑ Check', navigate: '→ Navigate', assert: '✓ Assertions', confirm: '⚠ Confirm' };
|
|
984
|
+
|
|
985
|
+
// Action badge (always)
|
|
986
|
+
const badge = document.createElement('div');
|
|
987
|
+
badge.className = `action-badge ${action}`;
|
|
988
|
+
badge.textContent = actionLabels[action] || action;
|
|
989
|
+
container.appendChild(badge);
|
|
990
|
+
|
|
991
|
+
if (action === 'assert' && step.assertions) {
|
|
992
|
+
// Show ALL assertion highlights simultaneously
|
|
993
|
+
const assertionList = document.createElement('div');
|
|
994
|
+
assertionList.className = 'assertion-list';
|
|
995
|
+
|
|
996
|
+
step.assertions.forEach((assertion, i) => {
|
|
997
|
+
// Draw bounding boxes for each assertion
|
|
998
|
+
(assertion.bboxes || []).forEach(bbox => {
|
|
999
|
+
const box = document.createElement('div');
|
|
1000
|
+
box.className = `overlay-box ${assertion.negative ? 'assert-negative' : 'assert'}`;
|
|
1001
|
+
box.style.left = `${m.offsetX + bbox.x * m.scaleX - 4}px`;
|
|
1002
|
+
box.style.top = `${m.offsetY + bbox.y * m.scaleY - 4}px`;
|
|
1003
|
+
box.style.width = `${bbox.width * m.scaleX + 8}px`;
|
|
1004
|
+
box.style.height = `${bbox.height * m.scaleY + 8}px`;
|
|
1005
|
+
box.style.animationDelay = `${i * 0.1}s`;
|
|
1006
|
+
container.appendChild(box);
|
|
1007
|
+
|
|
1008
|
+
// Label above the box
|
|
1009
|
+
const label = document.createElement('div');
|
|
1010
|
+
label.className = `overlay-label ${assertion.negative ? 'negative' : 'positive'}`;
|
|
1011
|
+
label.textContent = assertion.text;
|
|
1012
|
+
label.style.left = `${m.offsetX + bbox.x * m.scaleX - 4}px`;
|
|
1013
|
+
label.style.top = `${m.offsetY + bbox.y * m.scaleY - 22}px`;
|
|
1014
|
+
label.style.animationDelay = `${i * 0.1}s`;
|
|
1015
|
+
container.appendChild(label);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
// Add to assertion list panel
|
|
1019
|
+
const item = document.createElement('div');
|
|
1020
|
+
item.className = `assertion-item ${assertion.negative ? 'negative' : 'positive'}`;
|
|
1021
|
+
item.textContent = `${assertion.negative ? '✗' : '✓'} ${assertion.text}`;
|
|
1022
|
+
item.style.animationDelay = `${i * 0.08}s`;
|
|
1023
|
+
assertionList.appendChild(item);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
container.appendChild(assertionList);
|
|
1027
|
+
|
|
1028
|
+
} else if (step.bbox) {
|
|
1029
|
+
// Single element highlight (click, fill, select, check)
|
|
1030
|
+
const box = document.createElement('div');
|
|
1031
|
+
box.className = `overlay-box ${action}`;
|
|
1032
|
+
const x = m.offsetX + step.bbox.x * m.scaleX;
|
|
1033
|
+
const y = m.offsetY + step.bbox.y * m.scaleY;
|
|
1034
|
+
const w = step.bbox.width * m.scaleX;
|
|
1035
|
+
const h = step.bbox.height * m.scaleY;
|
|
1036
|
+
box.style.left = `${x - 4}px`;
|
|
1037
|
+
box.style.top = `${y - 4}px`;
|
|
1038
|
+
box.style.width = `${w + 8}px`;
|
|
1039
|
+
box.style.height = `${h + 8}px`;
|
|
1040
|
+
container.appendChild(box);
|
|
1041
|
+
|
|
1042
|
+
// Tap circle for clicks — sized slightly larger than element
|
|
1043
|
+
if (action === 'click') {
|
|
1044
|
+
const tap = document.createElement('div');
|
|
1045
|
+
tap.className = 'tap-circle';
|
|
1046
|
+
const diameter = Math.min(w, h) * 1.6 + 16;
|
|
1047
|
+
tap.style.width = `${diameter}px`;
|
|
1048
|
+
tap.style.height = `${diameter}px`;
|
|
1049
|
+
tap.style.left = `${x + w / 2 - diameter / 2}px`;
|
|
1050
|
+
tap.style.top = `${y + h / 2 - diameter / 2}px`;
|
|
1051
|
+
container.appendChild(tap);
|
|
1052
|
+
|
|
1053
|
+
// Cursor icon
|
|
1054
|
+
const cursor = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
1055
|
+
cursor.setAttribute('viewBox', '0 0 24 24');
|
|
1056
|
+
cursor.setAttribute('width', '24');
|
|
1057
|
+
cursor.setAttribute('height', '24');
|
|
1058
|
+
cursor.className = 'cursor-icon';
|
|
1059
|
+
cursor.style.left = `${x + w / 2}px`;
|
|
1060
|
+
cursor.style.top = `${y + h / 2}px`;
|
|
1061
|
+
const cPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
1062
|
+
cPath.setAttribute('d', 'M4 2L4 20L8.5 15.5L12.5 22L15 21L11 14L17 14L4 2Z');
|
|
1063
|
+
cPath.setAttribute('fill', 'white');
|
|
1064
|
+
cPath.setAttribute('stroke', '#1e293b');
|
|
1065
|
+
cPath.setAttribute('stroke-width', '1.5');
|
|
1066
|
+
cursor.appendChild(cPath);
|
|
1067
|
+
container.appendChild(cursor);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function nextStep() {
|
|
1073
|
+
const filtered = filteredManifest();
|
|
1074
|
+
const example = filtered[currentExampleIdx];
|
|
1075
|
+
if (isGherkinExample(example)) {
|
|
1076
|
+
var total = gherkinStepCount(example);
|
|
1077
|
+
if (currentGherkinIdx < total - 1) {
|
|
1078
|
+
currentGherkinIdx++;
|
|
1079
|
+
renderGherkinAtIndex(example);
|
|
1080
|
+
} else if (currentExampleIdx < filtered.length - 1) {
|
|
1081
|
+
selectExample(currentExampleIdx + 1);
|
|
1082
|
+
} else {
|
|
1083
|
+
stopAutoplay();
|
|
1084
|
+
}
|
|
1085
|
+
} else {
|
|
1086
|
+
if (currentStepIdx < example.steps.length - 1) {
|
|
1087
|
+
currentStepIdx++;
|
|
1088
|
+
renderStep();
|
|
1089
|
+
} else if (currentExampleIdx < filtered.length - 1) {
|
|
1090
|
+
selectExample(currentExampleIdx + 1);
|
|
1091
|
+
} else {
|
|
1092
|
+
stopAutoplay();
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function prevStep() {
|
|
1098
|
+
const filtered = filteredManifest();
|
|
1099
|
+
const example = filtered[currentExampleIdx];
|
|
1100
|
+
if (isGherkinExample(example)) {
|
|
1101
|
+
if (currentGherkinIdx > 0) {
|
|
1102
|
+
currentGherkinIdx--;
|
|
1103
|
+
renderGherkinAtIndex(example);
|
|
1104
|
+
} else if (currentExampleIdx > 0) {
|
|
1105
|
+
// Jump to end of previous scenario
|
|
1106
|
+
var prevIdx = currentExampleIdx - 1;
|
|
1107
|
+
var prevEx = filtered[prevIdx];
|
|
1108
|
+
if (isGherkinExample(prevEx)) {
|
|
1109
|
+
currentGherkinIdx = gherkinStepCount(prevEx) - 1;
|
|
1110
|
+
} else {
|
|
1111
|
+
currentStepIdx = prevEx.steps.length - 1;
|
|
1112
|
+
}
|
|
1113
|
+
currentExampleIdx = prevIdx;
|
|
1114
|
+
// Update sidebar + viewer
|
|
1115
|
+
var scenarioName = (prevEx.groups || []).length >= 2 ? prevEx.groups[prevEx.groups.length - 1] : prevEx.description;
|
|
1116
|
+
if (prevEx.file.endsWith('.feature') && features[prevEx.file]) {
|
|
1117
|
+
renderFeatureSidebar(prevEx.file, scenarioName);
|
|
1118
|
+
}
|
|
1119
|
+
renderGherkinAtIndex(prevEx);
|
|
1120
|
+
}
|
|
1121
|
+
} else {
|
|
1122
|
+
if (currentStepIdx > 0) {
|
|
1123
|
+
currentStepIdx--;
|
|
1124
|
+
renderStep();
|
|
1125
|
+
} else if (currentExampleIdx > 0) {
|
|
1126
|
+
var prevIdx = currentExampleIdx - 1;
|
|
1127
|
+
var prevEx = filtered[prevIdx];
|
|
1128
|
+
if (isGherkinExample(prevEx)) {
|
|
1129
|
+
currentGherkinIdx = gherkinStepCount(prevEx) - 1;
|
|
1130
|
+
} else {
|
|
1131
|
+
currentStepIdx = prevEx.steps.length - 1;
|
|
1132
|
+
}
|
|
1133
|
+
currentExampleIdx = prevIdx;
|
|
1134
|
+
var scenarioName = (prevEx.groups || []).length >= 2 ? prevEx.groups[prevEx.groups.length - 1] : prevEx.description;
|
|
1135
|
+
if (prevEx.file.endsWith('.feature') && features[prevEx.file]) {
|
|
1136
|
+
renderFeatureSidebar(prevEx.file, scenarioName);
|
|
1137
|
+
} else {
|
|
1138
|
+
renderExampleList();
|
|
1139
|
+
}
|
|
1140
|
+
renderGherkinAtIndex(prevEx);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Navigate to a specific gherkin index — show screenshot or overlay
|
|
1146
|
+
// Index 0 = intro card, 1..N = gherkin steps (offset by 1), last = end card
|
|
1147
|
+
function renderGherkinAtIndex(example) {
|
|
1148
|
+
var bgSteps = example.gherkin.background || [];
|
|
1149
|
+
var scSteps = example.gherkin.scenario;
|
|
1150
|
+
var bgLen = bgSteps.length;
|
|
1151
|
+
var total = gherkinStepCount(example);
|
|
1152
|
+
var gi = currentGherkinIdx;
|
|
1153
|
+
|
|
1154
|
+
// Intro card
|
|
1155
|
+
if (gi === 0) {
|
|
1156
|
+
showIntroOverlay(example.description || '', example.status);
|
|
1157
|
+
renderGherkinStepList(example, -1);
|
|
1158
|
+
updateGherkinProgress(total, gi);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
// End card
|
|
1162
|
+
if (gi === total - 1) {
|
|
1163
|
+
showEndOverlay(example.description || '', example.status);
|
|
1164
|
+
renderGherkinStepList(example, -2);
|
|
1165
|
+
updateGherkinProgress(total, gi);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Actual gherkin step (offset by 1 for intro)
|
|
1170
|
+
var realGi = gi - 1;
|
|
1171
|
+
|
|
1172
|
+
// Build gi→screenshot map (prefer bbox > real actions > gherkin captures)
|
|
1173
|
+
var giToScreenshot = {};
|
|
1174
|
+
example.steps.forEach(function(s, si) {
|
|
1175
|
+
if (s.gi !== undefined) {
|
|
1176
|
+
var existing = giToScreenshot[s.gi] !== undefined ? example.steps[giToScreenshot[s.gi]] : null;
|
|
1177
|
+
if (!existing) {
|
|
1178
|
+
giToScreenshot[s.gi] = si;
|
|
1179
|
+
} else if (s.bbox && !existing.bbox) {
|
|
1180
|
+
giToScreenshot[s.gi] = si;
|
|
1181
|
+
} else if (s.action !== 'gherkin' && existing.action === 'gherkin') {
|
|
1182
|
+
giToScreenshot[s.gi] = si;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
var screenshotIdx = giToScreenshot[realGi];
|
|
1188
|
+
var screenshotStep = screenshotIdx !== undefined ? example.steps[screenshotIdx] : null;
|
|
1189
|
+
|
|
1190
|
+
// Show overlay for: no screenshot, or "gherkin" action (stale page capture from non-interactive step)
|
|
1191
|
+
if (!screenshotStep || screenshotStep.action === 'gherkin') {
|
|
1192
|
+
showSetupOverlay(realGi, bgSteps, scSteps, bgLen);
|
|
1193
|
+
renderGherkinStepList(example, realGi);
|
|
1194
|
+
updateGherkinProgress(total, gi);
|
|
1195
|
+
} else {
|
|
1196
|
+
currentStepIdx = screenshotIdx;
|
|
1197
|
+
renderStep();
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function updateGherkinProgress(total, gi) {
|
|
1202
|
+
var pct = ((gi + 1) / total * 100).toFixed(1);
|
|
1203
|
+
document.getElementById('progress-fill').style.width = pct + '%';
|
|
1204
|
+
document.getElementById('btn-prev').disabled = (gi === 0 && currentExampleIdx === 0);
|
|
1205
|
+
document.getElementById('btn-next').disabled = (gi === total - 1 && currentExampleIdx === filteredManifest().length - 1);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Render just the gherkin step list for a given active gi (used by overlay mode)
|
|
1209
|
+
function renderGherkinStepList(example, activeGi) {
|
|
1210
|
+
var bgSteps = example.gherkin.background || [];
|
|
1211
|
+
var scSteps = example.gherkin.scenario;
|
|
1212
|
+
var bgLen = bgSteps.length;
|
|
1213
|
+
var stepList = document.getElementById('step-list');
|
|
1214
|
+
|
|
1215
|
+
var giToScreenshot = {};
|
|
1216
|
+
example.steps.forEach(function(s, si) {
|
|
1217
|
+
if (s.gi !== undefined) {
|
|
1218
|
+
if (giToScreenshot[s.gi] === undefined || (s.bbox && !example.steps[giToScreenshot[s.gi]].bbox)) giToScreenshot[s.gi] = si;
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
function renderG(s, gi) {
|
|
1223
|
+
var isCurrent = gi === activeGi;
|
|
1224
|
+
var isTodo = gi > activeGi;
|
|
1225
|
+
var cls = isCurrent ? 'active' : '';
|
|
1226
|
+
var opacity = isTodo ? 'opacity:0.35;' : '';
|
|
1227
|
+
var kwColor = s.keyword === 'Then' ? '#4ade80' : '#60a5fa';
|
|
1228
|
+
var text = colorizeStep(s.text);
|
|
1229
|
+
return '<li class="' + cls + '" data-gi="' + gi + '" style="' + opacity + '"><span class="step-icon" style="color:' + kwColor + ';min-width:3.5em;display:inline-block;font-weight:600;">' + s.keyword + '</span> ' + text + '</li>';
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
var html = '';
|
|
1233
|
+
if (bgLen > 0) {
|
|
1234
|
+
html += '<li style="color:#64748b;font-size:0.7rem;padding:0.15rem 0.5rem;font-style:italic;">Background</li>';
|
|
1235
|
+
bgSteps.forEach(function(s, i) { html += renderG(s, i); });
|
|
1236
|
+
html += '<li style="border-bottom:1px solid #334155;padding:0;margin:0.25rem 0;"></li>';
|
|
1237
|
+
}
|
|
1238
|
+
html += '<li style="color:#c084fc;font-size:0.7rem;padding:0.15rem 0.5rem;font-style:italic;">Scenario: ' + (example.description || '') + '</li>';
|
|
1239
|
+
scSteps.forEach(function(s, i) { html += renderG(s, bgLen + i); });
|
|
1240
|
+
stepList.innerHTML = html;
|
|
1241
|
+
|
|
1242
|
+
stepList.querySelectorAll('li[data-gi]').forEach(function(el) {
|
|
1243
|
+
el.addEventListener('click', function() {
|
|
1244
|
+
if (hasTextSelection()) return;
|
|
1245
|
+
currentGherkinIdx = parseInt(el.dataset.gi) + 1; // +1 for intro offset
|
|
1246
|
+
renderGherkinAtIndex(example);
|
|
1247
|
+
});
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
var activeLi = stepList.querySelector('li.active');
|
|
1251
|
+
if (activeLi) activeLi.scrollIntoView({ block: 'nearest' });
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function toggleAutoplay() { autoplayTimer ? stopAutoplay() : startAutoplay(); }
|
|
1255
|
+
|
|
1256
|
+
function startAutoplay() {
|
|
1257
|
+
const speed = parseInt(document.getElementById('speed-select').value);
|
|
1258
|
+
document.getElementById('btn-play').innerHTML = '⏸ Pause';
|
|
1259
|
+
document.getElementById('autoplay-indicator').classList.add('active');
|
|
1260
|
+
autoplayTimer = setInterval(nextStep, speed);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function stopAutoplay() {
|
|
1264
|
+
clearInterval(autoplayTimer);
|
|
1265
|
+
autoplayTimer = null;
|
|
1266
|
+
document.getElementById('btn-play').innerHTML = '▶ Play';
|
|
1267
|
+
document.getElementById('autoplay-indicator').classList.remove('active');
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
document.getElementById('btn-next')?.addEventListener('click', nextStep);
|
|
1271
|
+
document.getElementById('btn-prev')?.addEventListener('click', prevStep);
|
|
1272
|
+
document.getElementById('btn-play')?.addEventListener('click', toggleAutoplay);
|
|
1273
|
+
document.getElementById('speed-select')?.addEventListener('change', () => {
|
|
1274
|
+
if (autoplayTimer) { stopAutoplay(); startAutoplay(); }
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
document.getElementById('progress-bar')?.addEventListener('click', (e) => {
|
|
1278
|
+
const filtered = filteredManifest();
|
|
1279
|
+
const example = filtered[currentExampleIdx];
|
|
1280
|
+
const rect = e.target.getBoundingClientRect();
|
|
1281
|
+
const pct = (e.clientX - rect.left) / rect.width;
|
|
1282
|
+
currentStepIdx = Math.min(Math.floor(pct * example.steps.length), example.steps.length - 1);
|
|
1283
|
+
renderStep();
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
// Switch to text cursor while dragging to select text
|
|
1287
|
+
document.addEventListener('mousemove', (e) => {
|
|
1288
|
+
if (e.buttons === 1 && window.getSelection().toString().length > 0) {
|
|
1289
|
+
document.body.classList.add('selecting');
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
document.addEventListener('mouseup', () => {
|
|
1293
|
+
document.body.classList.remove('selecting');
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
document.addEventListener('keydown', (e) => {
|
|
1297
|
+
if (e.key === 'ArrowRight' || e.key === 'l') nextStep();
|
|
1298
|
+
if (e.key === 'ArrowLeft' || e.key === 'h') prevStep();
|
|
1299
|
+
if (e.key === ' ') { e.preventDefault(); toggleAutoplay(); }
|
|
1300
|
+
if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault(); selectExample(currentExampleIdx + 1); }
|
|
1301
|
+
if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault(); selectExample(currentExampleIdx - 1); }
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => {
|
|
1305
|
+
btn.addEventListener('click', () => {
|
|
1306
|
+
document.querySelectorAll('.filter-btn[data-filter]').forEach(b => b.classList.remove('active'));
|
|
1307
|
+
btn.classList.add('active');
|
|
1308
|
+
filter = btn.dataset.filter;
|
|
1309
|
+
currentExampleIdx = 0;
|
|
1310
|
+
currentStepIdx = 0;
|
|
1311
|
+
renderExampleList();
|
|
1312
|
+
if (filteredManifest().length > 0) renderStep();
|
|
1313
|
+
});
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
// Redraw overlays on window resize
|
|
1317
|
+
window.addEventListener('resize', () => {
|
|
1318
|
+
const filtered = filteredManifest();
|
|
1319
|
+
if (filtered.length > 0) {
|
|
1320
|
+
const step = filtered[currentExampleIdx]?.steps[currentStepIdx];
|
|
1321
|
+
if (step) updateOverlays(step);
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
if (manifest.length > 0) {
|
|
1326
|
+
// Update counts
|
|
1327
|
+
var passCount = manifest.filter(function(ex) { return ex.status !== 'failed'; }).length;
|
|
1328
|
+
var failCount = manifest.filter(function(ex) { return ex.status === 'failed'; }).length;
|
|
1329
|
+
document.getElementById('total-count').textContent = manifest.length + ' specs';
|
|
1330
|
+
document.getElementById('pass-count').textContent = passCount + ' passing';
|
|
1331
|
+
document.getElementById('fail-count').textContent = failCount > 0 ? failCount + ' failing' : '';
|
|
1332
|
+
|
|
1333
|
+
selectExample(0);
|
|
1334
|
+
}
|
|
1335
|
+
</script>
|