@matware/e2e-runner 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +52 -0
- package/.claude-plugin/plugin.json +17 -3
- package/.mcp.json +2 -2
- package/.opencode/commands/create-test.md +63 -0
- package/.opencode/commands/run.md +50 -0
- package/.opencode/commands/verify-issue.md +62 -0
- package/.opencode/skills/e2e-testing/SKILL.md +181 -0
- package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
- package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
- package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
- package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
- package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
- package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
- package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/.opencode/skills/e2e-testing/references/variables.md +41 -0
- package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
- package/LICENSE +190 -0
- package/OPENCODE.md +166 -0
- package/README.md +165 -104
- package/agents/test-creator.md +54 -1
- package/agents/test-improver.md +37 -0
- package/bin/cli.js +409 -16
- package/commands/capture.md +45 -0
- package/commands/create-test.md +16 -1
- package/opencode.json +11 -0
- package/package.json +7 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +10 -3
- package/skills/e2e-testing/references/action-types.md +48 -5
- package/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/skills/e2e-testing/references/graphql.md +59 -0
- package/skills/e2e-testing/references/issue-verification.md +59 -0
- package/skills/e2e-testing/references/multi-pool.md +60 -0
- package/skills/e2e-testing/references/network-debugging.md +62 -0
- package/skills/e2e-testing/references/test-json-format.md +4 -0
- package/skills/e2e-testing/references/troubleshooting.md +44 -2
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +475 -2
- package/src/ai-generate.js +139 -8
- package/src/app-pool.js +339 -0
- package/src/config.js +266 -5
- package/src/dashboard.js +216 -17
- package/src/db.js +191 -7
- package/src/index.js +12 -9
- package/src/learner-sqlite.js +458 -0
- package/src/learner.js +78 -6
- package/src/mcp-tools.js +1348 -51
- package/src/module-resolver.js +37 -0
- package/src/narrate.js +65 -0
- package/src/pool-manager.js +229 -0
- package/src/pool.js +301 -31
- package/src/reporter.js +86 -2
- package/src/runner.js +480 -71
- package/src/sync/auth.js +354 -0
- package/src/sync/client.js +572 -0
- package/src/sync/hub-routes.js +816 -0
- package/src/sync/index.js +68 -0
- package/src/sync/middleware.js +347 -0
- package/src/sync/queue.js +209 -0
- package/src/sync/schema.js +540 -0
- package/src/verify.js +10 -7
- package/src/visual-diff.js +446 -0
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +47 -6
- package/templates/dashboard/js/api.js +62 -0
- package/templates/dashboard/js/init.js +13 -0
- package/templates/dashboard/js/keyboard.js +46 -0
- package/templates/dashboard/js/state.js +40 -0
- package/templates/dashboard/js/toast.js +41 -0
- package/templates/dashboard/js/utils.js +216 -0
- package/templates/dashboard/js/view-live.js +181 -0
- package/templates/dashboard/js/view-runs.js +676 -0
- package/templates/dashboard/js/view-tests.js +294 -0
- package/templates/dashboard/js/view-watch.js +242 -0
- package/templates/dashboard/js/websocket.js +116 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +117 -0
- package/templates/dashboard/styles/view-live.css +97 -0
- package/templates/dashboard/styles/view-runs.css +243 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +181 -100
- package/templates/dashboard.html +1614 -547
- package/templates/sample-test.json +0 -8
- package/templates/dashboard/app.js +0 -1152
- package/templates/dashboard/styles.css +0 -413
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/* ══════════════════════════════════════════════════════════════════
|
|
2
|
+
Tests View — Suites + Modules + Variables (inner tabs)
|
|
3
|
+
══════════════════════════════════════════════════════════════════ */
|
|
4
|
+
function refreshSuites(){
|
|
5
|
+
var grid=$('#suiteGrid'),empty=$('#suitesEmpty'),accordion=$('#suiteAccordionContainer');
|
|
6
|
+
grid.textContent='';
|
|
7
|
+
var moduleSection=$('#moduleSection');
|
|
8
|
+
moduleSection.textContent='';
|
|
9
|
+
|
|
10
|
+
if(S.project){
|
|
11
|
+
api('/api/db/projects/'+S.project+'/suites').then(function(suites){
|
|
12
|
+
if(!Array.isArray(suites)||suites.length===0){empty.style.display='block';empty.querySelector('p').textContent='No test suites found for this project.';return}
|
|
13
|
+
empty.style.display='none';
|
|
14
|
+
$('#badgeSuites').textContent=suites.length;
|
|
15
|
+
renderSuiteCards(grid,suites,S.project);
|
|
16
|
+
}).catch(function(){});
|
|
17
|
+
api('/api/db/projects/'+S.project+'/modules').then(function(modules){
|
|
18
|
+
renderModules(moduleSection,modules);
|
|
19
|
+
}).catch(function(){});
|
|
20
|
+
} else {
|
|
21
|
+
api('/api/db/projects').then(function(projects){
|
|
22
|
+
if(!Array.isArray(projects)||projects.length===0){empty.style.display='block';empty.querySelector('p').textContent='No projects registered yet.';return}
|
|
23
|
+
var loaded=0,hasAny=false,totalSuites=0;
|
|
24
|
+
projects.forEach(function(p){
|
|
25
|
+
api('/api/db/projects/'+p.id+'/suites').then(function(suites){
|
|
26
|
+
loaded++;
|
|
27
|
+
if(Array.isArray(suites)&&suites.length>0){
|
|
28
|
+
hasAny=true;totalSuites+=suites.length;
|
|
29
|
+
var label=el('div',{style:'grid-column:1/-1;font-family:var(--sans);font-size:13px;font-weight:600;margin-top:'+(grid.children.length?'16':'0')+'px;padding-bottom:6px;border-bottom:1px solid var(--border);color:var(--text2)'},p.name);
|
|
30
|
+
grid.appendChild(label);
|
|
31
|
+
renderSuiteCards(grid,suites,p.id);
|
|
32
|
+
}
|
|
33
|
+
if(loaded===projects.length){
|
|
34
|
+
$('#badgeSuites').textContent=totalSuites;
|
|
35
|
+
if(!hasAny){empty.style.display='block';empty.querySelector('p').textContent='No test suites found.'}
|
|
36
|
+
}
|
|
37
|
+
}).catch(function(){loaded++;});
|
|
38
|
+
});
|
|
39
|
+
}).catch(function(){});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderProjectAccordion(container,project,suites){
|
|
44
|
+
var totalTests=suites.reduce(function(sum,s){return sum+(s.testCount||0)},0);
|
|
45
|
+
var body=el('div',{className:'project-accordion-body'});
|
|
46
|
+
var innerGrid=el('div',{className:'suite-grid'});
|
|
47
|
+
renderSuiteCards(innerGrid,suites,project.id);
|
|
48
|
+
body.appendChild(innerGrid);
|
|
49
|
+
|
|
50
|
+
var header=el('div',{className:'project-accordion-header'},[
|
|
51
|
+
el('span',{className:'project-accordion-chevron'},'\u25B6'),
|
|
52
|
+
el('span',{className:'project-accordion-name'},project.name),
|
|
53
|
+
el('div',{className:'project-accordion-meta'},[
|
|
54
|
+
el('span',{className:'project-accordion-badge'},suites.length+(suites.length===1?' suite':' suites')),
|
|
55
|
+
el('span',{className:'project-accordion-badge'},totalTests+(totalTests===1?' test':' tests'))
|
|
56
|
+
])
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
var wrapper=el('div',{className:'project-accordion'},[header,body]);
|
|
60
|
+
header.addEventListener('click',function(){wrapper.classList.toggle('open')});
|
|
61
|
+
container.appendChild(wrapper);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ── Suite Modal ── */
|
|
65
|
+
var _suiteCache={};
|
|
66
|
+
|
|
67
|
+
function openSuiteModal(suiteName,projectId){
|
|
68
|
+
var overlay=$('#suiteModalOverlay');
|
|
69
|
+
var body=$('#suiteModalBody');
|
|
70
|
+
$('#suiteModalName').textContent=suiteName;
|
|
71
|
+
$('#suiteModalFile').textContent=suiteName+'.json';
|
|
72
|
+
body.textContent='';
|
|
73
|
+
body.appendChild(el('div',{className:'suite-modal-loading'},'Loading\u2026'));
|
|
74
|
+
overlay.classList.add('open');
|
|
75
|
+
|
|
76
|
+
$('#suiteModalRun').onclick=function(){triggerRun(suiteName,projectId)};
|
|
77
|
+
$('#suiteModalClose').onclick=function(){overlay.classList.remove('open')};
|
|
78
|
+
overlay.addEventListener('click',function(e){if(e.target===overlay)overlay.classList.remove('open')});
|
|
79
|
+
|
|
80
|
+
var cacheKey=projectId+'::'+suiteName;
|
|
81
|
+
var p=_suiteCache[cacheKey]||api('/api/db/projects/'+projectId+'/suites/'+encodeURIComponent(suiteName));
|
|
82
|
+
_suiteCache[cacheKey]=p;
|
|
83
|
+
p.then(function(data){
|
|
84
|
+
body.textContent='';
|
|
85
|
+
if(!data||!data.tests||!data.tests.length){
|
|
86
|
+
body.appendChild(el('div',{className:'suite-modal-loading'},'No tests found'));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
data.tests.forEach(function(test){
|
|
90
|
+
var actionsDiv=el('div',{className:'suite-modal-test-actions'});
|
|
91
|
+
(test.actions||[]).forEach(function(a,i){
|
|
92
|
+
var detailContent;
|
|
93
|
+
if(a.selector&&(a.value||a.text)){
|
|
94
|
+
detailContent=[el('span',{className:'step-sel'},a.selector),el('span',{className:'step-arrow'},'\u2192'),el('span',{className:'step-val'},a.text||a.value)];
|
|
95
|
+
} else {
|
|
96
|
+
detailContent=a.selector||a.value||a.text||'';
|
|
97
|
+
}
|
|
98
|
+
actionsDiv.appendChild(el('div',{className:'suite-modal-step'},[
|
|
99
|
+
el('span',{className:'suite-modal-step-num'},String(i+1)),
|
|
100
|
+
el('span',{className:'suite-modal-step-type'},a.type),
|
|
101
|
+
el('span',{className:'suite-modal-step-detail'},detailContent)
|
|
102
|
+
]));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
var header=el('div',{className:'suite-modal-test-header'},[
|
|
106
|
+
el('span',{className:'suite-modal-test-chevron'},'\u25B6'),
|
|
107
|
+
el('span',{className:'suite-modal-test-name'},test.name),
|
|
108
|
+
el('span',{className:'suite-modal-test-badge'},(test.actions||[]).length+' actions')
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
var testEl=el('div',{className:'suite-modal-test'},[header,actionsDiv]);
|
|
112
|
+
if(test.expect){
|
|
113
|
+
var expectText=Array.isArray(test.expect)?test.expect.join(', '):test.expect;
|
|
114
|
+
var expectEl=el('div',{className:'suite-modal-expect'},[
|
|
115
|
+
el('span',{className:'suite-modal-expect-label'},'Expect:'),
|
|
116
|
+
document.createTextNode(expectText)
|
|
117
|
+
]);
|
|
118
|
+
testEl.insertBefore(expectEl,actionsDiv);
|
|
119
|
+
}
|
|
120
|
+
header.addEventListener('click',function(){testEl.classList.toggle('open')});
|
|
121
|
+
body.appendChild(testEl);
|
|
122
|
+
});
|
|
123
|
+
}).catch(function(){
|
|
124
|
+
body.textContent='';
|
|
125
|
+
body.appendChild(el('div',{className:'suite-modal-loading',style:'color:var(--red)'},'Failed to load suite'));
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderSuiteCards(container,suites,projectId){
|
|
130
|
+
suites.forEach(function(s){
|
|
131
|
+
var tests=el('ul',{className:'suite-card-tests'});
|
|
132
|
+
var pid=projectId;
|
|
133
|
+
(s.tests||[]).forEach(function(t){
|
|
134
|
+
var li=el('li',null,t);
|
|
135
|
+
li.addEventListener('click',function(e){
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
var existing=li.querySelector('.suite-test-steps');
|
|
138
|
+
if(existing){existing.remove();li.classList.remove('expanded');return}
|
|
139
|
+
tests.querySelectorAll('.suite-test-steps').forEach(function(d){d.remove()});
|
|
140
|
+
tests.querySelectorAll('li.expanded').forEach(function(l){l.classList.remove('expanded')});
|
|
141
|
+
var stepsDiv=el('div',{className:'suite-test-steps'});
|
|
142
|
+
stepsDiv.appendChild(el('div',{style:'color:var(--text3);font-size:10px'},'Loading...'));
|
|
143
|
+
li.appendChild(stepsDiv);
|
|
144
|
+
li.classList.add('expanded');
|
|
145
|
+
var cacheKey=pid+'::'+s.name;
|
|
146
|
+
var p=_suiteCache[cacheKey]||api('/api/db/projects/'+pid+'/suites/'+encodeURIComponent(s.name));
|
|
147
|
+
_suiteCache[cacheKey]=p;
|
|
148
|
+
p.then(function(data){
|
|
149
|
+
stepsDiv.textContent='';
|
|
150
|
+
var test=(data.tests||[]).find(function(x){return x.name===t});
|
|
151
|
+
if(!test||!test.actions||!test.actions.length){
|
|
152
|
+
stepsDiv.appendChild(el('div',{style:'color:var(--text3);font-size:10px'},'No actions'));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if(test.serial){
|
|
156
|
+
var sb=el('span',{className:'serial-badge'},'Serial');
|
|
157
|
+
li.insertBefore(sb,li.querySelector('.suite-test-steps'));
|
|
158
|
+
}
|
|
159
|
+
test.actions.forEach(function(a,i){
|
|
160
|
+
var detail=a.selector||a.value||a.text||'';
|
|
161
|
+
if(a.selector&&(a.value||a.text))detail=a.selector+' \u2192 '+(a.text||a.value);
|
|
162
|
+
stepsDiv.appendChild(el('div',{className:'lt-step'},[
|
|
163
|
+
el('span',{className:'step-icon',style:'color:var(--text3)'},String(i+1)),
|
|
164
|
+
el('span',{className:'step-type'},a.type),
|
|
165
|
+
el('span',{className:'step-detail'},detail)
|
|
166
|
+
]));
|
|
167
|
+
});
|
|
168
|
+
}).catch(function(){
|
|
169
|
+
stepsDiv.textContent='';
|
|
170
|
+
stepsDiv.appendChild(el('div',{style:'color:var(--red);font-size:10px'},'Failed to load'));
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
tests.appendChild(li);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
var cardHead=el('div',{className:'suite-card-head',style:'cursor:pointer'},[
|
|
177
|
+
el('div',{className:'suite-card-icon'},'\u25B6'),
|
|
178
|
+
el('div',{className:'suite-card-info'},[
|
|
179
|
+
el('div',{className:'suite-card-name'},s.name),
|
|
180
|
+
el('div',{className:'suite-card-file'},s.file||s.name+'.json')
|
|
181
|
+
]),
|
|
182
|
+
el('div',{className:'suite-card-count'},[
|
|
183
|
+
el('div',{className:'suite-card-count-num'},String(s.testCount||0)),
|
|
184
|
+
el('div',{className:'suite-card-count-lbl'},'tests')
|
|
185
|
+
])
|
|
186
|
+
]);
|
|
187
|
+
(function(name,projId){
|
|
188
|
+
cardHead.addEventListener('click',function(){openSuiteModal(name,projId)});
|
|
189
|
+
})(s.name,pid);
|
|
190
|
+
|
|
191
|
+
var card=el('div',{className:'suite-card'},[
|
|
192
|
+
cardHead,
|
|
193
|
+
el('div',{className:'suite-card-body'},[tests]),
|
|
194
|
+
el('div',{className:'suite-card-footer'},[
|
|
195
|
+
el('button',{className:'btn sm primary',onclick:function(){triggerRun(s.name,pid)}},'Run Suite')
|
|
196
|
+
])
|
|
197
|
+
]);
|
|
198
|
+
container.appendChild(card);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderModules(container,modules){
|
|
203
|
+
if(!Array.isArray(modules)||modules.length===0)return;
|
|
204
|
+
var title=el('div',{className:'module-section-title'},[
|
|
205
|
+
el('span',{className:'mod-icon'},'\u{1F9E9}'),
|
|
206
|
+
document.createTextNode(' Reusable Modules ('+modules.length+')')
|
|
207
|
+
]);
|
|
208
|
+
container.appendChild(title);
|
|
209
|
+
var grid=el('div',{className:'module-grid'});
|
|
210
|
+
modules.forEach(function(m){
|
|
211
|
+
var paramsEl=null;
|
|
212
|
+
if(m.params&&m.params.length){
|
|
213
|
+
var items=m.params.map(function(p){return el('li',null,typeof p==='string'?p:(p.name||String(p)))});
|
|
214
|
+
paramsEl=el('ul',{className:'module-card-params'},items);
|
|
215
|
+
}
|
|
216
|
+
var card=el('div',{className:'module-card'},[
|
|
217
|
+
el('div',{className:'module-card-name'},m.name),
|
|
218
|
+
m.description?el('div',{className:'module-card-desc'},m.description):null,
|
|
219
|
+
el('div',{className:'module-card-meta'},[
|
|
220
|
+
el('span',null,m.actionCount+' actions'),
|
|
221
|
+
m.params&&m.params.length?el('span',null,m.params.length+' params'):null
|
|
222
|
+
]),
|
|
223
|
+
paramsEl
|
|
224
|
+
]);
|
|
225
|
+
grid.appendChild(card);
|
|
226
|
+
});
|
|
227
|
+
container.appendChild(grid);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ── Variables ── */
|
|
231
|
+
function refreshVariables(){
|
|
232
|
+
var container=$('#variablesContainer'),empty=$('#variablesEmpty');
|
|
233
|
+
container.textContent='';
|
|
234
|
+
if(!S.project){empty.style.display='block';empty.querySelector('p').textContent='Select a project to manage variables.';return}
|
|
235
|
+
api('/api/db/projects/'+S.project+'/variables').then(function(vars){
|
|
236
|
+
if(!Array.isArray(vars)||!vars.length){empty.style.display='block';empty.querySelector('p').textContent='No variables set. Add variables to use {{var.KEY}} in your tests.';return}
|
|
237
|
+
empty.style.display='none';
|
|
238
|
+
renderVariables(vars);
|
|
239
|
+
}).catch(function(){empty.style.display='block'});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderVariables(vars){
|
|
243
|
+
var container=$('#variablesContainer');
|
|
244
|
+
var tbl=el('table',{className:'var-table'});
|
|
245
|
+
var thead=document.createElement('thead');
|
|
246
|
+
var hr=document.createElement('tr');
|
|
247
|
+
['Key','Value','Scope','Actions'].forEach(function(h){hr.appendChild(el('th',null,h))});
|
|
248
|
+
thead.appendChild(hr);tbl.appendChild(thead);
|
|
249
|
+
var tbody=document.createElement('tbody');
|
|
250
|
+
vars.forEach(function(v){
|
|
251
|
+
var tr=document.createElement('tr');
|
|
252
|
+
tr.appendChild(el('td',null,[el('code',null,v.key)]));
|
|
253
|
+
tr.appendChild(el('td',{style:'max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'},v.is_secret?'\u2022\u2022\u2022\u2022\u2022\u2022':v.value));
|
|
254
|
+
tr.appendChild(el('td',{style:'color:var(--text3)'},v.scope||'project'));
|
|
255
|
+
var delBtn=el('button',{className:'btn sm danger',onclick:function(){
|
|
256
|
+
if(!confirm('Delete variable "'+v.key+'"?'))return;
|
|
257
|
+
fetch('/api/db/projects/'+S.project+'/variables/'+encodeURIComponent(v.key),{method:'DELETE'}).then(function(){refreshVariables();showToast('Variable deleted','success')}).catch(function(){showToast('Delete failed','error')});
|
|
258
|
+
}},'\u2715');
|
|
259
|
+
tr.appendChild(el('td',null,[delBtn]));
|
|
260
|
+
tbody.appendChild(tr);
|
|
261
|
+
});
|
|
262
|
+
tbl.appendChild(tbody);
|
|
263
|
+
container.appendChild(tbl);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* ── Variable Add Form ── */
|
|
267
|
+
$('#btnAddVar').addEventListener('click',function(){
|
|
268
|
+
var form=$('#varAddForm');
|
|
269
|
+
if(form.style.display==='none'){
|
|
270
|
+
form.style.display='';
|
|
271
|
+
form.textContent='';
|
|
272
|
+
var keyInput=el('input',{type:'text',placeholder:'KEY',style:'margin-right:8px;width:120px'});
|
|
273
|
+
var valInput=el('input',{type:'text',placeholder:'Value',style:'margin-right:8px;width:200px'});
|
|
274
|
+
var secretCheck=el('input',{type:'checkbox',style:'margin-right:4px'});
|
|
275
|
+
var saveBtn=el('button',{className:'btn sm primary',onclick:function(){
|
|
276
|
+
var k=keyInput.value.trim(),v=valInput.value;
|
|
277
|
+
if(!k){showToast('Key is required','error');return}
|
|
278
|
+
if(!S.project){showToast('Select a project first','error');return}
|
|
279
|
+
fetch('/api/db/projects/'+S.project+'/variables',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:k,value:v,is_secret:secretCheck.checked})}).then(function(r){return r.json()}).then(function(){
|
|
280
|
+
form.style.display='none';refreshVariables();showToast('Variable saved','success');
|
|
281
|
+
}).catch(function(){showToast('Save failed','error')});
|
|
282
|
+
}},'Save');
|
|
283
|
+
var cancelBtn=el('button',{className:'btn sm',onclick:function(){form.style.display='none'}},'Cancel');
|
|
284
|
+
form.appendChild(el('div',{className:'var-add-form',style:'display:flex;align-items:center;gap:8px;flex-wrap:wrap'},[
|
|
285
|
+
keyInput,valInput,
|
|
286
|
+
el('label',{style:'font-size:11px;color:var(--text2);display:flex;align-items:center;gap:4px'},[secretCheck,document.createTextNode('Secret')]),
|
|
287
|
+
saveBtn,cancelBtn
|
|
288
|
+
]));
|
|
289
|
+
} else {
|
|
290
|
+
form.style.display='none';
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
$('#btnRunAll').addEventListener('click',function(){triggerRun()});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/* ══════════════════════════════════════════════════════════════════
|
|
2
|
+
Watch View — Project Cards + Sparklines + Event Log
|
|
3
|
+
══════════════════════════════════════════════════════════════════ */
|
|
4
|
+
var _watchInterval=null;
|
|
5
|
+
var _countdownInterval=null;
|
|
6
|
+
var _watchData=null;
|
|
7
|
+
|
|
8
|
+
function refreshWatch(){
|
|
9
|
+
// Fetch projects overview (sparklines)
|
|
10
|
+
api('/api/db/projects/overview').then(function(projects){
|
|
11
|
+
if(!Array.isArray(projects)||!projects.length){
|
|
12
|
+
$('#watchCards').textContent='';
|
|
13
|
+
$('#watchEmpty').style.display='block';
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
$('#watchEmpty').style.display='none';
|
|
17
|
+
_watchData=projects;
|
|
18
|
+
renderWatchCards(projects);
|
|
19
|
+
}).catch(function(){
|
|
20
|
+
// Fallback: use regular projects list
|
|
21
|
+
api('/api/db/projects').then(function(projects){
|
|
22
|
+
if(!Array.isArray(projects)||!projects.length){$('#watchEmpty').style.display='block';return}
|
|
23
|
+
$('#watchEmpty').style.display='none';
|
|
24
|
+
_watchData=projects.map(function(p){return Object.assign({},p,{sparkline:[]})});
|
|
25
|
+
renderWatchCards(_watchData);
|
|
26
|
+
}).catch(function(){});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Fetch event log (recent runs)
|
|
30
|
+
var runsUrl=S.project?'/api/db/projects/'+S.project+'/runs':'/api/db/runs';
|
|
31
|
+
api(runsUrl).then(function(runs){
|
|
32
|
+
renderEventLog(runs);
|
|
33
|
+
}).catch(function(){});
|
|
34
|
+
|
|
35
|
+
// Fetch watch jobs status for countdown
|
|
36
|
+
fetch('/api/watch/status').then(function(r){
|
|
37
|
+
if(!r.ok)throw new Error('not running');
|
|
38
|
+
return r.json();
|
|
39
|
+
}).then(function(jobs){
|
|
40
|
+
applyWatchJobData(jobs);
|
|
41
|
+
}).catch(function(){
|
|
42
|
+
// Watch engine not running — that's fine, cards still show
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderWatchCards(projects){
|
|
47
|
+
var container=$('#watchCards');
|
|
48
|
+
container.textContent='';
|
|
49
|
+
|
|
50
|
+
projects.forEach(function(p){
|
|
51
|
+
var sparkline=p.sparkline||[];
|
|
52
|
+
var lastRate=sparkline.length?sparkline[sparkline.length-1]:null;
|
|
53
|
+
var rateColor=lastRate===null?'dim':lastRate>=90?'green':lastRate>=70?'amber':'red';
|
|
54
|
+
var dotColor=rateColor;
|
|
55
|
+
|
|
56
|
+
var sparkEl=el('div',{className:'watch-sparkline'});
|
|
57
|
+
if(sparkline.length>=2){
|
|
58
|
+
sparkEl.appendChild(buildSparkline(sparkline));
|
|
59
|
+
} else {
|
|
60
|
+
sparkEl.style.cssText='height:40px;display:flex;align-items:center;justify-content:center;color:var(--text3);font-size:10px';
|
|
61
|
+
sparkEl.textContent=sparkline.length?'1 run':'No runs yet';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
var triggerBtn=el('button',{className:'btn sm',onclick:function(e){e.stopPropagation();triggerRun(null,p.id)}},'\u25B6');
|
|
65
|
+
var detailBtn=el('button',{className:'btn sm',onclick:function(e){
|
|
66
|
+
e.stopPropagation();
|
|
67
|
+
S.project=p.id;$('#projectSelect').value=p.id;
|
|
68
|
+
showView('runs');
|
|
69
|
+
refreshRuns();refreshSuites();
|
|
70
|
+
}},'\uD83D\uDD0D');
|
|
71
|
+
|
|
72
|
+
var card=el('div',{className:'watch-card',id:'watch-card-'+p.id},[
|
|
73
|
+
el('div',{className:'watch-card-header'},[
|
|
74
|
+
el('div',{className:'watch-card-name'},p.name),
|
|
75
|
+
el('div',{className:'watch-card-icons'},[triggerBtn,detailBtn])
|
|
76
|
+
]),
|
|
77
|
+
sparkEl,
|
|
78
|
+
el('div',{className:'watch-card-footer'},[
|
|
79
|
+
el('div',{className:'watch-card-status'},[
|
|
80
|
+
el('span',{className:'status-dot '+dotColor}),
|
|
81
|
+
el('span',{className:'watch-card-rate '+rateColor},lastRate!==null?lastRate+'%':'—')
|
|
82
|
+
]),
|
|
83
|
+
el('span',{style:'color:var(--text3);font-size:10px'},p.runCount?p.runCount+' runs':'')
|
|
84
|
+
]),
|
|
85
|
+
el('div',{className:'watch-card-meta'},[
|
|
86
|
+
el('span',{className:'watch-card-countdown',id:'watch-countdown-'+p.id},''),
|
|
87
|
+
p.lastCommit?el('span',{className:'watch-card-commit'},'\u{1F4CB} '+p.lastCommit.slice(0,8)):null
|
|
88
|
+
])
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
container.appendChild(card);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildSparkline(data){
|
|
96
|
+
var ns='http://www.w3.org/2000/svg';
|
|
97
|
+
var svg=document.createElementNS(ns,'svg');
|
|
98
|
+
svg.setAttribute('viewBox','0 0 200 40');
|
|
99
|
+
svg.setAttribute('preserveAspectRatio','none');
|
|
100
|
+
|
|
101
|
+
var n=data.length;
|
|
102
|
+
var w=200/(n-1||1);
|
|
103
|
+
var pts=data.map(function(v,i){return (i*w)+','+(40-v*0.4)}).join(' ');
|
|
104
|
+
|
|
105
|
+
// Gradient fill
|
|
106
|
+
var poly=document.createElementNS(ns,'polygon');
|
|
107
|
+
poly.setAttribute('points','0,40 '+pts+' '+((n-1)*w)+',40');
|
|
108
|
+
poly.setAttribute('fill','var(--accent-dim)');
|
|
109
|
+
svg.appendChild(poly);
|
|
110
|
+
|
|
111
|
+
// Line
|
|
112
|
+
var pl=document.createElementNS(ns,'polyline');
|
|
113
|
+
pl.setAttribute('points',pts);
|
|
114
|
+
pl.setAttribute('fill','none');
|
|
115
|
+
pl.setAttribute('stroke','var(--accent)');
|
|
116
|
+
pl.setAttribute('stroke-width','1.5');
|
|
117
|
+
svg.appendChild(pl);
|
|
118
|
+
|
|
119
|
+
// End dot
|
|
120
|
+
if(n>0){
|
|
121
|
+
var lastVal=data[n-1];
|
|
122
|
+
var dotColor=lastVal>=90?'var(--green)':lastVal>=70?'var(--amber)':'var(--red)';
|
|
123
|
+
var circle=document.createElementNS(ns,'circle');
|
|
124
|
+
circle.setAttribute('cx',''+(n-1)*w);
|
|
125
|
+
circle.setAttribute('cy',''+(40-lastVal*0.4));
|
|
126
|
+
circle.setAttribute('r','3');
|
|
127
|
+
circle.setAttribute('fill',dotColor);
|
|
128
|
+
svg.appendChild(circle);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return svg;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function applyWatchJobData(jobs){
|
|
135
|
+
if(!jobs||!jobs.length)return;
|
|
136
|
+
jobs.forEach(function(j){
|
|
137
|
+
// Find matching card by project name
|
|
138
|
+
if(!_watchData)return;
|
|
139
|
+
var match=_watchData.find(function(p){return p.name===j.name||p.cwd===j.cwd});
|
|
140
|
+
if(!match)return;
|
|
141
|
+
var cdEl=$('#watch-countdown-'+match.id);
|
|
142
|
+
if(cdEl&&j.nextRunAt){
|
|
143
|
+
cdEl.dataset.nextRunAt=j.nextRunAt;
|
|
144
|
+
updateCountdown(cdEl);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
startCountdownTimer();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function startCountdownTimer(){
|
|
151
|
+
if(_countdownInterval)return;
|
|
152
|
+
_countdownInterval=setInterval(function(){
|
|
153
|
+
$$('.watch-card-countdown[data-next-run-at]').forEach(updateCountdown);
|
|
154
|
+
},1000);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function updateCountdown(cdEl){
|
|
158
|
+
var next=cdEl.dataset.nextRunAt;
|
|
159
|
+
if(!next){cdEl.textContent='';return}
|
|
160
|
+
var diff=new Date(next)-Date.now();
|
|
161
|
+
if(diff<=0){cdEl.textContent='\u23F1 Running...';return}
|
|
162
|
+
var m=Math.floor(diff/60000);
|
|
163
|
+
var s=Math.floor((diff%60000)/1000);
|
|
164
|
+
cdEl.textContent='\u23F1 Next: '+m+'m '+String(s).padStart(2,'0')+'s';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function renderEventLog(runs){
|
|
168
|
+
var container=$('#watchEventLog');
|
|
169
|
+
if(!container)return;
|
|
170
|
+
container.textContent='';
|
|
171
|
+
|
|
172
|
+
if(!Array.isArray(runs)||!runs.length){
|
|
173
|
+
container.appendChild(el('div',{style:'padding:16px;text-align:center;color:var(--text3);font-size:11px'},'No runs recorded yet.'));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Column header row
|
|
178
|
+
container.appendChild(el('div',{className:'watch-event-row we-header'},[
|
|
179
|
+
el('span',null,'Time'),
|
|
180
|
+
el('span',null,'Project'),
|
|
181
|
+
el('span',null,'Suite'),
|
|
182
|
+
el('span',{style:'justify-self:center'},'Status'),
|
|
183
|
+
el('span',{style:'text-align:center'},'Tests'),
|
|
184
|
+
el('span',{style:'text-align:right'},'Rate'),
|
|
185
|
+
el('span',{style:'text-align:right'},'Duration'),
|
|
186
|
+
el('span',{style:'text-align:right'},'Source')
|
|
187
|
+
]));
|
|
188
|
+
|
|
189
|
+
var recent=runs.slice(0,30);
|
|
190
|
+
recent.forEach(function(r){
|
|
191
|
+
var rate=parseFloat(r.pass_rate)||0;
|
|
192
|
+
var badgeCls=r.failed>0?'fail':'pass';
|
|
193
|
+
var badgeText=r.failed>0?'FAIL':'PASS';
|
|
194
|
+
|
|
195
|
+
// Test counts: "5/5" or "3/5 (2 fail)"
|
|
196
|
+
var countsText=r.passed+'/'+r.total;
|
|
197
|
+
var countsParts=[el('span',{className:'we-counts-ok'},String(r.passed))];
|
|
198
|
+
countsParts.push(document.createTextNode('/'+r.total));
|
|
199
|
+
if(r.failed>0){
|
|
200
|
+
countsParts.push(document.createTextNode(' ('));
|
|
201
|
+
countsParts.push(el('span',{style:'color:var(--red)'},r.failed+' fail'));
|
|
202
|
+
countsParts.push(document.createTextNode(')'));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Trigger badge
|
|
206
|
+
var triggerIcon={'cli':'\u2318','dashboard':'\uD83D\uDCBB','mcp':'\u2699','watch':'\u23F1','api':'\u26A1'};
|
|
207
|
+
var trigSrc=r.triggered_by||'cli';
|
|
208
|
+
var trigEl=el('span',{className:'we-trigger',title:'Triggered by: '+trigSrc},(triggerIcon[trigSrc]||'\u2318')+' '+trigSrc);
|
|
209
|
+
|
|
210
|
+
var row=el('div',{className:'watch-event-row',style:'cursor:pointer'},[
|
|
211
|
+
el('span',{className:'watch-event-time'},fdate(r.generated_at)),
|
|
212
|
+
el('span',{className:'watch-event-project'},r.project_name||'—'),
|
|
213
|
+
el('span',{className:'watch-event-suite'},r.suite_name||'all'),
|
|
214
|
+
el('span',{className:'watch-event-result'},[el('span',{className:'badge '+badgeCls},badgeText)]),
|
|
215
|
+
el('span',{className:'watch-event-counts'},countsParts),
|
|
216
|
+
el('span',{className:'watch-event-rate'},rate>0?rate.toFixed(0)+'%':'—'),
|
|
217
|
+
el('span',{className:'watch-event-duration'},r.duration?dur(r.duration):'—'),
|
|
218
|
+
trigEl
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
// Click to navigate to run detail
|
|
222
|
+
(function(run){
|
|
223
|
+
row.addEventListener('click',function(){
|
|
224
|
+
S.project=run.project_id;$('#projectSelect').value=run.project_id;
|
|
225
|
+
showView('runs');
|
|
226
|
+
refreshRuns();
|
|
227
|
+
});
|
|
228
|
+
})(r);
|
|
229
|
+
|
|
230
|
+
container.appendChild(row);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function startWatchPolling(){
|
|
235
|
+
if(_watchInterval)return;
|
|
236
|
+
refreshWatch();
|
|
237
|
+
_watchInterval=setInterval(refreshWatch,10000);
|
|
238
|
+
}
|
|
239
|
+
function stopWatchPolling(){
|
|
240
|
+
if(_watchInterval){clearInterval(_watchInterval);_watchInterval=null}
|
|
241
|
+
if(_countdownInterval){clearInterval(_countdownInterval);_countdownInterval=null}
|
|
242
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* ── WebSocket ── */
|
|
2
|
+
function connectWS(){
|
|
3
|
+
var proto=location.protocol==='https:'?'wss:':'ws:';
|
|
4
|
+
S.ws=new WebSocket(proto+'//'+location.host);
|
|
5
|
+
S.ws.onopen=function(){
|
|
6
|
+
$('#wsDot').style.background='var(--green)';$('#wsLabel').textContent='ws: connected';$('#wsLabel').style.color='var(--green)';
|
|
7
|
+
showToast('WebSocket connected','info');
|
|
8
|
+
};
|
|
9
|
+
S.ws.onclose=function(){
|
|
10
|
+
$('#wsDot').style.background='var(--red)';$('#wsLabel').textContent='ws: disconnected';$('#wsLabel').style.color='var(--text3)';
|
|
11
|
+
setTimeout(connectWS,3000);
|
|
12
|
+
};
|
|
13
|
+
S.ws.onerror=function(){};
|
|
14
|
+
S.ws.onmessage=function(e){try{handleWS(JSON.parse(e.data))}catch(x){}};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getLiveRun(m){
|
|
18
|
+
var rid=m.runId;if(!rid)return null;
|
|
19
|
+
if(!S.liveRuns[rid])S.liveRuns[rid]={on:true,done:false,total:0,completed:0,passed:0,failed:0,active:0,tests:{},project:m.project||null,cwd:m.cwd||null,triggeredBy:m.triggeredBy||null,runId:rid,_lastEvent:Date.now()};
|
|
20
|
+
S.liveRuns[rid]._lastEvent=Date.now();
|
|
21
|
+
return S.liveRuns[rid];
|
|
22
|
+
}
|
|
23
|
+
function anyLiveRunning(){for(var k in S.liveRuns)if(S.liveRuns[k].on)return true;return false}
|
|
24
|
+
|
|
25
|
+
setInterval(function(){
|
|
26
|
+
var changed=false;
|
|
27
|
+
for(var k in S.liveRuns){
|
|
28
|
+
var r=S.liveRuns[k];
|
|
29
|
+
var age=Date.now()-r._lastEvent;
|
|
30
|
+
if(r.on&&!r.done){
|
|
31
|
+
if(r.total===0&&age>10000){r.on=false;r.done=true;r.stale=true;r.active=0;changed=true}
|
|
32
|
+
else if(r.completed>=r.total&&r.total>0&&age>15000){r.on=false;r.done=true;r.active=0;changed=true}
|
|
33
|
+
else if(age>30000){r.on=false;r.done=true;r.stale=true;r.active=0;changed=true}
|
|
34
|
+
}
|
|
35
|
+
if(r.done&&r.stale&&r.total===0&&age>15000){delete S.liveRuns[k];changed=true}
|
|
36
|
+
else if(r.done&&age>120000){delete S.liveRuns[k];changed=true}
|
|
37
|
+
}
|
|
38
|
+
if(changed)renderLive();
|
|
39
|
+
},5000);
|
|
40
|
+
|
|
41
|
+
function handleWS(m){
|
|
42
|
+
switch(m.event){
|
|
43
|
+
case 'pool:status':renderPool(m.data);break;
|
|
44
|
+
case 'run:start':
|
|
45
|
+
for(var dk in S.liveRuns){if(S.liveRuns[dk].done)delete S.liveRuns[dk]}
|
|
46
|
+
var r=getLiveRun(m);
|
|
47
|
+
r.total=m.total;r.on=true;r.done=false;
|
|
48
|
+
S.liveCollapsed=new Set();S.liveSSOpen=new Set();
|
|
49
|
+
showView('live');renderLive();break;
|
|
50
|
+
case 'test:start':
|
|
51
|
+
var r2=getLiveRun(m);if(!r2)break;
|
|
52
|
+
r2.active=m.activeCount;
|
|
53
|
+
r2.tests[m.name]={status:'running',actions:0,totalActions:0,error:null,actionLog:[],screenshots:[],serial:m.serial||false};
|
|
54
|
+
renderLive();break;
|
|
55
|
+
case 'test:pool':
|
|
56
|
+
var rp=getLiveRun(m);if(!rp||!rp.tests[m.name])break;
|
|
57
|
+
rp.tests[m.name].poolUrl=m.poolUrl||null;
|
|
58
|
+
rp.tests[m.name].actionLog.unshift({type:'pool',narrative:'\uD83D\uDD17 '+m.name+' \u2192 '+(m.poolUrl||'').replace('ws://','').replace('wss://',''),success:true,duration:null,isPoolLog:true});
|
|
59
|
+
renderLive();break;
|
|
60
|
+
case 'test:action':
|
|
61
|
+
var r3=getLiveRun(m);if(!r3||!r3.tests[m.name])break;
|
|
62
|
+
var t=r3.tests[m.name];
|
|
63
|
+
t.actions=m.actionIndex+1;t.totalActions=m.totalActions;t.actionType=m.action.type;
|
|
64
|
+
t.actionLog.push({type:m.action.type,selector:m.action.selector||null,value:m.action.value||null,text:m.action.text||null,success:m.success,duration:m.duration,error:m.error||null,narrative:m.narrative||null,actionRetries:m.action.retries||0});
|
|
65
|
+
if(m.screenshotPath)t.screenshots.push(m.screenshotPath);
|
|
66
|
+
renderLive();break;
|
|
67
|
+
case 'test:retry':
|
|
68
|
+
var r4=getLiveRun(m);if(!r4||!r4.tests[m.name])break;
|
|
69
|
+
r4.tests[m.name].retry=m.attempt+'/'+m.maxAttempts;
|
|
70
|
+
renderLive();break;
|
|
71
|
+
case 'test:complete':
|
|
72
|
+
var r5=getLiveRun(m);if(!r5)break;
|
|
73
|
+
r5.completed++;
|
|
74
|
+
if(m.success){r5.passed++;if(r5.tests[m.name])r5.tests[m.name].status='passed'}
|
|
75
|
+
else{r5.failed++;if(r5.tests[m.name]){r5.tests[m.name].status='failed';r5.tests[m.name].error=m.error}}
|
|
76
|
+
if(r5.tests[m.name]){
|
|
77
|
+
r5.tests[m.name].duration=m.duration;
|
|
78
|
+
if(m.screenshots&&m.screenshots.length)r5.tests[m.name].screenshots=m.screenshots;
|
|
79
|
+
if(m.errorScreenshot)r5.tests[m.name].errorScreenshot=m.errorScreenshot;
|
|
80
|
+
if(m.networkLogs&&m.networkLogs.length)r5.tests[m.name].networkLogs=m.networkLogs;
|
|
81
|
+
if(m.poolUrl)r5.tests[m.name].poolUrl=m.poolUrl;
|
|
82
|
+
}
|
|
83
|
+
r5.active=Math.max(0,r5.active-1);
|
|
84
|
+
renderLive();break;
|
|
85
|
+
case 'run:complete':
|
|
86
|
+
var r6=getLiveRun(m);if(r6){r6.on=false;r6.done=true;r6.active=0}
|
|
87
|
+
var summary=m.summary||{};
|
|
88
|
+
var baseMsg='Run complete: '+(summary.failed>0?summary.failed+' failed':'all '+(summary.total||0)+' passed');
|
|
89
|
+
var baseType=summary.failed>0?'error':'success';
|
|
90
|
+
var healthUrl=S.project?'/api/db/projects/'+S.project+'/health':'/api/db/health';
|
|
91
|
+
fetch(healthUrl).then(function(r){return r.json()}).then(function(h){
|
|
92
|
+
if(h&&h.passRate!==undefined){
|
|
93
|
+
var extra='. Pass rate: '+h.passRate+'%';
|
|
94
|
+
if(h.passRateTrend==='declining')extra+=' (declining, '+h.trendDelta+'%)';
|
|
95
|
+
else if(h.passRateTrend==='improving')extra+=' (improving, +'+h.trendDelta+'%)';
|
|
96
|
+
if(h.flakyCount>0)extra+='. '+h.flakyCount+' flaky test(s)';
|
|
97
|
+
showEnrichedToast(baseMsg+extra,baseType);
|
|
98
|
+
} else {
|
|
99
|
+
showToast(baseMsg,baseType);
|
|
100
|
+
}
|
|
101
|
+
}).catch(function(){showToast(baseMsg,baseType)});
|
|
102
|
+
renderLive();refreshRuns();refreshProjects();refreshWatch();break;
|
|
103
|
+
case 'run:error':
|
|
104
|
+
var r7=getLiveRun(m);if(r7){r7.on=false;r7.done=true;r7.tests.__error={status:'failed',error:m.error}}
|
|
105
|
+
showToast('Run error: '+m.error,'error');
|
|
106
|
+
renderLive();break;
|
|
107
|
+
case 'test:frame':
|
|
108
|
+
if(S.screencastTest===m.name&&m.data){
|
|
109
|
+
var img=$('#screencastImg');
|
|
110
|
+
if(img)img.src='data:image/jpeg;base64,'+m.data;
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
case 'db:updated':
|
|
114
|
+
refreshRuns();refreshProjects();refreshScreenshots();refreshLearnings();refreshWatch();break;
|
|
115
|
+
}
|
|
116
|
+
}
|