kaloko 0.4.9 → 0.4.10

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/src/lib/ci.mjs ADDED
@@ -0,0 +1,207 @@
1
+ // Packaged CI (#15): `kaloko ci github|gitlab` writes a workflow into the customer's repository that walks the
2
+ // affected scenarios on the pull request's preview deployment and comments on the PR; `kaloko ci preview-url` finds
3
+ // that deployment at run time (Vercel, Netlify, Cloudflare Pages and anything that reports GitHub deployments).
4
+ import { readFile, appendFile } from 'node:fs/promises'
5
+
6
+ const clean = (u) => (u ? (/^https?:\/\//.test(u) ? u : `https://${u}`).replace(/\/$/, '') : null)
7
+
8
+ /** Preview URL from variables CI and hosting providers set; { url, source } or null. */
9
+ export function previewFromEnv (vars = process.env, event = null) {
10
+ if (vars.KALOKO_PREVIEW_URL) return { url: clean(vars.KALOKO_PREVIEW_URL), source: 'KALOKO_PREVIEW_URL' }
11
+ const ds = event?.deployment_status
12
+ if (ds && ds.state === 'success' && (ds.environment_url || ds.target_url)) return { url: clean(ds.environment_url || ds.target_url), source: `deployment_status (${ds.environment || event.deployment?.environment || 'deployment'})` }
13
+ for (const [name, label] of [['VERCEL_BRANCH_URL', 'Vercel'], ['VERCEL_URL', 'Vercel'], ['DEPLOY_PRIME_URL', 'Netlify'], ['DEPLOY_URL', 'Netlify'], ['CF_PAGES_URL', 'Cloudflare Pages'], ['CI_ENVIRONMENT_URL', 'GitLab environment'], ['RENDER_EXTERNAL_URL', 'Render'], ['RAILWAY_PUBLIC_DOMAIN', 'Railway']]) {
14
+ if (vars[name]) return { url: clean(vars[name]), source: `${name} (${label})` }
15
+ }
16
+ return null
17
+ }
18
+
19
+ async function readEvent (vars) {
20
+ if (!vars.GITHUB_EVENT_PATH) return null
21
+ try { return JSON.parse(await readFile(vars.GITHUB_EVENT_PATH, 'utf8')) } catch { return null }
22
+ }
23
+
24
+ function github (vars, fetchImpl) {
25
+ const api = (vars.GITHUB_API_URL || 'https://api.github.com').replace(/\/$/, '')
26
+ const token = vars.GITHUB_TOKEN || vars.GH_TOKEN
27
+ return async (path) => {
28
+ const res = await fetchImpl(`${api}${path}`, { headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', ...(token ? { Authorization: `Bearer ${token}` } : {}) } })
29
+ if (!res.ok) throw new Error(`GitHub ${path} → HTTP ${res.status}`)
30
+ return res.json()
31
+ }
32
+ }
33
+
34
+ /** The pull request a CI run belongs to: { number, base, head, sha } from the event or, for a deployment, its commit. */
35
+ export async function pullRequestOf ({ vars = process.env, event = undefined, fetchImpl = fetch } = {}) {
36
+ event = event === undefined ? await readEvent(vars) : event
37
+ if (vars.KALOKO_PR) return { number: Number(vars.KALOKO_PR), base: vars.KALOKO_BASE?.replace(/^origin\//, '') || null, sha: vars.GITHUB_SHA || null }
38
+ if (event?.pull_request) return { number: event.pull_request.number, base: event.pull_request.base?.ref || null, head: event.pull_request.head?.ref || null, sha: event.pull_request.head?.sha || null }
39
+ if (vars.CI_MERGE_REQUEST_IID) return { number: Number(vars.CI_MERGE_REQUEST_IID), base: vars.CI_MERGE_REQUEST_TARGET_BRANCH_NAME || null, head: vars.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || null, sha: vars.CI_COMMIT_SHA || null }
40
+ const sha = event?.deployment?.sha || vars.GITHUB_SHA
41
+ if (sha && vars.GITHUB_REPOSITORY) {
42
+ try {
43
+ const pulls = await github(vars, fetchImpl)(`/repos/${vars.GITHUB_REPOSITORY}/commits/${sha}/pulls`)
44
+ const pr = pulls.find((p) => p.state === 'open') || pulls[0]
45
+ if (pr) return { number: pr.number, base: pr.base?.ref || null, head: pr.head?.ref || null, sha }
46
+ } catch {}
47
+ }
48
+ return null
49
+ }
50
+
51
+ /**
52
+ * The preview deployment of this commit: variables first, then GitHub deployments of the commit (Vercel, Netlify and
53
+ * Cloudflare Pages report theirs there), polled until one succeeds or `wait` seconds pass. Production is skipped.
54
+ */
55
+ export async function findPreview ({ vars = process.env, fetchImpl = fetch, wait = 0, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), say = () => {} } = {}) {
56
+ const event = await readEvent(vars)
57
+ const direct = previewFromEnv(vars, event)
58
+ if (direct) return direct
59
+ const sha = event?.pull_request?.head?.sha || event?.deployment?.sha || vars.GITHUB_SHA
60
+ if (!sha || !vars.GITHUB_REPOSITORY) return null
61
+ const gh = github(vars, fetchImpl)
62
+ const until = Date.now() + wait * 1000
63
+ for (let round = 0; ; round++) {
64
+ try {
65
+ const deployments = await gh(`/repos/${vars.GITHUB_REPOSITORY}/deployments?sha=${sha}&per_page=30`)
66
+ for (const d of deployments.filter((x) => !/^prod/i.test(x.environment || '') && !x.production_environment)) {
67
+ const statuses = await gh(`/repos/${vars.GITHUB_REPOSITORY}/deployments/${d.id}/statuses?per_page=10`)
68
+ const ok = statuses.find((s) => s.state === 'success' && (s.environment_url || s.target_url))
69
+ if (ok) return { url: clean(ok.environment_url || ok.target_url), source: `GitHub deployment ${d.environment}` }
70
+ }
71
+ } catch (e) { if (round === 0) say(` (GitHub deployments not readable: ${e.message})`) }
72
+ if (Date.now() >= until) return null
73
+ if (round === 0) say(`Waiting up to ${wait} s for a preview deployment of ${sha.slice(0, 7)}…`)
74
+ await sleep(10000)
75
+ }
76
+ }
77
+
78
+ /** Lines for $GITHUB_ENV (or a dotenv file): the preview URL, the PR and its base for --affected and share --pr. */
79
+ export function envLines ({ preview, pr }) {
80
+ return [
81
+ preview && `KALOKO_PREVIEW_URL=${preview.url}`,
82
+ pr?.number && `KALOKO_PR=${pr.number}`,
83
+ pr?.base && `KALOKO_BASE=origin/${pr.base}`,
84
+ pr?.head && `KALOKO_BRANCH=${pr.head}`
85
+ ].filter(Boolean)
86
+ }
87
+
88
+ export const writeEnvFile = (file, lines) => appendFile(file, lines.map((l) => l + '\n').join(''))
89
+
90
+ const q = (s) => /^[A-Za-z0-9_./-]+$/.test(s) ? s : JSON.stringify(s)
91
+
92
+ /** How the workflow runs kaloko: through the project's package manager when kaloko is a dependency, else npx. */
93
+ export function runner ({ pm = 'npm', dependency = true, version = 'latest' }) {
94
+ if (!dependency) return { install: null, kaloko: `npx --yes kaloko@${version}` }
95
+ return {
96
+ install: { npm: 'npm ci', pnpm: 'pnpm install --frozen-lockfile', yarn: 'yarn install --frozen-lockfile', bun: 'bun install --frozen-lockfile' }[pm] || 'npm ci',
97
+ kaloko: { npm: 'npx kaloko', pnpm: 'pnpm exec kaloko', yarn: 'yarn kaloko', bun: 'bunx kaloko' }[pm] || 'npx kaloko'
98
+ }
99
+ }
100
+
101
+ /** The loop over scenarios both workflows run: walk what the change reaches, share, comment on the PR. */
102
+ function walkScript ({ scenarios, env, affected, k }) {
103
+ return [
104
+ 'status=0',
105
+ `for s in ${scenarios.map(q).join(' ')}; do`,
106
+ ...(affected ? [` if ! ${k} affected --scenario "$s" --check; then echo "Nothing in $s is affected; skipped."; continue; fi`] : []),
107
+ ` ${k} start --scenario "$s" --env ${env}`,
108
+ ` ${k} walk --ephemeral${affected ? ' --affected' : ''} || status=1`,
109
+ ` ${k} share --pr || status=1`,
110
+ 'done',
111
+ 'exit $status'
112
+ ]
113
+ }
114
+
115
+ /** .github/workflows/kaloko.yml */
116
+ export function githubWorkflow ({ scenarios, env = 'preview', trigger = 'deployment', affected = true, pm = 'npm', dependency = true, version = 'latest', node = 22 }) {
117
+ const { install, kaloko: k } = runner({ pm, dependency, version })
118
+ const indent = (lines, n) => lines.map((l) => ' '.repeat(n) + l).join('\n')
119
+ const on = trigger === 'deployment'
120
+ ? ' # Vercel, Netlify and Cloudflare Pages report each preview as a GitHub deployment\n deployment_status:\n workflow_dispatch:'
121
+ : ' pull_request:\n workflow_dispatch:'
122
+ const guard = trigger === 'deployment'
123
+ ? " if: github.event_name != 'deployment_status' || (github.event.deployment_status.state == 'success' && !startsWith(github.event.deployment_status.environment, 'Production') && !startsWith(github.event.deployment_status.environment, 'production'))\n"
124
+ : ''
125
+ return `# Kaloko: walk the scenarios this pull request affects on its preview deployment and comment on the PR.
126
+ # Written by kaloko ci github. Secrets: KALOKO_TOKEN (tester role, upload scope; on Enterprise a service account's);
127
+ # TYPESAFE_API_KEY only if semantic criteria should use your own evaluator key.
128
+ name: Kaloko
129
+ on:
130
+ ${on}
131
+ concurrency:
132
+ group: kaloko-\${{ github.event.deployment.sha || github.event.pull_request.head.sha || github.sha }}
133
+ cancel-in-progress: true
134
+ permissions:
135
+ contents: read
136
+ deployments: read
137
+ pull-requests: write
138
+ jobs:
139
+ walk:
140
+ ${guard} runs-on: ubuntu-latest
141
+ timeout-minutes: 30
142
+ env:
143
+ KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
144
+ TYPESAFE_API_KEY: \${{ secrets.TYPESAFE_API_KEY }}
145
+ GH_TOKEN: \${{ github.token }}
146
+ KALOKO_AGENT: github-actions
147
+ KALOKO_BROWSER_CHANNEL: chromium
148
+ steps:
149
+ - uses: actions/checkout@v4
150
+ with:
151
+ ref: \${{ github.event.deployment.sha || github.event.pull_request.head.sha || github.sha }}
152
+ fetch-depth: 0 # walk --affected compares with the pull request's base
153
+ - uses: actions/setup-node@v4
154
+ with:
155
+ node-version: ${node}
156
+ ${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - run: corepack enable\n' : ''} - run: ${install}\n` : ''} - run: ${k} browser install --with-deps # the Chromium of kaloko's own Playwright
157
+ - name: Preview deployment
158
+ # the deployment's URL, the pull request and its base; waits for the preview when it is still building
159
+ run: ${k} ci preview-url --wait 600 --github-env
160
+ - name: Walk, share, comment
161
+ run: |
162
+ ${indent(walkScript({ scenarios, env, affected, k }), 10)}
163
+ `
164
+ }
165
+
166
+ /** kaloko.gitlab-ci.yml, included from .gitlab-ci.yml */
167
+ export function gitlabJob ({ scenarios, env = 'preview', affected = true, pm = 'npm', dependency = true, version = 'latest', playwright = '1.55.0' }) {
168
+ const { install, kaloko: k } = runner({ pm, dependency, version })
169
+ const indent = (lines, n) => lines.map((l) => ' '.repeat(n) + l).join('\n')
170
+ return `# Kaloko: walk the scenarios a merge request affects on its review app and comment on the MR.
171
+ # Written by kaloko ci gitlab. Include it from .gitlab-ci.yml: include: { local: kaloko.gitlab-ci.yml }
172
+ # CI/CD variables (masked): KALOKO_TOKEN (tester role, upload scope); KALOKO_GITLAB_TOKEN (project access
173
+ # token with the api scope) for the MR comment; TYPESAFE_API_KEY optional.
174
+ kaloko:
175
+ stage: test # run it after the job that deploys the review app (needs: [deploy_review])
176
+ image: mcr.microsoft.com/playwright:v${playwright}-noble
177
+ rules:
178
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
179
+ variables:
180
+ GIT_DEPTH: "0" # walk --affected compares with the target branch
181
+ KALOKO_BROWSER_CHANNEL: chromium
182
+ KALOKO_AGENT: gitlab-ci
183
+ # KALOKO_PREVIEW_URL: https://$CI_COMMIT_REF_SLUG.review.example.com # when the review app URL is known
184
+ script:
185
+ ${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - corepack enable\n' : ''} - ${install}\n` : ''} - ${k} browser install
186
+ - export KALOKO_PREVIEW_URL="$(${k} ci preview-url --wait 600)"
187
+ - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
188
+ - |
189
+ ${indent(walkScript({ scenarios, env, affected, k }), 6)}
190
+ `
191
+ }
192
+
193
+ /** Adds the preview environment to kaloko.config.yml text (block style); null when it cannot be placed safely. */
194
+ export function addPreviewEnvironment (text, name = 'preview') {
195
+ if (new RegExp(`^ ${name}:`, 'm').test(text)) return text
196
+ const m = text.match(/^environments:\s*$/m)
197
+ if (!m) return null
198
+ const block = [
199
+ ` ${name}: # pull request previews (kaloko ci): the URL comes from CI`,
200
+ ' base_url: ${KALOKO_PREVIEW_URL}',
201
+ ' # access: # Vercel deployment protection: a bypass for automation',
202
+ ' # headers:',
203
+ ' # x-vercel-protection-bypass: { env: VERCEL_AUTOMATION_BYPASS_SECRET }'
204
+ ].join('\n')
205
+ const at = m.index + m[0].length
206
+ return text.slice(0, at) + '\n' + block + text.slice(at)
207
+ }
@@ -48,6 +48,8 @@ export async function loadConfig (start = process.cwd(), explicit) {
48
48
  config.output ||= !(await exists(join(dirname(file), 'tmp', 'kaloko'))) && (await exists(join(dirname(file), 'tmp', 'qawalk'))) ? 'tmp/qawalk' : 'tmp/kaloko'
49
49
  config.browser ||= {}
50
50
  config.browser.port ||= 9333
51
+ // CI runs the bundled Chromium without a separate config: KALOKO_BROWSER_CHANNEL=chromium (or chrome, msedge)
52
+ if (process.env.KALOKO_BROWSER_CHANNEL != null) config.browser.channel = ['', 'chromium', 'bundled'].includes(process.env.KALOKO_BROWSER_CHANNEL) ? undefined : process.env.KALOKO_BROWSER_CHANNEL
51
53
  return { config, root: dirname(file), file }
52
54
  }
53
55
 
@@ -101,8 +101,10 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
101
101
  const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && !(c.check.about === 'console' && !(capture.console_errors || []).length))
102
102
  // quick signals ride along on every captured page state (also steps without semantic criteria)
103
103
  const signals = settings.signals !== false && !outOfSample && !!capture.state
104
- const request = (semantic.length || signals) && current ? buildRequest({ node, capture, criteria: semantic, model: settings.model, signals }) : null
105
- work.push({ entry, node, capture, current, outOfSample, request, asks: semantic.length > 0, jev: current ? null : { skipped: 'Capture has no matching criteria fingerprint; capture this step again.' } })
104
+ // an imported screen without page text (#11) has nothing to ask about: a person decides on the canvas
105
+ const textless = capture.capture?.source === 'import' && !capture.state
106
+ const request = (semantic.length || signals) && current && !textless ? buildRequest({ node, capture, criteria: semantic, model: settings.model, signals }) : null
107
+ work.push({ entry, node, capture, current, outOfSample, request, asks: semantic.length > 0, jev: !current ? { skipped: 'Capture has no matching criteria fingerprint; capture this step again.' } : textless && semantic.length ? { skipped: 'imported screenshot without page text; a person decides on the canvas' } : null })
106
108
  if (!current) report.outdated.add(entry.node)
107
109
  }
108
110
 
package/src/lib/git.mjs CHANGED
@@ -59,12 +59,16 @@ export async function gitMeta (cwd) {
59
59
  out('git', ['rev-parse', 'HEAD'], cwd)
60
60
  ])
61
61
  let pr = null
62
- const json = await out('gh', ['pr', 'view', '--json', 'number,url,state'], cwd)
62
+ const vars = process.env
63
+ // CI checks out a detached commit: the branch and pull request come from the environment
64
+ const ciBranch = vars.KALOKO_BRANCH || vars.GITHUB_HEAD_REF || vars.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || vars.CI_COMMIT_REF_NAME || null
65
+ if (vars.GITLAB_CI && vars.CI_MERGE_REQUEST_IID) pr = { number: Number(vars.CI_MERGE_REQUEST_IID), url: `${vars.CI_MERGE_REQUEST_PROJECT_URL || vars.CI_PROJECT_URL}/-/merge_requests/${vars.CI_MERGE_REQUEST_IID}`, state: 'open' }
66
+ const json = pr ? null : await out('gh', ['pr', 'view', ...(vars.KALOKO_PR ? [String(vars.KALOKO_PR)] : []), '--json', 'number,url,state'], cwd)
63
67
  if (json) {
64
68
  try {
65
69
  const p = JSON.parse(json)
66
70
  pr = { number: p.number, url: p.url, state: String(p.state).toLowerCase() }
67
71
  } catch {}
68
72
  }
69
- return { branch, commit, sha, message, author, pr }
73
+ return { branch: branch === 'HEAD' && ciBranch ? ciBranch : branch, commit, sha, message, author, pr }
70
74
  }
@@ -0,0 +1,207 @@
1
+ // Self-healing selectors (#13): when a step script waits in vain for a selector, propose a replacement from the page
2
+ // as it is now (role and accessible name, test ids, text), preferring the element the old selector matched in the
3
+ // step's previous capture. The proposal is printed as a patch; `walk --heal` applies it to the script only after the
4
+ // step passed with it. Nothing is ever changed silently.
5
+ import { readFile, writeFile, rm, readdir } from 'node:fs/promises'
6
+ import { join, dirname, basename, relative, resolve } from 'node:path'
7
+ import { pathToFileURL } from 'node:url'
8
+ import { readJson } from './util.mjs'
9
+
10
+ /** The selector a Playwright timeout waited for, or null (only plain locator strings can be patched). */
11
+ export function failedSelector (message) {
12
+ const text = String(message || '')
13
+ if (!/Timeout \d+ms exceeded|waiting for/i.test(text) || /strict mode violation/i.test(text)) return null
14
+ const unq = (s, q) => s.replace(new RegExp(`\\\\${q}`, 'g'), q)
15
+ let m = text.match(/waiting for locator\('((?:\\'|[^'])+)'\)/)
16
+ if (m) return unq(m[1], "'")
17
+ m = text.match(/waiting for locator\("((?:\\"|[^"])+)"\)/)
18
+ if (m) return unq(m[1], '"')
19
+ m = text.match(/waiting for selector "((?:\\"|[^"])+)"/)
20
+ if (m) return unq(m[1], '"')
21
+ return null
22
+ }
23
+
24
+ /** Words a selector is made of: ids, classes, attribute values and text, split on punctuation and camelCase. */
25
+ export function tokensOf (selector) {
26
+ return [...new Set(String(selector)
27
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
28
+ .toLowerCase()
29
+ .split(/[^a-z0-9áčďéěíňóřšťúůýž]+/i)
30
+ .filter((w) => w.length > 1 && !['text', 'has', 'nth', 'first', 'last', 'visible', 'true', 'role', 'name', 'internal', 'data', 'testid', 'test', 'id', 'qa', 'cy', 'div', 'span'].includes(w)))]
31
+ }
32
+
33
+ /**
34
+ * In the page (self-contained, passed to evaluate): interactive and labelled elements with role, accessible name,
35
+ * test id, id and classes; given an element, that element only.
36
+ */
37
+ function describeAll (limit) {
38
+ const TEST = ['data-testid', 'data-test', 'data-qa', 'data-cy', 'data-test-id']
39
+ const roleOf = (el) => {
40
+ const r = el.getAttribute('role'); if (r) return r
41
+ const t = el.tagName.toLowerCase()
42
+ if (t === 'a' && el.hasAttribute('href')) return 'link'
43
+ if (t === 'button' || (t === 'input' && ['button', 'submit', 'reset'].includes(el.type))) return 'button'
44
+ if (t === 'input') return { checkbox: 'checkbox', radio: 'radio', range: 'slider', search: 'searchbox' }[el.type] || 'textbox'
45
+ if (t === 'textarea') return 'textbox'
46
+ if (t === 'select') return 'combobox'
47
+ if (/^h[1-6]$/.test(t)) return 'heading'
48
+ if (t === 'img') return 'img'
49
+ return null
50
+ }
51
+ const text = (s) => String(s || '').replace(/\s+/g, ' ').trim()
52
+ const nameOf = (el) => {
53
+ const l = el.getAttribute('aria-label'); if (l) return text(l)
54
+ const by = el.getAttribute('aria-labelledby'); if (by) { const t = by.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' '); if (text(t)) return text(t) }
55
+ if (el.id) { const lab = document.querySelector(`label[for="${CSS.escape(el.id)}"]`); if (lab) return text(lab.textContent) }
56
+ const wrap = el.closest('label'); if (wrap && wrap !== el && /^(input|select|textarea)$/i.test(el.tagName)) return text(wrap.textContent)
57
+ if (/^(input|textarea)$/i.test(el.tagName)) return text(el.getAttribute('placeholder') || el.getAttribute('title') || (['button', 'submit'].includes(el.type) ? el.value : ''))
58
+ if (el.tagName === 'IMG') return text(el.getAttribute('alt'))
59
+ return text(el.innerText || el.textContent).slice(0, 80)
60
+ }
61
+ const describe = (el) => {
62
+ const testid = TEST.map((a) => el.getAttribute(a) ? [a, el.getAttribute(a)] : null).find(Boolean) || null
63
+ const r = el.getBoundingClientRect(); const cs = getComputedStyle(el)
64
+ return { tag: el.tagName.toLowerCase(), role: roleOf(el), name: nameOf(el), testid, id: el.id || null, classes: [...el.classList].slice(0, 8), visible: r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' }
65
+ }
66
+ if (typeof limit === 'object' && limit?.nodeType === 1) return describe(limit)
67
+ const sel = 'a[href], button, input, select, textarea, summary, [role], [aria-label], [data-testid], [data-test], [data-qa], [data-cy], [data-test-id], h1, h2, h3, label'
68
+ return [...document.querySelectorAll(sel)].slice(0, limit).map(describe).filter((d) => d.visible && (d.name || d.testid || d.id))
69
+ }
70
+
71
+ /** The element the old selector matched in the previous capture of this step (its HTML), described; null if none. */
72
+ async function previousFingerprint ({ page, selector, root, output, run, node, locale }) {
73
+ const base = resolve(root, output)
74
+ let names = []
75
+ try { names = (await readdir(base, { withFileTypes: true })).filter((e) => e.isDirectory() && e.name !== run.meta.id && !e.name.startsWith('.')).map((e) => e.name).sort().reverse() } catch { return null }
76
+ for (const name of names.slice(0, 20)) {
77
+ const meta = await readJson(join(base, name, 'meta.json'), null)
78
+ if (meta?.scenario?.id !== run.meta.scenario.id) continue
79
+ let html = null
80
+ for (const vp of Object.keys(run.meta.scope.viewports || {})) { html = await readFile(join(base, name, 'steps', node.id, locale, `${vp}.html`), 'utf8').catch(() => null); if (html) break }
81
+ if (!html) continue
82
+ let scratch = null
83
+ try {
84
+ const browser = page.context().browser()
85
+ scratch = browser ? await browser.newContext({ javaScriptEnabled: false }) : null
86
+ if (!scratch) return null
87
+ const p = await scratch.newPage()
88
+ await p.route('**/*', (r) => r.abort())
89
+ await p.setContent(html.replace(/<script[\s\S]*?<\/script>/gi, ''), { waitUntil: 'domcontentloaded', timeout: 5000 })
90
+ const el = p.locator(selector).first()
91
+ if (!(await el.count().catch(() => 0))) continue
92
+ const fp = await el.evaluate(describeAll)
93
+ return { ...fp, run: name }
94
+ } catch { continue } finally { await scratch?.close().catch(() => {}) }
95
+ }
96
+ return null
97
+ }
98
+
99
+ const q = (s) => JSON.stringify(String(s))
100
+
101
+ /** Scores a candidate against the fingerprint of the old element and the words of the old selector. */
102
+ export function score (c, { fp, tokens }) {
103
+ let s = 0; const why = []
104
+ if (fp) {
105
+ if (fp.testid && c.testid && fp.testid[1] === c.testid[1]) { s += 6; why.push(`same ${c.testid[0]} "${c.testid[1]}"`) }
106
+ if (fp.name && c.name && fp.name.toLowerCase() === c.name.toLowerCase()) { s += 4; why.push(`same name "${c.name}"`) }
107
+ if (fp.role && fp.role === c.role) { s += 2; why.push(`role ${c.role}`) }
108
+ if (fp.tag === c.tag) s += 1
109
+ if (fp.id && fp.id === c.id) { s += 3; why.push(`same id ${c.id}`) }
110
+ const shared = (fp.classes || []).filter((k) => c.classes.includes(k)).length
111
+ s += Math.min(2, shared)
112
+ }
113
+ const hay = [c.id, ...(c.classes || []), c.testid?.[1], c.name, c.role, c.tag].filter(Boolean).join(' ').toLowerCase()
114
+ const words = tokens.filter((t) => hay.includes(t))
115
+ if (words.length) { s += words.length * 1.5; why.push(`words ${words.map((w) => `"${w}"`).join(', ')} of the old selector`) }
116
+ return { score: s, why }
117
+ }
118
+
119
+ /** Selectors for an element, most stable first: test id, role + name, id, text. */
120
+ export function selectorsFor (c) {
121
+ const out = []
122
+ if (c.testid) out.push(`[${c.testid[0]}=${q(c.testid[1])}]`)
123
+ if (c.role && c.name) out.push(`role=${c.role}[name=${q(c.name)}]`)
124
+ if (c.id && !/\d{3,}|^[a-f0-9-]{16,}$|^(radix|headlessui|mui|react-aria|:r)/i.test(c.id)) out.push(`#${c.id}`)
125
+ if (c.name && c.name.length <= 60) out.push(`text=${q(c.name)}`)
126
+ return out
127
+ }
128
+
129
+ /**
130
+ * A replacement for `selector` on the page as it is now: { replacement, why, from } or null.
131
+ * The replacement matches exactly one element.
132
+ */
133
+ export async function proposeSelector ({ page, selector, root, output, run, node, locale }) {
134
+ const fp = await previousFingerprint({ page, selector, root, output, run, node, locale }).catch(() => null)
135
+ const tokens = tokensOf(selector)
136
+ let candidates
137
+ try { candidates = await page.evaluate(describeAll, 1500) } catch { return null }
138
+ const ranked = candidates.map((c) => ({ c, ...score(c, { fp, tokens }) })).filter((x) => x.score >= (fp ? 4 : 3)).sort((a, b) => b.score - a.score)
139
+ for (const { c, why } of ranked.slice(0, 8)) {
140
+ for (const sel of selectorsFor(c)) {
141
+ if (sel === selector) continue
142
+ const n = await page.locator(sel).count().catch(() => 0)
143
+ if (n === 1) return { replacement: sel, why: why.join(', ') || 'closest element', from: fp?.run || null, element: { role: c.role, name: c.name, tag: c.tag } }
144
+ }
145
+ }
146
+ return null
147
+ }
148
+
149
+ /** Replaces the selector's string literal in a script; { text, diff, count } or null when it is not written literally. */
150
+ export function patchScript (source, selector, replacement, file = 'script.mjs') {
151
+ const lines = source.split('\n')
152
+ const out = []; const diff = []; let count = 0
153
+ for (const [i, line] of lines.entries()) {
154
+ let next = line
155
+ for (const quote of ["'", '"', '`']) {
156
+ const lit = quote + selector.replaceAll('\\', '\\\\').replaceAll(quote, '\\' + quote) + quote
157
+ if (next.includes(lit)) {
158
+ const rep = quote + replacement.replaceAll('\\', '\\\\').replaceAll(quote, '\\' + quote) + quote
159
+ count += next.split(lit).length - 1
160
+ next = next.replaceAll(lit, rep)
161
+ }
162
+ }
163
+ if (next !== line) diff.push(`@@ -${i + 1} +${i + 1} @@`, `-${line}`, `+${next}`)
164
+ out.push(next)
165
+ }
166
+ if (!count) return null
167
+ return { text: out.join('\n'), count, diff: [`--- a/${file}`, `+++ b/${file}`, ...diff].join('\n') }
168
+ }
169
+
170
+ /**
171
+ * After a failed step: the proposal and, with `apply`, a re-run of the step with the patched script. `rerun(module)`
172
+ * runs the step with a script module and throws when it fails. Returns { selector, proposal, patch, applied, note }.
173
+ */
174
+ export async function healStep ({ error, page, scriptFile, root, output, run, node, locale, apply = false, rerun }) {
175
+ const selector = failedSelector(error?.message)
176
+ if (!selector) return null
177
+ const proposal = await proposeSelector({ page, selector, root, output, run, node, locale })
178
+ if (!proposal) return { selector, proposal: null, note: `no element on the page now resembles ${selector}` }
179
+ const source = await readFile(scriptFile, 'utf8').catch(() => null)
180
+ const rel = relative(root, scriptFile)
181
+ const patch = source ? patchScript(source, selector, proposal.replacement, rel) : null
182
+ if (!patch) return { selector, proposal, patch: null, note: `${selector} is not written literally in ${rel}; change it by hand` }
183
+ if (!apply) return { selector, proposal, patch, applied: false }
184
+ // the patched script runs from a sibling file (its relative imports resolve the same) and replaces the original
185
+ // only when the step passed with it
186
+ const trial = join(dirname(scriptFile), `.${basename(scriptFile, '.mjs')}.kaloko-heal-${process.pid}.mjs`)
187
+ try {
188
+ await writeFile(trial, patch.text)
189
+ await rerun(await import(`${pathToFileURL(trial).href}?t=${Date.now()}`))
190
+ await writeFile(scriptFile, patch.text)
191
+ return { selector, proposal, patch, applied: true }
192
+ } catch (e) {
193
+ return { selector, proposal, patch, applied: false, note: `the step still fails with the proposal (${String(e.message).split('\n')[0]}); ${rel} is unchanged` }
194
+ } finally { await rm(trial, { force: true }) }
195
+ }
196
+
197
+ /** Lines for the terminal: what failed, what is proposed and why, and the patch. */
198
+ export function formatHeal (h, { stepArg }) {
199
+ if (!h) return ''
200
+ if (!h.proposal) return ` ↳ selector ${h.selector}: ${h.note}`
201
+ const lines = [` ↳ selector ${h.selector} not found; proposed ${h.proposal.replacement} (${h.proposal.why}${h.proposal.from ? `; the old selector matched it in run ${h.proposal.from}` : ''})`]
202
+ if (h.applied) lines.push(` ↳ healed: the step passed with it, ${h.patch.count} occurrence(s) changed:`)
203
+ else if (h.note) lines.push(` ↳ ${h.note}`)
204
+ if (h.patch) lines.push(h.patch.diff.split('\n').map((l) => ` ${l}`).join('\n'))
205
+ if (h.patch && !h.applied && !h.note) lines.push(` ↳ apply it after the step passes: kaloko walk --steps ${stepArg} --heal`)
206
+ return lines.join('\n')
207
+ }