kaloko 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.
Files changed (68) hide show
  1. package/README.md +119 -0
  2. package/bin/kaloko.mjs +4 -0
  3. package/bin/qawalk.mjs +4 -0
  4. package/package.json +42 -0
  5. package/schemas/bundle.v1.json +19 -0
  6. package/schemas/config.v1.json +268 -0
  7. package/schemas/revision.v1.json +36 -0
  8. package/schemas/run.v1.json +549 -0
  9. package/schemas/scenario.v1.json +668 -0
  10. package/schemas/tokens.v1.json +102 -0
  11. package/skills/kaloko/SKILL.md +94 -0
  12. package/skills/kaloko/reference/acceptance.md +208 -0
  13. package/skills/kaloko/reference/apps.md +161 -0
  14. package/skills/kaloko/reference/collaboration.md +218 -0
  15. package/skills/kaloko/reference/design.md +143 -0
  16. package/skills/kaloko/reference/documentation.md +72 -0
  17. package/skills/kaloko/reference/environments.md +140 -0
  18. package/skills/kaloko/reference/evaluation.md +62 -0
  19. package/skills/kaloko/reference/execution.md +75 -0
  20. package/skills/kaloko/reference/mcp.md +69 -0
  21. package/skills/kaloko/reference/stacks.md +122 -0
  22. package/src/cli.mjs +1344 -0
  23. package/src/lib/access.mjs +169 -0
  24. package/src/lib/apps/capture.mjs +59 -0
  25. package/src/lib/apps/drivers.mjs +284 -0
  26. package/src/lib/apps/helper.swift +102 -0
  27. package/src/lib/apps/tree.mjs +222 -0
  28. package/src/lib/browser-daemon.mjs +41 -0
  29. package/src/lib/browser.mjs +161 -0
  30. package/src/lib/build.mjs +149 -0
  31. package/src/lib/calibrate.mjs +97 -0
  32. package/src/lib/capture.mjs +233 -0
  33. package/src/lib/compare.mjs +88 -0
  34. package/src/lib/config.mjs +117 -0
  35. package/src/lib/console.mjs +13 -0
  36. package/src/lib/coverage.mjs +75 -0
  37. package/src/lib/design-cli.mjs +440 -0
  38. package/src/lib/design-core.mjs +163 -0
  39. package/src/lib/design.mjs +164 -0
  40. package/src/lib/doctor.mjs +149 -0
  41. package/src/lib/draft.mjs +76 -0
  42. package/src/lib/drift.mjs +145 -0
  43. package/src/lib/evaluate.mjs +222 -0
  44. package/src/lib/git.mjs +70 -0
  45. package/src/lib/htmlview.mjs +130 -0
  46. package/src/lib/jev.mjs +95 -0
  47. package/src/lib/mail.mjs +182 -0
  48. package/src/lib/manifest-core.mjs +127 -0
  49. package/src/lib/pack-help.mjs +55 -0
  50. package/src/lib/packs.mjs +121 -0
  51. package/src/lib/pr.mjs +78 -0
  52. package/src/lib/probe.mjs +379 -0
  53. package/src/lib/recheck.mjs +70 -0
  54. package/src/lib/run.mjs +85 -0
  55. package/src/lib/scenario.mjs +98 -0
  56. package/src/lib/schema.mjs +25 -0
  57. package/src/lib/secrets.mjs +59 -0
  58. package/src/lib/service.mjs +52 -0
  59. package/src/lib/stack.mjs +130 -0
  60. package/src/lib/tokens.mjs +352 -0
  61. package/src/lib/util.mjs +97 -0
  62. package/templates/ACCEPTANCE.md +55 -0
  63. package/templates/cs/ACCEPTANCE.md +55 -0
  64. package/templates/kaloko.config.yml +76 -0
  65. package/templates/mail/letter-opener.mjs +47 -0
  66. package/templates/scenario.yml +119 -0
  67. package/templates/steps/consent.mjs +33 -0
  68. package/viewer/index.html +2772 -0
@@ -0,0 +1,97 @@
1
+ // Calibration of the semantic evaluator (#10): compare evaluator scores with human labels, report precision and
2
+ // recall per criterion at the configured thresholds, and suggest thresholds that keep automatic verdicts precise.
3
+ // Labels come from a golden file (local runs) or from reviewers' verdicts on criteria in the service.
4
+ import { readFile } from 'node:fs/promises'
5
+ import { resolve, dirname, join } from 'node:path'
6
+ import yaml from 'js-yaml'
7
+
8
+ const GRID = Array.from({ length: 19 }, (_, i) => Math.round((0.05 + i * 0.05) * 100) / 100)
9
+
10
+ /** Verdict of a score under thresholds { pass, fail }: pass, fail or needs_review (the band between). */
11
+ const decide = (score, t) => (score >= t.pass ? 'pass' : score <= t.fail ? 'fail' : 'needs_review')
12
+
13
+ function metrics (cases, t) {
14
+ const n = cases.length
15
+ const by = (pred, exp) => cases.filter((c) => decide(c.score, t) === pred && (exp == null || c.expected === exp)).length
16
+ const pp = by('pass'); const pf = by('fail'); const tp = by('pass', 'pass'); const tf = by('fail', 'fail')
17
+ const ep = cases.filter((c) => c.expected === 'pass').length; const ef = n - ep
18
+ const ratio = (a, b) => (b ? Math.round((a / b) * 1000) / 1000 : null)
19
+ return {
20
+ n, expected_pass: ep, expected_fail: ef,
21
+ pass_precision: ratio(tp, pp), pass_recall: ratio(tp, ep),
22
+ fail_precision: ratio(tf, pf), fail_recall: ratio(tf, ef),
23
+ needs_review: ratio(n - pp - pf, n),
24
+ wrong: pp - tp + pf - tf
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Suggested thresholds: the lowest pass threshold whose automatic passes are at least `target` precise and the
30
+ * highest fail threshold whose automatic fails are, with fail < pass. Null when the labels do not support one.
31
+ */
32
+ export function suggest (cases, { target = 0.95 } = {}) {
33
+ const precise = (sel, exp) => { const s = cases.filter(sel); return s.length > 0 && s.filter((c) => c.expected === exp).length / s.length >= target }
34
+ // widest precise band, then tightened to the scores actually observed so it does not reach into gaps without data
35
+ const pmin = GRID.find((t) => precise((c) => c.score >= t, 'pass'))
36
+ const pass = pmin == null ? null : [...GRID].reverse().find((t) => t <= Math.min(...cases.filter((c) => c.score >= pmin).map((c) => c.score)))
37
+ const fmax = GRID.filter((t) => precise((c) => c.score <= t, 'fail') && (pass == null || t < pass)).pop()
38
+ const fail = fmax == null ? null : GRID.find((t) => t >= Math.max(...cases.filter((c) => c.score <= fmax).map((c) => c.score)))
39
+ return { pass: pass ?? null, fail: fail != null && (pass == null || fail < pass) ? fail : null }
40
+ }
41
+
42
+ /** cases: [{ criterion, score, expected: 'pass'|'fail', ref? }] → per criterion and overall report. */
43
+ export function calibrate (cases, thresholds, { target = 0.95 } = {}) {
44
+ const usable = cases.filter((c) => typeof c.score === 'number' && (c.expected === 'pass' || c.expected === 'fail'))
45
+ const groups = new Map()
46
+ for (const c of usable) { if (!groups.has(c.criterion)) groups.set(c.criterion, []); groups.get(c.criterion).push(c) }
47
+ const criteria = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([criterion, list]) => ({
48
+ criterion, ...metrics(list, thresholds), suggested: suggest(list, { target }),
49
+ misses: list.filter((c) => { const d = decide(c.score, thresholds); return d !== 'needs_review' && d !== c.expected }).map((c) => ({ ref: c.ref || null, score: c.score, expected: c.expected }))
50
+ }))
51
+ return { thresholds, target, skipped: cases.length - usable.length, overall: { ...metrics(usable, thresholds), suggested: suggest(usable, { target }) }, criteria }
52
+ }
53
+
54
+ const pct = (v) => (v == null ? ' — ' : `${(v * 100).toFixed(0).padStart(3)} %`)
55
+ export function formatCalibration (r) {
56
+ const row = (name, m) => `${name.padEnd(28).slice(0, 28)} ${String(m.n).padStart(4)} ${pct(m.pass_precision)} ${pct(m.pass_recall)} ${pct(m.fail_precision)} ${pct(m.fail_recall)} ${pct(m.needs_review)} ${m.suggested.pass ?? '—'} / ${m.suggested.fail ?? '—'}`
57
+ const lines = [
58
+ `Thresholds pass ≥ ${r.thresholds.pass}, fail ≤ ${r.thresholds.fail}; suggestions keep automatic verdicts ≥ ${Math.round(r.target * 100)} % precise.`,
59
+ `${'criterion'.padEnd(28)} n pass P pass R fail P fail R review suggest pass / fail`,
60
+ ...r.criteria.map((c) => row(c.criterion, c)),
61
+ row('all', r.overall)
62
+ ]
63
+ const misses = r.criteria.flatMap((c) => c.misses.map((m) => ` ${c.criterion}: ${m.ref || ''} score ${m.score}, labelled ${m.expected}`))
64
+ if (misses.length) lines.push('', 'Automatic verdicts that contradict the labels:', ...misses.slice(0, 30))
65
+ if (r.skipped) lines.push('', `${r.skipped} labelled case(s) had no evaluator score and were skipped.`)
66
+ return lines.join('\n')
67
+ }
68
+
69
+ /**
70
+ * Golden file: `cases: [{ run: <run dir>, step, locale, viewport, criterion, expected }]`, paths relative to the
71
+ * file. Scores are read from the stored evaluations (<run>/steps/<step>/<locale>/<viewport>.eval.json).
72
+ */
73
+ export async function casesFromGolden (file) {
74
+ const doc = yaml.load(await readFile(file, 'utf8')) || {}
75
+ const base = dirname(resolve(file))
76
+ const out = []
77
+ for (const k of doc.cases || []) {
78
+ const ev = JSON.parse(await readFile(join(resolve(base, k.run), 'steps', k.step, k.locale, `${k.viewport}.eval.json`), 'utf8').catch(() => 'null'))
79
+ const c = ev?.criteria?.find((x) => x.id === k.criterion)
80
+ out.push({ criterion: `${k.step}/${k.criterion}`, score: typeof c?.score === 'number' ? c.score : null, expected: k.expected, ref: `${k.run} ${k.step} ${k.locale}/${k.viewport}` })
81
+ }
82
+ return out
83
+ }
84
+
85
+ /** Labels from the service: reviewers' pass/fail verdicts on criteria, matched with the evaluator scores in the manifests. */
86
+ export function casesFromRun (manifest, verdicts) {
87
+ const out = []
88
+ for (const v of verdicts || []) {
89
+ const node = manifest.nodes?.find((n) => n.id === v.node_id)
90
+ const crit = node?.criteria?.find((c) => c.id === v.criterion_id)
91
+ if (!crit || crit.type !== 'semantic') continue
92
+ for (const [vp, r] of Object.entries(crit.by_locale?.[v.locale] || {})) {
93
+ if (typeof r.score === 'number') out.push({ criterion: `${node.id}/${crit.id}`, score: r.score, expected: v.verdict, ref: `${manifest.id} ${node.id} ${v.locale}/${vp}` })
94
+ }
95
+ }
96
+ return out
97
+ }
@@ -0,0 +1,233 @@
1
+ import { writeFile, mkdir, rm } from 'node:fs/promises'
2
+ import { dirname, relative, join } from 'node:path'
3
+ import { buildHtmlView } from './htmlview.mjs'
4
+ import { probe } from './probe.mjs'
5
+ import { writeJson, readJsonl } from './util.mjs'
6
+ import { addCapture, runPaths } from './run.mjs'
7
+
8
+ /**
9
+ * Encodes a PNG screenshot inside the page (no native dependency): the image as WebP (or PNG, lossless) and a
10
+ * WebP thumbnail for the canvas grid; `marks` are numbered circles drawn on the image (documentation captures).
11
+ */
12
+ export async function encodeImage (page, png, { format = 'webp', quality = 0.82, thumbWidth = 320, thumbQuality = 0.7, marks = [], scale = 1, masks = [] } = {}) {
13
+ const result = await page.evaluate(async ({ dataUrl, format, quality, thumbWidth, thumbQuality, marks, scale, masks }) => {
14
+ const img = new Image()
15
+ await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = dataUrl })
16
+ const full = document.createElement('canvas'); full.width = img.width; full.height = img.height
17
+ const g = full.getContext('2d'); g.drawImage(img, 0, 0)
18
+ g.fillStyle = '#1f1f1d'; for (const r of masks) g.fillRect(Math.floor(r.x * scale), Math.floor(r.y * scale), Math.ceil(r.w * scale), Math.ceil(r.h * scale))
19
+ for (const m of marks) {
20
+ const r = 13 * scale; const x = Math.max(r + 2, m.x * scale); const y = Math.max(r + 2, m.y * scale)
21
+ g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fillStyle = '#ffe452'; g.fill(); g.lineWidth = 2 * scale; g.strokeStyle = '#151514'; g.stroke()
22
+ g.fillStyle = '#151514'; g.font = `700 ${14 * scale}px system-ui, sans-serif`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(String(m.n), x, y + scale)
23
+ }
24
+ const blob = (c, type, q) => new Promise((res, rej) => c.toBlob((b) => (b ? b.arrayBuffer().then((buf) => res(Array.from(new Uint8Array(buf)))) : rej(new Error('encode failed'))), type, q))
25
+ const k = Math.min(1, thumbWidth / img.width)
26
+ const t = document.createElement('canvas'); t.width = Math.round(img.width * k); t.height = Math.round(img.height * k)
27
+ t.getContext('2d').drawImage(full, 0, 0, t.width, t.height)
28
+ return { image: await blob(full, format === 'png' ? 'image/png' : 'image/webp', quality), thumb: await blob(t, 'image/webp', thumbQuality) }
29
+ }, { dataUrl: `data:image/png;base64,${png.toString('base64')}`, format, quality, thumbWidth, thumbQuality, marks, scale, masks })
30
+ return { image: Buffer.from(result.image), thumb: Buffer.from(result.thumb) }
31
+ }
32
+
33
+ /** WebP encoding for the canvas (kept for callers of the old name). */
34
+ export const toWebp = (page, png, opts = {}) => encodeImage(page, png, { ...opts, format: 'webp' })
35
+
36
+ /**
37
+ * Capture options: scenario-wide `capture:` merged with the node's. Documentation runs use
38
+ * { format: png, scale: 2, mask: [selectors], annotate: true }; daily acceptance keeps the WebP default.
39
+ */
40
+ export function captureOptions (scenario, node) {
41
+ const o = { ...(scenario?.capture || {}), ...(node?.capture || {}) }
42
+ const q = Number(o.quality)
43
+ return {
44
+ format: o.format === 'png' ? 'png' : 'webp',
45
+ scale: Number(o.scale) === 2 ? 2 : 1,
46
+ quality: Number.isFinite(q) && q > 0 ? Math.min(1, Math.max(0.3, q)) : 0.82,
47
+ mask: Array.isArray(o.mask) ? o.mask.map(String).filter(Boolean) : [],
48
+ annotate: o.annotate === true,
49
+ // stack pages default to the first screen: hundreds of full-page shots would swamp storage
50
+ fullPage: o.full_page != null ? o.full_page !== false : node?.kind !== 'stack'
51
+ }
52
+ }
53
+
54
+ import { criteriaHash } from './evaluate.mjs'
55
+ import { consoleEntriesFor } from './console.mjs'
56
+ import { redact, redactDeep } from './secrets.mjs'
57
+ import { readFields } from './stack.mjs'
58
+
59
+ /** Probe arguments derived from the node's criteria. */
60
+ export function probeArgs (node, maxChars, design = false) {
61
+ const criteria = node.criteria || []
62
+ return {
63
+ maxChars,
64
+ // design summary (structure + used styles) for design steps and for checks against the design reference
65
+ design: design || criteria.some((c) => ['uses_tokens', 'design_pixels', 'design_structure'].includes(c.check.assert)),
66
+ checks: criteria.filter((c) => c.check.type === 'deterministic').map((c) => ({ id: c.id, check: c.check })),
67
+ highlights: criteria.map((c) => ({ id: c.id, selector: c.highlight || c.check.selector || c.check.scope })).filter((h) => h.selector),
68
+ scopes: criteria.filter((c) => c.check.type === 'semantic' && c.check.scope).map((c) => ({ id: c.id, selector: c.check.scope }))
69
+ }
70
+ }
71
+
72
+ // Open overlays that many UI libraries close on scroll or resize (MUI Menu/Popover, Angular Material
73
+ // md-menu, listboxes, tooltips). A step that leaves one open wants it on the capture.
74
+ const OVERLAY = '[role=menu], [role=listbox], [role=tooltip], .MuiPopover-root, .MuiPopper-root, .MuiMenu-root, md-menu-content, .md-open-menu-container.md-active, .md-panel-outer-wrapper.md-panel-is-showing'
75
+
76
+ export async function settle (page) {
77
+ try { await page.waitForLoadState('networkidle', { timeout: 5000 }) } catch {}
78
+ try { await page.evaluate(() => document.fonts?.ready) } catch {}
79
+ // walk the page once so lazy images and sticky headers settle, unless an overlay is open:
80
+ // scrolling would close it and the capture would miss what the step opened
81
+ try {
82
+ await page.evaluate(async (overlay) => {
83
+ const open = [...document.querySelectorAll(overlay)].some((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 })
84
+ if (open) return
85
+ const step = innerHeight * 0.8
86
+ for (let y = 0; y < document.documentElement.scrollHeight; y += step) { scrollTo(0, y); await new Promise((r) => setTimeout(r, 60)) }
87
+ scrollTo(0, 0)
88
+ }, OVERLAY)
89
+ } catch {}
90
+ await page.waitForTimeout(250)
91
+ }
92
+
93
+ /**
94
+ * Captures the current state of `page` for one step and locale in every
95
+ * requested viewport: full-page PNG, rendered HTML, probe results.
96
+ */
97
+ export async function captureStep ({ page, node, locale, viewports, run, consoleFile, maxChars = 90000, since, extra = {}, format = null, options = null, designSummary = false, htmlView = null, item = null, fields = null }) {
98
+ const opts = { ...(options || captureOptions(null, node)), ...(format ? { format } : {}) }
99
+ // a page of a stack step lives under steps/<node>/items/<item>/…; its capture record names node and item
100
+ const stepId = item ? `${node.id}/items/${item.id}` : node.id
101
+ const paths = runPaths(run.dir)
102
+ const original = page.viewportSize()
103
+ const results = []
104
+ for (const [viewport, [width, height]] of Object.entries(viewports)) {
105
+ const now = page.viewportSize()
106
+ if (!now || now.width !== width || now.height !== height) await page.setViewportSize({ width, height })
107
+ await settle(page)
108
+ const html = paths.step(stepId, locale, viewport, 'html')
109
+ await rm(paths.step(stepId, locale, viewport, 'eval.json'), { force: true })
110
+ await mkdir(dirname(html), { recursive: true })
111
+ // PII selectors are covered by a solid box; at 2× Chrome renders the full page at double scale (CDP), and
112
+ // the boxes are drawn while encoding because Playwright's own mask only works at the page's pixel ratio
113
+ const maskRects = opts.mask.length ? await page.evaluate((sels) => sels.flatMap((sel) => { try { return [...document.querySelectorAll(sel)] } catch { return [] } }).map((el) => { const r = el.getBoundingClientRect(); return { x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height } }).filter((r) => r.w > 0 && r.h > 0), opts.mask) : []
114
+ let shot
115
+ if (opts.scale === 2) {
116
+ const cdp = await page.context().newCDPSession(page)
117
+ try {
118
+ const doc = await page.evaluate(() => ({ w: Math.ceil(document.documentElement.scrollWidth), h: Math.ceil(document.documentElement.scrollHeight) }))
119
+ const r = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: opts.fullPage, clip: { x: 0, y: 0, width: opts.fullPage ? doc.w : width, height: opts.fullPage ? doc.h : height, scale: 2 } })
120
+ shot = Buffer.from(r.data, 'base64')
121
+ } finally { await cdp.detach().catch(() => {}) }
122
+ } else shot = await page.screenshot({ fullPage: opts.fullPage, ...(opts.mask.length ? { mask: opts.mask.map((sel) => page.locator(sel)), maskColor: '#1f1f1d' } : {}) })
123
+ // secrets the CLI used (gate, accounts) never land in a capture, even if the page echoes them
124
+ await writeFile(html, redact(await page.content()))
125
+ let result
126
+ for (let attempt = 0; ; attempt++) {
127
+ try { result = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)); break } catch (error) {
128
+ // the page navigated under us (redirect, Turbo); wait once and probe the new document
129
+ if (attempt < 2 && /Execution context was destroyed|navigation/i.test(error.message)) { await page.waitForLoadState('domcontentloaded').catch(() => {}); await page.waitForTimeout(500); continue }
130
+ throw error
131
+ }
132
+ }
133
+ // numbered marks on the highlighted elements, in criteria order (first box of each)
134
+ const annotations = opts.annotate ? Object.entries(result.boxes || {}).filter(([, list]) => list?.length).map(([criterion, list], i) => ({ n: i + 1, criterion, x: list[0].x, y: list[0].y })) : []
135
+ let image = paths.step(stepId, locale, viewport, opts.format)
136
+ const thumb = paths.step(stepId, locale, viewport, 'thumb.webp')
137
+ let thumbWritten = true
138
+ try {
139
+ const enc = await encodeImage(page, shot, { format: opts.format, quality: opts.quality, marks: annotations, scale: opts.scale, masks: opts.scale === 2 ? maskRects : [] })
140
+ await writeFile(image, enc.image)
141
+ await writeFile(thumb, enc.thumb)
142
+ } catch (error) {
143
+ // never store an unmasked image when masks were asked for
144
+ if (opts.scale === 2 && maskRects.length) throw new Error(`capture: could not draw the masks on ${node.id} (${error.message}); the image was not stored`)
145
+ image = paths.step(stepId, locale, viewport, 'png'); thumbWritten = false
146
+ await writeFile(image, shot)
147
+ }
148
+ const url = page.url()
149
+ const collected = consoleFile ? await readJsonl(consoleFile) : consoleEntriesFor(page)
150
+ const consoleEntries = (collected || []).filter((e) => !since || e.ts >= since)
151
+ // requests blocked by the readonly policy surface as ERR_BLOCKED_BY_CLIENT; they are reported separately
152
+ // an error page's own status ("Failed to load resource: … 404" for the document itself) is not a console error
153
+ const ownStatus = (e) => result.http_status >= 400 && /^Failed to load resource: the server responded with a status of \d+/.test(e.text) && e.src && sameDocument(e.src, url)
154
+ const errors = consoleEntries.filter((e) => e.type !== 'blocked' && sameDocument(e.url, url) && !e.text.includes('ERR_BLOCKED_BY_CLIENT') && !ownStatus(e)).map((e) => e.text)
155
+ const blocked = consoleEntries.filter((e) => e.type === 'blocked').map((e) => `${e.text}`)
156
+ for (const c of node.criteria || []) {
157
+ if (c.check.type === 'deterministic' && c.check.assert === 'no_console_errors') {
158
+ result.checks[c.id] = collected === null
159
+ ? { ok: null, evidence: 'Console was not observed.' }
160
+ : { ok: errors.length === 0, evidence: errors.length ? errors.slice(0, 3).join(' | ') : 'no console errors' }
161
+ }
162
+ }
163
+ // experimental HTML view (#42): a scrubbed, script-free static copy with rehosted assets
164
+ let view = null
165
+ if (htmlView) {
166
+ try {
167
+ // the same PII masks as the screenshot (#27), applied to the text of the copy
168
+ const rev = await buildHtmlView({ page, run, scenarioId: htmlView.scenarioId, node: node.id, locale, viewport, mask: [...(opts.mask || []), ...(htmlView.mask || [])] })
169
+ await writeJson(join(run.dir, 'views', `${rev.id}.json`), rev)
170
+ view = { revision: rev.id, entry: rev.entry }
171
+ } catch (error) { view = { error: String(error.message).slice(0, 200) } }
172
+ }
173
+ const record = {
174
+ schema: 'qawalk.capture.v1',
175
+ ...(view ? { view } : {}),
176
+ node: node.id, locale, viewport, url,
177
+ captured_at: new Date().toISOString(),
178
+ criteria_hash: criteriaHash(node),
179
+ console_errors: errors, blocked_requests: blocked,
180
+ ...result,
181
+ ...extra,
182
+ http_status: extra.http_status ?? result.http_status ?? null,
183
+ capture: { format: String(image).split('.').pop(), scale: opts.scale, masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
184
+ ...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}) } } : {}),
185
+ ...(fields ? { fields: { ...(await page.evaluate(readFields, fields).catch(() => ({}))), ...(fields.some((f) => (f.from || f.id) === 'http') ? Object.fromEntries(fields.filter((f) => (f.from || f.id) === 'http').map((f) => [f.id, extra.http_status ?? result.http_status ?? null])) : {}) } } : {}),
186
+ files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
187
+ }
188
+ const file = paths.step(stepId, locale, viewport, 'capture.json')
189
+ await writeJson(file, redactDeep(record))
190
+ await addCapture(run.dir, { type: 'capture', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, file: relative(run.dir, file), at: record.captured_at })
191
+ results.push(record)
192
+ }
193
+ const last = page.viewportSize()
194
+ if (original && (!last || last.width !== original.width || last.height !== original.height)) await page.setViewportSize(original)
195
+ return results
196
+ }
197
+
198
+ export async function captureCrop ({ page, node, locale, selector, title, run, padding = 0 }) {
199
+ const paths = runPaths(run.dir)
200
+ const el = page.locator(selector).first()
201
+ const name = `${node.id}-${locale}-${Date.now().toString(36)}.png`
202
+ const file = paths.attachment(name)
203
+ await mkdir(dirname(file), { recursive: true })
204
+ if (padding > 0) {
205
+ // element plus a margin of surrounding page, clipped from a full-page shot (documentation crops)
206
+ const box = await el.boundingBox()
207
+ if (!box) throw new Error(`crop: ${selector} is not visible`)
208
+ const doc = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight, x: scrollX, y: scrollY }))
209
+ const x = Math.max(0, box.x + doc.x - padding); const y = Math.max(0, box.y + doc.y - padding)
210
+ await page.screenshot({ path: file, fullPage: true, clip: { x, y, width: Math.min(doc.w - x, box.width + padding * 2), height: Math.min(doc.h - y, box.height + padding * 2) } })
211
+ } else await el.screenshot({ path: file })
212
+ const entry = { type: 'attachment', kind: 'crop', node: node.id, locale, title: title || selector, selector, file: relative(run.dir, file), viewport: page.viewportSize(), at: new Date().toISOString() }
213
+ await addCapture(run.dir, entry)
214
+ return entry
215
+ }
216
+
217
+ function sameDocument (a, b) {
218
+ try {
219
+ const x = new URL(a); const y = new URL(b)
220
+ return x.origin === y.origin && x.pathname === y.pathname
221
+ } catch { return a === b }
222
+ }
223
+
224
+ /**
225
+ * Accepts native dialogs (confirm, alert, prompt) in the page from now on, also after navigations. With several
226
+ * Playwright clients on the shared browser over CDP, a client without a dialog handler can dismiss a native dialog
227
+ * before the step's handler accepts it (#53); replacing the functions in the page avoids the race entirely.
228
+ */
229
+ export async function acceptDialogs (page) {
230
+ const stub = () => { window.confirm = () => true; window.alert = () => {}; window.prompt = (_m, d) => (d == null ? '' : String(d)) }
231
+ await page.addInitScript(stub)
232
+ await page.evaluate(stub).catch(() => {})
233
+ }
@@ -0,0 +1,88 @@
1
+ // Compares two run manifests of the same scenario: step statuses, criterion
2
+ // verdicts and evidence per locale and viewport, and which captures exist on
3
+ // both sides (the canvas computes pixel diffs from those in the browser).
4
+
5
+ const RANK = { fail: 4, needs_review: 3, not_evaluated: 2, pass: 1, not_reached: 0 }
6
+ const worst = (list) => list.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'not_reached')
7
+ const text = (v, locale) => v == null ? '' : typeof v === 'string' ? v : (v[locale] ?? v.default ?? Object.values(v)[0] ?? '')
8
+
9
+ const brief = (r) => ({ id: r.id, started_at: r.started_at, environment: r.environment?.name || null, git: r.git ? { branch: r.git.branch, commit: r.git.commit } : null, summary: r.summary || {} })
10
+
11
+ /** Result of one criterion in one locale and viewport, or null when the step was not reached. */
12
+ const cell = (c, locale, vp) => {
13
+ const r = c.by_locale?.[locale]?.[vp]
14
+ if (!r || !r.verdict || r.verdict === 'not_reached') return null
15
+ return { verdict: r.verdict, evidence: r.evidence || r.note || null, score: r.score ?? null }
16
+ }
17
+
18
+ /**
19
+ * @returns {{ run, base, same_scenario, nodes: Array, added: string[], removed: string[], changed: number }}
20
+ * nodes: { id, title, status: {run, base}, changed, criteria: [{ id, text, changed, cells: [{ locale, viewport, run, base }] }], captures: [{ locale, viewport, run, base }] }
21
+ */
22
+ export function compareRuns (run, base) {
23
+ const locales = [...new Set([...(run.scope?.locales || []), ...(base.scope?.locales || [])])]
24
+ const viewports = [...new Set([...Object.keys(run.scope?.viewports || {}), ...Object.keys(base.scope?.viewports || {})])]
25
+ const baseNodes = new Map((base.nodes || []).map((n) => [n.id, n]))
26
+ const runNodes = new Map((run.nodes || []).map((n) => [n.id, n]))
27
+ const nodes = []
28
+ for (const n of run.nodes || []) {
29
+ if (n.kind === 'decision') continue
30
+ const b = baseNodes.get(n.id)
31
+ if (!b) continue
32
+ const baseCrit = new Map((b.criteria || []).map((c) => [c.id, c]))
33
+ const criteria = []
34
+ for (const c of n.criteria || []) {
35
+ const bc = baseCrit.get(c.id)
36
+ const cells = []
37
+ for (const locale of locales) {
38
+ for (const vp of viewports) {
39
+ const a = cell(c, locale, vp), z = bc ? cell(bc, locale, vp) : null
40
+ if (!a && !z) continue
41
+ const changed = (a?.verdict || null) !== (z?.verdict || null) || (a?.evidence || '') !== (z?.evidence || '')
42
+ if (changed) cells.push({ locale, viewport: vp, run: a, base: z })
43
+ }
44
+ }
45
+ // one verdict per criterion: worst over locales and viewports
46
+ const all = (c2) => c2 ? locales.flatMap((l) => viewports.map((vp) => cell(c2, l, vp)?.verdict).filter(Boolean)) : []
47
+ const vRun = all(c), vBase = all(bc)
48
+ criteria.push({ id: c.id, text: c.text, verdict: { run: vRun.length ? worst(vRun) : null, base: vBase.length ? worst(vBase) : null }, cells, changed: cells.length > 0 })
49
+ }
50
+ const captures = []
51
+ for (const locale of locales) for (const vp of viewports) {
52
+ const a = n.captures?.[locale]?.[vp], z = b.captures?.[locale]?.[vp]
53
+ if (a || z) captures.push({ locale, viewport: vp, run: a ? (a.image || a.png) : null, base: z ? (z.image || z.png) : null, url: a?.url || z?.url || null })
54
+ }
55
+ const status = { run: n.status || null, base: b.status || null }
56
+ nodes.push({ id: n.id, title: text(n.title, locales[0]), status, changed: status.run !== status.base || criteria.some((c) => c.changed), criteria, captures })
57
+ }
58
+ const added = [...runNodes.keys()].filter((id) => !baseNodes.has(id) && runNodes.get(id).kind !== 'decision')
59
+ const removed = [...baseNodes.keys()].filter((id) => !runNodes.has(id) && baseNodes.get(id).kind !== 'decision')
60
+ return { run: brief(run), base: brief(base), same_scenario: run.scenario?.id === base.scenario?.id, nodes, added, removed, changed: nodes.filter((n) => n.changed).length + added.length + removed.length }
61
+ }
62
+
63
+ /** Plain-text report for the terminal and issue comments. */
64
+ export function formatComparison (d) {
65
+ const sum = (s) => `${s.pass || 0} pass · ${s.partial || 0} partial · ${s.fail || 0} fail`
66
+ const who = (r) => `${r.id} (${[r.environment, r.git && `${r.git.branch} @ ${r.git.commit}`].filter(Boolean).join(', ')})`
67
+ const out = [`Compare ${who(d.run)}\n with ${who(d.base)}`]
68
+ if (!d.same_scenario) out.push(' ! different scenarios')
69
+ out.push(`Steps: ${sum(d.run.summary)} ← ${sum(d.base.summary)}`)
70
+ for (const n of d.nodes.filter((x) => x.changed)) {
71
+ out.push(` ${n.id}: ${n.status.base || '—'} → ${n.status.run || '—'}${n.title ? ` (${n.title})` : ''}`)
72
+ for (const c of n.criteria.filter((x) => x.changed)) {
73
+ for (const k of c.cells) {
74
+ const v = (k.base?.verdict || '—') !== (k.run?.verdict || '—') ? `${k.base?.verdict || '—'} → ${k.run?.verdict || '—'}` : k.run?.verdict || '—'
75
+ const score = k.base?.score != null || k.run?.score != null ? ` (${k.base?.score != null ? k.base.score.toFixed(2) : '—'} → ${k.run?.score != null ? k.run.score.toFixed(2) : '—'})` : ''
76
+ const cut = (t) => { t = String(t || ''); return t.length > 100 ? t.slice(0, 97) + '…' : t }
77
+ const ev = (k.base?.evidence || '') !== (k.run?.evidence || '') ? `\n evidence: ${JSON.stringify(cut(k.base?.evidence))} → ${JSON.stringify(cut(k.run?.evidence))}` : ''
78
+ out.push(` ${c.id} [${k.locale}/${k.viewport}]: ${v}${score}${ev}`)
79
+ }
80
+ }
81
+ }
82
+ if (d.added.length) out.push(` added steps: ${d.added.join(', ')}`)
83
+ if (d.removed.length) out.push(` removed steps: ${d.removed.join(', ')}`)
84
+ const same = d.nodes.filter((x) => !x.changed).map((x) => x.id)
85
+ if (same.length) out.push(` unchanged: ${same.join(', ')}`)
86
+ if (!d.changed) out.push('No differences in statuses, verdicts or evidence.')
87
+ return out.join('\n')
88
+ }
@@ -0,0 +1,117 @@
1
+ import { readFile, access } from 'node:fs/promises'
2
+ import { join, dirname, resolve } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import yaml from 'js-yaml'
5
+ import { schemaErrors } from './schema.mjs'
6
+ import { UserError, localized, globToRegExp } from './util.mjs'
7
+
8
+ export const CONFIG_FILE = 'kaloko.config.yml'
9
+ /** The file name before the rename to Kaloko; still found, so existing projects keep working. */
10
+ export const LEGACY_CONFIG_FILE = 'qawalk.config.yml'
11
+
12
+ /** Environment variables were QAWALK_*; the old names still work and fill the KALOKO_* ones that are not set. */
13
+ export function aliasLegacyEnv (vars = process.env) {
14
+ for (const [k, v] of Object.entries(vars)) if (k.startsWith('QAWALK_') && vars[`KALOKO_${k.slice(7)}`] == null) vars[`KALOKO_${k.slice(7)}`] = v
15
+ }
16
+ aliasLegacyEnv()
17
+
18
+ async function exists (file) {
19
+ try { await access(file); return true } catch { return false }
20
+ }
21
+
22
+ /** Finds kaloko.config.yml (or the former qawalk.config.yml) from `start` upwards and returns {config, root}. */
23
+ export async function loadConfig (start = process.cwd(), explicit) {
24
+ let file = explicit ? resolve(explicit) : null
25
+ if (!file) {
26
+ let dir = resolve(start)
27
+ while (true) {
28
+ if (await exists(join(dir, CONFIG_FILE))) { file = join(dir, CONFIG_FILE); break }
29
+ if (await exists(join(dir, LEGACY_CONFIG_FILE))) { file = join(dir, LEGACY_CONFIG_FILE); break }
30
+ const parent = dirname(dir)
31
+ if (parent === dir) break
32
+ dir = parent
33
+ }
34
+ }
35
+ if (!file) throw new UserError(`${CONFIG_FILE} not found. Run "kaloko init" in the project root.`)
36
+ // the user-level file holds personal keys and tokens: tests (node --test) and KALOKO_USER_ENV=0 leave it out
37
+ const userEnv = process.env.KALOKO_USER_ENV !== '0' && !process.env.NODE_TEST_CONTEXT
38
+ await loadEnvFiles([join(dirname(file), '.env'), ...(userEnv ? [join(homedir(), '.config', 'kaloko', '.env'), join(homedir(), '.config', 'qawalk', '.env')] : [])])
39
+ aliasLegacyEnv()
40
+ const config = yaml.load(await readFile(file, 'utf8'))
41
+ const problems = schemaErrors('config.v1', config)
42
+ if (problems.length) throw new UserError(`${file} is invalid:\n - ${problems.join('\n - ')}`)
43
+ // projects started before the rename keep their run folder (it is in their .gitignore)
44
+ config.output ||= !(await exists(join(dirname(file), 'tmp', 'kaloko'))) && (await exists(join(dirname(file), 'tmp', 'qawalk'))) ? 'tmp/qawalk' : 'tmp/kaloko'
45
+ config.browser ||= {}
46
+ config.browser.port ||= 9333
47
+ return { config, root: dirname(file), file }
48
+ }
49
+
50
+ export function environment (config, name, vars = process.env) {
51
+ const env = config.environments[name]
52
+ if (!env) throw new UserError(`Unknown environment "${name}". Known: ${Object.keys(config.environments).join(', ')}`)
53
+ // a design environment has no application: the CLI serves its folder on 127.0.0.1 while walking (#42)
54
+ if (env.kind === 'design') return { name, readonly: false, may_create_data: false, blocked_paths: [], allowed_post_paths: [], ...env, kind: 'design', base_url: 'http://127.0.0.1' }
55
+ // `${VAR}` in base_url: review apps and preview deployments get their URL from CI
56
+ const expand = (url) => url.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, v) => {
57
+ if (!vars[v]) throw new UserError(`Environment ${name}: base_url needs ${v} (set it in the environment or .env).`)
58
+ return vars[v]
59
+ })
60
+ // app environments may have no base URL (deep links are app schemes)
61
+ const base_url = env.base_url == null ? null : typeof env.base_url === 'string' ? expand(env.base_url) : Object.fromEntries(Object.entries(env.base_url).map(([k, v]) => [k, expand(v)]))
62
+ return { name, readonly: false, may_create_data: true, blocked_paths: [], allowed_post_paths: [], ...env, base_url, ...(name === 'production' ? { readonly: true, may_create_data: false } : {}) }
63
+ }
64
+
65
+ /** Origins of the environment's base URL (every locale). */
66
+ export function baseOrigins (env) {
67
+ if (!env.base_url) return []
68
+ return [...new Set(Object.values(typeof env.base_url === 'string' ? { d: env.base_url } : env.base_url).map((u) => new URL(u).origin))]
69
+ }
70
+
71
+ export function baseUrl (env, locale) {
72
+ return env.base_url ? localized(env.base_url, locale).replace(/\/$/, '') : ''
73
+ }
74
+
75
+ export function urlFor (env, node, locale) {
76
+ // paths never fall back to another locale's path: a missing locale means the step does not exist there
77
+ const raw = typeof node.path === 'string' ? node.path : (node.path?.[locale] ?? node.path?.default)
78
+ if (!raw) return null
79
+ // `{random}` makes a path that cannot exist (error pages: /no-such-page-{random} must answer 404)
80
+ const path = raw.replaceAll('{random}', () => Math.random().toString(36).slice(2, 10))
81
+ if (/^https?:\/\//.test(path)) return path
82
+ return baseUrl(env, locale) + (path.startsWith('/') ? path : `/${path}`)
83
+ }
84
+
85
+ /**
86
+ * Readonly policy for a request. Returns null when allowed, otherwise a reason.
87
+ * Blocked paths are always blocked; readonly additionally blocks non-GET/HEAD
88
+ * requests to the environment's hosts unless the path is explicitly allowed.
89
+ */
90
+ export function policyViolation (env, { method, url }) {
91
+ let parsed
92
+ try { parsed = new URL(url) } catch { return null }
93
+ const path = parsed.pathname
94
+ const hosts = new Set(Object.values(typeof env.base_url === 'string' ? { d: env.base_url } : env.base_url || {}).map((u) => new URL(u).host))
95
+ if (!hosts.has(parsed.host)) return null
96
+ if (env.blocked_paths.some((g) => globToRegExp(g).test(path))) return `path ${path} is blocked in environment ${env.name}`
97
+ if (env.readonly && !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())) {
98
+ if (!env.allowed_post_paths.some((g) => globToRegExp(g).test(path))) return `${method} ${path} is not allowed in readonly environment ${env.name}`
99
+ }
100
+ return null
101
+ }
102
+
103
+ /**
104
+ * Loads KEY=value lines from env files into process.env without overriding
105
+ * variables that are already set. Project .env wins over the global file.
106
+ */
107
+ export async function loadEnvFiles (files) {
108
+ for (const file of files) {
109
+ let text
110
+ try { text = await readFile(file, 'utf8') } catch { continue }
111
+ for (const line of text.split('\n')) {
112
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/)
113
+ if (!m || process.env[m[1]] !== undefined) continue
114
+ process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2')
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,13 @@
1
+ // In-memory collection for browsers that are not managed by the daemon.
2
+ const logs = new WeakMap()
3
+
4
+ export function observeConsole (page) {
5
+ if (logs.has(page)) return
6
+ const entries = []
7
+ logs.set(page, entries)
8
+ const record = (text, src = null) => entries.push({ ts: new Date().toISOString(), url: page.url(), type: 'console', text, src })
9
+ page.on('console', (message) => { if (message.type() === 'error') record(message.text(), message.location()?.url || null) })
10
+ page.on('pageerror', (error) => record(error.message))
11
+ }
12
+
13
+ export const consoleEntriesFor = (page) => logs.get(page) ?? null
@@ -0,0 +1,75 @@
1
+ // Coverage between the acceptance plan (ACCEPTANCE.md) and the scenario, and flakiness across runs.
2
+
3
+ /**
4
+ * Reads the plan template shape: `### <step-id> — <title> (...)` headings and `- AC<n>: text` lines.
5
+ * @returns {{ steps: Record<string, { title: string, criteria: Record<string, string> }> }}
6
+ */
7
+ export function parsePlan (markdown) {
8
+ const steps = {}
9
+ let current = null
10
+ for (const raw of String(markdown || '').split('\n')) {
11
+ const h = raw.match(/^###\s+`?([A-Za-z0-9._-]+)`?\s*(?:—|-|–)\s*(.*?)\s*(?:\(`?[a-z|` ]+`?\))?\s*$/)
12
+ if (h) { current = h[1]; steps[current] = { title: h[2].trim(), criteria: {} }; continue }
13
+ if (/^##\s/.test(raw)) { current = null; continue }
14
+ const ac = raw.match(/^\s*[-*]\s+\*{0,2}(AC\d+)\*{0,2}\s*[:.]\s*(.*)$/)
15
+ if (ac && current) steps[current].criteria[ac[1]] = ac[2].trim()
16
+ }
17
+ return { steps }
18
+ }
19
+
20
+ /** Criteria planned but not checked, checks without a planned criterion, and steps only on one side. */
21
+ export function coverage (scenario, plan) {
22
+ const missing = []; const extra = []
23
+ const scenarioSteps = new Map((scenario.nodes || []).filter((n) => n.kind !== 'decision').map((n) => [n.id, n]))
24
+ for (const [stepId, step] of Object.entries(plan.steps)) {
25
+ const node = scenarioSteps.get(stepId)
26
+ for (const [acId, text] of Object.entries(step.criteria)) {
27
+ if (!node || !(node.criteria || []).some((c) => c.id === acId)) missing.push({ step: stepId, id: acId, text })
28
+ }
29
+ }
30
+ for (const [stepId, node] of scenarioSteps) {
31
+ for (const c of node.criteria || []) if (!c.pack && !plan.steps[stepId]?.criteria[c.id]) extra.push({ step: stepId, id: c.id, text: c.text })
32
+ }
33
+ const planned = Object.values(plan.steps).reduce((n, s) => n + Object.keys(s.criteria).length, 0)
34
+ const checked = planned - missing.length
35
+ return {
36
+ planned, checked, missing, extra,
37
+ steps_missing: Object.keys(plan.steps).filter((id) => !scenarioSteps.has(id)),
38
+ steps_extra: [...scenarioSteps.keys()].filter((id) => !plan.steps[id])
39
+ }
40
+ }
41
+
42
+ export function formatCoverage (file, cov) {
43
+ const lines = [`${file}: ${cov.checked}/${cov.planned} planned criteria have a check${cov.extra.length ? `, ${cov.extra.length} check(s) without a planned criterion` : ''}`]
44
+ for (const m of cov.missing) lines.push(` ✕ ${m.step} ${m.id} — planned, no check: ${m.text}`)
45
+ for (const e of cov.extra) lines.push(` • ${e.step} ${e.id} — checked, not in the plan: ${e.text}`)
46
+ if (cov.steps_missing.length) lines.push(` ✕ steps in the plan without a node: ${cov.steps_missing.join(', ')}`)
47
+ if (cov.steps_extra.length) lines.push(` • nodes not in the plan: ${cov.steps_extra.join(', ')}`)
48
+ return lines.join('\n')
49
+ }
50
+
51
+ /**
52
+ * Flaky criteria: verdict differs between runs of the same scenario on the same commit.
53
+ * @param {object} run the current manifest (nodes with criteria.verdict, git.commit)
54
+ * @param {object[]} others manifests of other runs of the scenario
55
+ * @returns {Set<string>} keys `${nodeId}|${criterionId}`
56
+ */
57
+ export function flaky (run, others) {
58
+ const out = new Set()
59
+ const commit = run.git?.commit
60
+ if (!commit) return out
61
+ const same = others.filter((r) => r.git?.commit === commit && r.environment?.name === run.environment?.name)
62
+ if (!same.length) return out
63
+ for (const n of run.nodes || []) {
64
+ for (const c of n.criteria || []) {
65
+ if (!c.verdict || c.verdict === 'not_reached') continue
66
+ for (const r of same) {
67
+ const oc = r.nodes?.find((x) => x.id === n.id)?.criteria?.find((x) => x.id === c.id)
68
+ // a criterion whose check changed between the runs is a scenario edit, not flakiness
69
+ const sameCheck = !oc?.check || !c.check || JSON.stringify(oc.check) === JSON.stringify(c.check)
70
+ if (oc?.verdict && oc.verdict !== 'not_reached' && oc.verdict !== c.verdict && sameCheck) { out.add(`${n.id}|${c.id}`); break }
71
+ }
72
+ }
73
+ }
74
+ return out
75
+ }