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/README.md +69 -4
- package/package.json +1 -1
- package/schemas/config.v1.json +7 -0
- package/schemas/scenario.v1.json +7 -0
- package/skills/kaloko/SKILL.md +13 -2
- package/skills/kaloko/reference/acceptance.md +16 -0
- package/skills/kaloko/reference/ci.md +70 -0
- package/skills/kaloko/reference/collaboration.md +9 -1
- package/skills/kaloko/reference/environments.md +5 -0
- package/skills/kaloko/reference/execution.md +56 -0
- package/skills/kaloko/reference/import.md +66 -0
- package/skills/kaloko/reference/mcp.md +1 -0
- package/src/cli.mjs +385 -96
- package/src/lib/affected-core.mjs +242 -0
- package/src/lib/affected.mjs +233 -0
- package/src/lib/ci.mjs +207 -0
- package/src/lib/config.mjs +2 -0
- package/src/lib/evaluate.mjs +4 -2
- package/src/lib/git.mjs +6 -2
- package/src/lib/heal.mjs +207 -0
- package/src/lib/import.mjs +374 -0
- package/src/lib/init-detect.mjs +219 -0
- package/src/lib/onboarding.mjs +15 -8
- package/src/lib/pr.mjs +24 -2
- package/src/lib/scenario.mjs +2 -1
- package/src/lib/service.mjs +7 -1
- package/src/lib/unzip.mjs +31 -0
- package/src/lib/upload.mjs +114 -0
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
|
+
}
|
package/src/lib/config.mjs
CHANGED
|
@@ -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
|
|
package/src/lib/evaluate.mjs
CHANGED
|
@@ -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
|
-
|
|
105
|
-
|
|
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
|
|
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
|
}
|
package/src/lib/heal.mjs
ADDED
|
@@ -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
|
+
}
|