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,182 @@
1
+ // E-mails of a flow, from wherever the project's stack puts them. Every source returns the same
2
+ // message shape: { id, subject, from, to: [address], date, html, text }.
3
+ // mailpit — built in (REST API), optional gate auth;
4
+ // script — a project module for anything else (letter_opener, Mailosaur, IMAP, the app's own API);
5
+ // file — `kaloko mail --file`: a message the agent obtained itself (.eml, .html, .json, .txt).
6
+ import { readFile } from 'node:fs/promises'
7
+ import { extname, resolve } from 'node:path'
8
+ import { pathToFileURL } from 'node:url'
9
+ import { secret } from './secrets.mjs'
10
+ import { UserError } from './util.mjs'
11
+
12
+ const address = (a) => (a?.Address ? (a.Name ? `${a.Name} <${a.Address}>` : a.Address) : '')
13
+
14
+ /** Mailpit REST client (https://mailpit.axllent.org/docs/api-v1/). */
15
+ export function mailpit (mail) {
16
+ if (!mail || mail.provider !== 'mailpit' || !mail.url) throw new UserError('Environment has no mail.provider: mailpit with url.')
17
+ const headers = {}
18
+ // `auth.basic` (older configs: user_env / password_env next to url), plus any headers of a gate in front of it
19
+ const basic = mail.auth?.basic || (mail.user_env ? { user_env: mail.user_env, password_env: mail.password_env } : null)
20
+ if (basic) headers.Authorization = 'Basic ' + Buffer.from(`${secret(basic.user_env, 'mailbox user')}:${secret(basic.password_env, 'mailbox password')}`).toString('base64')
21
+ for (const [name, spec] of Object.entries(mail.auth?.headers || {})) headers[name] = typeof spec === 'string' ? spec : secret(spec.env, `mailbox header ${name}`)
22
+ const base = mail.url.replace(/\/$/, '')
23
+ const get = async (path) => {
24
+ const res = await fetch(base + path, { headers })
25
+ if (!res.ok) throw new UserError(`Mailpit ${path} → HTTP ${res.status}${res.status === 401 || res.status === 403 ? ' (check mail.auth)' : ''}`)
26
+ return res.json()
27
+ }
28
+ return {
29
+ async find ({ to, subject, since, id }) {
30
+ let hitId = id
31
+ if (!hitId) {
32
+ const parts = []
33
+ if (to) parts.push(`to:"${to}"`)
34
+ if (subject) parts.push(`subject:"${subject.replace(/"/g, '')}"`)
35
+ // newest first; only messages received after `since` (the run start by default), with 5 s of clock skew
36
+ const data = await get(`/api/v1/search?${new URLSearchParams({ query: parts.join(' '), limit: since ? '50' : '1' })}`)
37
+ const floor = since ? Date.parse(since) - 5000 : null
38
+ const hit = (data.messages || []).find((x) => floor == null || Date.parse(x.Created || x.Date || 0) >= floor)
39
+ if (!hit) return null
40
+ hitId = hit.ID
41
+ }
42
+ const m = await get(`/api/v1/message/${encodeURIComponent(hitId)}`)
43
+ return { id: m.ID, subject: m.Subject || '', from: address(m.From), to: (m.To || []).map((t) => t.Address), date: m.Date, html: m.HTML || null, text: m.Text || null }
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Project adapter: `mail: { provider: script, script: qa/mail/inbox.mjs, options: {...} }`.
50
+ * The module's default export is `async ({ to, subject, since, options, root, env }) => message | null`
51
+ * and returns the newest matching message (see templates/mail/letter-opener.mjs).
52
+ */
53
+ export function scriptProvider (mail, root, env) {
54
+ if (!mail.script) throw new UserError('mail.provider: script needs mail.script (a module path relative to kaloko.config.yml).')
55
+ const file = resolve(root, mail.script)
56
+ let mod
57
+ return {
58
+ async find (query) {
59
+ mod ||= await import(pathToFileURL(file).href).catch((e) => { throw new UserError(`mail.script ${mail.script}: ${e.message}`) })
60
+ const fn = typeof mod.default === 'function' ? mod.default : mod.find
61
+ if (typeof fn !== 'function') throw new UserError(`mail.script ${mail.script} must export a default function.`)
62
+ const m = await fn({ ...query, options: mail.options || {}, root, env })
63
+ return m ? normalize(m) : null
64
+ }
65
+ }
66
+ }
67
+
68
+ /** Client for the environment's mailbox. */
69
+ export function mailClient (mail, root = process.cwd(), env = null) {
70
+ if (!mail || mail.provider === 'none') throw new UserError('This environment has no mailbox (mail.provider). Capture the message with kaloko mail --file, or configure mail in kaloko.config.yml.')
71
+ if (mail.provider === 'script') return scriptProvider(mail, root, env)
72
+ return mailpit(mail)
73
+ }
74
+
75
+ /** Polls until a matching message arrives (mailers run in background jobs). */
76
+ export async function waitForMessage (client, query, { timeoutMs = 60000, intervalMs = 2000 } = {}) {
77
+ const until = Date.now() + timeoutMs
78
+ while (true) {
79
+ const hit = await client.find(query)
80
+ if (hit) return hit
81
+ if (Date.now() > until) throw new UserError(`No e-mail matching ${JSON.stringify({ to: query.to, subject: query.subject, ...(query.id ? { id: query.id } : {}) })}${query.since && !query.id ? ` received since ${query.since}` : ''} within ${timeoutMs / 1000} s.${query.since && !query.id ? ' An older message: --since <date>, --any-time or --id <message id>.' : ''}`)
82
+ await new Promise((r) => setTimeout(r, intervalMs))
83
+ }
84
+ }
85
+
86
+ export const recipient = (mail, short) => (mail?.address || '').replace('{run}', short)
87
+
88
+ const list = (v) => (Array.isArray(v) ? v : v ? String(v).split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/) : []).map((s) => String(s).trim()).filter(Boolean)
89
+
90
+ /** Normalizes what an adapter or a JSON file returns (also accepts Mailpit-style capitalized keys). */
91
+ export function normalize (m) {
92
+ const from = m.from ?? m.From
93
+ return {
94
+ id: String(m.id ?? m.ID ?? ''),
95
+ subject: m.subject ?? m.Subject ?? '',
96
+ from: typeof from === 'object' && from ? address(from) : from || '',
97
+ to: list(m.to ?? (m.To || []).map?.((t) => t.Address ?? t)),
98
+ date: m.date ?? m.Date ?? null,
99
+ html: m.html ?? m.HTML ?? null,
100
+ text: m.text ?? m.Text ?? null
101
+ }
102
+ }
103
+
104
+ /** A message file the agent obtained itself. */
105
+ export async function readMessageFile (file) {
106
+ const ext = extname(file).toLowerCase()
107
+ if (!['.eml', '.msg', '.mbox', '.json', '.html', '.htm', '.txt'].includes(ext)) throw new UserError(`Unknown message file type ${ext || '(none)'}: use .eml, .html, .json or .txt.`)
108
+ const raw = await readFile(file)
109
+ if (ext === '.eml' || ext === '.msg' || ext === '.mbox') return parseEml(raw)
110
+ if (ext === '.json') return normalize(JSON.parse(raw.toString('utf8')))
111
+ const body = raw.toString('utf8')
112
+ if (ext === '.html' || ext === '.htm') return { id: '', subject: (body.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '').trim(), from: '', to: [], date: null, html: body, text: null }
113
+ return { id: '', subject: '', from: '', to: [], date: null, html: null, text: body }
114
+ }
115
+
116
+ // --- RFC 5322 / MIME, enough for what mailers send: multipart, base64, quoted-printable, charsets ---
117
+
118
+ const decodeBytes = (bytes, charset = 'utf-8') => {
119
+ try { return new TextDecoder(charset.toLowerCase().replace(/^(us-ascii|ascii)$/, 'utf-8')).decode(bytes) } catch { return new TextDecoder('utf-8').decode(bytes) }
120
+ }
121
+ const qpBytes = (s, header = false) => {
122
+ const src = (header ? s.replace(/_/g, ' ') : s).replace(/=\r?\n/g, '')
123
+ const out = []
124
+ for (let i = 0; i < src.length; i++) {
125
+ if (src[i] === '=' && /^[0-9A-Fa-f]{2}$/.test(src.slice(i + 1, i + 3))) { out.push(parseInt(src.slice(i + 1, i + 3), 16)); i += 2 } else out.push(src.charCodeAt(i) & 0xff)
126
+ }
127
+ return Buffer.from(out)
128
+ }
129
+ /** RFC 2047 encoded words in headers. */
130
+ export const decodeHeader = (v = '') => v.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=(\s+(?==\?))?/g, (_, cs, enc, text) => decodeBytes(enc.toUpperCase() === 'B' ? Buffer.from(text, 'base64') : qpBytes(text, true), cs))
131
+
132
+ function splitMessage (bin) {
133
+ const m = bin.match(/\r?\n\r?\n/)
134
+ const head = m ? bin.slice(0, m.index) : bin
135
+ const body = m ? bin.slice(m.index + m[0].length) : ''
136
+ const headers = {}
137
+ for (const line of head.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) {
138
+ const i = line.indexOf(':')
139
+ if (i > 0) { const k = line.slice(0, i).trim().toLowerCase(); if (!(k in headers)) headers[k] = line.slice(i + 1).trim() }
140
+ }
141
+ return { headers, body }
142
+ }
143
+ const param = (value, name) => value?.match(new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|([^;\\s]+))`, 'i'))?.slice(1).find(Boolean)
144
+
145
+ function walkParts ({ headers, body }, found) {
146
+ const type = (headers['content-type'] || 'text/plain').split(';')[0].trim().toLowerCase()
147
+ if (type.startsWith('multipart/')) {
148
+ const boundary = param(headers['content-type'], 'boundary')
149
+ if (!boundary) return
150
+ for (const chunk of body.split(`--${boundary}`).slice(1)) {
151
+ if (chunk.startsWith('--')) break
152
+ // the line break before a delimiter belongs to the delimiter (RFC 2046)
153
+ walkParts(splitMessage(chunk.replace(/^\r?\n/, '').replace(/\r?\n$/, '')), found)
154
+ }
155
+ return
156
+ }
157
+ if (type !== 'text/html' && type !== 'text/plain') return
158
+ if (/attachment/i.test(headers['content-disposition'] || '')) return
159
+ const enc = (headers['content-transfer-encoding'] || '').toLowerCase()
160
+ const bytes = enc === 'base64' ? Buffer.from(body.replace(/\s+/g, ''), 'base64') : enc === 'quoted-printable' ? qpBytes(body) : Buffer.from(body, 'latin1')
161
+ const key = type === 'text/html' ? 'html' : 'text'
162
+ found[key] ??= decodeBytes(bytes, param(headers['content-type'], 'charset') || 'utf-8')
163
+ }
164
+
165
+ /** Parses a raw RFC 5322 message (Buffer or string) into the message shape. */
166
+ export function parseEml (raw) {
167
+ const bin = Buffer.isBuffer(raw) ? raw.toString('latin1') : Buffer.from(raw, 'utf8').toString('latin1')
168
+ const top = splitMessage(bin)
169
+ const found = {}
170
+ walkParts(top, found)
171
+ // raw 8-bit header bytes are UTF-8 first; encoded words (ASCII) are decoded after that
172
+ const h = (k) => decodeHeader(decodeBytes(Buffer.from(top.headers[k] || '', 'latin1')))
173
+ return {
174
+ id: (top.headers['message-id'] || '').replace(/[<>]/g, ''),
175
+ subject: h('subject'),
176
+ from: h('from'),
177
+ to: list(h('to')),
178
+ date: top.headers.date ? new Date(top.headers.date).toISOString() : null,
179
+ html: found.html ?? null,
180
+ text: found.text ?? null
181
+ }
182
+ }
@@ -0,0 +1,127 @@
1
+ // Pure assembly of a run manifest's steps and summary: no file system, no Node APIs.
2
+ // Shared by the CLI (build.mjs) and the service, which composes design versions from a draft (#42),
3
+ // so both produce the same verdicts from the same captures.
4
+
5
+ import { PACK_HELP, packHelp } from './pack-help.mjs'
6
+
7
+ const RANK = { fail: 4, needs_review: 3, not_evaluated: 2, pass: 1, not_reached: 0 }
8
+ export const worst = (list) => list.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'not_reached')
9
+ export const aggregate = (list) => {
10
+ if (list.every((v) => v === 'not_reached')) return 'not_reached'
11
+ return worst(list.map((v) => v === 'not_reached' ? 'not_evaluated' : v))
12
+ }
13
+
14
+ /** Where a criterion lives when it is not a visible element: document head metadata. */
15
+ export function targetOf (c) {
16
+ const k = c.check
17
+ if (k.type !== 'deterministic') return null
18
+ if (k.assert === 'title_matches') return { kind: 'meta', selector: 'title' }
19
+ if (k.selector && /^\s*(meta|link|title|h[1-6]\b|script\[type=['"]?application\/ld\+json)/i.test(k.selector)) return { kind: 'meta', selector: k.selector }
20
+ if (k.assert === 'no_console_errors') return { kind: 'console' }
21
+ return null
22
+ }
23
+
24
+ /** Node status from criterion verdicts. */
25
+ export function statusOf (verdicts, captured) {
26
+ if (!captured) return 'skip'
27
+ if (!verdicts.length) return 'pass'
28
+ if (verdicts.includes('fail')) return 'fail'
29
+ if (verdicts.some((v) => v === 'needs_review' || v === 'not_evaluated' || v === 'not_reached')) return 'partial'
30
+ return 'pass'
31
+ }
32
+
33
+ /** The capture fields a manifest keeps (the full probe record stays with the capture). */
34
+ export function slimCapture (cap) {
35
+ return {
36
+ url: cap.url, size: cap.size, boxes: cap.boxes, captured_at: cap.captured_at,
37
+ console_errors: cap.console_errors || [], blocked_requests: cap.blocked_requests || [],
38
+ overflow: cap.overflow || [], meta: cap.meta, mail: cap.mail || null, metadata: cap.metadata || null, ...(cap.view ? { view: cap.view } : {}),
39
+ ...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.platform ? { platform: cap.platform, device: cap.device || null } : {}),
40
+ ...(cap.design ? { design: cap.design } : {}), ...(cap.capture ? { capture: cap.capture } : {}),
41
+ ...(cap.criteria_hash ? { criteria_hash: cap.criteria_hash } : {})
42
+ }
43
+ }
44
+
45
+ /**
46
+ * One manifest node from its scenario node and captures.
47
+ * captures: [{ locale, viewport, cap (capture record or slimCapture), ev (valid evaluation or null),
48
+ * files: { png, html, thumb?, eval? }, live? }]
49
+ */
50
+ export function assembleNode ({ node, pos, locales, viewportNames, captures, attachments = [] }) {
51
+ const base = { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: pos.col, lane: pos.lane }
52
+ if (node.kind === 'decision') return { ...base, status: null }
53
+ const applicable = locales.filter((l) => !node.path || typeof node.path === 'string' || (node.path[l] ?? node.path.default))
54
+ const mine = captures.filter((c) => applicable.includes(c.locale) && viewportNames.includes(c.viewport))
55
+ const capturesOut = {}
56
+ const perCriterion = new Map((node.criteria || []).map((c) => [c.id, { id: c.id, text: c.text, type: c.check.type, pack: c.pack || null, ...(c.pack && PACK_HELP[c.id] ? { help: { en: packHelp(c.id, 'en'), cs: packHelp(c.id, 'cs') } } : {}), by_locale: {}, evidence: null, question: c.check.question || null, target: targetOf(c), check: c.check }]))
57
+ const semanticByKey = {}
58
+ const metadata = {}
59
+ for (const c of mine) {
60
+ const { cap, ev } = c
61
+ ;(capturesOut[c.locale] ||= {})[c.viewport] = {
62
+ png: c.files.png, thumb: c.files.thumb || null, html: c.files.html || null, url: cap.url ?? null,
63
+ ...(cap.platform ? { platform: cap.platform, device: cap.device || null, tree: c.files.tree || null } : {}),
64
+ ...(cap.http_status ? { http_status: cap.http_status } : {}),
65
+ ...(cap.capture && (cap.capture.scale !== 1 || cap.capture.format === 'png' || cap.capture.masked || cap.capture.annotations?.length) ? { quality: cap.capture } : {}), size: [cap.size.width, cap.size.height],
66
+ boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 20), blocked_list: (cap.blocked_requests || []).slice(0, 20),
67
+ blocked: (cap.blocked_requests || []).length, overflow: (cap.overflow || []).length, meta: cap.meta,
68
+ mail: cap.mail || null, eval: ev ? c.files.eval : null,
69
+ // quick signals from the evaluator (probabilities: purpose met, expected language, looks broken)
70
+ ...(ev?.signals ? { signals: ev.signals } : {}),
71
+ ...(c.live ? { live: c.live } : {}),
72
+ ...(cap.view?.revision ? { view: cap.view } : {})
73
+ }
74
+ if (ev?.semantic || ev?.jev) semanticByKey[`${c.locale}|${c.viewport}`] = ev.semantic || ev.jev
75
+ if (cap.metadata && !metadata[c.locale]) metadata[c.locale] = cap.metadata
76
+ for (const agg of perCriterion.values()) ((agg.by_locale[c.locale] ||= {})[c.viewport] = { verdict: 'not_evaluated' })
77
+ for (const r of ev?.criteria || []) {
78
+ const agg = perCriterion.get(r.id)
79
+ if (!agg) continue
80
+ const loc = (agg.by_locale[c.locale] ||= {})
81
+ const score = r.score ?? r.jev?.noul ?? r.noul ?? null
82
+ // semantic answers carry the thresholds they were judged by and where they were evaluated (own key or Kaloko)
83
+ loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && ev.semantic?.via ? { via: ev.semantic.via } : {}) }
84
+ if (r.evidence && (!agg.evidence || r.verdict !== 'pass')) agg.evidence = r.evidence
85
+ }
86
+ }
87
+ const criteria = [...perCriterion.values()].map((agg) => {
88
+ for (const l of applicable) for (const vp of viewportNames) ((agg.by_locale[l] ||= {})[vp] ||= { verdict: 'not_reached' })
89
+ const verdicts = applicable.flatMap((l) => viewportNames.map((vp) => agg.by_locale[l][vp].verdict))
90
+ return { ...agg, verdict: aggregate(verdicts) }
91
+ })
92
+ const byLocale = {}
93
+ for (const l of locales) {
94
+ const captured = !!capturesOut[l]
95
+ const vs = criteria.map((c) => aggregate(Object.values(c.by_locale[l] || {}).map((v) => v.verdict)))
96
+ if (applicable.includes(l) && viewportNames.some((vp) => !capturesOut[l]?.[vp])) vs.push('not_reached')
97
+ byLocale[l] = statusOf(vs, captured)
98
+ }
99
+ const verdicts = criteria.map((c) => c.verdict)
100
+ if (applicable.some((l) => viewportNames.some((vp) => !capturesOut[l]?.[vp]))) verdicts.push('not_reached')
101
+ return {
102
+ ...base,
103
+ status: statusOf(verdicts, mine.length > 0),
104
+ by_locale: byLocale,
105
+ captures: capturesOut,
106
+ criteria,
107
+ semantic: semanticByKey,
108
+ metadata,
109
+ session: node.session || null,
110
+ execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : null,
111
+ attachments: attachments.map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale }))
112
+ }
113
+ }
114
+
115
+ /** Summary counts of the screens of a manifest. */
116
+ export function summarize (nodes, sessions = null) {
117
+ const screens = nodes.filter((n) => n.kind !== 'decision')
118
+ const summary = { pass: 0, partial: 0, fail: 0, skip: 0, criteria: {} }
119
+ for (const n of screens) summary[n.status]++
120
+ for (const n of screens) for (const c of n.criteria) summary.criteria[c.verdict] = (summary.criteria[c.verdict] || 0) + 1
121
+ if (sessions) {
122
+ summary.by_session = {}
123
+ for (const key of Object.keys(sessions)) summary.by_session[key] = { pass: 0, partial: 0, fail: 0, skip: 0 }
124
+ for (const n of screens) if (n.session && summary.by_session[n.session]) summary.by_session[n.session][n.status]++
125
+ }
126
+ return summary
127
+ }
@@ -0,0 +1,55 @@
1
+ // Help for pack criteria (#58 follow-up): a Czech name, why the check matters and how to fix a failure, in
2
+ // English and Czech. The manifest carries it on each pack criterion; the canvas and the walk summary show it.
3
+
4
+ /** @type {Record<string, { cs: string, why: [string, string], fix: [string, string] }>} id → { cs name, [en, cs] why, [en, cs] fix } */
5
+ export const PACK_HELP = {
6
+ AC101: { cs: 'Právě jeden nadpis H1', why: ['Search engines and screen readers take the H1 as the name of the page; none or several make it unclear.', 'Vyhledávače i čtečky obrazovky berou H1 jako název stránky; žádný nebo několik ho znejasní.'], fix: ['Keep one <h1> with the page\'s topic; turn other big headings into <h2>.', 'Nechte jeden <h1> s tématem stránky; ostatní velké nadpisy změňte na <h2>.'] },
7
+ AC102: { cs: 'Titulek má 10 až 70 znaků', why: ['The title is the blue link in search results; too short says nothing, too long gets cut.', 'Titulek je modrý odkaz ve výsledcích hledání; příliš krátký nic neřekne, příliš dlouhý se ořízne.'], fix: ['Write a specific <title> of about 50–60 characters: topic first, brand last.', 'Napište konkrétní <title> kolem 50–60 znaků: nejdřív téma, značka na konec.'] },
8
+ AC103: { cs: 'Popis stránky má 50 až 160 znaků', why: ['The meta description is the snippet under the link; without it search engines pick random text.', 'Meta popis je úryvek pod odkazem; bez něj vyhledávač vybere náhodný text.'], fix: ['Add <meta name="description"> that says in one or two sentences what the page offers.', 'Doplňte <meta name="description">, který jednou dvěma větami řekne, co stránka nabízí.'] },
9
+ AC104: { cs: 'Kanonický odkaz je uvedený', why: ['Without a canonical URL, copies of the page (parameters, filters) compete with each other in search.', 'Bez kanonické adresy si kopie stránky (parametry, filtry) konkurují ve vyhledávání.'], fix: ['Add <link rel="canonical" href="…"> with the page\'s preferred address.', 'Doplňte <link rel="canonical" href="…"> s preferovanou adresou stránky.'] },
10
+ AC105: { cs: 'hreflang pro každý jazyk', why: ['hreflang tells search engines which language version to show to whom.', 'hreflang říká vyhledávačům, kterou jazykovou verzi komu ukázat.'], fix: ['Add <link rel="alternate" hreflang="…"> for every language, including the page itself.', 'Doplňte <link rel="alternate" hreflang="…"> pro každý jazyk včetně stránky samotné.'] },
11
+ AC106: { cs: 'Open Graph titulek a popis', why: ['Shared links on social networks and chats use Open Graph for their preview.', 'Sdílené odkazy na sítích a v chatech berou náhled z Open Graph.'], fix: ['Add og:title and og:description (and ideally og:image) meta tags.', 'Doplňte meta značky og:title a og:description (ideálně i og:image).'] },
12
+ AC107: { cs: 'Stránka není vyřazená z indexace', why: ['A noindex robots tag keeps the page out of search results entirely.', 'Značka robots s noindex stránku úplně vyřadí z výsledků hledání.'], fix: ['Remove noindex from <meta name="robots"> on pages that should be found (often left over from staging).', 'Odstraňte noindex z <meta name="robots"> na stránkách, které mají být k nalezení (často zbytek ze stagingu).'] },
13
+ AC108: { cs: 'Viewport pro telefony', why: ['Without the viewport tag phones show a zoomed-out desktop page.', 'Bez značky viewport ukazují telefony zmenšenou desktopovou stránku.'], fix: ['Add <meta name="viewport" content="width=device-width, initial-scale=1">.', 'Doplňte <meta name="viewport" content="width=device-width, initial-scale=1">.'] },
14
+ AC109: { cs: 'Atribut lang u html', why: ['Screen readers pick the voice and search engines the language from <html lang>.', 'Čtečky obrazovky podle <html lang> volí hlas a vyhledávače jazyk.'], fix: ['Set <html lang="cs"> (or the page\'s language code).', 'Nastavte <html lang="cs"> (nebo kód jazyka stránky).'] },
15
+ AC110: { cs: 'Nic nepřetéká do strany', why: ['Content wider than the screen makes phones scroll sideways and breaks the layout.', 'Obsah širší než obrazovka nutí telefony posouvat stránku do strany a rozbíjí rozvržení.'], fix: ['Find the wide element in the evidence; limit it with max-width: 100% or let it wrap.', 'Najděte široký prvek v důkazu; omezte ho max-width: 100% nebo ho nechte zalomit.'] },
16
+ AC111: { cs: 'V konzoli nejsou chyby', why: ['Console errors often mean a feature silently does not work.', 'Chyby v konzoli často znamenají, že něco tiše nefunguje.'], fix: ['Open the error in the evidence and fix the script, or add the console pack to separate third-party noise.', 'Otevřete chybu v důkazu a opravte skript, nebo přidejte pack console, který oddělí šum cizích skriptů.'] },
17
+ AC201: { cs: 'Účel stránky je jasný hned na první obrazovce', why: ['Visitors decide within seconds whether they are in the right place.', 'Návštěvník se během pár vteřin rozhodne, jestli je na správném místě.'], fix: ['Make the heading and first sentence say what the page is for and for whom.', 'Ať nadpis a první věta řeknou, k čemu stránka je a pro koho.'] },
18
+ AC202: { cs: 'Jedna jasná hlavní akce', why: ['Several equally loud buttons make people hesitate; none leaves them without a next step.', 'Několik stejně výrazných tlačítek lidi zdrží; žádné je nechá bez dalšího kroku.'], fix: ['Give one action the primary style; make the others secondary or links.', 'Jedné akci dejte hlavní styl, ostatní udělejte vedlejší nebo jako odkazy.'] },
19
+ AC203: { cs: 'Formulářová pole mají viditelný popisek', why: ['A placeholder disappears as people type; they forget what the field was.', 'Placeholder při psaní zmizí a lidé zapomenou, co do pole patří.'], fix: ['Add a <label for="…"> (or aria-label) to every visible input.', 'Ke každému viditelnému poli doplňte <label for="…"> (nebo aria-label).'] },
20
+ AC204: { cs: 'Odkazy a tlačítka říkají, co udělají', why: ['"Click here" or "More" does not say where it leads, especially out of context or to a screen reader.', '„Klikněte zde“ nebo „Více“ neřekne, kam vede, zvlášť mimo kontext nebo ve čtečce.'], fix: ['Name the result: "Download the price list", "Continue to payment".', 'Pojmenujte výsledek: „Stáhnout ceník“, „Pokračovat k platbě“.'] },
21
+ AC301: { cs: 'Obrázky mají alternativní text', why: ['Without alt text, blind visitors and search engines do not know what an image shows.', 'Bez alt textu nevidomí ani vyhledávače nevědí, co obrázek ukazuje.'], fix: ['Add alt="…" describing the image; decorative images get alt="".', 'Doplňte alt="…" s popisem obrázku; dekorativní obrázky dostanou alt="".'] },
22
+ AC302: { cs: 'Odkazy a tlačítka mají přístupný název', why: ['An icon button without a name is read as just "button".', 'Tlačítko s ikonou bez názvu čtečka přečte jen jako „tlačítko“.'], fix: ['Add visible text or aria-label to icon-only links and buttons.', 'Odkazům a tlačítkům jen s ikonou doplňte text nebo aria-label.'] },
23
+ AC303: { cs: 'Formulářová pole mají popisky', why: ['Screen reader users hear only "edit text" for a field without a label.', 'Uživatel čtečky uslyší u pole bez popisku jen „editační pole“.'], fix: ['Connect a <label> to each field (for/id) or use aria-label / aria-labelledby.', 'Ke každému poli připojte <label> (for/id) nebo použijte aria-label / aria-labelledby.'] },
24
+ AC304: { cs: 'Úrovně nadpisů se nepřeskakují', why: ['People navigate by headings; a jump from H2 to H4 hides the structure.', 'Lidé se pohybují po nadpisech; skok z H2 na H4 zamlží strukturu.'], fix: ['Use heading levels in order; style by CSS, not by picking a smaller level.', 'Používejte úrovně nadpisů postupně; vzhled řešte CSS, ne volbou nižší úrovně.'] },
25
+ AC305: { cs: 'Text splňuje kontrast WCAG AA', why: ['Low contrast text is hard to read in sunlight and for people with weaker sight.', 'Text s nízkým kontrastem se špatně čte na slunci i lidem se slabším zrakem.'], fix: ['Darken the text or lighten the background to at least 4.5 : 1 (3 : 1 for large text).', 'Ztmavte text nebo zesvětlete pozadí aspoň na 4,5 : 1 (u velkého textu 3 : 1).'] },
26
+ AC306: { cs: 'Stránka má hlavní oblast (main)', why: ['Screen reader users jump straight to <main> to skip the navigation.', 'Uživatelé čteček skáčou rovnou na <main>, aby přeskočili navigaci.'], fix: ['Wrap the main content in <main> (once per page).', 'Hlavní obsah obalte do <main> (jednou na stránce).'] },
27
+ AC307: { cs: 'Id prvků jsou jedinečná', why: ['Duplicate ids break labels, anchors and scripts that look elements up by id.', 'Duplicitní id rozbijí popisky, kotvy i skripty, které prvky hledají podle id.'], fix: ['Rename the repeated ids listed in the evidence (often from a copied component).', 'Přejmenujte opakovaná id z důkazu (často z kopírované komponenty).'] },
28
+ AC401: { cs: 'Největší obsah se vykreslí do 2,5 s', why: ['LCP is how long people wait before the page looks ready; Google uses it for ranking.', 'LCP je doba, než stránka působí hotově; Google ho používá při řazení.'], fix: ['Make the hero image smaller (WebP/AVIF, right size), preload it, and cut render-blocking scripts.', 'Zmenšete hlavní obrázek (WebP/AVIF, správná velikost), přednačtěte ho a omezte blokující skripty.'] },
29
+ AC402: { cs: 'Rozvržení se při načítání nehýbe (CLS pod 0,1)', why: ['Content that jumps while loading makes people tap the wrong thing.', 'Obsah, který při načítání poskakuje, svádí k ťuknutí vedle.'], fix: ['Give images and embeds width and height, and reserve space for banners and fonts.', 'Dejte obrázkům a vloženým prvkům šířku a výšku a rezervujte místo pro bannery a písma.'] },
30
+ AC403: { cs: 'Server odpoví do 800 ms', why: ['A slow first byte delays everything else on the page.', 'Pomalý první bajt zdrží všechno ostatní na stránce.'], fix: ['Cache the page (CDN, full-page cache) or speed up the backend work behind it.', 'Stránku kešujte (CDN, cache celé stránky) nebo zrychlete práci backendu za ní.'] },
31
+ AC404: { cs: 'Stránka má do 3 MB', why: ['Heavy pages are slow and expensive on mobile data.', 'Těžké stránky jsou pomalé a drahé na mobilních datech.'], fix: ['Compress images, drop unused scripts and fonts; the evidence lists the size.', 'Zkomprimujte obrázky, odeberte nepoužité skripty a písma; velikost je v důkazu.'] },
32
+ AC601: { cs: 'Odpovídá chybovým stavem (4xx/5xx), ne „měkkou“ 200', why: ['A missing page that answers 200 gets indexed as real content and hides broken links from monitoring.', 'Chybějící stránka s odpovědí 200 se zaindexuje jako obsah a skryje rozbité odkazy před monitoringem.'], fix: ['Return 404 (or 410) from the server for missing pages; keep the friendly page as its body.', 'Na chybějící stránky vracejte ze serveru 404 (nebo 410); přívětivou stránku nechte jako obsah.'] },
33
+ AC602: { cs: 'Odkazuje zpět na úvod', why: ['A dead end loses the visitor.', 'Slepá ulička návštěvníka ztratí.'], fix: ['Add a clear link to the home page.', 'Doplňte zřetelný odkaz na úvodní stránku.'] },
34
+ AC603: { cs: 'Navigace nebo hledání zůstává dostupné', why: ['People on an error page want to find what they came for.', 'Lidé na chybové stránce chtějí najít, pro co přišli.'], fix: ['Keep the site header with navigation or add a search field.', 'Ponechte hlavičku webu s navigací nebo doplňte vyhledávání.'] },
35
+ AC604: { cs: 'Žádný výpis chyby, SQL ani ladicí stránka', why: ['Stack traces leak paths, versions and queries that help attackers.', 'Výpisy chyb prozradí cesty, verze a dotazy, které pomáhají útočníkům.'], fix: ['Turn off debug output in production and show a generic error page.', 'Na produkci vypněte ladicí výstup a ukažte obecnou chybovou stránku.'] },
36
+ AC605: { cs: 'Chybu vysvětlí lidsky a nabídne cestu dál', why: ['A calm explanation keeps people on the site instead of leaving.', 'Klidné vysvětlení udrží lidi na webu, místo aby odešli.'], fix: ['Say what happened without blaming the visitor and offer home, search or popular links.', 'Řekněte, co se stalo, bez obviňování návštěvníka, a nabídněte úvod, hledání nebo oblíbené odkazy.'] },
37
+ AC606: { cs: 'V konzoli nejsou chyby', why: ['Scripts failing on the error page often break its navigation too.', 'Skripty, které na chybové stránce padají, často rozbijí i její navigaci.'], fix: ['Fix the script error in the evidence.', 'Opravte chybu skriptu z důkazu.'] },
38
+ AC701: { cs: 'Každý ovládací prvek má přístupný popisek', why: ['VoiceOver and TalkBack read unlabeled controls as "button".', 'VoiceOver a TalkBack přečtou prvek bez popisku jen jako „tlačítko“.'], fix: ['Set contentDescription (Android) or accessibilityLabel (iOS) on icon controls.', 'Nastavte contentDescription (Android) nebo accessibilityLabel (iOS) u prvků s ikonou.'] },
39
+ AC702: { cs: 'Dotykové plochy aspoň 48 dp / 44 pt', why: ['Small targets are missed, especially by thumbs and older people.', 'Malé plochy se minou, hlavně palcem a u starších lidí.'], fix: ['Enlarge the touch area (padding or a minimum size), not necessarily the icon.', 'Zvětšete dotykovou plochu (odsazením nebo minimální velikostí), ne nutně ikonu.'] },
40
+ AC703: { cs: 'Žádné oříznuté texty (…)', why: ['Cut text hides prices, names or instructions, often only in longer languages.', 'Oříznutý text skryje ceny, jména nebo pokyny, často jen v delších jazycích.'], fix: ['Let the text wrap or give it room; check the longest language.', 'Nechte text zalomit nebo mu dejte místo; zkontrolujte nejdelší jazyk.'] },
41
+ AC704: { cs: 'Aplikace nespadla ani nezamrzla', why: ['A crash or an "app not responding" dialog ends the session.', 'Pád nebo hlášení „aplikace nereaguje“ ukončí práci.'], fix: ['Open the crash log for this step (adb logcat, Xcode) and fix the cause.', 'Otevřete záznam pádu u tohoto kroku (adb logcat, Xcode) a opravte příčinu.'] },
42
+ AC705: { cs: 'Účel obrazovky a další krok jsou jasné', why: ['On a small screen, people need to see at once what to do next.', 'Na malé obrazovce potřebují lidé hned vidět, co dál.'], fix: ['Give the screen a title and one prominent primary action.', 'Dejte obrazovce titulek a jednu výraznou hlavní akci.'] },
43
+ AC801: { cs: 'Vypadá jako reference designu (rozdíl pod 3 % pixelů)', why: ['Visible differences from the accepted design mean it was built differently than agreed.', 'Viditelné rozdíly od schváleného designu znamenají, že se postavilo jinak, než se domluvilo.'], fix: ['Open the pixel diff and adjust the implementation, not the reference.', 'Otevřete rozdíl v pixelech a upravte implementaci, ne referenci.'] },
44
+ AC802: { cs: 'Stejná struktura jako reference designu', why: ['Missing headings, actions or fields change what people can do, even if it looks close.', 'Chybějící nadpisy, akce nebo pole mění, co lidé mohou udělat, i když to vypadá podobně.'], fix: ['Add what the evidence lists as missing or remove what was added.', 'Doplňte, co důkaz uvádí jako chybějící, nebo odeberte, co přibylo.'] },
45
+ AC803: { cs: 'Barvy, písma, velikosti a mezery pocházejí z tokenů', why: ['Raw values drift from the design system and make later changes expensive.', 'Ruční hodnoty se odchylují od design systému a zdražují pozdější změny.'], fix: ['Replace each listed value with the suggested token (CSS variable).', 'Každou uvedenou hodnotu nahraďte navrženým tokenem (CSS proměnnou).'] },
46
+ AC501: { cs: 'Chyby v konzoli se netýkají toho, co krok přijímá', why: ['Separates real failures from third-party noise (analytics, extensions).', 'Odděluje skutečné chyby od šumu cizích skriptů (analytika, rozšíření).'], fix: ['If the AI says an error matters, fix that script; otherwise it is noise.', 'Pokud AI říká, že chyba vadí, opravte ten skript; jinak je to šum.'] }
47
+ }
48
+
49
+ /** Help for one pack criterion in a language: { name, why, fix }, or null. */
50
+ export function packHelp (id, lang = 'en') {
51
+ const h = PACK_HELP[id]
52
+ if (!h) return null
53
+ const i = lang === 'cs' ? 1 : 0
54
+ return { name: lang === 'cs' ? h.cs : null, why: h.why[i], fix: h.fix[i] }
55
+ }
@@ -0,0 +1,121 @@
1
+ // Check packs: reusable sets of criteria a node turns on with `packs: [seo, usability]`.
2
+ // Expanded before validation; pack criteria carry `pack` so coverage does not count them as drift.
3
+
4
+ const det = (assert, extra = {}) => ({ type: 'deterministic', assert, ...extra })
5
+
6
+ /** @type {Record<string, { base: number, title: string, criteria: (ctx: { locales: string[] }) => Array<{ id: string, text: string, check: object, highlight?: string }> }>} */
7
+ export const PACKS = {
8
+ seo: {
9
+ base: 100,
10
+ title: 'SEO basics: title, description, canonical, hreflang, Open Graph, robots, heading, viewport',
11
+ criteria: ({ locales }) => [
12
+ { id: 'AC101', text: 'Exactly one H1', check: det('count', { selector: 'h1', min: 1, max: 1 }) },
13
+ { id: 'AC102', text: 'Title between 10 and 70 characters', check: det('title_matches', { pattern: '^.{10,70}$' }) },
14
+ { id: 'AC103', text: 'Meta description between 50 and 160 characters', check: det('meta_matches', { selector: "meta[name='description']", pattern: '^.{50,160}$' }) },
15
+ { id: 'AC104', text: 'Canonical link present', check: det('exists', { selector: "link[rel='canonical']" }) },
16
+ ...(locales.length > 1 ? [{ id: 'AC105', text: 'hreflang for every language', check: det('count', { selector: "link[rel='alternate'][hreflang]", min: locales.length }) }] : []),
17
+ { id: 'AC106', text: 'Open Graph title and description', check: det('count', { selector: "meta[property='og:title'], meta[property='og:description']", min: 2 }) },
18
+ { id: 'AC107', text: 'Not excluded from indexing', check: det('absent', { selector: "meta[name='robots'][content*='noindex']" }) },
19
+ { id: 'AC108', text: 'Viewport meta for phones', check: det('exists', { selector: "meta[name='viewport']" }) },
20
+ { id: 'AC109', text: 'html lang attribute set', check: det('exists', { selector: 'html[lang]' }) },
21
+ { id: 'AC110', text: 'No horizontal overflow', check: det('no_overflow') },
22
+ { id: 'AC111', text: 'No console errors', check: det('no_console_errors') }
23
+ ]
24
+ },
25
+ design: {
26
+ base: 800,
27
+ title: 'Design reference: pixels, structure and tokens, checked against the accepted design version (#42); combine with a11y for contrast',
28
+ criteria: () => [
29
+ { id: 'AC801', text: 'Looks like the design reference (under 3 % of pixels differ)', check: det('design_pixels', { ratio: 0.03 }) },
30
+ { id: 'AC802', text: 'Same structure as the design reference: headings, landmarks, actions and form fields', check: det('design_structure') },
31
+ { id: 'AC803', text: 'Colors, fonts, sizes, spacing and radii come from the token set', check: det('uses_tokens') }
32
+ ]
33
+ },
34
+ usability: {
35
+ base: 200,
36
+ title: 'Usability: purpose, primary action, forms, feedback',
37
+ criteria: () => [
38
+ { id: 'AC201', text: 'The purpose of the page is clear within the first screen', check: { type: 'semantic', question: 'Is the purpose of this page clear from its heading and first paragraph alone?', scope: 'main, body' } },
39
+ { id: 'AC202', text: 'One clear primary action', check: { type: 'semantic', question: 'Is there exactly one clearly primary call to action on the page?', scope: 'main, body' } },
40
+ { id: 'AC203', text: 'Form fields have visible labels', check: det('count', { selector: 'input:not([type=hidden]):not([type=submit]):not([aria-label]):not([id])', min: 0, max: 0 }) },
41
+ { id: 'AC204', text: 'Links and buttons say what they do', check: { type: 'semantic', question: 'Do the link and button texts describe what happens when they are used, rather than generic words like "click here"?', scope: 'main, body' } }
42
+ ]
43
+ },
44
+ a11y: {
45
+ base: 300,
46
+ title: 'Accessibility basics (WCAG 2.2 AA subset): alt texts, names, labels, headings, contrast, landmarks, unique ids',
47
+ criteria: () => [
48
+ { id: 'AC301', text: 'Images have alternative text', check: det('a11y_images_alt') },
49
+ { id: 'AC302', text: 'Links and buttons have an accessible name', check: det('a11y_names') },
50
+ { id: 'AC303', text: 'Form fields have labels', check: det('a11y_labels') },
51
+ { id: 'AC304', text: 'Heading levels are not skipped', check: det('a11y_headings') },
52
+ { id: 'AC305', text: 'Text meets WCAG AA contrast', check: det('a11y_contrast') },
53
+ { id: 'AC306', text: 'The page has a main landmark', check: det('a11y_main') },
54
+ { id: 'AC307', text: 'Element ids are unique', check: det('a11y_unique_ids') }
55
+ ]
56
+ },
57
+ perf: {
58
+ base: 400,
59
+ title: 'Performance from the capture: LCP, CLS, TTFB, page weight (lab values of this walk, not field data)',
60
+ criteria: () => [
61
+ { id: 'AC401', text: 'Largest contentful paint within 2.5 s', check: det('perf_lcp', { max: 2500 }) },
62
+ { id: 'AC402', text: 'Cumulative layout shift below 0.1', check: det('perf_cls', { max: 0.1 }) },
63
+ { id: 'AC403', text: 'Time to first byte within 800 ms', check: det('perf_ttfb', { max: 800 }) },
64
+ { id: 'AC404', text: 'Page weight within 3 MB', check: det('perf_weight', { max: 3000 }) }
65
+ ]
66
+ },
67
+ errors: {
68
+ base: 600,
69
+ title: 'Error pages (404, 410, 500, maintenance): a real error status, a way forward, no technical leak',
70
+ criteria: () => [
71
+ { id: 'AC601', text: 'Answers with an error status (4xx/5xx), not a soft 200', check: det('http_status', { min: 400, max: 599 }) },
72
+ { id: 'AC602', text: 'Links back to the home page', check: det('link_home') },
73
+ { id: 'AC603', text: 'Navigation or search stays available', check: det('exists', { selector: "nav a, header a, [role=navigation] a, input[type=search], [role=search]" }) },
74
+ { id: 'AC604', text: 'No stack trace, SQL error or framework debug page', check: det('text_absent', { pattern: 'Traceback \\(most recent call last\\)|\\bat [\\w.$<>]+ \\([^)]*:\\d+:\\d+\\)|SQLSTATE|Fatal error:|Stack trace:|Whoops, looks like|NoMethodError|ActiveRecord::|RuntimeError at|Exception Value:|Warning: .+ on line \\d+' }) },
75
+ { id: 'AC605', text: 'Explains the error in plain language and offers a way forward', check: { type: 'semantic', question: 'Does the page tell the visitor in plain language that the page was not found or is unavailable, without blaming them, and offer a way forward such as the home page, search, popular links or contact?', scope: 'main, body' } },
76
+ { id: 'AC606', text: 'No console errors', check: det('no_console_errors') }
77
+ ]
78
+ },
79
+ app: {
80
+ base: 700,
81
+ title: 'Native app screens (Android, iOS, desktop): labelled controls, touch targets, no truncated text, no crash, clear purpose',
82
+ criteria: () => [
83
+ { id: 'AC701', text: 'Every control has an accessible label', check: det('app_labels') },
84
+ { id: 'AC702', text: 'Touch targets at least 48 dp (Android) / 44 pt (iOS)', check: det('app_touch_targets') },
85
+ { id: 'AC703', text: 'No truncated texts (…)', check: det('app_no_truncation') },
86
+ { id: 'AC704', text: 'The app did not crash or stop responding', check: det('app_no_crash') },
87
+ { id: 'AC705', text: 'The purpose of the screen and the next action are clear', check: { type: 'semantic', question: 'Is it clear from this app screen what it is for and what the user should do next, with one obvious primary action?' } }
88
+ ]
89
+ },
90
+ console: {
91
+ base: 500,
92
+ title: 'Console errors triaged by the evaluator: relevant to what the step accepts, or unrelated noise',
93
+ criteria: () => [
94
+ { id: 'AC501', text: 'Console errors do not touch what the step accepts', check: { type: 'semantic', about: 'console', question: 'Are all of these console errors unrelated to what this step accepts (for example third-party scripts, analytics, browser extensions, CSP reports or favicon requests), so that none of them affects the tested function?' } }
95
+ ]
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Adds the criteria of each pack listed on a node (`node.packs`) that the node does not already define.
101
+ * Mutates and returns the scenario; safe to call twice.
102
+ */
103
+ export function expandPacks (scenario) {
104
+ if (!scenario || !Array.isArray(scenario.nodes)) return scenario
105
+ const locales = scenario.locales || ['en']
106
+ for (const node of scenario.nodes) {
107
+ if (!Array.isArray(node.packs) || !node.packs.length) continue
108
+ node.criteria ||= []
109
+ const have = new Set(node.criteria.map((c) => c.id))
110
+ for (const name of node.packs) {
111
+ const pack = PACKS[name]
112
+ if (!pack) throw new Error(`node "${node.id}": unknown pack "${name}" (known: ${Object.keys(PACKS).join(', ')})`)
113
+ for (const c of pack.criteria({ locales })) {
114
+ if (have.has(c.id)) continue
115
+ node.criteria.push({ ...c, pack: name })
116
+ have.add(c.id)
117
+ }
118
+ }
119
+ }
120
+ return scenario
121
+ }
package/src/lib/pr.mjs ADDED
@@ -0,0 +1,78 @@
1
+ // `kaloko share --pr`: one comment per scenario on the branch's pull request, updated on later runs.
2
+ // Uses the gh CLI only; never throws — a missing gh or PR is reported and the share stays valid.
3
+ import { execFile } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+
6
+ const exec = promisify(execFile)
7
+ const RANK = { fail: 4, needs_review: 3, not_evaluated: 2, pass: 1, not_reached: 0 }
8
+ const worst = (list) => list.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'not_reached')
9
+ const text = (v) => v == null ? '' : typeof v === 'string' ? v : (v.en ?? v.default ?? Object.values(v)[0] ?? '')
10
+
11
+ export const marker = (scenarioId) => `<!-- qawalk:${scenarioId} -->`
12
+
13
+ /** Markdown body: canvas link, summary line, verdict, what needs attention with evidence. */
14
+ export function prCommentBody ({ manifest, url, at = new Date() }) {
15
+ const s = manifest.summary || {}
16
+ const steps = (manifest.nodes || []).filter((n) => n.kind !== 'decision')
17
+ const all = steps.flatMap((n) => n.criteria || [])
18
+ const okCrit = all.filter((c) => c.verdict === 'pass').length
19
+ const attention = []
20
+ for (const n of steps) {
21
+ for (const c of n.criteria || []) {
22
+ if (c.counts) { // stack step: verdicts per page, counted
23
+ const bad = Object.entries(c.counts).filter(([v]) => !['pass', 'not_reached', 'not_evaluated'].includes(v)).reduce((k, [, x]) => k + x, 0)
24
+ if (bad) attention.push(`- ${c.verdict === 'fail' ? '✕' : '!'} **${text(n.title)}** · \`${c.id}\` ${c.text} — _${String(c.verdict).replace('_', ' ')}_ (${bad}/${n.stack?.count ?? '?'} pages; the index on the canvas lists them)`)
25
+ continue
26
+ }
27
+ const cells = Object.entries(c.by_locale || {}).flatMap(([l, vps]) => Object.entries(vps).map(([vp, r]) => ({ l, vp, r })))
28
+ const bad = cells.filter(({ r }) => r?.verdict && !['pass', 'not_reached'].includes(r.verdict))
29
+ if (!bad.length) continue
30
+ const v = worst(bad.map(({ r }) => r.verdict))
31
+ const where = [...new Set(bad.map(({ l, vp }) => `${l}/${vp}`))].join(', ')
32
+ const ev = bad.find(({ r }) => r.evidence || r.note)?.r
33
+ attention.push(`- ${v === 'fail' ? '✕' : '!'} **${text(n.title)}** · \`${c.id}\` ${c.text} — _${v.replace('_', ' ')}_ (${where})${ev ? `\n \`${String(ev.evidence || ev.note).replace(/\s+/g, ' ').slice(0, 160)}\`` : ''}`)
34
+ }
35
+ }
36
+ const total = steps.length
37
+ const env = manifest.environment?.name || ''
38
+ const g = manifest.git || {}
39
+ const lines = [
40
+ marker(manifest.scenario.id),
41
+ `### Kaloko · ${text(manifest.scenario.title)}`,
42
+ `**${s.pass || 0}/${total} steps passed** · ${okCrit}/${all.length} criteria · ${env}${g.commit ? ` · \`${g.commit}\`` : ''} · [open the canvas](${url})`,
43
+ '',
44
+ attention.length ? `**Needs attention (${attention.length})**\n${attention.join('\n')}` : 'All checked criteria passed.',
45
+ '',
46
+ `<sub>Run \`${manifest.id}\` · ${at.toISOString().slice(0, 16).replace('T', ' ')} UTC · reviewers accept or return the run on the canvas; \`kaloko feedback\` shows their notes. Updated on every shared run of this scenario.</sub>`
47
+ ]
48
+ return lines.join('\n')
49
+ }
50
+
51
+ /**
52
+ * Creates or updates the scenario's comment on the current branch's PR.
53
+ * @returns {{ ok: boolean, action?: 'created'|'updated', url?: string, reason?: string }}
54
+ */
55
+ export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun }) {
56
+ const view = await run('gh', ['pr', 'view', '--json', 'number,url,headRepository,headRepositoryOwner'], cwd)
57
+ if (view == null) return { ok: false, reason: 'gh is not installed, not signed in, or this branch has no pull request' }
58
+ let pr
59
+ try { pr = JSON.parse(view) } catch { return { ok: false, reason: 'gh pr view returned no JSON' } }
60
+ if (!pr.number) return { ok: false, reason: 'this branch has no pull request' }
61
+ const repo = pr.headRepositoryOwner?.login && pr.headRepository?.name ? `${pr.headRepositoryOwner.login}/${pr.headRepository.name}` : null
62
+ const body = prCommentBody({ manifest, url })
63
+ const m = marker(manifest.scenario.id)
64
+ // find our earlier comment for this scenario
65
+ const listArgs = ['api', '--paginate', `repos/${repo || '{owner}/{repo}'}/issues/${pr.number}/comments`, '--jq', `.[] | select(.body | contains(${JSON.stringify(m)})) | .id`]
66
+ const found = (await run('gh', listArgs, cwd)) || ''
67
+ const existing = found.split(/\s+/).map((x) => x.trim()).filter(Boolean)[0]
68
+ if (existing) {
69
+ const r = await run('gh', ['api', '-X', 'PATCH', `repos/${repo || '{owner}/{repo}'}/issues/comments/${existing}`, '-f', `body=${body}`], cwd)
70
+ return r == null ? { ok: false, reason: 'could not update the existing comment' } : { ok: true, action: 'updated', url: `${pr.url}#issuecomment-${existing}` }
71
+ }
72
+ const r = await run('gh', ['pr', 'comment', String(pr.number), '--body', body], cwd)
73
+ return r == null ? { ok: false, reason: 'could not create the comment' } : { ok: true, action: 'created', url: r.trim() || pr.url }
74
+ }
75
+
76
+ async function defaultRun (cmd, args, cwd) {
77
+ try { const { stdout } = await exec(cmd, args, { cwd, timeout: 30000, maxBuffer: 4 * 1024 * 1024 }); return stdout } catch { return null }
78
+ }