@yemi33/minions 0.1.2105 → 0.1.2106
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/dashboard/js/qa.js +392 -198
- package/dashboard/pages/qa.html +37 -112
- package/dashboard.js +32 -0
- package/engine/qa-sessions.js +65 -0
- package/package.json +1 -1
package/dashboard/js/qa.js
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
|
-
// dashboard/js/qa.js — QA tab UI
|
|
1
|
+
// dashboard/js/qa.js — QA tab UI.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
3
|
+
// Originally W-mpeiwz6k0005bf34-d shipped this as a three-section tab with
|
|
4
|
+
// inline forms for "Start QA Session" and "+ New runbook". W-mpxp7a3e000g0a49
|
|
5
|
+
// (this rewrite) demoted the page to passive monitoring: session and runbook
|
|
6
|
+
// CREATION now flow through Command Center; this page only renders what is
|
|
7
|
+
// happening and exposes per-row operational controls.
|
|
8
|
+
//
|
|
9
|
+
// Sections (top → bottom):
|
|
10
|
+
// 1. Active QA Sessions — non-terminal sessions only, with a CC-pitch card
|
|
11
|
+
// at the top and a collapsed disclosure for terminal sessions (last 10).
|
|
12
|
+
// Per-card controls: approve / edit / cancel / dismiss / kill.
|
|
13
|
+
// 2. Live Targets — slim dedup'd list of /api/managed-processes +
|
|
14
|
+
// /api/keep-processes with a health badge and a link to /engine
|
|
15
|
+
// (W-mpdad3mq000m53bb — do NOT mirror the full inventory here).
|
|
16
|
+
// 3. Recent Runs — list from GET /api/qa/runs?limit=20, polled every 5s
|
|
17
|
+
// while the QA page is active. Each row links to the live agent stream
|
|
18
|
+
// and renders inline artifact previews via /api/qa/artifacts/<runId>/<file>
|
|
14
19
|
// (screenshots as <img>, videos as <video>, logs as 40-line text preview
|
|
15
20
|
// with a "View full" link). No direct filesystem paths are exposed —
|
|
16
21
|
// artifact URLs always go through /api/qa/artifacts/.
|
|
22
|
+
// 4. Validation Runbooks — collapsed disclosure with a small CC-pitch and
|
|
23
|
+
// the runbook list. Each row has a Run + Delete button.
|
|
17
24
|
//
|
|
18
|
-
// The polling interval is cleared on page navigation via the
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
25
|
+
// The polling interval is cleared on page navigation via the PAGE_LEAVE_HOOKS
|
|
26
|
+
// in state.js (W-mpgb0xa7000d90d4). closeManagedLog is invoked from the same
|
|
27
|
+
// hook so a modal opened from /engine doesn't keep streaming after the user
|
|
28
|
+
// navigates.
|
|
22
29
|
|
|
23
30
|
let _qaRunsPollInterval = null;
|
|
24
31
|
let _qaTargetsCache = [];
|
|
@@ -39,6 +46,22 @@ function _startQaRunsPoll() {
|
|
|
39
46
|
_qaRunsPollInterval = setInterval(loadQaRuns, 5000);
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
// W-mpxp7a3e000g0a49 — open Command Center drawer + focus the composer.
|
|
50
|
+
// toggleCommandCenter() flips _ccOpen, so re-invoking when already open
|
|
51
|
+
// would close it; guard against that and just focus the input instead.
|
|
52
|
+
function qaFocusCommandCenter() {
|
|
53
|
+
try {
|
|
54
|
+
const alreadyOpen = (typeof _ccOpen !== 'undefined') ? !!_ccOpen
|
|
55
|
+
: ((typeof window !== 'undefined') && !!window._ccOpen);
|
|
56
|
+
if (!alreadyOpen && typeof toggleCommandCenter === 'function') {
|
|
57
|
+
toggleCommandCenter();
|
|
58
|
+
return; // toggleCommandCenter focuses #cc-input on open
|
|
59
|
+
}
|
|
60
|
+
const input = document.getElementById('cc-input');
|
|
61
|
+
if (input) input.focus();
|
|
62
|
+
} catch (_e) { /* never let a focus helper crash the page */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
42
65
|
// ── Section 1: Targets ─────────────────────────────────────────────────────
|
|
43
66
|
async function loadQaTargets() {
|
|
44
67
|
const root = document.getElementById('qa-targets-content');
|
|
@@ -176,6 +199,7 @@ async function loadQaRunbooks() {
|
|
|
176
199
|
}
|
|
177
200
|
} catch (e) { err = e; }
|
|
178
201
|
_qaRunbooksCache = items;
|
|
202
|
+
_qaUpdateRunbooksDisclosureCount(items.length);
|
|
179
203
|
|
|
180
204
|
if (err) {
|
|
181
205
|
// eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: err.message)
|
|
@@ -187,7 +211,7 @@ async function loadQaRunbooks() {
|
|
|
187
211
|
}
|
|
188
212
|
if (!items.length) {
|
|
189
213
|
const frag = document.createRange().createContextualFragment(
|
|
190
|
-
'<p class="empty">No runbooks yet
|
|
214
|
+
'<p class="empty">No runbooks saved yet.</p>'
|
|
191
215
|
);
|
|
192
216
|
root.replaceChildren(frag);
|
|
193
217
|
return;
|
|
@@ -204,7 +228,10 @@ async function loadQaRunbooks() {
|
|
|
204
228
|
artifactsCount + ' expected artifact' + (artifactsCount === 1 ? '' : 's') +
|
|
205
229
|
'</div>' +
|
|
206
230
|
'</div>' +
|
|
207
|
-
'<
|
|
231
|
+
'<div class="qa-runbook-actions" style="display:flex;gap:var(--space-2)">' +
|
|
232
|
+
'<button class="qa-btn-primary qa-runbook-run-btn" data-runbook-id="' + escHtml(id) + '" onclick="qaRunRunbook(this.dataset.runbookId)">Run</button>' +
|
|
233
|
+
'<button class="qa-btn-ghost qa-btn-danger qa-runbook-delete-btn" data-runbook-id="' + escHtml(id) + '" onclick="qaDeleteRunbook(this.dataset.runbookId)">Delete</button>' +
|
|
234
|
+
'</div>' +
|
|
208
235
|
'</div>';
|
|
209
236
|
}).join('');
|
|
210
237
|
// eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: runbook id, name, target)
|
|
@@ -212,93 +239,27 @@ async function loadQaRunbooks() {
|
|
|
212
239
|
root.replaceChildren(frag);
|
|
213
240
|
}
|
|
214
241
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
_qaPopulateTargetDropdown();
|
|
221
|
-
const name = document.getElementById('qa-runbook-name');
|
|
222
|
-
if (name) name.focus();
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function qaHideNewRunbookForm() {
|
|
226
|
-
const wrap = document.getElementById('qa-runbook-form-wrap');
|
|
227
|
-
if (!wrap) return;
|
|
228
|
-
wrap.style.display = 'none';
|
|
229
|
-
const msg = document.getElementById('qa-runbook-form-msg');
|
|
230
|
-
if (msg) msg.textContent = '';
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function qaAddArtifactRow() {
|
|
234
|
-
const wrap = document.getElementById('qa-runbook-artifacts');
|
|
235
|
-
if (!wrap) return;
|
|
236
|
-
const row = document.createElement('div');
|
|
237
|
-
row.className = 'qa-artifact-row';
|
|
238
|
-
const input = document.createElement('input');
|
|
239
|
-
input.type = 'text';
|
|
240
|
-
input.className = 'qa-form-input qa-artifact-input';
|
|
241
|
-
input.placeholder = 'log:test.log';
|
|
242
|
-
const btn = document.createElement('button');
|
|
243
|
-
btn.type = 'button';
|
|
244
|
-
btn.className = 'qa-btn-ghost';
|
|
245
|
-
btn.textContent = '−';
|
|
246
|
-
btn.onclick = function () { qaRemoveArtifactRow(btn); };
|
|
247
|
-
row.appendChild(input);
|
|
248
|
-
row.appendChild(btn);
|
|
249
|
-
wrap.appendChild(row);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function qaRemoveArtifactRow(btn) {
|
|
253
|
-
const row = btn && btn.closest ? btn.closest('.qa-artifact-row') : null;
|
|
254
|
-
if (!row) return;
|
|
255
|
-
const wrap = document.getElementById('qa-runbook-artifacts');
|
|
256
|
-
if (wrap && wrap.children.length <= 1) {
|
|
257
|
-
// Don't delete the last row — just clear it.
|
|
258
|
-
const inp = row.querySelector('input');
|
|
259
|
-
if (inp) inp.value = '';
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
row.remove();
|
|
242
|
+
// W-mpxp7a3e000g0a49 — keep the "Show saved runbooks (N)" disclosure summary
|
|
243
|
+
// in sync with the runbook count after every load.
|
|
244
|
+
function _qaUpdateRunbooksDisclosureCount(n) {
|
|
245
|
+
const sum = document.getElementById('qa-runbooks-disclosure-summary');
|
|
246
|
+
if (sum) sum.textContent = 'Show saved runbooks (' + n + ')';
|
|
263
247
|
}
|
|
264
248
|
|
|
265
|
-
async function
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
const artifactInputs = document.querySelectorAll('#qa-runbook-artifacts .qa-artifact-input');
|
|
271
|
-
const expectedArtifacts = Array.from(artifactInputs)
|
|
272
|
-
.map(function (i) { return (i.value || '').trim(); })
|
|
273
|
-
.filter(Boolean);
|
|
274
|
-
if (!name.trim() || !target.trim() || !stepsRaw.trim()) {
|
|
275
|
-
if (msg) { msg.textContent = 'Name, target and steps are required.'; msg.style.color = 'var(--red)'; }
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
if (msg) { msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)'; }
|
|
249
|
+
async function qaDeleteRunbook(id) {
|
|
250
|
+
if (!id) return;
|
|
251
|
+
const rb = _qaRunbooksCache.find(function (r) { return (r.id || r.name) === id; });
|
|
252
|
+
const label = rb ? (rb.name || id) : id;
|
|
253
|
+
if (!confirm('Delete runbook "' + label + '"? This cannot be undone.')) return;
|
|
279
254
|
try {
|
|
280
|
-
const res = await fetch('/api/qa/runbooks', {
|
|
281
|
-
method: 'POST',
|
|
282
|
-
headers: { 'Content-Type': 'application/json' },
|
|
283
|
-
body: JSON.stringify({
|
|
284
|
-
name: name.trim(),
|
|
285
|
-
target: target.trim(),
|
|
286
|
-
steps: stepsRaw,
|
|
287
|
-
expectedArtifacts,
|
|
288
|
-
}),
|
|
289
|
-
});
|
|
255
|
+
const res = await fetch('/api/qa/runbooks/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
290
256
|
if (!res.ok) {
|
|
291
257
|
const txt = await res.text().catch(() => '');
|
|
292
258
|
throw new Error('HTTP ' + res.status + (txt ? ': ' + txt.slice(0, 200) : ''));
|
|
293
259
|
}
|
|
294
|
-
if (msg) { msg.textContent = 'Saved.'; msg.style.color = 'var(--green)'; }
|
|
295
|
-
// Reset form + reload list.
|
|
296
|
-
const form = document.getElementById('qa-runbook-form');
|
|
297
|
-
if (form) form.reset();
|
|
298
|
-
qaHideNewRunbookForm();
|
|
299
260
|
loadQaRunbooks();
|
|
300
261
|
} catch (e) {
|
|
301
|
-
|
|
262
|
+
alert('Failed to delete runbook "' + label + '": ' + (e.message || String(e)));
|
|
302
263
|
}
|
|
303
264
|
}
|
|
304
265
|
|
|
@@ -331,7 +292,7 @@ async function loadQaRuns() {
|
|
|
331
292
|
let items = [];
|
|
332
293
|
let err = null;
|
|
333
294
|
try {
|
|
334
|
-
const res = await fetch('/api/qa/runs?limit=
|
|
295
|
+
const res = await fetch('/api/qa/runs?limit=20');
|
|
335
296
|
if (res.ok) {
|
|
336
297
|
const data = await res.json();
|
|
337
298
|
items = Array.isArray(data && data.items) ? data.items : (Array.isArray(data) ? data : []);
|
|
@@ -550,29 +511,16 @@ function _startQaSessionsPoll() {
|
|
|
550
511
|
_qaSessionsPollInterval = setInterval(loadQaSessions, 3000);
|
|
551
512
|
}
|
|
552
513
|
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
pr: 'qa-session-target-pr-wrap',
|
|
560
|
-
branch: 'qa-session-target-branch-wrap',
|
|
561
|
-
commit: 'qa-session-target-sha-wrap',
|
|
562
|
-
current: 'qa-session-target-worktree-wrap',
|
|
563
|
-
};
|
|
564
|
-
for (const id of Object.values(wraps)) {
|
|
565
|
-
const el = document.getElementById(id);
|
|
566
|
-
if (el) el.style.display = 'none';
|
|
567
|
-
}
|
|
568
|
-
const showId = wraps[kind.value];
|
|
569
|
-
if (showId) {
|
|
570
|
-
const el = document.getElementById(showId);
|
|
571
|
-
if (el) el.style.display = '';
|
|
572
|
-
}
|
|
573
|
-
}
|
|
514
|
+
// W-mpxp7a3e000g0a49 — qaSessionTargetKindChanged removed; the inline
|
|
515
|
+
// "Start QA Session" form has moved to Command Center. loadQaRunners and
|
|
516
|
+
// loadQaProjectsSelect below are no-ops on this page (their target DOM
|
|
517
|
+
// elements were removed with the form) but remain exported so the
|
|
518
|
+
// PAGE_LAZY_LOADERS registry in state.js can still reference them by
|
|
519
|
+
// name without throwing.
|
|
574
520
|
|
|
575
521
|
async function loadQaRunners() {
|
|
522
|
+
// Form removed (W-mpxp7a3e000g0a49) — dropdown no longer exists, so this
|
|
523
|
+
// early-returns. Kept exported because PAGE_LAZY_LOADERS.qa references it.
|
|
576
524
|
const sel = document.getElementById('qa-session-runner');
|
|
577
525
|
if (!sel) return;
|
|
578
526
|
try {
|
|
@@ -628,8 +576,9 @@ async function loadQaProjectsSelect() {
|
|
|
628
576
|
}
|
|
629
577
|
|
|
630
578
|
async function loadQaSessions() {
|
|
631
|
-
const
|
|
632
|
-
|
|
579
|
+
const activeRoot = document.getElementById('qa-sessions-content');
|
|
580
|
+
const recentRoot = document.getElementById('qa-sessions-recent-content');
|
|
581
|
+
if (!activeRoot) return;
|
|
633
582
|
let sessions = [];
|
|
634
583
|
let err = null;
|
|
635
584
|
try {
|
|
@@ -643,27 +592,46 @@ async function loadQaSessions() {
|
|
|
643
592
|
const frag = document.createRange().createContextualFragment(
|
|
644
593
|
'<p class="empty" style="color:var(--red)">Failed to load sessions: ' + escHtml(err.message || String(err)) + '</p>'
|
|
645
594
|
);
|
|
646
|
-
|
|
595
|
+
activeRoot.replaceChildren(frag);
|
|
647
596
|
return;
|
|
648
597
|
}
|
|
649
598
|
|
|
650
599
|
_qaSessionsCache = sessions;
|
|
651
|
-
|
|
600
|
+
|
|
601
|
+
// W-mpxp7a3e000g0a49 — split into active (non-terminal) + recent (terminal,
|
|
602
|
+
// last 10). Active list is the primary surface; terminal sessions live
|
|
603
|
+
// behind a collapsed disclosure to keep the page scannable.
|
|
604
|
+
const active = sessions.filter(s => s && !QA_SESSION_TERMINAL_STATES.has(s.state));
|
|
605
|
+
const terminal = sessions.filter(s => s && QA_SESSION_TERMINAL_STATES.has(s.state)).slice(0, 10);
|
|
606
|
+
|
|
607
|
+
if (!active.length) {
|
|
652
608
|
const frag = document.createRange().createContextualFragment(
|
|
653
|
-
'<p class="empty">No QA sessions
|
|
609
|
+
'<p class="empty">No active QA sessions.</p>'
|
|
654
610
|
);
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
611
|
+
activeRoot.replaceChildren(frag);
|
|
612
|
+
} else {
|
|
613
|
+
let html = '';
|
|
614
|
+
for (const s of active) html += _qaRenderSessionCard(s);
|
|
615
|
+
// eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: session id, state, target sub-fields, mode, runner, error/summary/failureClass)
|
|
616
|
+
const frag = document.createRange().createContextualFragment(html);
|
|
617
|
+
activeRoot.replaceChildren(frag);
|
|
658
618
|
}
|
|
659
619
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
620
|
+
if (recentRoot) {
|
|
621
|
+
if (!terminal.length) {
|
|
622
|
+
const frag = document.createRange().createContextualFragment(
|
|
623
|
+
'<p class="empty">No recent sessions.</p>'
|
|
624
|
+
);
|
|
625
|
+
recentRoot.replaceChildren(frag);
|
|
626
|
+
} else {
|
|
627
|
+
let html = '';
|
|
628
|
+
for (const s of terminal) html += _qaRenderSessionCard(s);
|
|
629
|
+
// eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: session id, state, target sub-fields, mode, runner, error/summary/failureClass)
|
|
630
|
+
const frag = document.createRange().createContextualFragment(html);
|
|
631
|
+
recentRoot.replaceChildren(frag);
|
|
632
|
+
}
|
|
663
633
|
}
|
|
664
|
-
|
|
665
|
-
const frag = document.createRange().createContextualFragment(html);
|
|
666
|
-
root.replaceChildren(frag);
|
|
634
|
+
|
|
667
635
|
_qaAfterSessionsRender();
|
|
668
636
|
}
|
|
669
637
|
|
|
@@ -695,11 +663,13 @@ function _qaRenderSessionCard(s) {
|
|
|
695
663
|
const phases = _qaRenderPhaseChips(state);
|
|
696
664
|
|
|
697
665
|
// Action buttons depend on session state:
|
|
698
|
-
// - awaiting-approval → [APPROVE & RUN] [EDIT] [CANCEL]
|
|
666
|
+
// - awaiting-approval → [REVIEW DRAFT] [APPROVE & RUN] [EDIT] [CANCEL]
|
|
667
|
+
// The Review draft button comes first so users read before approving.
|
|
699
668
|
// - non-terminal → footer always shows [DISMISS] [KILL SPAWN]
|
|
700
669
|
// - terminal → no actions
|
|
701
670
|
let actions = '';
|
|
702
671
|
if (state === 'awaiting-approval') {
|
|
672
|
+
actions += '<button class="qa-btn-ghost" onclick="qaSessionReviewDraft(\'' + escHtml(id) + '\')">Review draft</button>';
|
|
703
673
|
actions += '<button class="qa-btn-primary" onclick="qaSessionApprove(\'' + escHtml(id) + '\')">Approve & run</button>';
|
|
704
674
|
actions += '<button class="qa-btn-ghost" onclick="qaSessionEditPrompt(\'' + escHtml(id) + '\')">Edit</button>';
|
|
705
675
|
actions += '<button class="qa-btn-ghost" onclick="qaSessionCancel(\'' + escHtml(id) + '\')">Cancel</button>';
|
|
@@ -788,71 +758,9 @@ function _qaSummarizeTarget(target) {
|
|
|
788
758
|
}
|
|
789
759
|
}
|
|
790
760
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
if (msg) msg.textContent = '';
|
|
795
|
-
if (submit) submit.disabled = true;
|
|
796
|
-
|
|
797
|
-
const kind = (document.getElementById('qa-session-target-kind') || {}).value;
|
|
798
|
-
const target = { kind };
|
|
799
|
-
if (kind === 'pr') target.prId = (document.getElementById('qa-session-target-pr-id') || {}).value || '';
|
|
800
|
-
if (kind === 'branch') target.branch = (document.getElementById('qa-session-target-branch') || {}).value || '';
|
|
801
|
-
if (kind === 'commit') target.sha = (document.getElementById('qa-session-target-sha') || {}).value || '';
|
|
802
|
-
if (kind === 'current') {
|
|
803
|
-
const wt = (document.getElementById('qa-session-target-worktree') || {}).value || '';
|
|
804
|
-
if (wt) target.worktree = wt;
|
|
805
|
-
}
|
|
806
|
-
const flowsRaw = (document.getElementById('qa-session-flows') || {}).value || '';
|
|
807
|
-
const mode = (document.getElementById('qa-session-mode') || {}).value || 'confirm';
|
|
808
|
-
const runner = (document.getElementById('qa-session-runner') || {}).value || '';
|
|
809
|
-
// W-mpq6xqzj000606d0 — Multi-select projects dropdown. First selected =
|
|
810
|
-
// primary (drives DRAFT/EXECUTE); rest = co-services (dev-up only).
|
|
811
|
-
// Empty selection = central (no project).
|
|
812
|
-
const projectsSel = document.getElementById('qa-session-projects');
|
|
813
|
-
const projects = projectsSel
|
|
814
|
-
? Array.from(projectsSel.selectedOptions || []).map(o => o.value).filter(Boolean)
|
|
815
|
-
: [];
|
|
816
|
-
const capture = {
|
|
817
|
-
video: !!(document.getElementById('qa-session-capture-video') || {}).checked,
|
|
818
|
-
screenshots: !!(document.getElementById('qa-session-capture-screenshots') || {}).checked,
|
|
819
|
-
logs: !!(document.getElementById('qa-session-capture-logs') || {}).checked,
|
|
820
|
-
};
|
|
821
|
-
const body = { target, flowsRaw, mode, capture };
|
|
822
|
-
if (runner) body.runner = runner;
|
|
823
|
-
if (projects.length > 0) body.projects = projects;
|
|
824
|
-
|
|
825
|
-
try {
|
|
826
|
-
const res = await fetch('/api/qa/session', {
|
|
827
|
-
method: 'POST',
|
|
828
|
-
headers: { 'Content-Type': 'application/json' },
|
|
829
|
-
body: JSON.stringify(body),
|
|
830
|
-
});
|
|
831
|
-
const json = await res.json().catch(() => ({}));
|
|
832
|
-
if (!res.ok) {
|
|
833
|
-
if (msg) msg.textContent = 'Error ' + res.status + ': ' + (json.error || 'unknown');
|
|
834
|
-
if (msg) msg.style.color = 'var(--red)';
|
|
835
|
-
return;
|
|
836
|
-
}
|
|
837
|
-
if (msg) {
|
|
838
|
-
msg.textContent = 'Session ' + (json.sessionId || '') + ' queued.';
|
|
839
|
-
msg.style.color = 'var(--green)';
|
|
840
|
-
}
|
|
841
|
-
// Reset only the flows (the rest is sticky for fast iteration).
|
|
842
|
-
const flowsEl = document.getElementById('qa-session-flows');
|
|
843
|
-
if (flowsEl) flowsEl.value = '';
|
|
844
|
-
// Optimistic refresh + restart polling — a new non-terminal session
|
|
845
|
-
// means the auto-stop heuristic should reactivate.
|
|
846
|
-
_startQaSessionsPoll();
|
|
847
|
-
} catch (e) {
|
|
848
|
-
if (msg) {
|
|
849
|
-
msg.textContent = 'Network error: ' + (e && e.message || String(e));
|
|
850
|
-
msg.style.color = 'var(--red)';
|
|
851
|
-
}
|
|
852
|
-
} finally {
|
|
853
|
-
if (submit) submit.disabled = false;
|
|
854
|
-
}
|
|
855
|
-
}
|
|
761
|
+
// W-mpxp7a3e000g0a49 — qaSubmitSessionForm removed; creating QA sessions
|
|
762
|
+
// now flows through Command Center (POST /api/qa/session is still wired
|
|
763
|
+
// for CC and external callers, but the inline form on this page is gone).
|
|
856
764
|
|
|
857
765
|
async function _qaSessionPost(url, label, body) {
|
|
858
766
|
try {
|
|
@@ -904,3 +812,289 @@ function qaSessionEditPrompt(id) {
|
|
|
904
812
|
if (!fb || !fb.trim()) return null;
|
|
905
813
|
return qaSessionEdit(id, fb);
|
|
906
814
|
}
|
|
815
|
+
|
|
816
|
+
// ── Review draft modal (W-mpxpvpwn000ra419) ──────────────────────────────
|
|
817
|
+
//
|
|
818
|
+
// Greenfield modal infrastructure. There is no reusable generic modal in
|
|
819
|
+
// dashboard/js/* today (modal-qa.js is doc-chat, not a modal shell), so the
|
|
820
|
+
// minimal one below is inlined here. Read-only by design — editing a draft
|
|
821
|
+
// continues to flow through the existing prompt()-based qaSessionEditPrompt
|
|
822
|
+
// path. The "Request edit" button in the modal footer just calls it.
|
|
823
|
+
//
|
|
824
|
+
// All DOM construction uses document.createElement + appendChild (and
|
|
825
|
+
// textContent for any caller-supplied content) so eslint-plugin-no-unsanitized
|
|
826
|
+
// stays happy. The only HTML-string interpolation is for static structural
|
|
827
|
+
// markup (icons + class names), which the lint rule explicitly allows.
|
|
828
|
+
//
|
|
829
|
+
// State invariants:
|
|
830
|
+
// - Idempotent open: a re-open while a modal is already mounted no-ops if
|
|
831
|
+
// the sessionId matches, or closes-then-reopens for a different session.
|
|
832
|
+
// - Close on backdrop click + Esc; both paths route through _qaModalClose
|
|
833
|
+
// so the keydown listener and focus restore happen exactly once.
|
|
834
|
+
// - Focus restores to the originating button on close (a11y).
|
|
835
|
+
|
|
836
|
+
let _qaModalState = null; // { sessionId, root, originButton, keydownHandler }
|
|
837
|
+
|
|
838
|
+
function qaSessionReviewDraft(id) {
|
|
839
|
+
if (!id) return null;
|
|
840
|
+
// Idempotent: same id → no-op; different id → close + reopen.
|
|
841
|
+
if (_qaModalState) {
|
|
842
|
+
if (_qaModalState.sessionId === id) return null;
|
|
843
|
+
_qaModalClose();
|
|
844
|
+
}
|
|
845
|
+
_qaInstallModalStyle();
|
|
846
|
+
// Capture the focused button so we can restore focus on close (a11y).
|
|
847
|
+
const originButton = (document.activeElement && document.activeElement.tagName === 'BUTTON')
|
|
848
|
+
? document.activeElement
|
|
849
|
+
: null;
|
|
850
|
+
// Mount the shell synchronously with a "Loading…" body so the modal feels
|
|
851
|
+
// responsive even on slow fetches. We replace the body once the test file
|
|
852
|
+
// comes back from /api/qa/sessions/<id>/test.
|
|
853
|
+
const root = _qaBuildModalShell(id);
|
|
854
|
+
document.body.appendChild(root);
|
|
855
|
+
const keydownHandler = (e) => {
|
|
856
|
+
if (e.key === 'Escape') {
|
|
857
|
+
e.preventDefault();
|
|
858
|
+
_qaModalClose();
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
document.addEventListener('keydown', keydownHandler);
|
|
862
|
+
_qaModalState = { sessionId: id, root, originButton, keydownHandler };
|
|
863
|
+
// Fetch + render body. We don't block opening on the fetch — the spinner
|
|
864
|
+
// is shown until the response lands.
|
|
865
|
+
fetch('/api/qa/sessions/' + encodeURIComponent(id) + '/test')
|
|
866
|
+
.then(res => res.json().catch(() => ({})).then(json => ({ res, json })))
|
|
867
|
+
.then(({ res, json }) => {
|
|
868
|
+
if (!_qaModalState || _qaModalState.sessionId !== id) return; // closed mid-flight
|
|
869
|
+
if (!res.ok || !json || !json.ok) {
|
|
870
|
+
_qaModalRenderError(json && json.error ? String(json.error) : 'Failed to load draft (' + res.status + ')');
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
_qaModalRenderBody(json);
|
|
874
|
+
})
|
|
875
|
+
.catch((e) => {
|
|
876
|
+
if (!_qaModalState || _qaModalState.sessionId !== id) return;
|
|
877
|
+
_qaModalRenderError(e && e.message || String(e));
|
|
878
|
+
});
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function _qaModalClose() {
|
|
883
|
+
if (!_qaModalState) return;
|
|
884
|
+
const { root, originButton, keydownHandler } = _qaModalState;
|
|
885
|
+
_qaModalState = null;
|
|
886
|
+
if (keydownHandler) document.removeEventListener('keydown', keydownHandler);
|
|
887
|
+
if (root && root.parentNode) root.parentNode.removeChild(root);
|
|
888
|
+
// Restore focus to the originating button for keyboard navigation.
|
|
889
|
+
if (originButton && typeof originButton.focus === 'function') {
|
|
890
|
+
try { originButton.focus(); } catch (_) { /* noop */ }
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function _qaBuildModalShell(sessionId) {
|
|
895
|
+
const backdrop = document.createElement('div');
|
|
896
|
+
backdrop.className = 'qa-modal-backdrop';
|
|
897
|
+
backdrop.setAttribute('role', 'dialog');
|
|
898
|
+
backdrop.setAttribute('aria-modal', 'true');
|
|
899
|
+
backdrop.setAttribute('aria-label', 'Review drafted QA test');
|
|
900
|
+
backdrop.addEventListener('click', (e) => {
|
|
901
|
+
if (e.target === backdrop) _qaModalClose();
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
const shell = document.createElement('div');
|
|
905
|
+
shell.className = 'qa-modal-shell';
|
|
906
|
+
backdrop.appendChild(shell);
|
|
907
|
+
|
|
908
|
+
// Header
|
|
909
|
+
const header = document.createElement('div');
|
|
910
|
+
header.className = 'qa-modal-header';
|
|
911
|
+
const title = document.createElement('div');
|
|
912
|
+
title.className = 'qa-modal-title';
|
|
913
|
+
title.textContent = 'Drafted test — (loading…)';
|
|
914
|
+
header.appendChild(title);
|
|
915
|
+
const closeBtn = document.createElement('button');
|
|
916
|
+
closeBtn.type = 'button';
|
|
917
|
+
closeBtn.className = 'qa-btn-ghost qa-modal-close';
|
|
918
|
+
closeBtn.setAttribute('aria-label', 'Close');
|
|
919
|
+
closeBtn.textContent = '×';
|
|
920
|
+
closeBtn.addEventListener('click', _qaModalClose);
|
|
921
|
+
header.appendChild(closeBtn);
|
|
922
|
+
shell.appendChild(header);
|
|
923
|
+
|
|
924
|
+
// Meta strip
|
|
925
|
+
const meta = document.createElement('div');
|
|
926
|
+
meta.className = 'qa-modal-meta';
|
|
927
|
+
meta.setAttribute('data-qa-modal-region', 'meta');
|
|
928
|
+
const metaSession = document.createElement('span');
|
|
929
|
+
metaSession.className = 'qa-modal-meta-item';
|
|
930
|
+
metaSession.textContent = 'session: ' + sessionId;
|
|
931
|
+
meta.appendChild(metaSession);
|
|
932
|
+
shell.appendChild(meta);
|
|
933
|
+
|
|
934
|
+
// Body
|
|
935
|
+
const body = document.createElement('div');
|
|
936
|
+
body.className = 'qa-modal-body';
|
|
937
|
+
body.setAttribute('data-qa-modal-region', 'body');
|
|
938
|
+
const loading = document.createElement('p');
|
|
939
|
+
loading.className = 'qa-modal-loading';
|
|
940
|
+
loading.textContent = 'Loading draft…';
|
|
941
|
+
body.appendChild(loading);
|
|
942
|
+
shell.appendChild(body);
|
|
943
|
+
|
|
944
|
+
// Footer
|
|
945
|
+
const footer = document.createElement('div');
|
|
946
|
+
footer.className = 'qa-modal-footer';
|
|
947
|
+
footer.setAttribute('data-qa-modal-region', 'footer');
|
|
948
|
+
shell.appendChild(footer);
|
|
949
|
+
|
|
950
|
+
return backdrop;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function _qaModalRenderError(message) {
|
|
954
|
+
if (!_qaModalState) return;
|
|
955
|
+
const body = _qaModalState.root.querySelector('[data-qa-modal-region="body"]');
|
|
956
|
+
if (!body) return;
|
|
957
|
+
body.textContent = '';
|
|
958
|
+
const err = document.createElement('p');
|
|
959
|
+
err.className = 'qa-modal-error';
|
|
960
|
+
err.textContent = message;
|
|
961
|
+
body.appendChild(err);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function _qaModalRenderBody(payload) {
|
|
965
|
+
if (!_qaModalState) return;
|
|
966
|
+
const id = _qaModalState.sessionId;
|
|
967
|
+
const root = _qaModalState.root;
|
|
968
|
+
// Update header title with the test file path.
|
|
969
|
+
const title = root.querySelector('.qa-modal-title');
|
|
970
|
+
if (title) title.textContent = 'Drafted test — ' + (payload.path || '(unknown file)');
|
|
971
|
+
|
|
972
|
+
// Augment meta strip with language + size.
|
|
973
|
+
const meta = root.querySelector('[data-qa-modal-region="meta"]');
|
|
974
|
+
if (meta) {
|
|
975
|
+
const lang = document.createElement('span');
|
|
976
|
+
lang.className = 'qa-modal-meta-item';
|
|
977
|
+
lang.textContent = 'language: ' + (payload.language || 'plaintext');
|
|
978
|
+
meta.appendChild(lang);
|
|
979
|
+
const size = document.createElement('span');
|
|
980
|
+
size.className = 'qa-modal-meta-item';
|
|
981
|
+
size.textContent = 'size: ' + (typeof payload.size === 'number' ? payload.size + ' bytes' : 'unknown');
|
|
982
|
+
meta.appendChild(size);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Body: either code block or "too large" placeholder.
|
|
986
|
+
const body = root.querySelector('[data-qa-modal-region="body"]');
|
|
987
|
+
if (body) {
|
|
988
|
+
body.textContent = '';
|
|
989
|
+
if (payload.truncated) {
|
|
990
|
+
const tooBig = document.createElement('p');
|
|
991
|
+
tooBig.className = 'qa-modal-too-big';
|
|
992
|
+
// payload.path is the relative session.testFile from the API; render the
|
|
993
|
+
// hint with textContent so escHtml-style XSS is impossible by construction.
|
|
994
|
+
tooBig.textContent = 'File is ' + (payload.size || 0) +
|
|
995
|
+
' bytes — open engine/qa-tests/' + id + '/' + (payload.path || '') +
|
|
996
|
+
' to review.';
|
|
997
|
+
body.appendChild(tooBig);
|
|
998
|
+
} else {
|
|
999
|
+
const pre = document.createElement('pre');
|
|
1000
|
+
pre.className = 'qa-modal-code';
|
|
1001
|
+
const code = document.createElement('code');
|
|
1002
|
+
// Assign the raw file content via textContent — the DOM treats the value
|
|
1003
|
+
// as literal text and neutralizes any HTML/script for free. Do NOT wrap
|
|
1004
|
+
// with escHtml(): textContent would then render the escape entities
|
|
1005
|
+
// (<, >, ', &) verbatim, defeating the source preview for
|
|
1006
|
+
// any TS/JS/Py file with <, >, &, or ' characters (W-mpxpvpwn000ra419).
|
|
1007
|
+
code.textContent = String(payload.content || '');
|
|
1008
|
+
pre.appendChild(code);
|
|
1009
|
+
body.appendChild(pre);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Footer: Approve & run + Request edit + Close. Approve is suppressed when
|
|
1014
|
+
// the file is truncated (>256KB) so users can't blind-approve a megabyte.
|
|
1015
|
+
const footer = root.querySelector('[data-qa-modal-region="footer"]');
|
|
1016
|
+
if (footer) {
|
|
1017
|
+
footer.textContent = '';
|
|
1018
|
+
if (!payload.truncated) {
|
|
1019
|
+
const approve = document.createElement('button');
|
|
1020
|
+
approve.type = 'button';
|
|
1021
|
+
approve.className = 'qa-btn-primary';
|
|
1022
|
+
approve.textContent = 'Approve & run';
|
|
1023
|
+
approve.addEventListener('click', () => {
|
|
1024
|
+
_qaModalClose();
|
|
1025
|
+
qaSessionApprove(id);
|
|
1026
|
+
});
|
|
1027
|
+
footer.appendChild(approve);
|
|
1028
|
+
}
|
|
1029
|
+
const editBtn = document.createElement('button');
|
|
1030
|
+
editBtn.type = 'button';
|
|
1031
|
+
editBtn.className = 'qa-btn-ghost';
|
|
1032
|
+
editBtn.textContent = 'Request edit';
|
|
1033
|
+
editBtn.addEventListener('click', () => {
|
|
1034
|
+
_qaModalClose();
|
|
1035
|
+
qaSessionEditPrompt(id);
|
|
1036
|
+
});
|
|
1037
|
+
footer.appendChild(editBtn);
|
|
1038
|
+
const closeBtn = document.createElement('button');
|
|
1039
|
+
closeBtn.type = 'button';
|
|
1040
|
+
closeBtn.className = 'qa-btn-ghost';
|
|
1041
|
+
closeBtn.textContent = 'Close';
|
|
1042
|
+
closeBtn.addEventListener('click', _qaModalClose);
|
|
1043
|
+
footer.appendChild(closeBtn);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Inject modal CSS once. Keyed off a data attribute on the <style> tag so
|
|
1048
|
+
// repeated calls (e.g. from a re-rendered card) don't duplicate the block.
|
|
1049
|
+
function _qaInstallModalStyle() {
|
|
1050
|
+
if (document.querySelector('style[data-qa-modal-style]')) return;
|
|
1051
|
+
const style = document.createElement('style');
|
|
1052
|
+
style.setAttribute('data-qa-modal-style', '1');
|
|
1053
|
+
style.textContent = [
|
|
1054
|
+
'.qa-modal-backdrop {',
|
|
1055
|
+
' position: fixed; inset: 0; z-index: 9999;',
|
|
1056
|
+
' background: rgba(0,0,0,0.5);',
|
|
1057
|
+
' display: flex; align-items: center; justify-content: center;',
|
|
1058
|
+
'}',
|
|
1059
|
+
'.qa-modal-shell {',
|
|
1060
|
+
' background: var(--surface, #fff);',
|
|
1061
|
+
' color: var(--text, #111);',
|
|
1062
|
+
' border-radius: 8px;',
|
|
1063
|
+
' max-width: 80vw; max-height: 80vh;',
|
|
1064
|
+
' width: 800px;',
|
|
1065
|
+
' display: flex; flex-direction: column;',
|
|
1066
|
+
' box-shadow: 0 8px 32px rgba(0,0,0,0.3);',
|
|
1067
|
+
' border: 1px solid var(--border, #ddd);',
|
|
1068
|
+
'}',
|
|
1069
|
+
'.qa-modal-header {',
|
|
1070
|
+
' display: flex; align-items: center; justify-content: space-between;',
|
|
1071
|
+
' padding: 12px 16px; border-bottom: 1px solid var(--border, #ddd);',
|
|
1072
|
+
'}',
|
|
1073
|
+
'.qa-modal-title { font-weight: 600; }',
|
|
1074
|
+
'.qa-modal-close { font-size: 20px; padding: 0 8px; }',
|
|
1075
|
+
'.qa-modal-meta {',
|
|
1076
|
+
' display: flex; gap: 12px; flex-wrap: wrap;',
|
|
1077
|
+
' padding: 8px 16px; border-bottom: 1px solid var(--border, #ddd);',
|
|
1078
|
+
' font-size: 12px; color: var(--muted, #666);',
|
|
1079
|
+
'}',
|
|
1080
|
+
'.qa-modal-meta-item { white-space: nowrap; }',
|
|
1081
|
+
'.qa-modal-body { flex: 1; overflow: auto; padding: 12px 16px; }',
|
|
1082
|
+
'.qa-modal-code {',
|
|
1083
|
+
' font-family: ui-monospace, Menlo, Consolas, monospace;',
|
|
1084
|
+
' font-size: 12px; line-height: 1.5;',
|
|
1085
|
+
' background: var(--surface2, #f6f6f6);',
|
|
1086
|
+
' border: 1px solid var(--border, #ddd);',
|
|
1087
|
+
' border-radius: 4px; padding: 12px;',
|
|
1088
|
+
' white-space: pre; overflow: auto;',
|
|
1089
|
+
' margin: 0;',
|
|
1090
|
+
'}',
|
|
1091
|
+
'.qa-modal-too-big { color: var(--muted, #666); font-style: italic; }',
|
|
1092
|
+
'.qa-modal-error { color: var(--red, #c00); }',
|
|
1093
|
+
'.qa-modal-loading { color: var(--muted, #666); }',
|
|
1094
|
+
'.qa-modal-footer {',
|
|
1095
|
+
' display: flex; gap: 8px; justify-content: flex-end;',
|
|
1096
|
+
' padding: 12px 16px; border-top: 1px solid var(--border, #ddd);',
|
|
1097
|
+
'}',
|
|
1098
|
+
].join('\n');
|
|
1099
|
+
document.head.appendChild(style);
|
|
1100
|
+
}
|
package/dashboard/pages/qa.html
CHANGED
|
@@ -1,131 +1,56 @@
|
|
|
1
1
|
<section>
|
|
2
2
|
<h2>QA</h2>
|
|
3
|
-
<p class="empty" style="margin:4px 0 12px 0">
|
|
3
|
+
<p class="empty" style="margin:4px 0 12px 0">Passive monitor for QA sessions, live targets, runs, and saved runbooks. Create new sessions or runbooks from <button type="button" class="qa-inline-link" onclick="qaFocusCommandCenter()">Command Center</button>.</p>
|
|
4
4
|
</section>
|
|
5
5
|
<section id="qa-sessions-section" class="qa-section">
|
|
6
|
-
<h2>QA Sessions <span class="qa-section-subtitle">
|
|
7
|
-
<div class="qa-
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
<select id="qa-session-projects" class="qa-form-input" multiple size="3"></select>
|
|
22
|
-
<p class="empty" style="margin-top:4px;font-size:0.85em">Hold Ctrl/Cmd to select multiple. First selected = primary (drives DRAFT/EXECUTE). Others = co-services (dev-up only). Leave empty = central.</p>
|
|
23
|
-
</div>
|
|
24
|
-
</div>
|
|
25
|
-
<div class="qa-form-row qa-session-target-fields">
|
|
26
|
-
<div id="qa-session-target-pr-wrap" class="qa-session-target-sub" style="display:none">
|
|
27
|
-
<label class="qa-form-label" for="qa-session-target-pr-id">PR id</label>
|
|
28
|
-
<input id="qa-session-target-pr-id" type="text" class="qa-form-input" placeholder="2887">
|
|
29
|
-
</div>
|
|
30
|
-
<div id="qa-session-target-branch-wrap" class="qa-session-target-sub" style="display:none">
|
|
31
|
-
<label class="qa-form-label" for="qa-session-target-branch">Branch</label>
|
|
32
|
-
<input id="qa-session-target-branch" type="text" class="qa-form-input" placeholder="work/my-feature">
|
|
33
|
-
</div>
|
|
34
|
-
<div id="qa-session-target-sha-wrap" class="qa-session-target-sub" style="display:none">
|
|
35
|
-
<label class="qa-form-label" for="qa-session-target-sha">Commit SHA</label>
|
|
36
|
-
<input id="qa-session-target-sha" type="text" class="qa-form-input" placeholder="abc1234">
|
|
37
|
-
</div>
|
|
38
|
-
<div id="qa-session-target-worktree-wrap" class="qa-session-target-sub">
|
|
39
|
-
<label class="qa-form-label" for="qa-session-target-worktree">Worktree path</label>
|
|
40
|
-
<input id="qa-session-target-worktree" type="text" class="qa-form-input" placeholder="(blank = $MINIONS_AGENT_CWD)">
|
|
41
|
-
</div>
|
|
42
|
-
</div>
|
|
43
|
-
<div class="qa-form-row">
|
|
44
|
-
<label class="qa-form-label" for="qa-session-flows">Flows</label>
|
|
45
|
-
<textarea id="qa-session-flows" class="qa-form-input qa-form-textarea" placeholder="open the homepage, click login, verify the dashboard renders" required></textarea>
|
|
46
|
-
</div>
|
|
47
|
-
<div class="qa-form-row qa-form-row--inline">
|
|
48
|
-
<div>
|
|
49
|
-
<label class="qa-form-label" for="qa-session-mode">Mode</label>
|
|
50
|
-
<select id="qa-session-mode" class="qa-form-input">
|
|
51
|
-
<option value="confirm">Confirm (review draft first)</option>
|
|
52
|
-
<option value="auto">Auto (skip approval)</option>
|
|
53
|
-
</select>
|
|
54
|
-
</div>
|
|
55
|
-
<div>
|
|
56
|
-
<label class="qa-form-label" for="qa-session-runner">Runner</label>
|
|
57
|
-
<select id="qa-session-runner" class="qa-form-input">
|
|
58
|
-
<option value="">Auto-detect</option>
|
|
59
|
-
</select>
|
|
60
|
-
</div>
|
|
61
|
-
<div class="qa-session-capture-group">
|
|
62
|
-
<label class="qa-form-label">Capture</label>
|
|
63
|
-
<label class="qa-session-capture-item"><input id="qa-session-capture-screenshots" type="checkbox" checked> screenshots</label>
|
|
64
|
-
<label class="qa-session-capture-item"><input id="qa-session-capture-video" type="checkbox"> video</label>
|
|
65
|
-
<label class="qa-session-capture-item"><input id="qa-session-capture-logs" type="checkbox" checked> logs</label>
|
|
66
|
-
</div>
|
|
67
|
-
</div>
|
|
68
|
-
<div class="qa-form-row qa-form-actions">
|
|
69
|
-
<button type="submit" id="qa-session-submit" class="qa-btn-primary">Start QA Session</button>
|
|
70
|
-
<span id="qa-session-form-msg" class="qa-form-msg"></span>
|
|
71
|
-
</div>
|
|
72
|
-
</form>
|
|
6
|
+
<h2>Active QA Sessions <span class="qa-section-subtitle">live, non-terminal runs — engine sets up a target, drafts a runner-native test, and (with your approval) executes it</span></h2>
|
|
7
|
+
<div id="qa-cc-pitch" class="qa-cc-pitch" style="border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface2);padding:var(--space-5);margin-bottom:var(--space-5);display:flex;flex-direction:column;gap:var(--space-3)">
|
|
8
|
+
<div style="font-weight:600;color:var(--text);display:flex;align-items:center;gap:var(--space-2)">💬 Ask Command Center to start a QA session</div>
|
|
9
|
+
<div style="color:var(--muted);font-size:var(--text-base);line-height:1.5">
|
|
10
|
+
Examples:
|
|
11
|
+
<ul style="margin:var(--space-2) 0 0 var(--space-5);padding:0;color:var(--muted)">
|
|
12
|
+
<li><code>QA the login flow on PR #1234</code></li>
|
|
13
|
+
<li><code>Smoke test the checkout flow on develop</code></li>
|
|
14
|
+
<li><code>Validate the signup journey on my current worktree</code></li>
|
|
15
|
+
</ul>
|
|
16
|
+
</div>
|
|
17
|
+
<div style="display:flex;gap:var(--space-3);align-items:center;margin-top:var(--space-2)">
|
|
18
|
+
<button type="button" id="qa-cc-pitch-open" class="qa-btn-primary" onclick="qaFocusCommandCenter()">Open Command Center →</button>
|
|
19
|
+
<a href="/state/docs/qa-runbook-lifecycle.md" target="_blank" rel="noopener" class="qa-cc-pitch-docs" style="color:var(--blue);font-size:var(--text-base)">Docs →</a>
|
|
20
|
+
</div>
|
|
73
21
|
</div>
|
|
74
22
|
<div id="qa-sessions-content" class="qa-sessions-list">
|
|
75
23
|
<p class="empty">Loading sessions…</p>
|
|
76
24
|
</div>
|
|
25
|
+
<details id="qa-sessions-recent" class="qa-disclosure" style="margin-top:var(--space-5);border-top:1px solid var(--border);padding-top:var(--space-4)">
|
|
26
|
+
<summary id="qa-sessions-recent-summary" style="cursor:pointer;font-size:var(--text-base);color:var(--muted)">Recent sessions (last 10)</summary>
|
|
27
|
+
<div id="qa-sessions-recent-content" class="qa-sessions-list" style="margin-top:var(--space-3)">
|
|
28
|
+
<p class="empty">No recent sessions.</p>
|
|
29
|
+
</div>
|
|
30
|
+
</details>
|
|
77
31
|
</section>
|
|
78
32
|
<section id="qa-targets-section" class="qa-section">
|
|
79
|
-
<h2>Targets <span class="qa-section-subtitle">
|
|
33
|
+
<h2>Live Targets <span class="qa-section-subtitle">managed/keep processes available as runbook targets — full inventory on <a href="/engine" class="qa-engine-link">/engine</a></span></h2>
|
|
80
34
|
<div id="qa-targets-content" class="qa-targets-list">
|
|
81
35
|
<p class="empty">Loading targets…</p>
|
|
82
36
|
</div>
|
|
83
37
|
</section>
|
|
84
|
-
<section id="qa-runbooks-section" class="qa-section">
|
|
85
|
-
<h2>Validation Runbooks <span class="qa-section-subtitle">human or agent-driven smoke / E2E flows against the targets above</span></h2>
|
|
86
|
-
<div class="qa-runbooks-actions">
|
|
87
|
-
<button id="qa-new-runbook-btn" class="qa-btn-primary" onclick="qaShowNewRunbookForm()">+ New runbook</button>
|
|
88
|
-
</div>
|
|
89
|
-
<div id="qa-runbook-form-wrap" class="qa-runbook-form" style="display:none">
|
|
90
|
-
<form id="qa-runbook-form" onsubmit="event.preventDefault();qaSaveRunbook();">
|
|
91
|
-
<div class="qa-form-row">
|
|
92
|
-
<label class="qa-form-label" for="qa-runbook-name">Name</label>
|
|
93
|
-
<input id="qa-runbook-name" type="text" class="qa-form-input" placeholder="login-smoke" required>
|
|
94
|
-
</div>
|
|
95
|
-
<div class="qa-form-row">
|
|
96
|
-
<label class="qa-form-label" for="qa-runbook-target">Target</label>
|
|
97
|
-
<select id="qa-runbook-target" class="qa-form-input" required>
|
|
98
|
-
<option value="">— select a target —</option>
|
|
99
|
-
</select>
|
|
100
|
-
</div>
|
|
101
|
-
<div class="qa-form-row">
|
|
102
|
-
<label class="qa-form-label" for="qa-runbook-steps">Steps</label>
|
|
103
|
-
<textarea id="qa-runbook-steps" class="qa-form-input qa-form-textarea" placeholder="1. Open the app 2. Click login 3. Verify dashboard loads" required></textarea>
|
|
104
|
-
</div>
|
|
105
|
-
<div class="qa-form-row">
|
|
106
|
-
<label class="qa-form-label">Expected artifacts</label>
|
|
107
|
-
<div id="qa-runbook-artifacts" class="qa-artifacts-repeater">
|
|
108
|
-
<div class="qa-artifact-row">
|
|
109
|
-
<input type="text" class="qa-form-input qa-artifact-input" placeholder="screenshot:dashboard.png">
|
|
110
|
-
<button type="button" class="qa-btn-ghost" onclick="qaRemoveArtifactRow(this)">−</button>
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
113
|
-
<button type="button" class="qa-btn-ghost qa-add-artifact-btn" onclick="qaAddArtifactRow()">+ Add artifact</button>
|
|
114
|
-
</div>
|
|
115
|
-
<div class="qa-form-row qa-form-actions">
|
|
116
|
-
<button type="submit" class="qa-btn-primary">Save</button>
|
|
117
|
-
<button type="button" class="qa-btn-ghost" onclick="qaHideNewRunbookForm()">Cancel</button>
|
|
118
|
-
<span id="qa-runbook-form-msg" class="qa-form-msg"></span>
|
|
119
|
-
</div>
|
|
120
|
-
</form>
|
|
121
|
-
</div>
|
|
122
|
-
<div id="qa-runbooks-content" class="qa-runbooks-list">
|
|
123
|
-
<p class="empty">Loading runbooks…</p>
|
|
124
|
-
</div>
|
|
125
|
-
</section>
|
|
126
38
|
<section id="qa-runs-section" class="qa-section">
|
|
127
|
-
<h2>Recent Runs <span class="qa-section-subtitle">latest
|
|
39
|
+
<h2>Recent Runs <span class="qa-section-subtitle">latest 20 validation runs — polled every 5s while this page is active</span></h2>
|
|
128
40
|
<div id="qa-runs-content" class="qa-runs-list">
|
|
129
41
|
<p class="empty">Loading runs…</p>
|
|
130
42
|
</div>
|
|
131
43
|
</section>
|
|
44
|
+
<section id="qa-runbooks-section" class="qa-section">
|
|
45
|
+
<details id="qa-runbooks-disclosure" class="qa-disclosure">
|
|
46
|
+
<summary id="qa-runbooks-disclosure-summary" style="cursor:pointer;font-size:var(--text-md);font-weight:600;color:var(--text)">Show saved runbooks (0)</summary>
|
|
47
|
+
<div style="margin-top:var(--space-4);display:flex;flex-direction:column;gap:var(--space-3)">
|
|
48
|
+
<div class="qa-cc-pitch qa-cc-pitch--small" style="border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface2);padding:var(--space-4);color:var(--muted);font-size:var(--text-base)">
|
|
49
|
+
💬 Ask Command Center to create a new runbook — e.g. <code>save a runbook that opens the cart and clicks checkout</code>. <button type="button" class="qa-inline-link" onclick="qaFocusCommandCenter()">Open Command Center →</button>
|
|
50
|
+
</div>
|
|
51
|
+
<div id="qa-runbooks-content" class="qa-runbooks-list">
|
|
52
|
+
<p class="empty">Loading runbooks…</p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</details>
|
|
56
|
+
</section>
|
package/dashboard.js
CHANGED
|
@@ -10484,6 +10484,37 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10484
10484
|
}
|
|
10485
10485
|
}
|
|
10486
10486
|
|
|
10487
|
+
// GET /api/qa/sessions/<id>/test — read-only fetch of the drafted test file
|
|
10488
|
+
// for the Review draft modal (W-mpxpvpwn000ra419). Sandboxed at two layers:
|
|
10489
|
+
// the per-segment _qaIsSafeSegment guard rejects hostile ids before they
|
|
10490
|
+
// reach qaSessions, and qaSessions.getSessionTestFile() repeats the path
|
|
10491
|
+
// resolution + prefix check internally so anything that slips through
|
|
10492
|
+
// (e.g. a session record whose testFile field already escapes the sandbox)
|
|
10493
|
+
// still returns null instead of leaking bytes. Read-only, so no CSRF gate.
|
|
10494
|
+
function handleQaSessionTestFile(req, res, match) {
|
|
10495
|
+
try {
|
|
10496
|
+
const qaSessions = require('./engine/qa-sessions');
|
|
10497
|
+
const id = decodeURIComponent(match[1] || '');
|
|
10498
|
+
if (!_qaIsSafeSegment(id)) return jsonReply(res, 400, { error: 'invalid session id' }, req);
|
|
10499
|
+
const session = qaSessions.getSession(id);
|
|
10500
|
+
if (!session) return jsonReply(res, 404, { error: 'qa session not found' }, req);
|
|
10501
|
+
const result = qaSessions.getSessionTestFile(id);
|
|
10502
|
+
if (!result) {
|
|
10503
|
+
return jsonReply(res, 404, { error: 'qa session test file not available' }, req);
|
|
10504
|
+
}
|
|
10505
|
+
return jsonReply(res, 200, {
|
|
10506
|
+
ok: true,
|
|
10507
|
+
path: session.testFile,
|
|
10508
|
+
content: result.content,
|
|
10509
|
+
language: result.language,
|
|
10510
|
+
truncated: !!result.truncated,
|
|
10511
|
+
size: result.size,
|
|
10512
|
+
}, req);
|
|
10513
|
+
} catch (e) {
|
|
10514
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
10515
|
+
}
|
|
10516
|
+
}
|
|
10517
|
+
|
|
10487
10518
|
// Shared helper for approve + edit + cancel + kill + dismiss — each takes
|
|
10488
10519
|
// a sessionId from the URL and a body, delegates to qaSessions.* and maps
|
|
10489
10520
|
// module errors to HTTP statuses.
|
|
@@ -10964,6 +10995,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10964
10995
|
{ method: 'POST', path: /^\/api\/qa\/sessions\/([^/?]+)\/cancel$/, template: '/api/qa/sessions/<id>/cancel', desc: 'Cancel a session (non-terminal → killed). Does NOT touch the managed-spawn — use /kill for that.', params: 'reason?', handler: handleQaSessionCancel },
|
|
10965
10996
|
{ method: 'POST', path: /^\/api\/qa\/sessions\/([^/?]+)\/kill$/, template: '/api/qa/sessions/<id>/kill', desc: 'Kill a session and its managed-spawn (non-terminal → killed). Best-effort on the spawn kill.', params: 'reason?', handler: handleQaSessionKill },
|
|
10966
10997
|
{ method: 'POST', path: /^\/api\/qa\/sessions\/([^/?]+)\/dismiss$/, template: '/api/qa/sessions/<id>/dismiss', desc: 'Mark a session done without running EXECUTE (non-terminal → done).', params: 'summary?', handler: handleQaSessionDismiss },
|
|
10998
|
+
{ method: 'GET', path: /^\/api\/qa\/sessions\/([^/?]+)\/test$/, template: '/api/qa/sessions/<id>/test', desc: 'Fetch the drafted test file for the Review draft modal. Read-only; returns { ok, path, content, language, truncated, size }. Files > 256KB return truncated:true with content:null.', handler: handleQaSessionTestFile },
|
|
10967
10999
|
{ method: 'GET', path: /^\/api\/qa\/sessions\/([^/?]+)$/, template: '/api/qa/sessions/<id>', desc: 'Fetch a single QA session record by id.', handler: handleQaSessionsById },
|
|
10968
11000
|
// QA Runners endpoints (P-d2f5a8c9). Pluggable runner-adapter registry.
|
|
10969
11001
|
{ method: 'GET', path: '/api/qa/runners', desc: 'List registered QA runner adapters (built-ins + qa-runners.d/ plugins). Returns metadata only (no hooks).', handler: handleQaRunnersList },
|
package/engine/qa-sessions.js
CHANGED
|
@@ -452,6 +452,70 @@ function getSession(id) {
|
|
|
452
452
|
return sessions.find(s => s && s.id === id) || null;
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
// W-mpxpvpwn000ra419 — Review draft modal: pure read helper that resolves
|
|
456
|
+
// session.testFile against qa-tests/<id>/ with the same sandbox guards as
|
|
457
|
+
// dashboard.js#handleQaArtifact (the canonical traversal-defense pattern).
|
|
458
|
+
// Used by GET /api/qa/sessions/<id>/test to feed the read-only Review draft
|
|
459
|
+
// modal on the /qa page.
|
|
460
|
+
//
|
|
461
|
+
// Contract (matches the work-item description, kept tight on purpose):
|
|
462
|
+
// - Returns null when session missing, session.testFile is null/empty, the
|
|
463
|
+
// resolved path escapes qa-tests/<id>/, or the file doesn't exist on disk.
|
|
464
|
+
// Never throws on hostile input — _isSafeSessionId + path.resolve + the
|
|
465
|
+
// baseWithSep prefix check together absorb every traversal shape.
|
|
466
|
+
// - Returns { path, content, language, truncated:false, size } on a normal
|
|
467
|
+
// file. `path` is the resolved absolute path (so callers can log it); the
|
|
468
|
+
// dashboard handler echoes the relative session.testFile back to clients.
|
|
469
|
+
// - Returns { path, content:null, language, truncated:true, size } when the
|
|
470
|
+
// file exceeds GET_SESSION_TEST_FILE_MAX_BYTES (256KB). The modal shows a
|
|
471
|
+
// "file too large" placeholder and suppresses in-modal Approve so users
|
|
472
|
+
// can't blind-approve a megabyte test.
|
|
473
|
+
// - Language inferred from extension: .spec.ts/.test.ts → typescript,
|
|
474
|
+
// .spec.js/.test.js/.js → javascript, .ts → typescript, .py → python,
|
|
475
|
+
// anything else → plaintext. Used by the dashboard to pick a syntax theme.
|
|
476
|
+
const GET_SESSION_TEST_FILE_MAX_BYTES = 256 * 1024;
|
|
477
|
+
|
|
478
|
+
function _inferTestLanguage(relPath) {
|
|
479
|
+
const lower = String(relPath || '').toLowerCase();
|
|
480
|
+
if (lower.endsWith('.spec.ts') || lower.endsWith('.test.ts') || lower.endsWith('.ts')) return 'typescript';
|
|
481
|
+
if (lower.endsWith('.spec.js') || lower.endsWith('.test.js') || lower.endsWith('.js')) return 'javascript';
|
|
482
|
+
if (lower.endsWith('.py')) return 'python';
|
|
483
|
+
return 'plaintext';
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function getSessionTestFile(sessionId) {
|
|
487
|
+
if (!_isSafeSessionId(sessionId)) return null;
|
|
488
|
+
const session = getSession(sessionId);
|
|
489
|
+
if (!session) return null;
|
|
490
|
+
const rel = session.testFile;
|
|
491
|
+
if (!_isNonEmptyString(rel)) return null;
|
|
492
|
+
// Reject early on null bytes — path.resolve does not strip them and the fs
|
|
493
|
+
// call would either throw or be silently truncated on some platforms.
|
|
494
|
+
if (rel.indexOf('\0') !== -1) return null;
|
|
495
|
+
// Reject absolute paths and Windows drive-letter paths before resolution.
|
|
496
|
+
// path.resolve(base, '/etc/passwd') returns '/etc/passwd' which would
|
|
497
|
+
// bypass the suffix check below if base happened to share a leading
|
|
498
|
+
// prefix; reject pre-resolution to make the intent obvious.
|
|
499
|
+
if (path.isAbsolute(rel)) return null;
|
|
500
|
+
if (/^[a-zA-Z]:/.test(rel)) return null;
|
|
501
|
+
const base = path.resolve(qaTestsDirForSession(sessionId));
|
|
502
|
+
const target = path.resolve(base, rel);
|
|
503
|
+
const baseWithSep = base.endsWith(path.sep) ? base : base + path.sep;
|
|
504
|
+
if (target !== base && !target.startsWith(baseWithSep)) return null;
|
|
505
|
+
let stat;
|
|
506
|
+
try { stat = fs.statSync(target); }
|
|
507
|
+
catch (e) { return null; }
|
|
508
|
+
if (!stat.isFile()) return null;
|
|
509
|
+
const language = _inferTestLanguage(rel);
|
|
510
|
+
if (stat.size > GET_SESSION_TEST_FILE_MAX_BYTES) {
|
|
511
|
+
return { path: target, content: null, language, truncated: true, size: stat.size };
|
|
512
|
+
}
|
|
513
|
+
let content;
|
|
514
|
+
try { content = fs.readFileSync(target, 'utf8'); }
|
|
515
|
+
catch (e) { return null; }
|
|
516
|
+
return { path: target, content, language, truncated: false, size: stat.size };
|
|
517
|
+
}
|
|
518
|
+
|
|
455
519
|
/**
|
|
456
520
|
* List sessions, newest first, optionally filtered by state, capped by limit.
|
|
457
521
|
*/
|
|
@@ -1247,6 +1311,7 @@ module.exports = {
|
|
|
1247
1311
|
// CRUD
|
|
1248
1312
|
createSession,
|
|
1249
1313
|
getSession,
|
|
1314
|
+
getSessionTestFile,
|
|
1250
1315
|
listSessions,
|
|
1251
1316
|
setSessionWorkItem,
|
|
1252
1317
|
setSessionQaRunId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2106",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|