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.
- package/README.md +119 -0
- package/bin/kaloko.mjs +4 -0
- package/bin/qawalk.mjs +4 -0
- package/package.json +42 -0
- package/schemas/bundle.v1.json +19 -0
- package/schemas/config.v1.json +268 -0
- package/schemas/revision.v1.json +36 -0
- package/schemas/run.v1.json +549 -0
- package/schemas/scenario.v1.json +668 -0
- package/schemas/tokens.v1.json +102 -0
- package/skills/kaloko/SKILL.md +94 -0
- package/skills/kaloko/reference/acceptance.md +208 -0
- package/skills/kaloko/reference/apps.md +161 -0
- package/skills/kaloko/reference/collaboration.md +218 -0
- package/skills/kaloko/reference/design.md +143 -0
- package/skills/kaloko/reference/documentation.md +72 -0
- package/skills/kaloko/reference/environments.md +140 -0
- package/skills/kaloko/reference/evaluation.md +62 -0
- package/skills/kaloko/reference/execution.md +75 -0
- package/skills/kaloko/reference/mcp.md +69 -0
- package/skills/kaloko/reference/stacks.md +122 -0
- package/src/cli.mjs +1344 -0
- package/src/lib/access.mjs +169 -0
- package/src/lib/apps/capture.mjs +59 -0
- package/src/lib/apps/drivers.mjs +284 -0
- package/src/lib/apps/helper.swift +102 -0
- package/src/lib/apps/tree.mjs +222 -0
- package/src/lib/browser-daemon.mjs +41 -0
- package/src/lib/browser.mjs +161 -0
- package/src/lib/build.mjs +149 -0
- package/src/lib/calibrate.mjs +97 -0
- package/src/lib/capture.mjs +233 -0
- package/src/lib/compare.mjs +88 -0
- package/src/lib/config.mjs +117 -0
- package/src/lib/console.mjs +13 -0
- package/src/lib/coverage.mjs +75 -0
- package/src/lib/design-cli.mjs +440 -0
- package/src/lib/design-core.mjs +163 -0
- package/src/lib/design.mjs +164 -0
- package/src/lib/doctor.mjs +149 -0
- package/src/lib/draft.mjs +76 -0
- package/src/lib/drift.mjs +145 -0
- package/src/lib/evaluate.mjs +222 -0
- package/src/lib/git.mjs +70 -0
- package/src/lib/htmlview.mjs +130 -0
- package/src/lib/jev.mjs +95 -0
- package/src/lib/mail.mjs +182 -0
- package/src/lib/manifest-core.mjs +127 -0
- package/src/lib/pack-help.mjs +55 -0
- package/src/lib/packs.mjs +121 -0
- package/src/lib/pr.mjs +78 -0
- package/src/lib/probe.mjs +379 -0
- package/src/lib/recheck.mjs +70 -0
- package/src/lib/run.mjs +85 -0
- package/src/lib/scenario.mjs +98 -0
- package/src/lib/schema.mjs +25 -0
- package/src/lib/secrets.mjs +59 -0
- package/src/lib/service.mjs +52 -0
- package/src/lib/stack.mjs +130 -0
- package/src/lib/tokens.mjs +352 -0
- package/src/lib/util.mjs +97 -0
- package/templates/ACCEPTANCE.md +55 -0
- package/templates/cs/ACCEPTANCE.md +55 -0
- package/templates/kaloko.config.yml +76 -0
- package/templates/mail/letter-opener.mjs +47 -0
- package/templates/scenario.yml +119 -0
- package/templates/steps/consent.mjs +33 -0
- 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
|
+
}
|