@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,250 @@
1
+ // Session state machine + boot/teardown pipeline for PR-QA previews.
2
+ // Pure reducer (unit-tested) + an orchestration function whose effectful
3
+ // collaborators (the five adapters, readBaseEnv) are injected.
4
+
5
+ // The two side-by-side panes. Everything else about a pane (containers,
6
+ // ports, env files, public origins) belongs to the Provisioner or to config.
7
+ export const ROLES = ['base', 'pr']
8
+
9
+ const ACTIVE = ['ensuring-image', 'cloning', 'migrating', 'starting', 'ready', 'tearing-down']
10
+ const STEPS = ['ensuring-image', 'cloning', 'migrating', 'starting']
11
+ // Boot stages that run against a provisioned pane, where a log tail makes sense.
12
+ export const PANE_STAGES = ['cloning', 'migrating', 'starting']
13
+
14
+ export function createSession() {
15
+ return {
16
+ status: 'idle',
17
+ pr: null,
18
+ baseTag: null,
19
+ prTag: null,
20
+ startedAt: null,
21
+ lastActivity: 0,
22
+ error: null,
23
+ tokens: null,
24
+ }
25
+ }
26
+
27
+ export function reduce(s, event) {
28
+ switch (event.type) {
29
+ case 'open':
30
+ if (s.status !== 'idle' && s.status !== 'error') return s
31
+ return { ...createSession(), status: 'ensuring-image', pr: event.pr }
32
+ case 'step':
33
+ if (!ACTIVE.includes(s.status) || !STEPS.includes(event.step)) return s
34
+ return { ...s, status: event.step }
35
+ case 'tags':
36
+ return { ...s, baseTag: event.baseTag ?? s.baseTag, prTag: event.prTag ?? s.prTag }
37
+ case 'ready':
38
+ if (s.status !== 'starting') return s
39
+ return { ...s, status: 'ready', startedAt: event.now, lastActivity: event.now, tokens: event.tokens }
40
+ case 'error':
41
+ if (s.status === 'idle') return s
42
+ return { ...s, status: 'error', error: { step: event.step, message: event.message } }
43
+ case 'teardown':
44
+ if (s.status === 'idle') return s
45
+ return { ...s, status: 'tearing-down' }
46
+ case 'torn-down':
47
+ return createSession()
48
+ default:
49
+ return s
50
+ }
51
+ }
52
+
53
+ export function touch(s, now) {
54
+ return { ...s, lastActivity: now }
55
+ }
56
+
57
+ export function isIdle(s, now, idleMs) {
58
+ return s.status === 'ready' && now - s.lastActivity > idleMs
59
+ }
60
+
61
+ // --- env-file derivation --------------------------------------------------
62
+
63
+ export function parseEnv(text) {
64
+ const out = {}
65
+ for (const line of text.split('\n')) {
66
+ const t = line.trim()
67
+ if (!t || t.startsWith('#')) continue
68
+ const i = t.indexOf('=')
69
+ if (i < 1) continue
70
+ out[t.slice(0, i)] = t.slice(i + 1)
71
+ }
72
+ return out
73
+ }
74
+
75
+ // Deriving a pane's env is app policy: it lives in the consumer's EnvTransform.
76
+
77
+ export function renderEnv(env) {
78
+ return `${Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n')}\n`
79
+ }
80
+
81
+ // Derive the migrate companion image for an app image tag (a helper for
82
+ // BuildConventions that publish `migrate-<tag>` images):
83
+ // ghcr.io/x/app:1.0.0-rc.38 -> ghcr.io/x/app:migrate-1.0.0-rc.38
84
+ // ghcr.io/x/app:pr-7-abc123 -> ghcr.io/x/app:migrate-pr-7-abc123
85
+ export function migrateImageFor(appImage) {
86
+ const i = appImage.lastIndexOf(':')
87
+ return `${appImage.slice(0, i)}:migrate-${appImage.slice(i + 1)}`
88
+ }
89
+
90
+ // --- boot + teardown ------------------------------------------------------
91
+
92
+ // v3 orchestrator: composes the five seams. The core owns ordering,
93
+ // cancellation and failure cleanup; it never touches docker, the filesystem
94
+ // or a registry. deps:
95
+ // adapters — { provisioner, build, seed, envTransform, auth }
96
+ // env — { operatorEmail, paneOrigins: { base, pr } }
97
+ // readBaseEnv? — async () => env map the pane env is derived from (homefree:
98
+ // the prod stack's .env). Default: {}.
99
+ // onProgress, onBuild?
100
+ // signal? — AbortSignal. The caller aborts it when the session is torn down
101
+ // or taken over. bootSession checks it between stages, between
102
+ // panes and before each launch, and threads it into
103
+ // build.ensureBuilt(pr, {signal}) and every Provisioner call
104
+ // (provisionDatabase, reserveServices, runMigrate, launchServices,
105
+ // waitHealthy) so long waits exit. Provisioners may ignore it.
106
+ // Build progress is surfaced through build.subscribeBuild when the adapter
107
+ // offers it; a missing hook is a no-op (the #186 pattern).
108
+ //
109
+ // Returns { baseTag, prTag, loginUrls, upstreams }: upstreams are each pane's
110
+ // reserved primary-service port (`app`, else the first service), which the
111
+ // pane proxies route to. A tag is the resolve* result's `label` when it gives
112
+ // one, else the primary service's ref.
113
+ //
114
+ // A failed (not aborted) boot annotates the error before tearing down: the
115
+ // failing pane's log tail as `err.logTail` (read while the pane still exists)
116
+ // and its role as `err.failedRole`.
117
+ //
118
+ // An ABORTED boot never tears down: whoever aborted it already did, and a
119
+ // newer session may now own the deterministic pane resources. (2026-09-25
120
+ // incident: a stale boot stuck in ensureBuilt survived /api/teardown, then its
121
+ // failure path destroyed the next session's containers.)
122
+ export async function bootSession(deps, prNumber) {
123
+ const { adapters, env, readBaseEnv = async () => ({}), onProgress, onBuild, signal } = deps
124
+ const { provisioner, build, seed, envTransform, auth } = adapters
125
+ const checkpoint = () => {
126
+ if (signal?.aborted) throw Object.assign(new Error('boot aborted'), { name: 'AbortError' })
127
+ }
128
+ // Where the boot is, so a failure can name its pane and read that pane's logs.
129
+ let stage = null
130
+ let role = null
131
+ const enter = next => { stage = next; role = null; onProgress(next) }
132
+ try {
133
+ enter('ensuring-image')
134
+ if (onBuild && typeof build.subscribeBuild === 'function') build.subscribeBuild(onBuild)
135
+ await build.ensureBuilt(prNumber, { signal })
136
+ checkpoint()
137
+ const imagesByRole = { base: await build.resolveBaseImages(), pr: await build.resolvePrImages(prNumber) }
138
+
139
+ checkpoint()
140
+ enter('cloning')
141
+ const panes = []
142
+ for (const r of ROLES) {
143
+ checkpoint()
144
+ role = r
145
+ const ref = { role, slug: `qa-${prNumber}-${role}`, publicOrigin: env.paneOrigins[role] }
146
+ const { dsn, db } = await provisioner.provisionDatabase({ paneRef: ref, databases: seed.databases, signal })
147
+ await seed.seedPane({ paneRef: ref, db, databases: seed.databases })
148
+ const services = await provisioner.reserveServices({ paneRef: ref, services: imagesByRole[role].services, signal })
149
+ panes.push({ ref, dsn, db, services, publicOrigin: ref.publicOrigin })
150
+ }
151
+
152
+ checkpoint()
153
+ enter('migrating')
154
+ const prodEnv = await readBaseEnv()
155
+ const authEnv = typeof auth.envContributions === 'function' ? auth.envContributions() : {}
156
+ for (const pane of panes) {
157
+ role = pane.ref.role
158
+ pane.env = mergeEnv(envTransform.derivePaneEnv({ prodEnv, pane }), authEnv)
159
+ const { migrate } = imagesByRole[pane.ref.role]
160
+ if (build.migrationStrategy === 'one-shot-image' && migrate) {
161
+ if (typeof provisioner.runMigrate !== 'function') {
162
+ throw new Error('build uses one-shot-image migrations but the provisioner has no runMigrate')
163
+ }
164
+ await provisioner.runMigrate({ paneRef: pane.ref, migrate, env: pane.env, signal })
165
+ }
166
+ }
167
+
168
+ checkpoint()
169
+ enter('starting')
170
+ for (const pane of panes) {
171
+ checkpoint()
172
+ role = pane.ref.role
173
+ await provisioner.launchServices({
174
+ paneRef: pane.ref,
175
+ services: imagesByRole[pane.ref.role].services,
176
+ env: pane.env,
177
+ reserved: pane.services,
178
+ signal,
179
+ })
180
+ }
181
+ checkpoint()
182
+ for (const pane of panes) {
183
+ role = pane.ref.role
184
+ await provisioner.waitHealthy({ services: pane.services, signal })
185
+ }
186
+
187
+ checkpoint()
188
+ const loginUrls = {}
189
+ for (const pane of panes) {
190
+ role = pane.ref.role
191
+ const args = { pane, operator: env.operatorEmail, ...(auth.requiresDb ? { db: pane.db } : {}) }
192
+ loginUrls[pane.ref.role] = await auth.establishSession(args)
193
+ }
194
+ const upstreams = {}
195
+ for (const pane of panes) upstreams[pane.ref.role] = pane.services[primaryService(pane.services)]?.port ?? null
196
+ return { baseTag: displayTag(imagesByRole.base), prTag: displayTag(imagesByRole.pr), loginUrls, upstreams }
197
+ } catch (err) {
198
+ if (!signal?.aborted) {
199
+ await annotateFailure(err, { provisioner, stage, role })
200
+ // The log read is async (docker logs): an abort that lands during it
201
+ // means a newer session may own the deterministic panes now.
202
+ if (!signal?.aborted) await teardownSession({ provisioner }).catch(() => {})
203
+ }
204
+ throw err
205
+ }
206
+ }
207
+
208
+ // Attach the failing pane's log tail and role to a boot error. Runs BEFORE
209
+ // teardown, because a Provisioner's logs usually die with its pane. An error
210
+ // that already carries a tail (e.g. a BuildConvention's install output) keeps
211
+ // it, and a failing logs() never masks the boot error.
212
+ async function annotateFailure(err, { provisioner, stage, role }) {
213
+ if (!err || typeof err !== 'object') return
214
+ const wantsTail = PANE_STAGES.includes(stage) && role && typeof err.logTail !== 'string'
215
+ if (wantsTail && typeof provisioner.logs === 'function') {
216
+ try {
217
+ const tail = await provisioner.logs({ paneRef: { role }, stage, lines: 40 })
218
+ if (typeof tail === 'string') err.logTail = tail
219
+ } catch { /* no tail */ }
220
+ }
221
+ err.failedRole = role
222
+ }
223
+
224
+ // The service a pane's proxy fronts and whose image identifies the pane.
225
+ function primaryService(services) {
226
+ return 'app' in services ? 'app' : Object.keys(services)[0]
227
+ }
228
+
229
+ // What the header and the verdict comment call a pane: the BuildConvention's
230
+ // label when it gives one (needed when refs are paths or objects), else the
231
+ // primary service's ref.
232
+ function displayTag(images) {
233
+ if (typeof images.label === 'string' && images.label) return images.label
234
+ return images.services[primaryService(images.services)]
235
+ }
236
+
237
+ function mergeEnv(perService, contributions) {
238
+ const out = { ...perService }
239
+ for (const [svc, extra] of Object.entries(contributions)) out[svc] = { ...(out[svc] ?? {}), ...extra }
240
+ return out
241
+ }
242
+
243
+ export async function teardownSession({ provisioner }) {
244
+ // The Provisioner removes everything it created for each pane (containers,
245
+ // network, env files, managed branches); teardown is idempotent for panes
246
+ // that never came up.
247
+ for (const role of ROLES) {
248
+ await provisioner.teardown({ paneRef: { role } }).catch(() => {})
249
+ }
250
+ }
@@ -0,0 +1,33 @@
1
+ // QA verdict: pure PR-comment formatter + post (comment + exclusive label).
2
+
3
+ export function formatVerdict({ verdict, notes, pr, baseTag, prTag, durationMin }) {
4
+ const approved = verdict === 'accept'
5
+ const heading = approved ? '## ✅ QA approved' : '## ❌ QA changes requested'
6
+ const body = notes && notes.trim() ? notes : '(no notes)'
7
+ return [
8
+ '<!-- qa-conductor-verdict -->',
9
+ heading,
10
+ '',
11
+ `Side-by-side QA session for #${pr}.`,
12
+ '',
13
+ body,
14
+ '',
15
+ '| pane | image |',
16
+ '| --- | --- |',
17
+ `| base | \`${baseTag}\` |`,
18
+ `| PR | \`${prTag}\` |`,
19
+ '',
20
+ `Session: ${durationMin} min`,
21
+ '',
22
+ ].join('\n')
23
+ }
24
+
25
+ export async function postVerdict({
26
+ github, pr, verdict, notes, baseTag, prTag, durationMin,
27
+ labels = { accept: 'qa-approved', reject: 'qa-changes-requested' },
28
+ }) {
29
+ const body = formatVerdict({ verdict, notes, pr, baseTag, prTag, durationMin })
30
+ const comment = await github.postComment(pr, body)
31
+ await github.setQaLabel(pr, verdict === 'accept' ? labels.accept : labels.reject)
32
+ return comment?.html_url
33
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@critical-labs/qa-conductor",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Side-by-side PR-QA harness: boots base-vs-PR app panes against cloned data behind mirrored proxies, with a pluggable five-seam adapter interface (Provisioner, BuildConvention, Seed, EnvTransform, AuthBootstrap) plus an optional Exposure seam.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/critical-labs/qa-conductor.git"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public",
16
+ "provenance": true
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "lib",
21
+ "public"
22
+ ],
23
+ "bin": {
24
+ "qa-conductor-expose": "bin/qa-conductor-expose.mjs"
25
+ },
26
+ "exports": {
27
+ ".": "./lib/server.mjs",
28
+ "./session": "./lib/session.mjs",
29
+ "./config": "./lib/config.mjs",
30
+ "./docker": "./lib/docker.mjs",
31
+ "./github": "./lib/github.mjs",
32
+ "./identity": "./lib/identity.mjs",
33
+ "./exposure": "./lib/exposure.mjs",
34
+ "./exec": "./lib/exec.mjs",
35
+ "./proxy": "./lib/proxy.mjs",
36
+ "./verdict": "./lib/verdict.mjs",
37
+ "./adapters/provisioner-docker": "./lib/adapters/provisioner-docker.mjs",
38
+ "./adapters/build-worktree": "./lib/adapters/build-worktree.mjs",
39
+ "./adapters/provisioner-process": "./lib/adapters/provisioner-process.mjs",
40
+ "./adapters/exposure-tailscale": "./lib/adapters/exposure-tailscale.mjs",
41
+ "./package.json": "./package.json"
42
+ },
43
+ "scripts": {
44
+ "test": "node --test test/*.test.mjs",
45
+ "demo": "node demo/server.mjs",
46
+ "qa": "node qa/self.mjs",
47
+ "expose": "node bin/qa-conductor-expose.mjs --config qa/self.mjs#loadSelfQaConfig"
48
+ }
49
+ }
@@ -0,0 +1,324 @@
1
+ // QA mirror bridge — injected into each pane page by the qa-conductor pane
2
+ // proxy via <script src="/__qa/bridge.js" data-harness="<harness origin>">.
3
+ // Plain browser script: no ESM, no dependencies, zero app changes.
4
+ //
5
+ // The pure helpers (buildSelector / resolveSelector) live at top level and are
6
+ // exported through the CommonJS guard at the bottom so `node --test` can
7
+ // exercise them; the runtime IIFE is inert outside a browser.
8
+
9
+ // --- pure selector helpers -------------------------------------------------
10
+
11
+ function attrEscape(value) {
12
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
13
+ }
14
+
15
+ function nthOfType(el) {
16
+ const siblings = el.parentNode && el.parentNode.children ? el.parentNode.children : []
17
+ let n = 0
18
+ for (let i = 0; i < siblings.length; i++) {
19
+ if (siblings[i].tagName === el.tagName) {
20
+ n += 1
21
+ if (siblings[i] === el) return n
22
+ }
23
+ }
24
+ return 1
25
+ }
26
+
27
+ function structuralPath(el) {
28
+ const segments = []
29
+ let node = el
30
+ while (node && node.tagName && node.tagName !== 'BODY' && node.tagName !== 'HTML') {
31
+ segments.unshift(node.tagName + ':nth-of-type(' + nthOfType(node) + ')')
32
+ node = node.parentNode
33
+ }
34
+ return segments.join('>')
35
+ }
36
+
37
+ // Selector ladder, most stable first: data-testid → id → aria-label →
38
+ // button/link text → structural nth-of-type path from <body>. Returns a small
39
+ // descriptor object the peer pane resolves with resolveSelector.
40
+ function buildSelector(el) {
41
+ const testid = el.getAttribute('data-testid')
42
+ if (testid) return { t: 'testid', v: testid }
43
+ if (el.id) return { t: 'id', v: el.id }
44
+ const aria = el.getAttribute('aria-label')
45
+ if (aria) return { t: 'aria', v: aria }
46
+ if (el.tagName === 'BUTTON' || el.tagName === 'A') {
47
+ const text = (el.textContent || '').trim()
48
+ if (text.length >= 1 && text.length <= 60 && textIsUnique(el, text)) return { t: 'text', tag: el.tagName, v: text }
49
+ }
50
+ return { t: 'path', v: structuralPath(el) }
51
+ }
52
+
53
+ // Text only identifies an element when no other same-tag element on the page
54
+ // carries it: the peer resolves an ambiguous text match to nothing, so a
55
+ // repeated label ("Open", "Edit", one per row) must fall through to the path.
56
+ function textIsUnique(el, text) {
57
+ let root = el
58
+ while (root.parentNode) root = root.parentNode
59
+ const same = collectByTag(root, el.tagName, [])
60
+ let count = 0
61
+ for (let i = 0; i < same.length; i++) {
62
+ if ((same[i].textContent || '').trim() === text) count += 1
63
+ }
64
+ return count === 1
65
+ }
66
+
67
+ function collectByTag(root, tag, out) {
68
+ const kids = root && root.children ? root.children : []
69
+ for (let i = 0; i < kids.length; i++) {
70
+ if (kids[i].tagName === tag) out.push(kids[i])
71
+ collectByTag(kids[i], tag, out)
72
+ }
73
+ return out
74
+ }
75
+
76
+ function nthChildOfType(parent, tag, n) {
77
+ const kids = parent && parent.children ? parent.children : []
78
+ let seen = 0
79
+ for (let i = 0; i < kids.length; i++) {
80
+ if (kids[i].tagName === tag) {
81
+ seen += 1
82
+ if (seen === n) return kids[i]
83
+ }
84
+ }
85
+ return null
86
+ }
87
+
88
+ // Resolve a descriptor against a document. Returns the element, or null when
89
+ // it does not match exactly one element — "where UI matches" is literal, so an
90
+ // ambiguous text match is a non-match.
91
+ function resolveSelector(desc, doc) {
92
+ if (!desc || !doc) return null
93
+ if (desc.t === 'testid') return doc.querySelector('[data-testid="' + attrEscape(desc.v) + '"]') || null
94
+ if (desc.t === 'id') return doc.querySelector('[id="' + attrEscape(desc.v) + '"]') || null
95
+ if (desc.t === 'aria') return doc.querySelector('[aria-label="' + attrEscape(desc.v) + '"]') || null
96
+ if (desc.t === 'text') {
97
+ const candidates = collectByTag(doc.body, desc.tag, [])
98
+ const matches = []
99
+ for (let i = 0; i < candidates.length; i++) {
100
+ if ((candidates[i].textContent || '').trim() === desc.v) matches.push(candidates[i])
101
+ }
102
+ return matches.length === 1 ? matches[0] : null
103
+ }
104
+ if (desc.t === 'path') {
105
+ if (typeof desc.v !== 'string') return null
106
+ if (desc.v === '') return doc.body || null
107
+ let node = doc.body
108
+ const segments = desc.v.split('>')
109
+ for (let i = 0; i < segments.length; i++) {
110
+ const m = /^([A-Za-z0-9-]+):nth-of-type\((\d+)\)$/.exec(segments[i])
111
+ if (!m || !node) return null
112
+ node = nthChildOfType(node, m[1], Number(m[2]))
113
+ }
114
+ return node || null
115
+ }
116
+ return null
117
+ }
118
+
119
+ // --- browser runtime -------------------------------------------------------
120
+
121
+ ;(function () {
122
+ if (typeof window === 'undefined' || typeof document === 'undefined') return
123
+ if (window.__qaBridgeInstalled) return
124
+ window.__qaBridgeInstalled = true
125
+
126
+ // The harness origin comes from the pane proxy, on the tag that loaded this
127
+ // script (data-harness), never from the page URL: whoever frames or opens a
128
+ // pane chooses its URL. Without it the bridge neither sends nor replays.
129
+ const script = document.currentScript
130
+ const harnessOrigin = (script && script.dataset && script.dataset.harness) || null
131
+
132
+ // Only the harness, as this pane's parent frame, may drive or hear it. A
133
+ // top-level window (the harness's "Open in new tab") never mirrors.
134
+ function framedByHarness() {
135
+ return !!harnessOrigin && window.parent !== window
136
+ }
137
+
138
+ function send(msg) {
139
+ if (!framedByHarness()) return
140
+ try {
141
+ // targetOrigin, not '*': a parent on any other origin never receives
142
+ // it, so nav paths, captured values and unmatched replies stay with the
143
+ // harness
144
+ window.parent.postMessage(msg, harnessOrigin)
145
+ } catch (err) {
146
+ // parent gone, or a malformed origin (SyntaxError) — nothing to mirror to
147
+ }
148
+ }
149
+
150
+ // Heartbeat: a lightweight liveness ping so the harness can tell a live pane
151
+ // from a crashed or navigated-away one. Reuses send() for its postMessage +
152
+ // try/catch; independent of the capture/replay paths.
153
+ setInterval(function () {
154
+ send({ qa: 1, kind: 'ping' })
155
+ }, 5000)
156
+
157
+ function valueOf(el) {
158
+ const tag = el.tagName
159
+ if (tag === 'INPUT') {
160
+ if (el.type === 'checkbox' || el.type === 'radio') return el.checked
161
+ return el.value
162
+ }
163
+ if (tag === 'TEXTAREA' || tag === 'SELECT') return el.value
164
+ return undefined
165
+ }
166
+
167
+ // --- capture side ---------------------------------------------------------
168
+
169
+ const MIRRORED_KEYS = ['Enter', 'Escape', 'Tab']
170
+
171
+ function onCaptured(event) {
172
+ if (window.__qaReplaying) return
173
+ const target = event.target
174
+ if (!target || !target.tagName) return
175
+ // File pickers cannot be mirrored (browser security) — do those per-pane.
176
+ if (target.tagName === 'INPUT' && target.type === 'file') return
177
+ if (event.type === 'keydown' && MIRRORED_KEYS.indexOf(event.key) === -1) return
178
+ const msg = { qa: 1, kind: 'event', type: event.type, selector: buildSelector(target) }
179
+ if (event.type === 'keydown') msg.key = event.key
180
+ const value = valueOf(target)
181
+ if (value !== undefined) msg.value = value
182
+ send(msg)
183
+ }
184
+
185
+ const MIRRORED_EVENTS = ['click', 'dblclick', 'input', 'change', 'submit', 'keydown']
186
+ for (let i = 0; i < MIRRORED_EVENTS.length; i++) {
187
+ document.addEventListener(MIRRORED_EVENTS[i], onCaptured, true)
188
+ }
189
+
190
+ // Page scroll, coalesced to one message per animation frame (smooth, near
191
+ // real-time tracking rather than a 150 ms lurch). Each message carries both
192
+ // the absolute offset and the fraction of the scrollable extent plus that
193
+ // extent, so the receiver can hold the panes proportionally aligned when the
194
+ // two documents have different heights (base vs PR diff). A replayed scrollTo
195
+ // fires its own scroll event asynchronously, so capture is suppressed briefly
196
+ // after a replay to avoid echo loops.
197
+ function scrollMetrics() {
198
+ const de = document.documentElement
199
+ const maxX = Math.max(1, de.scrollWidth - de.clientWidth)
200
+ const maxY = Math.max(1, de.scrollHeight - de.clientHeight)
201
+ return { x: window.scrollX, y: window.scrollY, fx: window.scrollX / maxX, fy: window.scrollY / maxY, maxX: maxX, maxY: maxY }
202
+ }
203
+ let scrollScheduled = false
204
+ let suppressScrollUntil = 0
205
+ document.addEventListener('scroll', function (event) {
206
+ if (window.__qaReplaying || Date.now() < suppressScrollUntil) return
207
+ if (event.target !== document && event.target !== document.documentElement) return
208
+ if (scrollScheduled) return
209
+ scrollScheduled = true
210
+ requestAnimationFrame(function () {
211
+ scrollScheduled = false
212
+ if (window.__qaReplaying || Date.now() < suppressScrollUntil) return
213
+ const m = scrollMetrics()
214
+ send({ qa: 1, kind: 'event', type: 'scroll', scroll: [m.x, m.y], frac: [m.fx, m.fy], ext: [m.maxX, m.maxY] })
215
+ })
216
+ }, true)
217
+
218
+ // --- replay side ----------------------------------------------------------
219
+
220
+ function applyValue(el, value) {
221
+ const tag = el.tagName
222
+ if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
223
+ el.checked = !!value
224
+ el.dispatchEvent(new Event('change', { bubbles: true }))
225
+ return
226
+ }
227
+ if (tag === 'SELECT') {
228
+ el.value = value
229
+ el.dispatchEvent(new Event('change', { bubbles: true }))
230
+ return
231
+ }
232
+ if (tag === 'INPUT' || tag === 'TEXTAREA') {
233
+ // React tracks the value property descriptor, so go through the native
234
+ // prototype setter to make controlled components observe the change.
235
+ const proto = tag === 'INPUT' ? window.HTMLInputElement.prototype : window.HTMLTextAreaElement.prototype
236
+ const desc = Object.getOwnPropertyDescriptor(proto, 'value')
237
+ if (desc && desc.set) desc.set.call(el, value)
238
+ else el.value = value
239
+ el.dispatchEvent(new Event('input', { bubbles: true }))
240
+ el.dispatchEvent(new Event('change', { bubbles: true }))
241
+ }
242
+ }
243
+
244
+ function replay(data) {
245
+ if (data.type === 'scroll') {
246
+ if (Array.isArray(data.scroll)) {
247
+ suppressScrollUntil = Date.now() + 200
248
+ const de = document.documentElement
249
+ const myMaxX = Math.max(1, de.scrollWidth - de.clientWidth)
250
+ const myMaxY = Math.max(1, de.scrollHeight - de.clientHeight)
251
+ let x = data.scroll[0]
252
+ let y = data.scroll[1]
253
+ // When this pane's scrollable extent differs materially from the
254
+ // sender's, follow the proportional position instead of the raw pixel
255
+ // offset so the same region stays visible in both panes.
256
+ if (data.ext && data.frac) {
257
+ if (Math.abs(data.ext[1] - myMaxY) > 4) y = Math.round(data.frac[1] * myMaxY)
258
+ if (Math.abs(data.ext[0] - myMaxX) > 4) x = Math.round(data.frac[0] * myMaxX)
259
+ }
260
+ window.scrollTo(x, y)
261
+ }
262
+ return
263
+ }
264
+ const el = resolveSelector(data.selector, document)
265
+ if (!el) {
266
+ send({ qa: 1, kind: 'unmatched', type: data.type })
267
+ return
268
+ }
269
+ if (data.type === 'click' || data.type === 'dblclick') {
270
+ el.click()
271
+ return
272
+ }
273
+ if (data.type === 'keydown') {
274
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: data.key, bubbles: true, cancelable: true }))
275
+ return
276
+ }
277
+ if (data.type === 'input' || data.type === 'change') {
278
+ applyValue(el, data.value)
279
+ }
280
+ // 'submit' is captured for the harness but deliberately not replayed: the
281
+ // mirrored click that raised it already drives the peer's own submit path,
282
+ // and replaying it as well would double-submit.
283
+ }
284
+
285
+ window.addEventListener('message', function (event) {
286
+ if (!framedByHarness() || event.source !== window.parent || event.origin !== harnessOrigin) return
287
+ const data = event.data
288
+ if (!data || data.qa !== 1 || data.kind !== 'replay') return
289
+ window.__qaReplaying = true
290
+ try {
291
+ replay(data)
292
+ } finally {
293
+ window.__qaReplaying = false
294
+ }
295
+ })
296
+
297
+ // --- navigation notifications --------------------------------------------
298
+ // The harness only uses these for its per-pane URL indicators.
299
+
300
+ function sendNav() {
301
+ send({ qa: 1, kind: 'nav', href: window.location.pathname + window.location.search })
302
+ }
303
+
304
+ const originalPushState = window.history.pushState
305
+ window.history.pushState = function () {
306
+ const result = originalPushState.apply(this, arguments)
307
+ sendNav()
308
+ return result
309
+ }
310
+ const originalReplaceState = window.history.replaceState
311
+ window.history.replaceState = function () {
312
+ const result = originalReplaceState.apply(this, arguments)
313
+ sendNav()
314
+ return result
315
+ }
316
+ window.addEventListener('popstate', sendNav)
317
+ sendNav()
318
+ })()
319
+
320
+ // --- test exports (node --test evaluates this file through a CJS wrapper) ---
321
+
322
+ if (typeof module !== 'undefined' && module.exports) {
323
+ module.exports = { buildSelector, resolveSelector }
324
+ }