@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
package/lib/session.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/verdict.mjs
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/public/bridge.js
ADDED
|
@@ -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
|
+
}
|