@critical-labs/qa-conductor 0.0.0-stage → 0.3.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.
@@ -0,0 +1,542 @@
1
+ // PR-QA harness: hash-routed picker → boot → side-by-side session → verdict.
2
+ // Plain browser JS, zero dependencies. Server state is authoritative on load.
3
+ //
4
+ // The pure helpers live at top level and are exported through the CommonJS
5
+ // guard at the bottom (as bridge.js does) so `node --test` can exercise them;
6
+ // the UI IIFE is inert outside a browser. Text that came from a server,
7
+ // adapter or PR (titles, build messages, reasons, errors) is rendered with
8
+ // textContent or esc(); links from adapters must be https.
9
+ /* eslint-env browser */
10
+
11
+ // One name per boot step, used by the step list, announcements and errors.
12
+ // The step ids are the session states and never change.
13
+ const LABELS = { 'ensuring-image': 'Building', cloning: 'Preparing data', migrating: 'Migrating', starting: 'Starting' }
14
+
15
+ function esc(s) {
16
+ return String(s ?? '').replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }[c]))
17
+ }
18
+
19
+ // Only https links from adapters (build runs) become anchors: no javascript:.
20
+ function isHttpsUrl(u) {
21
+ return typeof u === 'string' && /^https:\/\//i.test(u)
22
+ }
23
+
24
+ // Where to send a viewer who opened the harness somewhere other than its
25
+ // configured origin (say localhost for 127.0.0.1): the panes refuse to load
26
+ // in, or mirror with, any other origin. null when there's nothing to say.
27
+ function harnessOriginNotice(configured, current) {
28
+ if (!configured || configured === current) return null
29
+ return `${configured}/qa/`
30
+ }
31
+
32
+ ;(() => {
33
+ if (typeof window === 'undefined') return
34
+ const $ = id => document.getElementById(id)
35
+ const api = (path, opts) => fetch(`/qa/api${path}`, opts).then(async r => {
36
+ const body = await r.json().catch(() => ({}))
37
+ if (!r.ok) { const e = new Error(body.error || `HTTP ${r.status}`); e.status = r.status; e.body = body; throw e }
38
+ return body
39
+ })
40
+ // The harness refuses non-JSON POSTs (415), so every write goes through here.
41
+ const postJson = (path, body = {}) => api(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
42
+ const announce = (msg, assertive) => { const el = $(assertive ? 'announcerAssertive' : 'announcer'); el.textContent = ''; el.textContent = msg }
43
+ const stepLabel = step => LABELS[step] || step
44
+ const tagOf = img => (img || '').split(':').pop()
45
+ const mmss = ms => { const s = Math.max(0, Math.round(ms / 1000)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` }
46
+ const BOOTING = ['ensuring-image', 'cloning', 'migrating', 'starting']
47
+
48
+ const S = {
49
+ pr: null, panes: null, paneOrigins: null, mounted: null,
50
+ mirror: true, scrollMirror: true,
51
+ path: { base: '', pr: '' }, lastMsg: { base: 0, pr: 0 }, hasNav: { base: false, pr: false },
52
+ unmatched: { base: 0, pr: 0 },
53
+ es: null, idlePoll: null, dotPoll: null, notesTimer: null, confirmTimer: null, pendingVerdict: null,
54
+ }
55
+
56
+ // --- router --------------------------------------------------------------
57
+ function parseHash() {
58
+ const h = location.hash.replace(/^#/, '') || '/'
59
+ let m = h.match(/^\/pr\/(\d+)\/boot$/); if (m) return { view: 'boot', pr: +m[1] }
60
+ m = h.match(/^\/pr\/(\d+)\/verdict$/); if (m) return { view: 'verdict', pr: +m[1] }
61
+ m = h.match(/^\/pr\/(\d+)$/); if (m) return { view: 'session', pr: +m[1] }
62
+ return { view: 'picker', pr: null }
63
+ }
64
+ const go = hash => { if (location.hash !== hash) location.hash = hash; else render() }
65
+ const setHashSilent = hash => history.replaceState(null, '', hash)
66
+
67
+ function showView(id) {
68
+ for (const v of ['picker', 'boot', 'session', 'verdictDone']) $(v).hidden = v !== id
69
+ $('sessionControls').hidden = id !== 'session'
70
+ $('verdictBar').hidden = id !== 'session'
71
+ const heading = { picker: '#picker h1', boot: 'bootHeading', session: 'sessionHeading', verdictDone: '#verdictDone h1' }[id]
72
+ const h = id === 'picker' ? document.querySelector('#picker h1') : id === 'verdictDone' ? document.querySelector('#verdictDone h1') : $(heading)
73
+ if (h) setTimeout(() => h.focus(), 0)
74
+ }
75
+ function setPill(text) { $('statusPill').textContent = text }
76
+
77
+ // The banner for a harness opened at another origin. Its text is set as
78
+ // text, and the link is made only for an http(s) URL.
79
+ function showOriginNotice(configured) {
80
+ const el = $('originNotice')
81
+ const url = harnessOriginNotice(configured, location.origin)
82
+ el.textContent = ''
83
+ el.hidden = !url
84
+ if (!url) return
85
+ el.append('This harness is meant to be opened at ')
86
+ if (/^https?:\/\//i.test(url)) {
87
+ const a = document.createElement('a'); a.href = url; a.textContent = url
88
+ el.append(a)
89
+ } else {
90
+ el.append(url)
91
+ }
92
+ el.append('. The panes load and mirror only there.')
93
+ }
94
+
95
+ async function render() {
96
+ const route = parseHash()
97
+ let s
98
+ try { s = await api('/state'); showOriginNotice(s.harnessOrigin) } catch (e) {
99
+ // At another host or port than the harness origin's, the API refuses a
100
+ // browser (403 not the harness origin) and names the origin instead.
101
+ showOriginNotice(e && e.body ? e.body.harnessOrigin : null)
102
+ s = { status: 'idle' }
103
+ }
104
+ const active = s.status !== 'idle'
105
+ if (route.view === 'picker' || !route.pr) { teardownSessionUi(); return renderPicker(s) }
106
+ if (active && s.pr === route.pr) {
107
+ if (s.status === 'ready') return renderSession(s)
108
+ return renderBoot(route.pr, s)
109
+ }
110
+ teardownSessionUi()
111
+ if (active) return renderPicker(s, { conflictFor: route.pr })
112
+ return renderPicker(s, { endedFor: route.pr })
113
+ }
114
+
115
+ // --- picker (#164 #167 #169) --------------------------------------------
116
+ const rows = new Map()
117
+ let picking = false
118
+
119
+ async function renderPicker(state, opts = {}) {
120
+ setHashSilent('#/')
121
+ showView('picker')
122
+ setPill(state && state.status !== 'idle' ? `session: #${state.pr}` : 'idle')
123
+ renderConflict(state, opts)
124
+ try {
125
+ const { prs, session } = await api('/prs')
126
+ patchRows(prs, session)
127
+ } catch (err) { $('prList').textContent = `Failed to load PRs: ${err.message}` }
128
+ }
129
+
130
+ function renderConflict(state, opts) {
131
+ const slot = $('conflictSlot'); slot.innerHTML = ''
132
+ if (opts.endedFor) {
133
+ const n = document.createElement('div'); n.className = 'note'
134
+ n.textContent = `Session for #${opts.endedFor} has ended (idle teardown or manual). Pick a PR to start a new one.`
135
+ slot.appendChild(n); return
136
+ }
137
+ if (opts.conflictFor && state && state.status !== 'idle') {
138
+ slot.appendChild(conflictCard(state, opts.conflictFor))
139
+ }
140
+ }
141
+
142
+ function conflictCard(state, wantPr) {
143
+ const now = Date.now()
144
+ const readyFor = state.startedAt ? mmss(now - state.startedAt) : '—'
145
+ const idleFor = state.lastActivity ? mmss(now - state.lastActivity) : '—'
146
+ const endsIn = state.lastActivity && state.idleMinutes ? mmss(state.lastActivity + state.idleMinutes * 60000 - now) : '—'
147
+ const c = document.createElement('div'); c.className = 'conflict'
148
+ c.innerHTML = `<h2>A session is already running on #${state.pr}${state.title ? ' — ' + esc(state.title) : ''}</h2>`
149
+ + `<div class="times">${state.status === 'ready' ? `ready ${readyFor}` : esc(state.status)} · idle ${idleFor} · auto-ends in ${endsIn}</div>`
150
+ + `<div class="acts"><button class="secondary" data-resume="${state.pr}">Resume #${state.pr}</button>`
151
+ + `<button class="danger-ghost" data-takeover="${wantPr}">End #${state.pr} and open #${wantPr}</button>`
152
+ + `<button class="ghost" data-back="1">Back</button></div>`
153
+ c.querySelector('[data-resume]').onclick = () => go(`#/pr/${state.pr}`)
154
+ c.querySelector('[data-back]').onclick = () => { $('conflictSlot').innerHTML = '' }
155
+ twoStepConfirm(c.querySelector('[data-takeover]'), 'End & open?', () => openSession(wantPr, { takeover: true }))
156
+ return c
157
+ }
158
+
159
+ function patchRows(prs, session) {
160
+ const list = $('prList')
161
+ if (list.textContent === 'Loading…') list.textContent = ''
162
+ if (!prs.length && !rows.size) { list.textContent = 'No open PRs.'; return }
163
+ const seen = new Set()
164
+ for (const pr of prs) {
165
+ seen.add(pr.number)
166
+ let row = rows.get(pr.number)
167
+ if (!row) { row = document.createElement('div'); row.className = 'pr'; row.dataset.pr = pr.number; rows.set(pr.number, row); list.appendChild(row) }
168
+ const holds = session && session.pr === pr.number && session.status !== 'idle'
169
+ const num = esc(pr.number)
170
+ // A blocked PR can still be opened: the build's ensureBuilt is the real gate.
171
+ const action = holds
172
+ ? `<button class="primary" data-open="${num}" data-resume="1">QA session active — Resume</button>`
173
+ : `<button class="primary" data-open="${num}">Open QA</button>`
174
+ row.innerHTML = `<span class="num">#${num}</span>`
175
+ + `<span class="main"><span class="title">${esc(pr.title)}</span>`
176
+ + `<span class="submeta">${esc(pr.headRef || '')}${pr.author ? ' · ' + esc(pr.author) : ''} </span>`
177
+ + `<span class="rowErr" hidden></span></span>${action}`
178
+ row.querySelector('.submeta').appendChild(imageBadge(pr))
179
+ }
180
+ for (const [num, row] of rows) if (!seen.has(num)) { row.remove(); rows.delete(num) }
181
+ }
182
+
183
+ function runLink(url, text) {
184
+ const a = document.createElement('a'); a.href = url; a.target = '_blank'; a.rel = 'noreferrer'; a.textContent = text
185
+ return a
186
+ }
187
+
188
+ function imageBadge(pr) {
189
+ const badge = document.createElement('span'); badge.className = 'badge'
190
+ const dot = document.createElement('span')
191
+ let text
192
+ if (pr.imageStatus === 'built') { dot.className = 'dot green'; text = 'ready — opens in seconds' }
193
+ else if (pr.imageStatus === 'building') { dot.className = 'dot amber pulse'; text = 'building…' }
194
+ else if (pr.imageStatus === 'blocked') { dot.className = 'dot grey'; badge.classList.add('blocked'); text = `can't boot: ${pr.reason || 'not allowed'}` }
195
+ else { dot.className = 'dot amber'; text = 'needs build (~10 min)' }
196
+ badge.append(dot, document.createTextNode(text))
197
+ if (pr.imageStatus === 'building' && isHttpsUrl(pr.runUrl)) badge.append(' ', runLink(pr.runUrl, 'run ↗'))
198
+ return badge
199
+ }
200
+
201
+ // delegated picker clicks — survive re-renders (#164)
202
+ $('prList').addEventListener('click', e => {
203
+ const btn = e.target.closest('button[data-open]'); if (!btn) return
204
+ const num = +btn.dataset.open
205
+ if (btn.dataset.resume) return go(`#/pr/${num}`)
206
+ const rowButtons = $('prList').querySelectorAll('button[data-open]')
207
+ for (const b of rowButtons) b.disabled = true
208
+ btn.textContent = 'Opening…'; btn.closest('.pr').setAttribute('aria-busy', 'true')
209
+ openSession(num).catch(err => {
210
+ btn.closest('.pr').removeAttribute('aria-busy')
211
+ for (const b of rowButtons) b.disabled = false
212
+ if (err.status === 409 && err.body && err.body.session) {
213
+ $('conflictSlot').innerHTML = ''; $('conflictSlot').appendChild(conflictCard(err.body.session, num))
214
+ } else {
215
+ const slot = btn.closest('.pr').querySelector('.rowErr'); slot.hidden = false; slot.textContent = err.message
216
+ }
217
+ })
218
+ })
219
+ $('refreshBtn').onclick = () => renderPicker(null)
220
+
221
+ async function openSession(num, opts = {}) {
222
+ if (picking) return; picking = true
223
+ try {
224
+ await postJson('/session', { pr: num, ...opts })
225
+ go(`#/pr/${num}/boot`)
226
+ } finally { picking = false }
227
+ }
228
+
229
+ // --- boot (#163 #165 #166) ----------------------------------------------
230
+ const stepAt = {}
231
+ let bootPr = null
232
+ for (const li of $('bootSteps').querySelectorAll('li')) li.querySelector('.stepLabel').textContent = stepLabel(li.dataset.step)
233
+
234
+ function renderBoot(pr, state) {
235
+ showView('boot'); setPill(`booting #${pr}`)
236
+ if (bootPr !== pr) { bootPr = pr; for (const k of Object.keys(stepAt)) delete stepAt[k]; resetBootUi() }
237
+ $('bootPr').textContent = pr
238
+ if (state && state.buildRun) setBuildSub(state.buildRun)
239
+ attachSse()
240
+ }
241
+ function resetBootUi() {
242
+ for (const li of $('bootSteps').querySelectorAll('li')) { li.classList.remove('now', 'done'); li.removeAttribute('aria-current'); li.querySelector('.stepTime').textContent = '' }
243
+ $('bootSteps').querySelector('[data-step="ensuring-image"] .stepSub').textContent = ''
244
+ $('bootError').hidden = true; $('bootActions').hidden = true; $('bootActions').textContent = ''; $('bootBar').hidden = false
245
+ $('bootTotal').textContent = ''
246
+ }
247
+ function stepIndex(step) { return BOOTING.indexOf(step) }
248
+ function markStep(step, at) {
249
+ stepAt[step] = at || Date.now()
250
+ const cur = stepIndex(step)
251
+ for (const li of $('bootSteps').querySelectorAll('li')) {
252
+ const i = stepIndex(li.dataset.step)
253
+ li.classList.toggle('now', i === cur); li.classList.toggle('done', i < cur)
254
+ if (i === cur) li.setAttribute('aria-current', 'step'); else li.removeAttribute('aria-current')
255
+ }
256
+ announce(`${stepLabel(step)}, step ${cur + 1} of 4`)
257
+ }
258
+ // Build progress under the first step: the BuildConvention's own message
259
+ // when it sends one, else a summary of its CI run, plus an https run link.
260
+ function setBuildSub({ url, status, message } = {}) {
261
+ const sub = $('bootSteps').querySelector('[data-step="ensuring-image"] .stepSub')
262
+ const summary = message || (url || status ? (status === 'completed' ? 'Build complete' : `Build ${status || 'in progress'}`) : 'Build started')
263
+ sub.textContent = ` — ${summary}`
264
+ if (isHttpsUrl(url)) sub.append(' ', runLink(url, 'view run ↗'))
265
+ }
266
+ let bootTimer = null
267
+ function tickBoot() {
268
+ const now = Date.now()
269
+ const first = stepAt['ensuring-image']
270
+ if (first) $('bootTotal').textContent = mmss(now - first)
271
+ for (const li of $('bootSteps').querySelectorAll('li')) {
272
+ const step = li.dataset.step, tEl = li.querySelector('.stepTime')
273
+ const started = stepAt[step]; if (!started) continue
274
+ const next = BOOTING[stepIndex(step) + 1]
275
+ const ended = next && stepAt[next]
276
+ tEl.textContent = mmss((ended || now) - started)
277
+ }
278
+ }
279
+
280
+ function attachSse() {
281
+ if (S.es) return
282
+ const es = new EventSource('/qa/api/progress'); S.es = es
283
+ if (!bootTimer) bootTimer = setInterval(tickBoot, 1000)
284
+ es.onmessage = ev => {
285
+ const e = JSON.parse(ev.data)
286
+ if (e.kind === 'step') markStep(e.step, e.at)
287
+ else if (e.kind === 'build') setBuildSub({ url: e.runUrl, status: e.runStatus, message: e.message })
288
+ else if (e.kind === 'ready') { closeSse(); onReady(e) }
289
+ else if (e.kind === 'error') showBootError(e)
290
+ else if (e.kind === 'torn-down') { closeSse(); go('#/') }
291
+ }
292
+ }
293
+ function closeSse() { if (S.es) { S.es.close(); S.es = null } if (bootTimer) { clearInterval(bootTimer); bootTimer = null } }
294
+
295
+ function showBootError(e) {
296
+ const cached = stepAt['ensuring-image'] === undefined
297
+ if (cached) $('bootSteps').querySelector('[data-step="ensuring-image"] .stepSub').textContent = 'Already built'
298
+ $('bootBar').hidden = true
299
+ showErrorText(`Failed at ${stepLabel(e.step)}: ${e.message}`, e.logTail)
300
+ announce(`Boot failed at ${stepLabel(e.step)}: ${e.message}`, true)
301
+ const acts = $('bootActions'); acts.hidden = false; acts.textContent = ''
302
+ const retry = mkBtn('Retry boot', 'primary', () => { resetBootUi(); openSession(bootPr).catch(showOpenErr) })
303
+ const back = mkBtn('Back to pull requests', 'ghost', () => go('#/'))
304
+ acts.append(retry, back)
305
+ if (e.step === 'ensuring-image' && isHttpsUrl(e.runUrl)) { const a = runLink(e.runUrl, 'View build log ↗'); a.className = 'btn secondary'; acts.append(a) }
306
+ }
307
+ function showErrorText(line, tail) {
308
+ const pre = $('bootError'); pre.hidden = false; pre.textContent = ''
309
+ const span = document.createElement('span'); span.className = 'errline'; span.textContent = line
310
+ pre.append(span)
311
+ if (tail) pre.append(tail)
312
+ }
313
+ function showOpenErr(err) { showErrorText(err.message) }
314
+ function mkBtn(text, cls, onclick) { const b = document.createElement('button'); b.className = cls; b.textContent = text; b.onclick = onclick; return b }
315
+ $('cancelBoot').onclick = async () => { await postJson('/teardown').catch(() => {}); go('#/') }
316
+
317
+ // --- session (#163 #170 #171 #172) --------------------------------------
318
+ function onReady(meta) { setHashSilent(`#/pr/${meta.pr || bootPr}`); renderSession(meta) }
319
+
320
+ function renderSession(meta) {
321
+ const pr = meta.pr || bootPr || parseHash().pr
322
+ showView('session'); setPill(`session: #${pr}`)
323
+ const panes = meta.panes
324
+ if (S.mounted !== pr) {
325
+ S.mounted = pr; S.pr = pr; S.panes = panes; S.paneOrigins = [panes.baseOrigin, panes.prOrigin]
326
+ S.path = { base: '', pr: '' }; S.hasNav = { base: false, pr: false }; S.lastMsg = { base: Date.now(), pr: Date.now() }
327
+ S.unmatched = { base: 0, pr: 0 }; renderUnmatched('base'); renderUnmatched('pr')
328
+ // as given: the pane proxy tells each pane's bridge whom to talk to
329
+ $('baseFrame').src = panes.base; $('prFrame').src = panes.pr
330
+ $('baseOpen').href = panes.baseOrigin; $('prOpen').href = panes.prOrigin
331
+ setDot('base', 'amber', 'signing in…'); setDot('pr', 'amber', 'signing in…')
332
+ $('baseUrl').textContent = 'signing in…'; $('prUrl').textContent = 'signing in…'
333
+ restoreDraft(pr)
334
+ startIdlePoll(); startDotPoll()
335
+ announce('QA session ready')
336
+ }
337
+ $('sessionMeta').innerHTML = meta.baseTag ? `#${pr} · <span class="tag">${esc(tagOf(meta.baseTag))}</span> vs <span class="tag">${esc(tagOf(meta.prTag))}</span>` : `#${pr}`
338
+ $('sessionHeading').textContent = `QA session for PR ${pr}`
339
+ }
340
+
341
+ function teardownSessionUi() {
342
+ S.mounted = null; closeSse()
343
+ if (S.idlePoll) { clearInterval(S.idlePoll); S.idlePoll = null }
344
+ if (S.dotPoll) { clearInterval(S.dotPoll); S.dotPoll = null }
345
+ $('baseFrame').removeAttribute('src'); $('prFrame').removeAttribute('src')
346
+ hideVerdictBody()
347
+ }
348
+
349
+ const paneOf = origin => origin === S.paneOrigins[0] ? 'base' : origin === S.paneOrigins[1] ? 'pr' : null
350
+ const frameOf = pane => pane === 'base' ? $('baseFrame') : $('prFrame')
351
+ function setDot(pane, cls, title) { const d = $(pane + 'Dot'); d.className = 'dot ' + cls; d.title = title }
352
+ function renderUnmatched(pane) {
353
+ const el = $(pane + 'Unmatched'); const n = S.unmatched[pane]
354
+ el.hidden = n === 0; el.textContent = `${n} unmatched`
355
+ const tab = document.querySelector(`.tab[data-tab="${pane}"]`)
356
+ if (tab) { let b = tab.querySelector('.tabBadge'); if (n === 0) { if (b) b.remove() } else { if (!b) { b = document.createElement('span'); b.className = 'tabBadge'; tab.appendChild(b) } b.textContent = n } }
357
+ }
358
+ function updateDivergence() {
359
+ const diverged = S.hasNav.base && S.hasNav.pr && S.path.base !== S.path.pr
360
+ document.querySelector('.pane[data-pane="base"]').classList.toggle('diverged', diverged)
361
+ document.querySelector('.pane[data-pane="pr"]').classList.toggle('diverged', diverged)
362
+ let dot = $('resyncBtn').querySelector('.dot'); if (diverged && !dot) { dot = document.createElement('span'); dot.className = 'dot amber'; dot.style.marginLeft = '4px'; $('resyncBtn').appendChild(dot) } else if (!diverged && dot) dot.remove()
363
+ }
364
+
365
+ window.addEventListener('message', ev => {
366
+ if (!S.paneOrigins || !S.paneOrigins.includes(ev.origin)) return
367
+ const d = ev.data; if (!d || d.qa !== 1) return
368
+ const pane = paneOf(ev.origin); if (!pane) return
369
+ S.lastMsg[pane] = Date.now()
370
+ if (d.kind === 'event') {
371
+ if (!S.mirror) return
372
+ if (d.type === 'scroll' && !S.scrollMirror) return
373
+ const other = pane === 'base' ? 'pr' : 'base'
374
+ frameOf(other).contentWindow.postMessage({ ...d, kind: 'replay' }, S.paneOrigins[other === 'base' ? 0 : 1])
375
+ } else if (d.kind === 'nav') {
376
+ S.path[pane] = d.href; S.hasNav[pane] = true
377
+ $(pane + 'Url').textContent = d.href; setDot(pane, 'green', 'live')
378
+ updateDivergence()
379
+ } else if (d.kind === 'ping') {
380
+ if (S.hasNav[pane]) setDot(pane, 'green', 'live')
381
+ } else if (d.kind === 'unmatched') {
382
+ S.unmatched[pane]++; renderUnmatched(pane)
383
+ const wrap = frameOf(pane).closest('.paneWrap'); wrap.classList.remove('flash'); void wrap.offsetWidth; wrap.classList.add('flash')
384
+ announce(`Interaction not mirrored to ${pane === 'pr' ? 'PR' : 'base'} pane`)
385
+ }
386
+ })
387
+
388
+ function startDotPoll() {
389
+ S.dotPoll = setInterval(() => {
390
+ const now = Date.now()
391
+ for (const pane of ['base', 'pr']) {
392
+ if (!S.hasNav[pane]) continue
393
+ if (now - S.lastMsg[pane] > 10000) setDot(pane, 'grey', 'stale — no bridge signal >10s')
394
+ }
395
+ }, 3000)
396
+ }
397
+
398
+ // pane bar controls (#171)
399
+ document.querySelector('#paneRow').addEventListener('click', e => {
400
+ const btn = e.target.closest('button[data-act]'); if (!btn) return
401
+ const pane = btn.dataset.pane
402
+ if (btn.dataset.act === 'reload') { const f = frameOf(pane); f.src = f.src }
403
+ else if (btn.dataset.act === 'copy') navigator.clipboard && navigator.clipboard.writeText(S.path[pane] || '/')
404
+ })
405
+ for (const p of ['base', 'pr']) $(p + 'Unmatched').onclick = () => { S.unmatched[p] = 0; renderUnmatched(p) }
406
+
407
+ // header controls
408
+ $('mirrorBtn').onclick = () => setMirror(!S.mirror)
409
+ function setMirror(on) { S.mirror = on; $('mirrorBtn').textContent = `Mirror: ${on ? 'on' : 'off'}`; $('mirrorBtn').classList.toggle('on', on) }
410
+ menuToggle('mirrorMenuBtn', 'mirrorMenu')
411
+ menuToggle('resyncMenuBtn', 'resyncMenu')
412
+ $('scrollMirror').onchange = e => { S.scrollMirror = e.target.checked }
413
+ $('resyncBtn').onclick = () => { $('prFrame').src = S.paneOrigins[1] + (S.path.base || '/'); S.path.pr = S.path.base; updateDivergence() }
414
+ $('resyncLeft').onclick = () => { $('baseFrame').src = S.paneOrigins[0] + (S.path.pr || '/'); S.path.base = S.path.pr; updateDivergence(); $('resyncMenu').hidden = true }
415
+ for (const btn of document.querySelectorAll('button[data-width]')) btn.onclick = () => setWidth(+btn.dataset.width, btn)
416
+ function setWidth(w, btn) {
417
+ for (const f of [$('baseFrame'), $('prFrame')]) f.style.width = w ? `${w}px` : '100%'
418
+ for (const b of document.querySelectorAll('button[data-width]')) b.classList.toggle('on', b === (btn || document.querySelector(`button[data-width="${w}"]`)))
419
+ }
420
+ function menuToggle(btnId, menuId) {
421
+ $(btnId).onclick = e => { e.stopPropagation(); const m = $(menuId); m.hidden = !m.hidden; $(btnId).setAttribute('aria-expanded', String(!m.hidden)) }
422
+ }
423
+ document.addEventListener('click', () => { $('mirrorMenu').hidden = true; $('resyncMenu').hidden = true })
424
+ for (const m of ['mirrorMenu', 'resyncMenu']) $(m).addEventListener('click', e => e.stopPropagation())
425
+
426
+ // idle countdown chip (#170)
427
+ function startIdlePoll() { pollIdle(); S.idlePoll = setInterval(pollIdle, 60000) }
428
+ async function pollIdle() {
429
+ let s; try { s = await api('/state') } catch { return }
430
+ if (s.status !== 'ready') return
431
+ if (!s.lastActivity || !s.idleMinutes) return
432
+ const left = s.lastActivity + s.idleMinutes * 60000 - Date.now()
433
+ const chip = $('idleChip'); chip.textContent = `auto-end ${mmss(left)}`
434
+ const low = left < 5 * 60000; chip.classList.toggle('warn', low)
435
+ if (low) announce(`QA session ends in ${Math.ceil(left / 60000)} minutes unless you interact`)
436
+ }
437
+
438
+ // teardown two-step (#170)
439
+ twoStepConfirm($('teardownBtn'), 'Confirm end? (3s)', async () => { await postJson('/teardown').catch(() => {}); go('#/') })
440
+ function twoStepConfirm(btn, confirmLabel, action) {
441
+ const orig = btn.textContent
442
+ btn.onclick = () => {
443
+ if (btn.dataset.armed) { clearTimeout(S.confirmTimer); btn.dataset.armed = ''; btn.textContent = orig; btn.classList.remove('confirm'); return action() }
444
+ btn.dataset.armed = '1'; btn.textContent = confirmLabel; btn.classList.add('confirm')
445
+ S.confirmTimer = setTimeout(() => { btn.dataset.armed = ''; btn.textContent = orig; btn.classList.remove('confirm') }, 3000)
446
+ }
447
+ btn._disarm = () => { if (btn.dataset.armed) { clearTimeout(S.confirmTimer); btn.dataset.armed = ''; btn.textContent = orig; btn.classList.remove('confirm') } }
448
+ }
449
+
450
+ // --- verdict drawer (#168 #173) -----------------------------------------
451
+ $('verdictToggle').onclick = () => toggleVerdict()
452
+ function toggleVerdict(force) {
453
+ const body = $('verdictBody'); const open = force !== undefined ? force : body.hidden
454
+ body.hidden = !open; $('verdictToggle').setAttribute('aria-expanded', String(open))
455
+ if (open) { $('verdictPreview').hidden = true; $('verdictEdit').hidden = false; $('notes').focus() }
456
+ }
457
+ function hideVerdictBody() { $('verdictBody').hidden = true; $('verdictToggle').setAttribute('aria-expanded', 'false') }
458
+ const draftKey = pr => `qa-notes-${pr}`
459
+ function restoreDraft(pr) { $('notes').value = localStorage.getItem(draftKey(pr)) || '' }
460
+ $('notes').addEventListener('input', () => { clearTimeout(S.notesTimer); S.notesTimer = setTimeout(() => { if (S.pr) localStorage.setItem(draftKey(S.pr), $('notes').value) }, 500) })
461
+
462
+ $('acceptBtn').onclick = () => openPreview('accept')
463
+ $('rejectBtn').onclick = () => openPreview('reject')
464
+ async function openPreview(verdict) {
465
+ S.pendingVerdict = verdict
466
+ const q = `?verdict=${verdict}&notes=${encodeURIComponent($('notes').value)}`
467
+ let body = '(preview unavailable — post will still work)', applies = verdict === 'accept' ? 'qa-approved' : 'qa-changes-requested', removes = verdict === 'accept' ? 'qa-changes-requested' : 'qa-approved'
468
+ try { const r = await api('/verdict/preview' + q); body = r.body || body; applies = r.applies || applies; removes = r.removes || removes } catch { /* keep fallback */ }
469
+ $('previewBody').textContent = body
470
+ $('previewLabelChange').textContent = `Will apply ${applies}, remove ${removes}.`
471
+ const postBtn = $('postBtn'); postBtn.textContent = `Post to PR #${S.pr}`; postBtn.className = verdict === 'accept' ? 'primary' : 'danger'
472
+ $('verdictEdit').hidden = true; $('verdictPreview').hidden = false
473
+ }
474
+ $('previewBack').onclick = () => { $('verdictPreview').hidden = true; $('verdictEdit').hidden = false }
475
+ $('postBtn').onclick = async () => {
476
+ const postBtn = $('postBtn'); postBtn.disabled = true; $('previewBack').disabled = true
477
+ try {
478
+ const { url } = await postJson('/verdict', { verdict: S.pendingVerdict, notes: $('notes').value })
479
+ localStorage.removeItem(draftKey(S.pr))
480
+ announce('Verdict posted')
481
+ $('doneP').textContent = S.pr; $('doneLink').href = url
482
+ go(`#/pr/${S.pr}/verdict`)
483
+ } catch (err) { postBtn.disabled = false; $('previewBack').disabled = false; $('previewLabelChange').textContent = `Failed: ${err.message}` }
484
+ }
485
+
486
+ // verdict-done view is rendered on demand; wire once
487
+ $('doneEnd').onclick = async () => { await postJson('/teardown').catch(() => {}); go('#/') }
488
+ $('doneKeep').onclick = () => go(`#/pr/${S.pr}`)
489
+ $('doneBack').onclick = () => go('#/')
490
+
491
+ // when routed to #/pr/n/verdict but state lost (reload), fall back to session
492
+ function maybeVerdictDone() {
493
+ const r = parseHash()
494
+ if (r.view === 'verdict' && $('doneLink').href && +$('doneP').textContent === r.pr) { showView('verdictDone'); setPill(`session: #${r.pr}`); return true }
495
+ return false
496
+ }
497
+
498
+ // --- keyboard (#177) -----------------------------------------------------
499
+ document.addEventListener('keydown', e => {
500
+ if ($('shortcuts').open && e.key === 'Escape') return $('shortcuts').close()
501
+ if (e.key === '?') { e.preventDefault(); return $('shortcuts').showModal() }
502
+ const t = e.target
503
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return
504
+ if ($('session').hidden) return
505
+ const keys = ['m', 's', 'r', '1', '2', '3', 'v', 'e']
506
+ if (keys.includes(e.key)) e.preventDefault()
507
+ switch (e.key) {
508
+ case 'm': setMirror(!S.mirror); break
509
+ case 's': $('scrollMirror').checked = !$('scrollMirror').checked; S.scrollMirror = $('scrollMirror').checked; break
510
+ case 'r': $('resyncBtn').click(); break
511
+ case '1': setWidth(375); break
512
+ case '2': setWidth(768); break
513
+ case '3': setWidth(0); break
514
+ case 'v': toggleVerdict(); break
515
+ case 'e': $('teardownBtn').click(); break
516
+ case 'Escape': $('teardownBtn')._disarm && $('teardownBtn')._disarm(); if (!$('verdictBody').hidden) hideVerdictBody(); break
517
+ }
518
+ })
519
+ $('shortcutsClose').onclick = () => $('shortcuts').close()
520
+
521
+ // --- compact <900px (#176) ----------------------------------------------
522
+ const mq = window.matchMedia('(max-width: 900px)')
523
+ function applyCompact() { document.body.classList.toggle('compact', mq.matches); $('tabs').hidden = !mq.matches; if (mq.matches) selectTab(currentTab) }
524
+ let currentTab = 'base'
525
+ $('tabs').addEventListener('click', e => { const t = e.target.closest('.tab'); if (t) selectTab(t.dataset.tab) })
526
+ function selectTab(which) {
527
+ currentTab = which
528
+ for (const tab of $('tabs').querySelectorAll('.tab')) tab.classList.toggle('on', tab.dataset.tab === which)
529
+ for (const pane of document.querySelectorAll('#paneRow .pane')) pane.classList.toggle('activeTab', pane.dataset.pane === which)
530
+ }
531
+ mq.addEventListener('change', applyCompact); applyCompact()
532
+
533
+ // --- boot ----------------------------------------------------------------
534
+ window.addEventListener('hashchange', () => { if (!maybeVerdictDone()) render() })
535
+ render()
536
+ })()
537
+
538
+ // --- test exports (node --test evaluates this file through a CJS wrapper) ---
539
+
540
+ if (typeof module !== 'undefined' && module.exports) {
541
+ module.exports = { esc, isHttpsUrl, LABELS, harnessOriginNotice }
542
+ }