@yemi33/minions 0.1.2105 → 0.1.2107

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.
@@ -1,24 +1,31 @@
1
- // dashboard/js/qa.js — QA tab UI (W-mpeiwz6k0005bf34-d).
1
+ // dashboard/js/qa.js — QA tab UI.
2
2
  //
3
- // Three sections:
4
- // 1. Targets slim dedup'd list of /api/managed-processes + /api/keep-processes
5
- // with a health badge and a link back to /engine (full inventory tables
6
- // live on /engine; see W-mpdad3mq000m53bb do NOT mirror them here).
7
- // 2. Runbooks list from GET /api/qa/runbooks with an enabled "+ New runbook"
8
- // button that opens an inline form (name / target dropdown / steps textarea
9
- // / expected-artifacts repeater) wired to POST /api/qa/runbooks. Each row
10
- // has a "Run" button that POSTs to /api/qa/runbooks/run.
11
- // 3. Runs list from GET /api/qa/runs?limit=50, polled every 5s while the
12
- // QA page is active. Each row links to the live agent stream and renders
13
- // inline artifact previews via /api/qa/artifacts/<runId>/<file>
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 switchPage
19
- // wrapper below (matches the same pattern keep_processes / managed-spawn
20
- // uses on /engine). The wrapper ALSO closes any open managed-log SSE so a
21
- // modal opened from /engine doesn't keep streaming after the user navigates.
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. Click <strong>+ New runbook</strong> to create one.</p>'
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
- '<button class="qa-btn-primary qa-runbook-run-btn" data-runbook-id="' + escHtml(id) + '" onclick="qaRunRunbook(this.dataset.runbookId)">Run</button>' +
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
- function qaShowNewRunbookForm() {
216
- const wrap = document.getElementById('qa-runbook-form-wrap');
217
- if (!wrap) return;
218
- wrap.style.display = 'block';
219
- // Make sure target dropdown reflects the current target cache.
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 qaSaveRunbook() {
266
- const msg = document.getElementById('qa-runbook-form-msg');
267
- const name = (document.getElementById('qa-runbook-name') || {}).value || '';
268
- const target = (document.getElementById('qa-runbook-target') || {}).value || '';
269
- const stepsRaw = (document.getElementById('qa-runbook-steps') || {}).value || '';
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
- if (msg) { msg.textContent = 'Failed to save: ' + (e.message || String(e)); msg.style.color = 'var(--red)'; }
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=50');
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
- // Show / hide the per-target-kind sub-input. Called from the kind dropdown
554
- // onchange so users only see the field that maps to their selected kind.
555
- function qaSessionTargetKindChanged() {
556
- const kind = document.getElementById('qa-session-target-kind');
557
- if (!kind) return;
558
- const wraps = {
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 root = document.getElementById('qa-sessions-content');
632
- if (!root) return;
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
- root.replaceChildren(frag);
595
+ activeRoot.replaceChildren(frag);
647
596
  return;
648
597
  }
649
598
 
650
599
  _qaSessionsCache = sessions;
651
- if (!sessions.length) {
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 yet. Use the form above to start one.</p>'
609
+ '<p class="empty">No active QA sessions.</p>'
654
610
  );
655
- root.replaceChildren(frag);
656
- _qaAfterSessionsRender();
657
- return;
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
- let html = '';
661
- for (const s of sessions) {
662
- html += _qaRenderSessionCard(s);
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
- // 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)
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 &amp; 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
- async function qaSubmitSessionForm() {
792
- const msg = document.getElementById('qa-session-form-msg');
793
- const submit = document.getElementById('qa-session-submit');
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
+ // (&lt;, &gt;, &#39;, &amp;) 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
+ }