mcprigor 1.0.0-rc.3 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +186 -1
- package/README.md +24 -10
- package/dist/cli.js +0 -0
- package/dist/data.js +14 -21
- package/dist/data.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +2 -1
- package/dist/session.js.map +1 -1
- package/dist/trace.d.ts.map +1 -1
- package/dist/trace.js +2 -1
- package/dist/trace.js.map +1 -1
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +129 -33
- package/dist/workspace.js.map +1 -1
- package/dist/xlsx.d.ts +6 -0
- package/dist/xlsx.d.ts.map +1 -0
- package/dist/xlsx.js +199 -0
- package/dist/xlsx.js.map +1 -0
- package/docs/ENGINEER-SETUP.md +1 -1
- package/docs/GETTING-STARTED.md +1 -1
- package/docs/README.md +1 -1
- package/package.json +2 -2
- package/workspace-assets/app.js +550 -1
- package/workspace-assets/index.html +143 -1
- package/workspace-assets/style.css +225 -1
package/workspace-assets/app.js
CHANGED
|
@@ -1 +1,550 @@
|
|
|
1
|
-
const state={csrf:'',path:'',etag:'',dirty:false
|
|
1
|
+
const state = { csrf: '', path: '', etag: '', dirty: false, files: [], selected: new Set(), run: null, history: [], tab: 'run', runSel: -1 };
|
|
2
|
+
const $ = id => document.getElementById(id);
|
|
3
|
+
|
|
4
|
+
/* ---------- API ---------- */
|
|
5
|
+
async function api(path, options = {}) {
|
|
6
|
+
const headers = { ...(options.body ? { 'content-type': 'application/json' } : {}), ...(options.method && options.method !== 'GET' ? { 'x-mcp-csrf': state.csrf } : {}) };
|
|
7
|
+
const response = await fetch(path, { ...options, headers: { ...headers, ...options.headers } });
|
|
8
|
+
const value = await response.json();
|
|
9
|
+
if (!response.ok) { const error = new Error(value.error?.message || `HTTP ${response.status}`); error.status = response.status; throw error; }
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* ---------- boot ---------- */
|
|
14
|
+
async function start() {
|
|
15
|
+
const boot = await api('/api/v1/bootstrap');
|
|
16
|
+
state.csrf = boot.csrf;
|
|
17
|
+
$('version').textContent = `v${boot.version}`;
|
|
18
|
+
$('workspace').textContent = `Folder: ${boot.root}`;
|
|
19
|
+
$('connection').classList.add('ok');
|
|
20
|
+
$('connection').innerHTML = '<span class="dot"></span>Local · ready';
|
|
21
|
+
const count = await refreshList();
|
|
22
|
+
if (!count) showWelcome(true);
|
|
23
|
+
loadHistory().catch(() => {});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* ---------- sidebar ---------- */
|
|
27
|
+
async function refreshList() {
|
|
28
|
+
const value = await api('/api/v1/suites');
|
|
29
|
+
state.files = value.suites;
|
|
30
|
+
for (const path of [...state.selected]) if (!value.suites.some(f => f.path === path)) state.selected.delete(path);
|
|
31
|
+
renderList();
|
|
32
|
+
return value.suites.length;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderList() {
|
|
36
|
+
const query = $('filter').value.trim().toLowerCase();
|
|
37
|
+
const nav = $('suites');
|
|
38
|
+
nav.replaceChildren();
|
|
39
|
+
const files = state.files.filter(f => !query || f.path.toLowerCase().includes(query));
|
|
40
|
+
if (!files.length) {
|
|
41
|
+
const p = document.createElement('p');
|
|
42
|
+
p.className = 'empty';
|
|
43
|
+
p.textContent = state.files.length ? 'No files match the filter.' : 'No test files yet. Create one to get started.';
|
|
44
|
+
nav.append(p);
|
|
45
|
+
}
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
const row = document.createElement('div');
|
|
48
|
+
row.className = 'suite-row';
|
|
49
|
+
const check = document.createElement('input');
|
|
50
|
+
check.type = 'checkbox';
|
|
51
|
+
check.checked = state.selected.has(file.path);
|
|
52
|
+
check.title = 'Select for batch run';
|
|
53
|
+
check.onchange = () => { check.checked ? state.selected.add(file.path) : state.selected.delete(file.path); renderBatchBar(); };
|
|
54
|
+
const button = document.createElement('button');
|
|
55
|
+
const icon = document.createElement('span');
|
|
56
|
+
icon.className = 'file-icon';
|
|
57
|
+
icon.textContent = file.path.endsWith('.mcpr') ? '📝' : '⚙️';
|
|
58
|
+
const name = document.createElement('span');
|
|
59
|
+
name.className = 'file-name';
|
|
60
|
+
name.textContent = file.path;
|
|
61
|
+
button.title = file.path;
|
|
62
|
+
const rn = document.createElement('span');
|
|
63
|
+
rn.className = 'rn';
|
|
64
|
+
rn.textContent = '✏️';
|
|
65
|
+
rn.title = 'Rename';
|
|
66
|
+
rn.onclick = event => { event.stopPropagation(); openRename(file.path); };
|
|
67
|
+
button.append(icon, name, rn);
|
|
68
|
+
if (file.path === state.path) button.classList.add('active');
|
|
69
|
+
button.onclick = () => open(file.path).catch(show);
|
|
70
|
+
row.append(check, button);
|
|
71
|
+
nav.append(row);
|
|
72
|
+
}
|
|
73
|
+
renderBatchBar();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderBatchBar() {
|
|
77
|
+
const n = state.selected.size;
|
|
78
|
+
$('batch-bar').hidden = n === 0;
|
|
79
|
+
$('batch-count').textContent = `${n} file${n === 1 ? '' : 's'} selected`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ---------- editor ---------- */
|
|
83
|
+
const escapeHtml = t => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
84
|
+
function highlightSrc(text) {
|
|
85
|
+
return text.split('\n').map(line => {
|
|
86
|
+
if (/^\s*#/.test(line)) return `<span class="tok-cmt">${escapeHtml(line)}</span>`;
|
|
87
|
+
let html = escapeHtml(line);
|
|
88
|
+
html = html.replace(/"/g, '"');
|
|
89
|
+
html = html.replace(/"([^"]*)"/g, (m, inner) => `<span class="tok-str">"${inner}"</span>`);
|
|
90
|
+
html = html.replace(/^(\s*)([A-Za-z][A-Za-z0-9 _-]*?)(:)/, (m, sp, word, colon) => `${sp}<span class="tok-key">${word}</span>${colon}`);
|
|
91
|
+
html = html.replace(/^(\s*)(Call tool|Read resource|Get prompt|Expect|Save|Send|Require|Import flows from|Use flow|Keep rows where|Test|Suite|Server|MCP URL)\b/g,
|
|
92
|
+
(m, sp, word) => sp + (m.includes('tok-key') ? word : `<span class="tok-key">${word}</span>`));
|
|
93
|
+
html = html.replace(/\b(-?\d+(?:\.\d+)?)\b(?![^<]*<\/span>)/g, '<span class="tok-num">$1</span>');
|
|
94
|
+
return html;
|
|
95
|
+
}).join('\n');
|
|
96
|
+
}
|
|
97
|
+
let errorLine = 0;
|
|
98
|
+
function markErrorLine(line) { errorLine = line; syncEditor(); }
|
|
99
|
+
function jumpToLine(line) {
|
|
100
|
+
const el = $('editor');
|
|
101
|
+
const index = el.value.split('\n').slice(0, line - 1).join('\n').length + (line > 1 ? 1 : 0);
|
|
102
|
+
el.focus();
|
|
103
|
+
el.setSelectionRange(index, index);
|
|
104
|
+
el.scrollTop = Math.max(0, (line - 4) * 13.5 * 1.65);
|
|
105
|
+
syncScroll();
|
|
106
|
+
}
|
|
107
|
+
function syncEditor() {
|
|
108
|
+
const text = $('editor').value;
|
|
109
|
+
const lines = highlightSrc(text).split('\n');
|
|
110
|
+
if (errorLine >= 1 && errorLine <= lines.length) lines[errorLine - 1] = `<span class="line-err">${lines[errorLine - 1] || ' '}</span>`;
|
|
111
|
+
$('highlight-code').innerHTML = lines.join('\n') + '\n';
|
|
112
|
+
$('gutter').textContent = Array.from({ length: text.split('\n').length }, (_, i) => i + 1).join('\n');
|
|
113
|
+
syncScroll();
|
|
114
|
+
}
|
|
115
|
+
function syncScroll() { $('highlight').scrollTop = $('editor').scrollTop; $('gutter').scrollTop = $('editor').scrollTop; }
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
/* ---------- autocomplete ---------- */
|
|
119
|
+
const SUGGESTIONS = [
|
|
120
|
+
{ text: 'MCP Test 1', desc: 'Version header — first line of every file', top: true },
|
|
121
|
+
{ text: 'Suite: "', desc: 'Name this suite (shown in reports and history)', top: true },
|
|
122
|
+
{ text: 'Server: ', desc: 'Command that starts your MCP server, e.g. node dist/server.js', top: true },
|
|
123
|
+
{ text: 'MCP URL: ', desc: 'Connect to a deployed HTTP endpoint instead', top: true },
|
|
124
|
+
{ text: 'Data source: ', desc: 'CSV or XLSX file for data-driven tests', top: true },
|
|
125
|
+
{ text: 'Import flows from ', desc: 'Reuse shared flows from another file', top: true },
|
|
126
|
+
{ text: 'Test: "', desc: 'Start a new test with a descriptive name', top: true },
|
|
127
|
+
{ text: 'Flow: "', desc: 'Define a reusable sequence of steps', top: true },
|
|
128
|
+
{ text: 'Call tool "', desc: 'Invoke an MCP tool (add with: for arguments)' },
|
|
129
|
+
{ text: 'Read resource "', desc: 'Read an MCP resource by URI' },
|
|
130
|
+
{ text: 'Get prompt "', desc: 'Fetch an MCP prompt' },
|
|
131
|
+
{ text: 'Send "ping"', desc: 'Protocol-level request' },
|
|
132
|
+
{ text: 'Use flow "', desc: 'Run a flow defined or imported in this file' },
|
|
133
|
+
{ text: 'Require: tools', desc: 'Skip this test unless the server declares the capability' },
|
|
134
|
+
{ text: 'Expect "', desc: 'Assert on a response field, e.g. Expect "structuredContent.sum" equals 5' },
|
|
135
|
+
{ text: 'Expect it succeeds', desc: 'Assert the call did not error' },
|
|
136
|
+
{ text: 'Expect an error', desc: 'Assert the call fails' },
|
|
137
|
+
{ text: 'Expect error code ', desc: 'Assert a specific JSON-RPC error code' },
|
|
138
|
+
{ text: 'Expect error message matches "', desc: 'Regex match on the error message' },
|
|
139
|
+
{ text: 'Save "', desc: 'Store a response field: Save "field.path" as "name"' },
|
|
140
|
+
{ text: 'Wait for notification "', desc: 'Wait for a server notification within a timeout' },
|
|
141
|
+
{ text: 'Subscribe to "', desc: 'Subscribe to resource updates' },
|
|
142
|
+
];
|
|
143
|
+
const EXPECT_TAILS = [
|
|
144
|
+
{ text: 'equals ', desc: 'Exact value match' },
|
|
145
|
+
{ text: 'contains "', desc: 'Substring match' },
|
|
146
|
+
{ text: 'matches "', desc: 'Regular-expression match' },
|
|
147
|
+
{ text: 'exists', desc: 'Field must be present' },
|
|
148
|
+
{ text: 'is a number', desc: 'Type check' },
|
|
149
|
+
{ text: 'has ', desc: 'Array length, e.g. has 3 items' },
|
|
150
|
+
{ text: 'does not equal ', desc: 'Negated match' },
|
|
151
|
+
{ text: 'matches snapshot "', desc: 'Compare against a stored snapshot' },
|
|
152
|
+
];
|
|
153
|
+
let suggest = { open: false, items: [], sel: 0, from: 0 };
|
|
154
|
+
|
|
155
|
+
function currentLine() {
|
|
156
|
+
const el = $('editor');
|
|
157
|
+
const upTo = el.value.slice(0, el.selectionStart);
|
|
158
|
+
const startIdx = upTo.lastIndexOf('\n') + 1;
|
|
159
|
+
return { text: upTo.slice(startIdx), startIdx, caret: el.selectionStart };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function computeSuggestions() {
|
|
163
|
+
const { text, startIdx } = currentLine();
|
|
164
|
+
const stripped = text.replace(/^\s+/, '');
|
|
165
|
+
const indentLen = text.length - stripped.length;
|
|
166
|
+
// After an Expect "field" — offer comparison tails
|
|
167
|
+
const tail = stripped.match(/^Expect\s+"[^"]*"\s+(\w*)$/i);
|
|
168
|
+
if (tail) {
|
|
169
|
+
const prefix = tail[1] || '';
|
|
170
|
+
const items = EXPECT_TAILS.filter(i => i.text.toLowerCase().startsWith(prefix.toLowerCase()));
|
|
171
|
+
return { items, from: startIdx + text.length - prefix.length, prefix };
|
|
172
|
+
}
|
|
173
|
+
if (!stripped.length || /["\d]/.test(stripped[0])) return { items: [], from: 0, prefix: '' };
|
|
174
|
+
const topLevel = indentLen === 0;
|
|
175
|
+
const items = SUGGESTIONS
|
|
176
|
+
.filter(i => i.text.toLowerCase().startsWith(stripped.toLowerCase()) && i.text.length > stripped.length)
|
|
177
|
+
.filter(i => topLevel ? true : !i.top);
|
|
178
|
+
return { items, from: startIdx + indentLen, prefix: stripped };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function renderSuggest() {
|
|
182
|
+
const box = $('suggest');
|
|
183
|
+
if (!suggest.open || !suggest.items.length) { box.hidden = true; suggest.open = false; return; }
|
|
184
|
+
box.hidden = false;
|
|
185
|
+
box.replaceChildren();
|
|
186
|
+
suggest.items.forEach((item, index) => {
|
|
187
|
+
const button = document.createElement('button');
|
|
188
|
+
button.type = 'button';
|
|
189
|
+
button.className = index === suggest.sel ? 'sel' : '';
|
|
190
|
+
const prefixLen = suggest.prefix.length;
|
|
191
|
+
button.innerHTML = `<b>${escapeHtml(item.text.slice(0, prefixLen))}</b>${escapeHtml(item.text.slice(prefixLen))}<span class="desc">${escapeHtml(item.desc)}</span>`;
|
|
192
|
+
button.onmousedown = event => { event.preventDefault(); acceptSuggestion(index); };
|
|
193
|
+
box.append(button);
|
|
194
|
+
});
|
|
195
|
+
// position under the caret line
|
|
196
|
+
const el = $('editor');
|
|
197
|
+
const lines = el.value.slice(0, el.selectionStart).split('\n');
|
|
198
|
+
const lineHeight = 13.5 * 1.65;
|
|
199
|
+
const top = Math.min((lines.length) * lineHeight - el.scrollTop + 22, el.clientHeight - 40);
|
|
200
|
+
const col = lines[lines.length - 1].length;
|
|
201
|
+
box.style.top = `${Math.max(30, top)}px`;
|
|
202
|
+
box.style.left = `${Math.min(18 + col * 8.1, el.clientWidth - 300)}px`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function openSuggest() {
|
|
206
|
+
const { items, from, prefix } = computeSuggestions();
|
|
207
|
+
suggest = { open: items.length > 0, items: items.slice(0, 9), sel: 0, from, prefix };
|
|
208
|
+
renderSuggest();
|
|
209
|
+
}
|
|
210
|
+
function closeSuggest() { suggest.open = false; renderSuggest(); }
|
|
211
|
+
function acceptSuggestion(index) {
|
|
212
|
+
const item = suggest.items[index ?? suggest.sel];
|
|
213
|
+
if (!item) return;
|
|
214
|
+
const el = $('editor');
|
|
215
|
+
el.setRangeText(item.text, suggest.from, el.selectionStart, 'end');
|
|
216
|
+
closeSuggest();
|
|
217
|
+
state.dirty = true; $('dirty').textContent = '● unsaved';
|
|
218
|
+
syncEditor();
|
|
219
|
+
el.focus();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* ---------- rename ---------- */
|
|
223
|
+
let renameTarget = '';
|
|
224
|
+
function openRename(path) {
|
|
225
|
+
renameTarget = path;
|
|
226
|
+
const base = path.replace(/\.[^.]+$/, '');
|
|
227
|
+
$('rename-error').hidden = true;
|
|
228
|
+
$('rename-name').value = base;
|
|
229
|
+
$('rename-dialog').showModal();
|
|
230
|
+
$('rename-name').select();
|
|
231
|
+
}
|
|
232
|
+
async function doRename(to) {
|
|
233
|
+
const value = await api('/api/v1/rename', { method: 'POST', body: JSON.stringify({ from: renameTarget, to }) });
|
|
234
|
+
const wasOpen = state.path === renameTarget;
|
|
235
|
+
if (state.selected.has(renameTarget)) { state.selected.delete(renameTarget); state.selected.add(value.path); }
|
|
236
|
+
await refreshList();
|
|
237
|
+
if (wasOpen) { state.path = value.path; $('filename').textContent = value.path; renderList(); }
|
|
238
|
+
loadHistory().catch(() => {});
|
|
239
|
+
toast(`Renamed to ${value.path}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* ---------- states ---------- */
|
|
243
|
+
function showWelcome(on) { $('welcome').hidden = !on; setEnabled(!on && !!state.path); }
|
|
244
|
+
function setEnabled(on) { for (const id of ['save', 'validate', 'run', 'parity']) $(id).disabled = !on; }
|
|
245
|
+
|
|
246
|
+
/* ---------- file ops ---------- */
|
|
247
|
+
async function open(path) {
|
|
248
|
+
if (state.dirty && !confirm('Discard unsaved changes?')) return;
|
|
249
|
+
const value = await api(`/api/v1/file?path=${encodeURIComponent(path)}`);
|
|
250
|
+
state.path = path; state.etag = value.etag; state.dirty = false;
|
|
251
|
+
$('editor').value = value.text;
|
|
252
|
+
syncEditor();
|
|
253
|
+
$('filename').textContent = path;
|
|
254
|
+
$('filename').classList.add('open');
|
|
255
|
+
$('dirty').textContent = '';
|
|
256
|
+
showWelcome(false); setEnabled(true); renderList(); hideDiag(); markErrorLine(0);
|
|
257
|
+
$('rename').hidden = false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function createFile(name) {
|
|
261
|
+
const value = await api('/api/v1/file', { method: 'POST', body: JSON.stringify({ name }) });
|
|
262
|
+
await refreshList();
|
|
263
|
+
await open(value.path);
|
|
264
|
+
toast(`Created ${value.path}`);
|
|
265
|
+
diag('Next: set the "Server:" line to your MCP server command (or use MCP URL: for a deployed endpoint), then click Validate and ▶ Run tests.', 'warn');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function save(silent) {
|
|
269
|
+
if (!state.path) return;
|
|
270
|
+
const value = await api('/api/v1/file', { method: 'PUT', body: JSON.stringify({ path: state.path, text: $('editor').value, etag: state.etag }) });
|
|
271
|
+
state.etag = value.etag; state.dirty = false;
|
|
272
|
+
$('dirty').textContent = '';
|
|
273
|
+
if (!silent) toast('Saved');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function validate() {
|
|
277
|
+
if (!state.path) return diag('Open or create a test file first.', 'warn');
|
|
278
|
+
if (state.dirty) await save(true);
|
|
279
|
+
const value = await api('/api/v1/validate', { method: 'POST', body: JSON.stringify({ path: state.path }) });
|
|
280
|
+
if (value.valid) { markErrorLine(0); diag(`✓ Valid — ${value.suite.tests} test${value.suite.tests === 1 ? '' : 's'} ready${value.suite.parityTargets.length ? ` · ${value.suite.parityTargets.length} parity targets` : ''}. Click ▶ Run tests.`, 'ok'); }
|
|
281
|
+
else {
|
|
282
|
+
const first = value.diagnostics[0];
|
|
283
|
+
diag(value.diagnostics.map(x => x.message).join('\n'), 'err');
|
|
284
|
+
if (first?.line) { markErrorLine(first.line); jumpToLine(first.line); }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* ---------- runs ---------- */
|
|
289
|
+
function colorize(output, query) {
|
|
290
|
+
return escapeHtml(output).split('\n').map(line => {
|
|
291
|
+
let html = line;
|
|
292
|
+
if (query) html = markMatches(html, query);
|
|
293
|
+
if (/^\s*✓/.test(line)) return `<span class="out-pass">${html}</span>`;
|
|
294
|
+
if (/^\s*✗/.test(line)) return `<span class="out-fail">${html}</span>`;
|
|
295
|
+
if (/^MCP Rigor/.test(line) || /\d+ passed/.test(line)) return `<span class="out-head">${html}</span>`;
|
|
296
|
+
return html;
|
|
297
|
+
}).join('\n');
|
|
298
|
+
}
|
|
299
|
+
function markMatches(html, query) {
|
|
300
|
+
const safe = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
301
|
+
return html.replace(new RegExp(`(${safe})`, 'gi'), '<span class="mark">$1</span>');
|
|
302
|
+
}
|
|
303
|
+
function badge(status) {
|
|
304
|
+
const el = $('run-badge');
|
|
305
|
+
if (!status) { el.hidden = true; return; }
|
|
306
|
+
el.hidden = false;
|
|
307
|
+
el.className = `badge ${status}`;
|
|
308
|
+
el.textContent = status === 'running' ? '● Running' : status === 'passed' ? '✓ Passed' : '✗ Failed';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function run(mode, paths) {
|
|
312
|
+
const targets = paths ?? (state.path ? [state.path] : null);
|
|
313
|
+
if (!targets) return diag('Open or create a test file first.', 'warn');
|
|
314
|
+
if (state.dirty) await save(true);
|
|
315
|
+
switchTab('run');
|
|
316
|
+
badge('running');
|
|
317
|
+
state.run = null; state.runSel = -1;
|
|
318
|
+
$('run-list').replaceChildren();
|
|
319
|
+
$('output').textContent = `Running ${targets.length === 1 ? targets[0] : targets.length + ' files'}…`;
|
|
320
|
+
hideDiag();
|
|
321
|
+
try {
|
|
322
|
+
const value = await api('/api/v1/runs', { method: 'POST', body: JSON.stringify({ paths: targets, mode }) });
|
|
323
|
+
const poll = setInterval(async () => {
|
|
324
|
+
try {
|
|
325
|
+
const result = await api(`/api/v1/runs/${value.runId}`);
|
|
326
|
+
state.run = result;
|
|
327
|
+
renderRun();
|
|
328
|
+
if (result.status !== 'running') {
|
|
329
|
+
clearInterval(poll);
|
|
330
|
+
badge(result.status);
|
|
331
|
+
const failed = result.items.filter(i => i.status === 'failed').length;
|
|
332
|
+
if (result.status === 'passed') diag(`✓ ${result.items.length === 1 ? 'All tests passed.' : `All ${result.items.length} files passed.`}`, 'ok');
|
|
333
|
+
else diag(hintFor(result.items.map(i => i.error || i.output).join('\n')) || `✗ ${failed} of ${result.items.length} file${result.items.length === 1 ? '' : 's'} failed — click a file above for its report.`, 'err');
|
|
334
|
+
if (mode === 'test') loadHistory().catch(() => {});
|
|
335
|
+
}
|
|
336
|
+
} catch (error) { clearInterval(poll); badge('failed'); $('output').textContent = error.message; }
|
|
337
|
+
}, 500);
|
|
338
|
+
} catch (error) { badge(null); show(error); }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function renderRun() {
|
|
342
|
+
const run = state.run;
|
|
343
|
+
if (!run) return;
|
|
344
|
+
const query = $('results-filter').value.trim();
|
|
345
|
+
const list = $('run-list');
|
|
346
|
+
list.replaceChildren();
|
|
347
|
+
if (run.items.length > 1) {
|
|
348
|
+
run.items.forEach((item, index) => {
|
|
349
|
+
const row = document.createElement('button');
|
|
350
|
+
row.className = 'run-item' + (index === state.runSel ? ' selected' : '');
|
|
351
|
+
row.innerHTML = `<span class="st ${item.status === 'passed' ? 'pass' : item.status === 'failed' ? 'fail' : 'running'}">${item.status === 'passed' ? '✓' : item.status === 'failed' ? '✗' : '●'}</span><span class="nm">${escapeHtml(item.suite)}</span><span class="ms">${item.durationMs != null ? item.durationMs + 'ms' : ''}</span>`;
|
|
352
|
+
row.onclick = () => { state.runSel = index; renderRun(); };
|
|
353
|
+
list.append(row);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
const active = run.items.length === 1 ? run.items[0] : run.items[state.runSel] ?? null;
|
|
357
|
+
if (active) $('output').innerHTML = colorize(active.output, query);
|
|
358
|
+
else {
|
|
359
|
+
const matching = query ? run.items.map(i => i.output).join('\n\n').split('\n').filter(l => l.toLowerCase().includes(query.toLowerCase())).join('\n') : '';
|
|
360
|
+
$('output').innerHTML = query && matching ? colorize(matching, query) : '<span class="muted">Click a file above to see its report' + (query ? ' — or matching lines are shown when you search.' : '.') + '</span>';
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function hintFor(text) {
|
|
365
|
+
if (!text) return '';
|
|
366
|
+
if (text.includes('MCP-SPAWN-001')) return '✗ The server command was not found. Edit the "Server:" line — it must start your MCP server from this folder.';
|
|
367
|
+
if (text.includes('no targets section')) return '✗ Parity needs a targets section with two named targets. See the cookbook for an example.';
|
|
368
|
+
return '';
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* ---------- history ---------- */
|
|
372
|
+
async function loadHistory() {
|
|
373
|
+
const value = await api('/api/v1/history');
|
|
374
|
+
state.history = value.entries;
|
|
375
|
+
renderHistory();
|
|
376
|
+
renderTrends();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function renderHistory() {
|
|
380
|
+
const query = $('results-filter').value.trim().toLowerCase();
|
|
381
|
+
const list = $('history-list');
|
|
382
|
+
list.replaceChildren();
|
|
383
|
+
const entries = [...state.history].reverse().filter(e =>
|
|
384
|
+
!query || e.suite.toLowerCase().includes(query) || e.tests.some(t => t.name.toLowerCase().includes(query) || (t.error || '').toLowerCase().includes(query)));
|
|
385
|
+
if (!entries.length) {
|
|
386
|
+
list.innerHTML = `<p class="muted pad">${state.history.length ? 'No history matches the search.' : 'No runs recorded yet. History appears after the first ▶ Run.'}</p>`;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
for (const entry of entries.slice(0, 100)) {
|
|
390
|
+
const el = document.createElement('div');
|
|
391
|
+
el.className = 'h-entry';
|
|
392
|
+
const matchingTests = query ? entry.tests.filter(t => t.name.toLowerCase().includes(query) || (t.error || '').toLowerCase().includes(query)) : entry.tests;
|
|
393
|
+
el.innerHTML = `<div class="h-head"><span class="st ${entry.status === 'passed' ? 'pass' : 'fail'}">${entry.status === 'passed' ? '✓' : '✗'}</span><span class="suite">${escapeHtml(entry.suite)}</span><span class="when">${timeAgo(entry.at)} · ${entry.durationMs}ms</span></div>
|
|
394
|
+
<div class="h-tests">${(query ? matchingTests : entry.tests).map(t => `<div class="h-test"><span class="st ${t.status === 'passed' ? 'pass' : 'fail'}">${t.status === 'passed' ? '✓' : '✗'}</span><span>${query ? markMatches(escapeHtml(t.name), query) : escapeHtml(t.name)} <span class="muted">(${t.durationMs}ms)</span>${t.error ? `<div class="err">${query ? markMatches(escapeHtml(t.error), query) : escapeHtml(t.error)}</div>` : ''}</span></div>`).join('')}</div>`;
|
|
395
|
+
el.querySelector('.h-head').onclick = () => el.classList.toggle('open');
|
|
396
|
+
if (query && matchingTests.length) el.classList.add('open');
|
|
397
|
+
list.append(el);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/* ---------- trends ---------- */
|
|
402
|
+
function renderTrends() {
|
|
403
|
+
const query = $('results-filter').value.trim().toLowerCase();
|
|
404
|
+
const wrap = $('trends');
|
|
405
|
+
wrap.replaceChildren();
|
|
406
|
+
const bySuite = new Map();
|
|
407
|
+
for (const entry of state.history) {
|
|
408
|
+
if (!bySuite.has(entry.suite)) bySuite.set(entry.suite, []);
|
|
409
|
+
bySuite.get(entry.suite).push(entry);
|
|
410
|
+
}
|
|
411
|
+
const suites = [...bySuite.entries()].filter(([name]) => !query || name.toLowerCase().includes(query));
|
|
412
|
+
if (!suites.length) {
|
|
413
|
+
wrap.innerHTML = `<p class="muted pad">${state.history.length ? 'No suites match the search.' : 'Trends appear after a few runs.'}</p>`;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
for (const [suite, entries] of suites) {
|
|
417
|
+
const recent = entries.slice(-30);
|
|
418
|
+
const passRate = Math.round(100 * entries.filter(e => e.status === 'passed').length / entries.length);
|
|
419
|
+
const avg = Math.round(entries.reduce((total, e) => total + e.durationMs, 0) / entries.length);
|
|
420
|
+
const maxMs = Math.max(...recent.map(e => e.durationMs), 1);
|
|
421
|
+
const testStats = new Map();
|
|
422
|
+
for (const entry of entries) for (const test of entry.tests) {
|
|
423
|
+
if (!testStats.has(test.name)) testStats.set(test.name, { pass: 0, total: 0 });
|
|
424
|
+
const stat = testStats.get(test.name);
|
|
425
|
+
stat.total++; if (test.status === 'passed') stat.pass++;
|
|
426
|
+
}
|
|
427
|
+
const testRows = [...testStats.entries()]
|
|
428
|
+
.filter(([name]) => !query || suite.toLowerCase().includes(query) || name.toLowerCase().includes(query))
|
|
429
|
+
.map(([name, s]) => {
|
|
430
|
+
const pct = Math.round(100 * s.pass / s.total);
|
|
431
|
+
return `<div class="t-test"><span class="nm">${escapeHtml(name)}</span><span class="bar"><i style="width:${pct}%"></i></span><span class="pct">${pct}%</span></div>`;
|
|
432
|
+
}).join('');
|
|
433
|
+
const el = document.createElement('div');
|
|
434
|
+
el.className = 't-suite';
|
|
435
|
+
el.innerHTML = `<div class="t-name">${escapeHtml(suite)}</div>
|
|
436
|
+
<div class="t-row">
|
|
437
|
+
<span class="spark">${recent.map(e => `<i class="${e.status === 'passed' ? 'pass' : 'fail'}" style="height:${Math.max(13, Math.round(30 * e.durationMs / maxMs))}px" title="${new Date(e.at).toLocaleString()} · ${e.status} · ${e.durationMs}ms"></i>`).join('')}</span>
|
|
438
|
+
<span class="t-stats"><span class="t-rate">${passRate}% pass rate</span><span>${entries.length} run${entries.length === 1 ? '' : 's'} · avg ${avg}ms</span></span>
|
|
439
|
+
</div>
|
|
440
|
+
${testRows ? `<div class="t-test-list">${testRows}</div>` : ''}`;
|
|
441
|
+
wrap.append(el);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function timeAgo(iso) {
|
|
446
|
+
const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
447
|
+
if (seconds < 60) return 'just now';
|
|
448
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
449
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
450
|
+
return `${Math.floor(seconds / 86400)}d ago`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* ---------- tabs ---------- */
|
|
454
|
+
function switchTab(tab) {
|
|
455
|
+
state.tab = tab;
|
|
456
|
+
for (const name of ['run', 'history', 'trends']) {
|
|
457
|
+
$(`tab-${name}`).classList.toggle('active', name === tab);
|
|
458
|
+
$(`tab-${name}`).setAttribute('aria-selected', String(name === tab));
|
|
459
|
+
$(`panel-${name}`).hidden = name !== tab;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
$('tab-run').onclick = () => switchTab('run');
|
|
463
|
+
$('tab-history').onclick = () => { switchTab('history'); loadHistory().catch(show); };
|
|
464
|
+
$('tab-trends').onclick = () => { switchTab('trends'); loadHistory().catch(show); };
|
|
465
|
+
$('results-filter').addEventListener('input', () => { renderRun(); renderHistory(); renderTrends(); });
|
|
466
|
+
|
|
467
|
+
/* ---------- feedback ---------- */
|
|
468
|
+
let toastTimer;
|
|
469
|
+
function toast(message) {
|
|
470
|
+
const el = $('toast');
|
|
471
|
+
el.textContent = message; el.hidden = false;
|
|
472
|
+
clearTimeout(toastTimer);
|
|
473
|
+
toastTimer = setTimeout(() => { el.hidden = true; }, 2200);
|
|
474
|
+
}
|
|
475
|
+
function diag(message, kind) {
|
|
476
|
+
const el = $('diagnostics');
|
|
477
|
+
el.hidden = false;
|
|
478
|
+
el.className = `diagnostics${kind ? ` ${kind}` : ''}`;
|
|
479
|
+
el.textContent = message;
|
|
480
|
+
}
|
|
481
|
+
function hideDiag() { $('diagnostics').hidden = true; }
|
|
482
|
+
function show(error) { diag(hintFor(error.message) || error.message || String(error), 'err'); }
|
|
483
|
+
|
|
484
|
+
/* ---------- dialog ---------- */
|
|
485
|
+
function openDialog() {
|
|
486
|
+
$('new-error').hidden = true;
|
|
487
|
+
$('new-name').value = '';
|
|
488
|
+
$('new-dialog').showModal();
|
|
489
|
+
$('new-name').focus();
|
|
490
|
+
}
|
|
491
|
+
$('new-form').addEventListener('submit', event => {
|
|
492
|
+
event.preventDefault();
|
|
493
|
+
createFile($('new-name').value.trim()).then(() => $('new-dialog').close()).catch(error => {
|
|
494
|
+
$('new-error').textContent = error.message;
|
|
495
|
+
$('new-error').hidden = false;
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
$('new-cancel').onclick = () => $('new-dialog').close();
|
|
499
|
+
$('rename-form').addEventListener('submit', event => {
|
|
500
|
+
event.preventDefault();
|
|
501
|
+
doRename($('rename-name').value.trim()).then(() => $('rename-dialog').close()).catch(error => {
|
|
502
|
+
$('rename-error').textContent = error.message;
|
|
503
|
+
$('rename-error').hidden = false;
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
$('rename-cancel').onclick = () => $('rename-dialog').close();
|
|
507
|
+
$('rename').onclick = () => { if (state.path) openRename(state.path); };
|
|
508
|
+
$('filename').ondblclick = () => { if (state.path) openRename(state.path); };
|
|
509
|
+
|
|
510
|
+
/* ---------- wiring ---------- */
|
|
511
|
+
$('editor').addEventListener('input', () => { state.dirty = true; $('dirty').textContent = '● unsaved'; markErrorLine(0); openSuggest(); });
|
|
512
|
+
$('editor').addEventListener('blur', () => setTimeout(closeSuggest, 150));
|
|
513
|
+
$('editor').addEventListener('click', closeSuggest);
|
|
514
|
+
$('editor').addEventListener('scroll', syncScroll);
|
|
515
|
+
$('editor').addEventListener('keydown', event => {
|
|
516
|
+
if (suggest.open) {
|
|
517
|
+
if (event.key === 'ArrowDown') { event.preventDefault(); suggest.sel = (suggest.sel + 1) % suggest.items.length; renderSuggest(); return; }
|
|
518
|
+
if (event.key === 'ArrowUp') { event.preventDefault(); suggest.sel = (suggest.sel - 1 + suggest.items.length) % suggest.items.length; renderSuggest(); return; }
|
|
519
|
+
if (event.key === 'Tab' || event.key === 'Enter') { event.preventDefault(); acceptSuggestion(); return; }
|
|
520
|
+
if (event.key === 'Escape') { closeSuggest(); return; }
|
|
521
|
+
}
|
|
522
|
+
if (event.key === ' ' && event.ctrlKey) { event.preventDefault(); openSuggest(); return; }
|
|
523
|
+
if (event.key === 'Tab') {
|
|
524
|
+
event.preventDefault();
|
|
525
|
+
const el = $('editor');
|
|
526
|
+
el.setRangeText(' ', el.selectionStart, el.selectionEnd, 'end');
|
|
527
|
+
state.dirty = true; $('dirty').textContent = '● unsaved';
|
|
528
|
+
syncEditor();
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
document.addEventListener('keydown', event => {
|
|
532
|
+
const meta = event.metaKey || event.ctrlKey;
|
|
533
|
+
if (meta && event.key === 's') { event.preventDefault(); save().catch(show); }
|
|
534
|
+
if (meta && event.key === 'Enter') { event.preventDefault(); run('test').catch(show); }
|
|
535
|
+
if (meta && event.key === 'n' && !event.shiftKey) { event.preventDefault(); openDialog(); }
|
|
536
|
+
});
|
|
537
|
+
$('new').onclick = openDialog;
|
|
538
|
+
$('welcome-new').onclick = openDialog;
|
|
539
|
+
$('save').onclick = () => save().catch(show);
|
|
540
|
+
$('validate').onclick = () => validate().catch(show);
|
|
541
|
+
$('run').onclick = () => run('test').catch(show);
|
|
542
|
+
$('parity').onclick = () => run('parity').catch(show);
|
|
543
|
+
$('batch-run').onclick = () => run('test', [...state.selected]).catch(show);
|
|
544
|
+
$('batch-parity').onclick = () => run('parity', [...state.selected]).catch(show);
|
|
545
|
+
$('batch-validate').onclick = () => run('validate', [...state.selected]).catch(show);
|
|
546
|
+
$('batch-clear').onclick = () => { state.selected.clear(); renderList(); };
|
|
547
|
+
$('refresh').onclick = () => refreshList().then(n => { if (!n && !state.path) showWelcome(true); }).catch(show);
|
|
548
|
+
$('filter').addEventListener('input', renderList);
|
|
549
|
+
setEnabled(false);
|
|
550
|
+
start().catch(show);
|