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,222 @@
1
+ import { join, resolve } from 'node:path'
2
+ import { readJson, writeJson, sha256 } from './util.mjs'
3
+ import { readCaptures, runPaths } from './run.mjs'
4
+ import { buildRequest, evaluateWithJev, verdictFor, DEFAULTS, PROMPT_VERSION, SIGNAL_NAMES } from './jev.mjs'
5
+ import { needsDrift, driftForCapture, loadTokens, driftCacheDir } from './drift.mjs'
6
+ import { packHelp } from './pack-help.mjs'
7
+
8
+ /** Latest capture per node/locale/viewport. */
9
+ export function latestCaptures (entries) {
10
+ const map = new Map()
11
+ for (const e of entries) if (e.type === 'capture') map.set(`${e.node}|${e.item || ''}|${e.locale}|${e.viewport}`, e)
12
+ return [...map.values()]
13
+ }
14
+
15
+ export const criteriaHash = (node) => sha256(node.criteria || [])
16
+ /** Folder of a capture under steps/: the node, or steps/<node>/items/<item> for a page of a stack step. */
17
+ export const stepIdOf = (e) => (e.item ? `${e.node}/items/${e.item}` : e.node)
18
+ export const evaluationSource = (node, capture) => sha256({ capture, criteria: node.criteria || [], purpose: node.purpose || '' })
19
+
20
+ /** Criterion verdicts for one capture, combining deterministic results and JEV answers. */
21
+ export function combine ({ node, capture, jev, thresholds }) {
22
+ return (node.criteria || []).map((c) => {
23
+ if (c.check.type === 'deterministic') {
24
+ const r = capture.checks?.[c.id]
25
+ const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
26
+ const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || /^perf_/.test(c.check.assert || '')
27
+ return { id: c.id, type: 'deterministic', verdict, evidence: r?.evidence ?? null, ...(capture.rechecked_at && !live ? { rechecked: true } : {}) }
28
+ }
29
+ if (c.check.type === 'semantic' && c.check.about === 'console' && !(capture.console_errors || []).length && capture.checks) {
30
+ return { id: c.id, type: 'semantic', verdict: 'pass', evidence: 'no console errors', score: null, provider: null, note: null }
31
+ }
32
+ if (c.check.type === 'semantic') {
33
+ const answer = jev?.response?.answers?.[c.id]
34
+ const score = answer?.noul ?? null
35
+ return { id: c.id, type: 'semantic', verdict: verdictFor(score, thresholds), evidence: null, score, provider: answer ? 'typesafe-jev' : null, note: jev?.skipped || jev?.error || null }
36
+ }
37
+ return { id: c.id, type: 'manual', verdict: 'not_evaluated', evidence: c.check.note || null }
38
+ })
39
+ }
40
+
41
+ const BATCH = 50
42
+
43
+ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
44
+ const settings = { ...DEFAULTS, ...(config.evaluators?.jev || {}), thresholds: { ...DEFAULTS.thresholds, ...(config.evaluators?.jev?.thresholds || {}) } }
45
+ const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
46
+ const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
47
+ const paths = runPaths(run.dir)
48
+ const report = { evaluated: 0, semantic_calls: 0, semantic_cached: 0, skipped: [], model: settings.model, drift: 0, outdated: new Set(), via: {}, hosted: null, hostedStop: null, findings: [] }
49
+ // checks against the design reference and the token set (pack design, #42) need the service and a browser
50
+ const drift = scenario.nodes.some(needsDrift) ? await driftContext({ run, scenario, config, root }) : null
51
+ try {
52
+ const latest = latestCaptures(await readCaptures(run.dir))
53
+ // stack steps: semantic criteria only on the first N pages (items.semantic_sample, default 20); cost stays bounded
54
+ const sampled = new Map()
55
+ for (const e of latest) {
56
+ if (!e.item) continue
57
+ const node = nodes.get(e.node); if (!node) continue
58
+ const n = node.items?.semantic_sample ?? 20
59
+ const set = sampled.get(e.node) || new Set(); sampled.set(e.node, set)
60
+ if (set.size < n) set.add(e.item)
61
+ }
62
+ // in batches of 50 captures: memory holds one batch of page states, not a whole stack of thousands
63
+ const hosted = hostedSender(config, report)
64
+ for (let at = 0; at < latest.length; at += BATCH) {
65
+ const batch = latest.slice(at, at + BATCH)
66
+ // 1. what each capture needs; 2. the evaluator calls, several at a time; 3. drift and verdicts in order
67
+ const work = []
68
+ for (const entry of batch) {
69
+ const node = nodes.get(entry.node)
70
+ if (!node) continue
71
+ const capture = await readJson(join(run.dir, entry.file))
72
+ // Old captures remain readable, but checks cannot be reinterpreted after
73
+ // their definitions change. Recapture to bind them to the current criteria.
74
+ const current = capture.criteria_hash === criteriaHash(node)
75
+ // console triage needs the evaluator only when the capture has console errors
76
+ const outOfSample = entry.item && !sampled.get(entry.node)?.has(entry.item)
77
+ const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && !(c.check.about === 'console' && !(capture.console_errors || []).length))
78
+ // quick signals ride along on every captured page state (also steps without semantic criteria)
79
+ const signals = settings.signals !== false && !outOfSample && !!capture.state
80
+ const request = (semantic.length || signals) && current ? buildRequest({ node, capture, criteria: semantic, model: settings.model, signals }) : null
81
+ work.push({ entry, node, capture, current, outOfSample, request, asks: semantic.length > 0, jev: current ? null : { skipped: 'Capture has no matching criteria fingerprint; capture this step again.' } })
82
+ if (!current) report.outdated.add(entry.node)
83
+ }
84
+
85
+ const queue = work.filter((w) => w.request)
86
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(settings.concurrency || 6, queue.length)) }, async () => {
87
+ for (let w = queue.shift(); w; w = queue.shift()) {
88
+ w.jev = report.hostedStop && !process.env[settings.api_key_env] ? { skipped: report.hostedStop } : await evaluateWithJev({ request: w.request, settings, cacheDir, fetchImpl, hosted })
89
+ if (w.jev.response) { w.jev.cached ? report.semantic_cached++ : report.semantic_calls++; report.via[w.jev.via] = (report.via[w.jev.via] || 0) + 1; if (w.jev.response.qawalk) report.hosted = w.jev.response.qawalk }
90
+ // signals alone are best effort: only captures with semantic criteria report why they were not evaluated
91
+ if ((w.jev.skipped || w.jev.error) && w.asks) report.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${w.jev.skipped || w.jev.error}`)
92
+ }
93
+ }))
94
+ for (const { entry, node, capture, current, outOfSample, request, jev } of work) {
95
+ const source = evaluationSource(node, capture)
96
+ if (drift && current && needsDrift(node)) { capture.checks = { ...capture.checks, ...await driftForCapture({ node, entry, capture, runDir: run.dir, ctx: drift }) }; report.drift++ }
97
+ let criteria = combine({ node, capture: current ? capture : {}, jev, thresholds: settings.thresholds })
98
+ // pages outside the semantic sample carry only the deterministic results
99
+ if (outOfSample) criteria = criteria.filter((c) => c.type !== 'semantic' || c.evidence === 'no console errors')
100
+ const evaluation = {
101
+ schema: 'qawalk.eval.v1',
102
+ source,
103
+ node: entry.node, ...(entry.item ? { item: entry.item, semantic_sample: !outOfSample } : {}), locale: entry.locale, viewport: entry.viewport,
104
+ evaluated_at: new Date().toISOString(),
105
+ semantic: jev?.response ? {
106
+ provider: 'typesafe-jev',
107
+ via: jev.via || 'own-key',
108
+ model: jev.response.model || settings.model,
109
+ prompt_version: PROMPT_VERSION,
110
+ state_hash: jev.key,
111
+ cached: jev.cached,
112
+ usage: jev.response.usage || null,
113
+ answers: jev.response.answers
114
+ } : (jev ? { provider: 'typesafe-jev', skipped: jev.skipped || null, error: jev.error || null } : null),
115
+ thresholds: settings.thresholds,
116
+ state_chars: request ? JSON.stringify(request.state).length : null,
117
+ ...(signalsOf(jev) ? { signals: signalsOf(jev) } : {}),
118
+ criteria
119
+ }
120
+ await writeJson(paths.step(stepIdOf(entry), entry.locale, entry.viewport, 'eval.json'), evaluation)
121
+ report.evaluated++
122
+ // what did not pass, for the walk summary and agents: step, locale, viewport, criterion and why
123
+ for (const c of criteria) if (c.verdict === 'fail' || c.verdict === 'needs_review') report.findings.push({ node: entry.node, ...(entry.item ? { item: entry.item } : {}), locale: entry.locale, viewport: entry.viewport, id: c.id, verdict: c.verdict, type: c.type, why: c.evidence || (c.score != null ? `AI ${Math.round(c.score * 100)} % yes` : c.note) || null, ...((node.criteria || []).find((x) => x.id === c.id)?.pack && packHelp(c.id) ? { fix: packHelp(c.id).fix } : {}) })
124
+ }
125
+ }
126
+ } finally { if (drift?.browser) await drift.browser.close() }
127
+ const { hostedStop, ...out } = report
128
+ return { ...out, outdated: [...report.outdated] }
129
+ }
130
+
131
+ /**
132
+ * While a walk runs: the semantic questions of each new capture go to the evaluator in the background, so the
133
+ * answers are cached by the time the walk ends and `evaluateRun` only assembles verdicts. Pages of stack steps
134
+ * are left to evaluateRun (it samples them).
135
+ */
136
+ /** The quick signals of an answer: { purpose, language, broken } as probabilities of "yes". */
137
+ function signalsOf (jev) {
138
+ const a = jev?.response?.answers
139
+ if (!a) return null
140
+ const out = {}
141
+ for (const [id, name] of Object.entries(SIGNAL_NAMES)) if (a[id]?.noul != null) out[name] = a[id].noul
142
+ return Object.keys(out).length ? out : null
143
+ }
144
+
145
+ export function semanticPrefetch ({ scenario, config, root }) {
146
+ const settings = { ...DEFAULTS, ...(config.evaluators?.jev || {}), thresholds: { ...DEFAULTS.thresholds, ...(config.evaluators?.jev?.thresholds || {}) } }
147
+ const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
148
+ const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
149
+ const report = { hostedStop: null }
150
+ const hosted = hostedSender(config, report)
151
+ const queue = []; const running = new Set(); let asked = 0
152
+ let idle = null // resolves when the queue drains, for done()
153
+ const pump = () => {
154
+ while (running.size < (settings.concurrency || 6) && queue.length) {
155
+ const job = queue.shift()
156
+ const p = job().catch(() => {}).finally(() => { running.delete(p); pump() })
157
+ running.add(p)
158
+ }
159
+ if (!running.size && !queue.length && idle) { idle.resolve(); idle = null }
160
+ }
161
+ return {
162
+ add (entries, runDir) {
163
+ if (config.evaluators?.jev === false || config.evaluators?.jev === null) return
164
+ for (const e of [].concat(entries || [])) {
165
+ if (e?.type !== 'capture' || e.item) continue
166
+ const node = nodes.get(e.node)
167
+ const semantic = (node?.criteria || []).filter((c) => c.check.type === 'semantic')
168
+ if (!semantic.length && settings.signals === false) continue
169
+ queue.push(async () => {
170
+ const capture = await readJson(join(runDir, e.file))
171
+ const crit = semantic.filter((c) => !(c.check.about === 'console' && !(capture.console_errors || []).length))
172
+ const signals = settings.signals !== false && !!capture.state
173
+ if ((!crit.length && !signals) || capture.criteria_hash !== criteriaHash(node)) return
174
+ asked++
175
+ await evaluateWithJev({ request: buildRequest({ node, capture, criteria: crit, model: settings.model, signals }), settings, cacheDir, hosted })
176
+ })
177
+ }
178
+ pump()
179
+ },
180
+ async done () {
181
+ if (running.size || queue.length) { let resolve; const p = new Promise((r) => { resolve = r }); idle = { resolve }; await p }
182
+ return { asked, stopped: report.hostedStop }
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Sends a JEV request through the organization's service (hosted evaluation) when a service token is set.
189
+ * An allowance used up or a service without hosting stops further hosted calls for this run with one message.
190
+ */
191
+ function hostedSender (config, report) {
192
+ let client = null
193
+ return async (body) => {
194
+ if (report.hostedStop) return { skipped: report.hostedStop }
195
+ try { if (!client) { const { service } = await import('./service.mjs'); client = service(config) } } catch { return { skipped: 'no evaluator key (TYPESAFE_API_KEY) and no service token (KALOKO_TOKEN) for hosted evaluation' } }
196
+ try { return { response: await client.request('POST', `/orgs/${config.org}/semantic`, JSON.parse(body)) } } catch (e) {
197
+ const m = String(e.message || e)
198
+ if (/HTTP (402|403|503)/.test(m)) { report.hostedStop = m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.hostedStop } }
199
+ return { error: m }
200
+ }
201
+ }
202
+ }
203
+
204
+ /** Context of the design checks: service client, token set, reference cache and a browser for pixel diffs. */
205
+ async function driftContext ({ run, scenario, config, root }) {
206
+ const isDesign = run.meta.environment.kind === 'design'
207
+ const ctx = { org: config.org, scenarioId: scenario.id, isDesign, git: run.meta.git?.branch || null, references: new Map(), cacheDir: driftCacheDir(root, config.output), mode: null, tokens: null, client: null, page: null, browser: null }
208
+ try { const { service } = await import('./service.mjs'); ctx.client = service(config) } catch (e) { ctx.referenceError = `Design checks need the service: ${e.message}` }
209
+ let designDir = null
210
+ try {
211
+ const { environment } = await import('./config.mjs')
212
+ const d = Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design')
213
+ if (d) { designDir = resolve(root, d.serve); ctx.mode = d.tokens_mode || null }
214
+ } catch {}
215
+ ctx.tokens = await loadTokens({ client: ctx.client, config, root, designDir })
216
+ if (!isDesign && scenario.nodes.some((n) => (n.criteria || []).some((c) => c.check.assert === 'design_pixels'))) {
217
+ const { launchEphemeral } = await import('./browser.mjs')
218
+ ctx.browser = await launchEphemeral(config)
219
+ ctx.page = await (await ctx.browser.newContext()).newPage()
220
+ }
221
+ return ctx
222
+ }
@@ -0,0 +1,70 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+
4
+ const run = promisify(execFile)
5
+
6
+ async function out (cmd, args, cwd) {
7
+ try {
8
+ const { stdout } = await run(cmd, args, { cwd, timeout: 15000 })
9
+ return stdout.trim()
10
+ } catch {
11
+ return null
12
+ }
13
+ }
14
+
15
+ /** owner/name of the origin remote, for GitHub and GitLab style URLs; null when absent. */
16
+ export async function repoSlug (cwd) {
17
+ const url = await out('git', ['remote', 'get-url', 'origin'], cwd)
18
+ if (!url) return null
19
+ const m = url.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/)
20
+ return m ? { slug: m[1], host: url.includes('github.com') ? 'github' : url.includes('gitlab') ? 'gitlab' : 'other' } : null
21
+ }
22
+
23
+ /**
24
+ * Project = config.project, else the repository name. Issue = scenario.issue,
25
+ * else a number found in the scenario id (TASK-1273) or the branch (feat/1273-…).
26
+ */
27
+ export async function projectAndIssue ({ cwd, config, scenario, branch }) {
28
+ const repo = await repoSlug(cwd)
29
+ // the scenario may sort itself into a project; otherwise the config, then the repository name
30
+ const project = scenario.project
31
+ ? { slug: scenario.project, repo: repo?.slug || null, source: 'scenario' }
32
+ : config.project
33
+ ? { slug: config.project, repo: repo?.slug || null, source: 'config' }
34
+ : repo ? { slug: repo.slug.split('/').pop(), repo: repo.slug, source: 'git' } : { slug: cwd.split('/').pop(), repo: null, source: 'directory' }
35
+ let issue = null
36
+ const given = scenario.issue
37
+ const num = given?.number || Number((scenario.id.match(/(?:^|[-_#])(\d{2,6})(?:$|[-_])/) || [])[1]) || Number((String(branch || '').match(/(?:^|[/-])(\d{2,6})(?:-|$)/) || [])[1]) || null
38
+ if (num) {
39
+ const slug = given?.repo || repo?.slug || null
40
+ const url = given?.url || (slug && repo?.host === 'github' ? `https://github.com/${slug}/issues/${num}` : slug && repo?.host === 'gitlab' ? `https://gitlab.com/${slug}/-/issues/${num}` : null)
41
+ issue = { repo: slug, number: num, url, title: given?.title || null }
42
+ }
43
+ return { project, issue }
44
+ }
45
+
46
+ /** Branch, commit, author and open/merged PR (via gh, optional). */
47
+ /** Application version for documentation captures: the tag on HEAD (git describe --exact-match), else null. */
48
+ export async function appVersion (cwd) {
49
+ return (await out('git', ['describe', '--tags', '--exact-match', 'HEAD'], cwd)) || null
50
+ }
51
+
52
+ export async function gitMeta (cwd) {
53
+ const commit = await out('git', ['rev-parse', '--short', 'HEAD'], cwd)
54
+ if (!commit) return null
55
+ const [branch, message, author, sha] = await Promise.all([
56
+ out('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd),
57
+ out('git', ['log', '-1', '--format=%s'], cwd),
58
+ out('git', ['log', '-1', '--format=%an'], cwd),
59
+ out('git', ['rev-parse', 'HEAD'], cwd)
60
+ ])
61
+ let pr = null
62
+ const json = await out('gh', ['pr', 'view', '--json', 'number,url,state'], cwd)
63
+ if (json) {
64
+ try {
65
+ const p = JSON.parse(json)
66
+ pr = { number: p.number, url: p.url, state: String(p.state).toLowerCase() }
67
+ } catch {}
68
+ }
69
+ return { branch, commit, sha, message, author, pr }
70
+ }
@@ -0,0 +1,130 @@
1
+ // HTML view of captured app pages (#42, experimental): a static copy of the page as it rendered, with its
2
+ // stylesheets, images and fonts rehosted, every script and navigation removed, and secrets and personal data
3
+ // scrubbed. It opens in the same frame as live design steps, so the inspector and pins work on the real product.
4
+ // The service sanitises again when serving (defence in depth): nothing here is trusted by it.
5
+ import { mkdir, writeFile } from 'node:fs/promises'
6
+ import { join, dirname } from 'node:path'
7
+ import { sha256Hex, revisionId, bundleProblems } from './design-core.mjs'
8
+
9
+ const ASSET_TYPES = { 'text/css': 'css', 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/svg+xml': 'svg', 'image/avif': 'avif', 'font/woff2': 'woff2', 'font/woff': 'woff', 'application/font-woff2': 'woff2', 'application/font-woff': 'woff' }
10
+ export const VIEW_LIMITS = { max_assets: 150, max_asset_bytes: 3 * 1024 * 1024, max_total_bytes: 25 * 1024 * 1024 }
11
+
12
+ /** Runs in the page: a scrubbed clone of the document with asset URLs replaced by placeholders. Self-contained. */
13
+ export function viewInPage ({ mask = [] }) {
14
+ const doc = document.documentElement.cloneNode(true)
15
+ const liveEls = [...document.querySelectorAll('input, textarea, select')]
16
+ const cloneEls = [...doc.querySelectorAll('input, textarea, select')]
17
+ // form state as rendered (not passwords): values into attributes so the static copy shows them
18
+ liveEls.forEach((el, i) => {
19
+ const c = cloneEls[i]; if (!c) return
20
+ if (el.type === 'password' || el.type === 'hidden') { c.removeAttribute('value'); return }
21
+ if (el.tagName === 'TEXTAREA') c.textContent = el.value
22
+ else if (el.tagName === 'SELECT') [...c.options].forEach((o, k) => { if (el.options[k]?.selected) o.setAttribute('selected', ''); else o.removeAttribute('selected') })
23
+ else if (el.type === 'checkbox' || el.type === 'radio') { if (el.checked) c.setAttribute('checked', ''); else c.removeAttribute('checked') } else c.setAttribute('value', el.value)
24
+ })
25
+ const drop = 'script, noscript, iframe, frame, frameset, object, embed, applet, base, portal, template, link[rel~=manifest], link[rel~=serviceworker], link[rel~=modulepreload], link[rel~=preload][as=script], link[rel~=prefetch], link[rel~=dns-prefetch], link[rel~=preconnect], meta[http-equiv]'
26
+ doc.querySelectorAll(drop).forEach((e) => e.remove())
27
+ // inline JSON state (framework hydration data) and csrf metas carry session data
28
+ doc.querySelectorAll('meta[name*=csrf i], meta[name*=token i], meta[name*=nonce i]').forEach((e) => e.remove())
29
+ const SECRET_SRC = '\\b(eyJ[\\w-]{10,}\\.[\\w-]{10,}\\.[\\w-]{10,}|(?:sk|pk|rk|ghp|gho|xox[abp])_[A-Za-z0-9_-]{16,}|[A-Za-z0-9+/_-]{40,}={0,2})\\b'
30
+ // a fresh expression per use: a shared global one would skip matches through lastIndex
31
+ const SECRET = { test: (v) => new RegExp(SECRET_SRC).test(v), all: () => new RegExp(SECRET_SRC, 'g') }
32
+ const URL_ATTR = /^(href|src|action|formaction|xlink:href|poster|data|background|srcset|ping)$/i
33
+ for (const el of doc.querySelectorAll('*')) {
34
+ for (const a of [...el.attributes]) {
35
+ if (/^on/i.test(a.name)) { el.removeAttribute(a.name); continue }
36
+ if (URL_ATTR.test(a.name) && /^\s*(javascript|vbscript|data:(text\/html|application|image\/svg))/i.test(a.value)) { el.removeAttribute(a.name); continue }
37
+ if (/^(data-|aria-label|title|alt|value|content)/i.test(a.name) && SECRET.test(a.value)) el.setAttribute(a.name, a.value.replace(SECRET.all(), '<token>'))
38
+ }
39
+ }
40
+ doc.querySelectorAll('form').forEach((f) => { f.removeAttribute('action'); f.removeAttribute('method') })
41
+ // a view is for looking: links keep their target for reference but navigate nowhere
42
+ doc.querySelectorAll('a[href], area[href]').forEach((a) => { a.setAttribute('data-href', a.getAttribute('href')); a.setAttribute('href', '#') })
43
+ // personal data: masked text (not only blurred pixels)
44
+ for (const sel of mask) { try { doc.querySelectorAll(sel).forEach((el) => { el.textContent = '••••'; el.removeAttribute('value') }) } catch {} }
45
+ const walker = document.createTreeWalker(doc, NodeFilter.SHOW_TEXT)
46
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) if (SECRET.test(n.textContent)) n.textContent = n.textContent.replace(SECRET.all(), '<token>')
47
+ // assets → placeholders
48
+ const assets = []
49
+ const slot = (url) => { try { const abs = new URL(url, document.baseURI).href; if (!/^https?:/.test(abs)) return null; let i = assets.indexOf(abs); if (i < 0) { i = assets.push(abs) - 1 } return `__QW_ASSET_${i}__` } catch { return null } }
50
+ doc.querySelectorAll('link[rel~=stylesheet][href], link[rel~=icon][href]').forEach((l) => { const s = slot(l.getAttribute('href')); if (s) l.setAttribute('href', s); else l.remove() })
51
+ doc.querySelectorAll('link:not([rel~=stylesheet]):not([rel~=icon])').forEach((l) => l.remove())
52
+ doc.querySelectorAll('img[src], source[src], video[poster], image, use').forEach((el) => {
53
+ for (const at of ['src', 'poster', 'href', 'xlink:href']) { const v = el.getAttribute(at); if (!v || v.startsWith('data:image/') || v.startsWith('#')) continue; const s = slot(v); if (s) el.setAttribute(at, s); else el.removeAttribute(at) }
54
+ })
55
+ doc.querySelectorAll('img[srcset], source[srcset]').forEach((el) => {
56
+ // the candidate the browser picked is enough for a static copy
57
+ const cur = el.currentSrc || el.getAttribute('srcset').split(',')[0].trim().split(/\s+/)[0]
58
+ el.removeAttribute('srcset'); const s = slot(cur); if (s && el.tagName === 'IMG') el.setAttribute('src', s)
59
+ })
60
+ const cssUrls = (text) => text.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (m, q, u) => u.startsWith('data:') ? m : (slot(u) ? `url(${slot(u)})` : 'none'))
61
+ doc.querySelectorAll('style').forEach((s) => { s.textContent = cssUrls(s.textContent) })
62
+ doc.querySelectorAll('[style]').forEach((el) => el.setAttribute('style', cssUrls(el.getAttribute('style'))))
63
+ return { html: '<!doctype html>\n' + doc.outerHTML, assets, base: document.baseURI }
64
+ }
65
+
66
+ /**
67
+ * Builds the HTML view of the page for one capture: downloads the assets through the browser context (same
68
+ * cookies and gate headers as the capture), rewrites CSS url()s, writes the bundle into the run directory
69
+ * (live/<revision>/…) and returns the revision ({ id, entry, files, sizes }).
70
+ */
71
+ export async function buildHtmlView ({ page, run, scenarioId, node, locale, viewport, mask = [] }) {
72
+ const { html, assets } = await page.evaluate(viewInPage, { mask })
73
+ const request = page.context().request
74
+ const files = {}, sizes = {}, bodies = new Map()
75
+ let total = 0
76
+ const names = new Map()
77
+ const fetchAsset = async (url, depth = 0) => {
78
+ if (names.has(url)) return names.get(url)
79
+ if (names.size >= VIEW_LIMITS.max_assets) return null
80
+ names.set(url, null)
81
+ let res
82
+ try { res = await request.get(url, { timeout: 15000, failOnStatusCode: false, maxRedirects: 3 }) } catch { return null }
83
+ if (!res.ok()) return null
84
+ const type = (res.headers()['content-type'] || '').split(';')[0].trim().toLowerCase()
85
+ const ext = ASSET_TYPES[type]
86
+ if (!ext) return null
87
+ let body = await res.body()
88
+ if (body.length > VIEW_LIMITS.max_asset_bytes || total + body.length > VIEW_LIMITS.max_total_bytes) return null
89
+ if (ext === 'css' && depth < 2) {
90
+ // stylesheets point at fonts, images and other sheets relative to themselves: rehost those too
91
+ let css = body.toString('utf8')
92
+ const refs = [...css.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)|@import\s+(['"])([^'"]+)\3/gi)].map((m) => m[2] || m[4]).filter((u) => u && !u.startsWith('data:'))
93
+ for (const ref of [...new Set(refs)]) {
94
+ let abs; try { abs = new URL(ref, url).href } catch { continue }
95
+ const name = await fetchAsset(abs, depth + 1)
96
+ css = css.split(ref).join(name ? name.replace('assets/', '') : 'about:blank')
97
+ }
98
+ body = Buffer.from(css.replace(/expression\s*\(|javascript:|behavior\s*:/gi, ''), 'utf8')
99
+ }
100
+ const hash = await sha256Hex(body)
101
+ const name = `assets/${hash.slice(0, 16)}.${ext}`
102
+ total += body.length
103
+ names.set(url, name)
104
+ files[name] = hash; sizes[name] = body.length; bodies.set(name, body)
105
+ return name
106
+ }
107
+ let out = html
108
+ for (const [i, url] of assets.entries()) {
109
+ const name = await fetchAsset(url)
110
+ out = out.split(`__QW_ASSET_${i}__`).join(name || 'about:blank')
111
+ }
112
+ const htmlBody = Buffer.from(out, 'utf8')
113
+ files['index.html'] = await sha256Hex(htmlBody); sizes['index.html'] = htmlBody.length; bodies.set('index.html', htmlBody)
114
+ const problems = bundleProblems(Object.keys(files).map((p) => ({ path: p, bytes: sizes[p] })), 'index.html')
115
+ if (problems.length) throw new Error(`HTML view of ${node} (${locale}, ${viewport}): ${problems.join('; ')}`)
116
+ const state = `view:${viewport}`
117
+ const id = await revisionId({ scenario: scenarioId, node, locale, state, kind: 'html_view', entry: 'index.html', files })
118
+ for (const [p, body] of bodies) { const to = join(run.dir, 'live', id, p); await mkdir(dirname(to), { recursive: true }); await writeFile(to, body) }
119
+ return { id, node, locale, state, kind: 'html_view', entry: 'index.html', files, sizes }
120
+ }
121
+
122
+ /** Whether an environment may keep HTML views: opted in, and never production pages behind a sign-in. */
123
+ export function htmlViewAllowed (env, scenario) {
124
+ if (!env?.html_view) return false
125
+ if (env.name === 'production' || env.readonly) {
126
+ const signedIn = Object.keys(env.accounts || {}).length || Object.values(scenario.sessions || {}).some((s) => s.account)
127
+ if (signedIn) return false
128
+ }
129
+ return true
130
+ }
@@ -0,0 +1,95 @@
1
+ import { join } from 'node:path'
2
+ import { readJson, writeJson, sha256 } from './util.mjs'
3
+
4
+ export const PROMPT_VERSION = 'qawalk-jev-v1'
5
+ export const DEFAULTS = { hosted: true, signals: true, concurrency: 6, model: 'jev-1.13.0', base_url: 'https://api.typesafe.ai', api_key_env: 'TYPESAFE_API_KEY', thresholds: { pass: 0.85, fail: 0.15 }, max_state_chars: 90000 }
6
+
7
+ /**
8
+ * Quick signals asked beside the criteria of every capture: cheap, independent questions about the page that do
9
+ * not change verdicts; they point a person at a step that looks wrong. Question ids start with "_" (the service
10
+ * does not count them against the allowance).
11
+ */
12
+ export const SIGNALS = {
13
+ _purpose: (node) => node.purpose ? 'Does `page` do what `screen_purpose` says this screen must achieve?' : null,
14
+ _language: (node, capture) => capture.locale ? `Is the visible text of \`page\` mainly in the language with code "${capture.locale}"?` : null,
15
+ _broken: () => 'Does `page` look broken: an error message, placeholder text (lorem ipsum, TODO, {{variables}}), missing content or a blank page?'
16
+ }
17
+ export const SIGNAL_NAMES = { _purpose: 'purpose', _language: 'language', _broken: 'broken' }
18
+
19
+ /** Builds the JEV request for one capture: one `noul` question per semantic criterion, plus the quick signals. */
20
+ export function buildRequest ({ node, capture, criteria, model, signals = false }) {
21
+ const scopes = {}
22
+ const questions = {}
23
+ for (const c of criteria) {
24
+ const scoped = c.check.scope && capture.scoped?.[c.id]
25
+ if (scoped) scopes[c.id] = scoped
26
+ const target = c.check.about === 'console' ? '`console_errors` (browser console errors captured on this page) together with `screen_purpose`' : scoped ? `\`scopes.${c.id}\` (the part of the page matching ${c.check.scope})` : '`page`'
27
+ questions[c.id] = {
28
+ type: 'noul',
29
+ instructions: `The state describes one web page captured during an acceptance test. \`screen_purpose\` states what the page must achieve. Answer only from ${target}. Question: ${c.check.question}`,
30
+ ...(c.check.true || c.check.false ? { criteria: { true: c.check.true || 'Yes, the criterion is met.', false: c.check.false || 'No, the criterion is not met.' } } : {})
31
+ }
32
+ }
33
+ if (signals) {
34
+ for (const [id, q] of Object.entries(SIGNALS)) {
35
+ const text = q(node, capture)
36
+ if (text) questions[id] = { type: 'noul', instructions: `The state describes one web page captured during an acceptance test. Answer only from \`page\`. Question: ${text}` }
37
+ }
38
+ }
39
+ const state = {
40
+ screen_purpose: node.purpose || '',
41
+ url: capture.url,
42
+ locale: capture.locale,
43
+ viewport: capture.viewport,
44
+ page: capture.state,
45
+ ...(Object.keys(scopes).length ? { scopes } : {}),
46
+ ...(criteria.some((c) => c.check.about === 'console') ? { console_errors: (capture.console_errors || []).slice(0, 30) } : {})
47
+ }
48
+ return { model, state, questions }
49
+ }
50
+
51
+ export function verdictFor (noul, thresholds) {
52
+ if (noul == null) return 'not_evaluated'
53
+ if (noul >= thresholds.pass) return 'pass'
54
+ if (noul <= thresholds.fail) return 'fail'
55
+ return 'needs_review'
56
+ }
57
+
58
+ /**
59
+ * Calls JEV with a content-addressed cache. Returns {response, cached, via} or {skipped} / {error}.
60
+ * With the evaluator key set locally the call goes to TypeSafe directly (via: 'own-key'); without it, and with a
61
+ * `hosted` sender (the organization's service token), the service evaluates with its key (via: 'qawalk').
62
+ */
63
+ export async function evaluateWithJev ({ request, settings, cacheDir, fetchImpl = fetch, hosted = null }) {
64
+ const apiKey = process.env[settings.api_key_env]
65
+ let body = JSON.stringify(request)
66
+ if (body.length > settings.max_state_chars + 20000) return { skipped: `state too large (${body.length} chars)` }
67
+ const key = sha256(body + PROMPT_VERSION)
68
+ const cacheFile = join(cacheDir, `${key}.json`)
69
+ const cached = await readJson(cacheFile, null)
70
+ if (cached) return { response: cached, cached: true, key, via: cached.qawalk ? 'qawalk' : 'own-key' }
71
+ if (!apiKey && hosted && settings.hosted !== false) {
72
+ const r = await hosted(body)
73
+ if (r.skipped || r.error) return r
74
+ await writeJson(cacheFile, r.response)
75
+ return { response: r.response, cached: !!r.response.qawalk?.cached, key, via: 'qawalk' }
76
+ }
77
+ if (!apiKey) return { skipped: `${settings.api_key_env} is not set${hosted ? '' : ' and the service is not configured (KALOKO_TOKEN)'}` }
78
+ for (let attempt = 0; attempt < 4; attempt++) {
79
+ const res = await fetchImpl(`${settings.base_url.replace(/\/$/, '')}/v1/systemone`, {
80
+ method: 'POST',
81
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
82
+ body
83
+ })
84
+ if (res.status === 429 || res.status === 529) {
85
+ const wait = Number(res.headers.get('retry-after')) * 1000 || 1000 * 2 ** attempt
86
+ await new Promise((r) => setTimeout(r, wait))
87
+ continue
88
+ }
89
+ if (!res.ok) return { error: `JEV HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` }
90
+ const response = await res.json()
91
+ await writeJson(cacheFile, response)
92
+ return { response, cached: false, key, via: 'own-key' }
93
+ }
94
+ return { error: 'JEV rate limited after 4 attempts' }
95
+ }