@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.
- package/LICENSE +21 -0
- package/README.md +445 -2
- package/bin/qa-conductor-expose.mjs +113 -0
- package/lib/adapters/build-worktree.mjs +532 -0
- package/lib/adapters/exposure-tailscale.mjs +136 -0
- package/lib/adapters/provisioner-docker.mjs +116 -0
- package/lib/adapters/provisioner-process.mjs +653 -0
- package/lib/config.mjs +245 -0
- package/lib/docker.mjs +233 -0
- package/lib/exec.mjs +23 -0
- package/lib/exposure.mjs +193 -0
- package/lib/github.mjs +218 -0
- package/lib/identity.mjs +51 -0
- package/lib/net.mjs +56 -0
- package/lib/proxy.mjs +327 -0
- package/lib/request-guard.mjs +95 -0
- package/lib/server.mjs +721 -0
- package/lib/session.mjs +250 -0
- package/lib/verdict.mjs +33 -0
- package/package.json +47 -4
- package/public/bridge.js +324 -0
- package/public/harness.js +542 -0
- package/public/index.html +284 -0
|
@@ -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 => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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}¬es=${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
|
+
}
|