kaloko 0.4.8 → 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.
Files changed (51) hide show
  1. package/README.md +92 -5
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +33 -0
  4. package/schemas/scenario.v1.json +39 -0
  5. package/schemas/spec.v1.json +60 -0
  6. package/skills/kaloko/SKILL.md +24 -3
  7. package/skills/kaloko/reference/acceptance.md +50 -2
  8. package/skills/kaloko/reference/ci.md +70 -0
  9. package/skills/kaloko/reference/collaboration.md +9 -1
  10. package/skills/kaloko/reference/design.md +35 -2
  11. package/skills/kaloko/reference/environments.md +5 -0
  12. package/skills/kaloko/reference/evaluation.md +46 -3
  13. package/skills/kaloko/reference/execution.md +74 -0
  14. package/skills/kaloko/reference/import.md +66 -0
  15. package/skills/kaloko/reference/mcp.md +4 -1
  16. package/src/cli.mjs +428 -99
  17. package/src/lib/affected-core.mjs +242 -0
  18. package/src/lib/affected.mjs +233 -0
  19. package/src/lib/build.mjs +30 -5
  20. package/src/lib/capture.mjs +43 -1
  21. package/src/lib/ci.mjs +207 -0
  22. package/src/lib/config.mjs +2 -0
  23. package/src/lib/design-cli.mjs +7 -3
  24. package/src/lib/devspec.mjs +821 -0
  25. package/src/lib/doctor.mjs +7 -0
  26. package/src/lib/drift.mjs +16 -8
  27. package/src/lib/evaluate.mjs +98 -14
  28. package/src/lib/git.mjs +6 -2
  29. package/src/lib/heal.mjs +207 -0
  30. package/src/lib/import.mjs +374 -0
  31. package/src/lib/init-detect.mjs +219 -0
  32. package/src/lib/keyboard.mjs +261 -0
  33. package/src/lib/manifest-core.mjs +16 -5
  34. package/src/lib/onboarding.mjs +15 -8
  35. package/src/lib/pack-help.mjs +7 -0
  36. package/src/lib/packs.mjs +9 -2
  37. package/src/lib/png.mjs +61 -0
  38. package/src/lib/pr.mjs +24 -2
  39. package/src/lib/probe.mjs +57 -0
  40. package/src/lib/recheck.mjs +3 -1
  41. package/src/lib/scenario.mjs +2 -1
  42. package/src/lib/service.mjs +7 -1
  43. package/src/lib/spec-cli.mjs +90 -0
  44. package/src/lib/stability-core.mjs +114 -0
  45. package/src/lib/stability.mjs +249 -0
  46. package/src/lib/unzip.mjs +31 -0
  47. package/src/lib/upload.mjs +114 -0
  48. package/src/lib/vision-core.mjs +132 -0
  49. package/src/lib/vision.mjs +127 -0
  50. package/templates/kaloko.config.yml +6 -0
  51. package/viewer/index.html +500 -44
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
 
@@ -7,6 +7,7 @@ import { readJson, writeJson, UserError } from './util.mjs'
7
7
  import { readCaptures, addCapture, runPaths } from './run.mjs'
8
8
  import { latestCaptures, criteriaHash, evaluationSource } from './evaluate.mjs'
9
9
  import { captureStep } from './capture.mjs'
10
+ import { loadTokens } from './drift.mjs'
10
11
  import { layout } from './scenario.mjs'
11
12
  import { launchEphemeral } from './browser.mjs'
12
13
  import { observeConsole } from './console.mjs'
@@ -45,6 +46,8 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
45
46
  const browser = await launchEphemeral(config)
46
47
  const paths = runPaths(run.dir)
47
48
  const mode = env.tokens_mode || ''
49
+ // the design folder's own set names the values in the step spec (the one its live pages reference)
50
+ const tokens = await loadTokens({ client: null, config, root, designDir: dir })
48
51
  let rendered = 0, reused = 0
49
52
  try {
50
53
  const page = await (await browser.newContext()).newPage()
@@ -58,7 +61,8 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
58
61
  for (const [vp, size] of Object.entries(viewports)) {
59
62
  const ck = captureKey(rev.id, vp, mode)
60
63
  const cached = noCache ? null : await fromCache({ root, output: config.output, key: ck })
61
- if (cached && cached.record.criteria_hash === criteriaHash(node)) {
64
+ // a capture from before the step spec existed renders again, so Dev mode and `kaloko spec` have it
65
+ if (cached && cached.record.criteria_hash === criteriaHash(node) && cached.record.spec) {
62
66
  const record = await restoreCached({ cached, runDir: run.dir, paths, node: node.id, locale: rev.locale, viewport: vp })
63
67
  const file = paths.step(node.id, rev.locale, vp, 'capture.json')
64
68
  await writeJson(file, record)
@@ -69,7 +73,7 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
69
73
  if (Object.keys(todo).length) {
70
74
  const since = new Date().toISOString()
71
75
  await page.goto(`${server.url}/${rev.entry}`, { waitUntil: 'load' })
72
- const results = await captureStep({ page, node, locale: rev.locale, viewports: todo, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live }, designSummary: true })
76
+ const results = await captureStep({ page, node, locale: rev.locale, viewports: todo, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live }, designSummary: true, tokens })
73
77
  for (const r of results) { await toCache({ root, output: config.output, key: captureKey(rev.id, r.viewport, mode), runDir: run.dir, record: r }); rendered++ }
74
78
  }
75
79
  say(`✓ ${node.id} ${rev.locale} revision ${rev.id.slice(0, 8)}${Object.keys(todo).length ? '' : ' (unchanged, reused)'}`)
@@ -231,7 +235,7 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
231
235
  out[k] = h
232
236
  }
233
237
  const stored = await readJson(runPaths(run.dir).step(rev.node, rev.locale, vp, 'eval.json'), null)
234
- out.record = slimCapture(rec)
238
+ out.record = { ...slimCapture(rec), ...(rec.spec ? { spec: rec.spec } : {}) }
235
239
  out.evaluation = stored?.source === evaluationSource(node, rec) ? stored : null
236
240
  out.mode = env.tokens_mode || ''
237
241
  out.renderer = RENDERER