kaloko 0.7.1 → 0.9.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.
@@ -2,8 +2,10 @@
2
2
  // Every check returns { name, ok, detail, hint } so the CLI and tests can read it.
3
3
  import { appTools, macPermissions } from './apps/drivers.mjs'
4
4
  import { createServer } from 'node:net'
5
- import { access as exists, stat } from 'node:fs/promises'
6
- import { resolve } from 'node:path'
5
+ import { access as exists, stat, mkdir, readFile, writeFile } from 'node:fs/promises'
6
+ import { resolve, join, dirname } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { spawn } from 'node:child_process'
7
9
  import { environment } from './config.mjs'
8
10
  import { gateOf, gateHeaders, accountFor } from './access.mjs'
9
11
  import { missing } from './secrets.mjs'
@@ -38,21 +40,38 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
38
40
  * @param {Function} [o.launchEngine] async (engine) => { version } — throws when the engine is not installed
39
41
  * @param {object} [o.tools] app tools found (default: looked up on PATH)
40
42
  * @param {object[]} [o.scenarios] { file, product, module } of the scenario files (products checks)
43
+ * @param {boolean} [o.fix] repair what can be repaired here: install missing Playwright browsers, create the run and
44
+ * scenario folders, write org: from the token into the config, ask macOS for permissions
45
+ * @param {Function} [o.installBrowser] async (engine) => void — installs a Playwright engine (--fix)
41
46
  */
42
- export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [] }) {
47
+ export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [], installBrowser = defaultInstall }) {
43
48
  const checks = []
44
- const add = (name, ok, detail = '', hint = '') => checks.push({ name, ok, detail, hint })
49
+ const add = (name, ok, detail = '', hint = '', fixed = null) => checks.push({ name, ok, detail, hint, ...(fixed ? { fixed } : {}) })
50
+ // --fix: install an engine, then look again; returns the version or throws what the install said
51
+ const installed = async (engine, retry) => { await installBrowser(engine); return retry() }
45
52
 
46
53
  const major = Number(process.versions.node.split('.')[0])
47
54
  add('node', major >= 20, `Node ${process.versions.node}`, major >= 20 ? '' : 'Kaloko needs Node 20 or newer.')
48
55
 
49
56
  add('config', true, `${file || `${root}/kaloko.config.yml`} · org ${config.org || '—'} · ${Object.keys(config.environments || {}).length} environments`)
50
- if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the organization slug from kaloko.app).')
57
+ if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the part after kaloko.app/ when you are signed in), or run kaloko doctor --fix with a token to fill it in.')
58
+ await folderChecks({ config, root, fix, add })
51
59
 
52
- try { const c = await launchChrome(config); add('chrome', true, c.version ? `Chrome ${c.version}` : 'Chrome found') } catch (e) { add('chrome', false, e.message.split('\n')[0].slice(0, 160), config.browser?.channel ? 'Install Google Chrome, or remove browser.channel to use bundled Chromium (npx playwright install chromium).' : 'Run: npx playwright install chromium') }
60
+ try { const c = await launchChrome(config); add('chrome', true, c.version ? `Chrome ${c.version}` : 'Chrome found') } catch (e) {
61
+ let done = null
62
+ // the bundled Chromium can be installed from here; a branded Chrome (browser.channel) is the person's to install
63
+ if (fix && !config.browser?.channel) { try { done = await installed('chromium', () => launchChrome(config)) } catch {} }
64
+ if (done) add('chrome', true, `Chromium ${done.version || ''}`.trim(), '', 'installed Chromium for Playwright')
65
+ else add('chrome', false, e.message.split('\n')[0].slice(0, 160), config.browser?.channel ? 'Install Google Chrome, or remove browser.channel to use bundled Chromium (kaloko browser install).' : fix ? 'Installing Chromium did not work; run kaloko browser install and read its output (on Linux add --with-deps).' : 'Run kaloko doctor --fix (or kaloko browser install) to install Chromium for Playwright.')
66
+ }
53
67
  // cross-browser runs (batch E): the Playwright engines the scenarios and walk.browsers name
54
68
  for (const engine of [...new Set([...(engines || []), ...(config.walk?.browsers || [])])].filter((e) => e !== 'chromium')) {
55
- try { const v = await launchEngine(engine); add(`browser ${engine}`, true, `${engine} ${v.version || ''}`.trim()) } catch { add(`browser ${engine}`, false, `${engine} is not installed for Playwright`, `Run: kaloko browser install --browsers ${engine}${os === 'linux' ? ' --with-deps' : ''}`) }
69
+ try { const v = await launchEngine(engine); add(`browser ${engine}`, true, `${engine} ${v.version || ''}`.trim()) } catch {
70
+ let done = null
71
+ if (fix) { try { done = await installed(engine, () => launchEngine(engine)) } catch {} }
72
+ if (done) add(`browser ${engine}`, true, `${engine} ${done.version || ''}`.trim(), '', `installed ${engine} for Playwright`)
73
+ else add(`browser ${engine}`, false, `${engine} is not installed for Playwright`, `Run kaloko doctor --fix, or kaloko browser install --browsers ${engine}${os === 'linux' ? ' --with-deps' : ''}`)
74
+ }
56
75
  }
57
76
 
58
77
  for (const name of Object.keys(config.environments || {})) {
@@ -132,16 +151,21 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
132
151
  else {
133
152
  try {
134
153
  const res = await fetchImpl(`${service}/api/v1/me`, { headers: { Authorization: `Bearer ${token}` } })
135
- if (!res.ok) add('token', false, `${service} → HTTP ${res.status}`, res.status === 401 ? `${tokenEnv} is invalid, expired or revoked; create a new one in Settings.` : 'The service did not accept the request.')
154
+ if (!res.ok) add('token', false, `${service} → HTTP ${res.status}`, res.status === 401 ? `${tokenEnv} is invalid, expired or revoked. Run kaloko login for a new one (it replaces the old token), or create one in Settings → API tokens.` : `The service did not accept the request; try again in a few minutes (${service}/status).`)
136
155
  else {
137
156
  const me = await res.json()
157
+ // --fix: a config without org: takes the token's organization
158
+ if (!config.org && fix && me.org && file) {
159
+ const wrote = await writeOrg(file, me.org).catch(() => false)
160
+ if (wrote) { config.org = me.org; const i = checks.findIndex((c) => c.name === 'org'); if (i >= 0) checks[i] = { name: 'org', ok: true, detail: `org ${me.org}`, hint: '', fixed: `wrote org: ${me.org} into ${file}` } }
161
+ }
138
162
  const sameOrg = !config.org || me.org === config.org
139
163
  // sharing runs needs the Developer / Tester role (or admin) and, on tokens with scope sets, the upload scope
140
164
  const canShare = ['tester', 'admin'].includes(me.role) && (!Array.isArray(me.scopes) || me.scopes.includes('upload'))
141
165
  add('token', sameOrg, `${me.email} · ${me.role} · org ${me.org}${canShare ? '' : ' (this token cannot share runs)'}`, sameOrg ? '' : `The token belongs to organization "${me.org}" but the config says "${config.org}".`)
142
166
  if (!canShare) add('token role', false, Array.isArray(me.scopes) ? `${me.role} · ${me.scopes.join(', ')}` : me.role, 'kaloko share needs a Developer / Tester token with the upload scope.')
143
167
  }
144
- } catch (e) { add('token', false, `${service} unreachable: ${e.message}`, 'Check your network; KALOKO_URL overrides the service URL.') }
168
+ } catch (e) { add('token', false, `${service} unreachable: ${e.cause?.code || e.message}`, 'Check your network or VPN; KALOKO_URL points the CLI to another service.') }
145
169
  }
146
170
 
147
171
  const jev = config.evaluators?.jev
@@ -173,6 +197,44 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
173
197
  return { ok: checks.every((c) => c.ok), checks }
174
198
  }
175
199
 
200
+ /** The run folder and the folders the scenario globs start in: reported, and created with --fix. */
201
+ async function folderChecks ({ config, root, fix, add }) {
202
+ const there = (d) => exists(d).then(() => true, () => false)
203
+ const out = resolve(root, config.output || 'tmp/kaloko')
204
+ if (!await there(out)) {
205
+ if (fix && await mkdir(out, { recursive: true }).then(() => true, () => false)) add('folders', true, `${config.output} created`, '', `created ${config.output}`)
206
+ }
207
+ for (const pattern of config.scenarios || []) {
208
+ const fixed = []
209
+ for (const p of pattern.split('/')) { if (/[*?{[]/.test(p)) break; fixed.push(p) }
210
+ if (!fixed.length || fixed.length === pattern.split('/').length) continue
211
+ const dir = resolve(root, ...fixed)
212
+ if (await there(dir)) continue
213
+ if (fix && await mkdir(dir, { recursive: true }).then(() => true, () => false)) add(`scenarios ${fixed.join('/')}`, true, 'folder created; it has no scenarios yet', 'Draft a first one: kaloko draft <url>', `created ${fixed.join('/')}`)
214
+ else add(`scenarios ${fixed.join('/')}`, false, `${fixed.join('/')} does not exist (scenarios: ${pattern})`, 'Run kaloko doctor --fix to create it, then kaloko draft <url> for a first scenario.')
215
+ }
216
+ }
217
+
218
+ /** Writes org: <slug> into the config file, keeping its comments and layout. */
219
+ export async function writeOrg (file, org) {
220
+ if (!/^[a-z0-9-]+$/.test(org)) return false
221
+ const text = await readFile(file, 'utf8')
222
+ let next
223
+ if (/^org:.*$/m.test(text)) next = text.replace(/^org:.*$/m, `org: ${org}`)
224
+ else if (/^schema:.*$/m.test(text)) next = text.replace(/^(schema:.*)$/m, `$1\norg: ${org}`)
225
+ else next = `org: ${org}\n${text}`
226
+ if (next === text) return false
227
+ await writeFile(file, next)
228
+ return true
229
+ }
230
+
231
+ /** Installs a Playwright engine with the Playwright that kaloko itself uses (same as kaloko browser install). */
232
+ async function defaultInstall (engine) {
233
+ const cli = join(dirname(fileURLToPath(import.meta.resolve('playwright/package.json'))), 'cli.js')
234
+ const code = await new Promise((done) => spawn(process.execPath, [cli, 'install', engine], { stdio: 'inherit' }).on('close', done).on('error', () => done(1)))
235
+ if (code) throw new Error(`playwright install ${engine} exited with ${code}`)
236
+ }
237
+
176
238
  async function defaultEngine (engine) {
177
239
  const pw = await import('playwright')
178
240
  const browser = await pw[engine].launch({ headless: true })
@@ -189,4 +251,4 @@ async function defaultLaunch (config) {
189
251
  return { version }
190
252
  }
191
253
 
192
- export const formatDoctor = ({ ok, checks }) => [...checks.map((c) => `${c.ok ? '✓' : '✕'} ${c.name.padEnd(16)} ${c.detail}${!c.ok && c.hint ? `\n → ${c.hint}` : ''}`), ok ? 'All good.' : 'Fix the ✕ items above.'].join('\n')
254
+ export const formatDoctor = ({ ok, checks }) => [...checks.map((c) => `${c.ok ? '✓' : '✕'} ${c.name.padEnd(16)} ${c.detail}${c.fixed ? ` (fixed: ${c.fixed})` : ''}${(!c.ok || c.fixed) && c.hint ? `\n → ${c.hint}` : ''}`), ok ? 'All good.' : checks.some((c) => !c.ok && /doctor --fix/.test(c.hint || '')) ? 'Fix the ✕ items above; kaloko doctor --fix repairs the ones that say so.' : 'Fix the ✕ items above.'].join('\n')
@@ -17,6 +17,8 @@ export function latestCaptures (entries) {
17
17
  }
18
18
 
19
19
  export const criteriaHash = (node) => sha256(node.criteria || [])
20
+ /** A criterion with `environments` (e.g. [production]) applies only there; without a known environment it applies. */
21
+ export const appliesOn = (c, environment) => !environment || !Array.isArray(c.environments) || !c.environments.length || c.environments.includes(environment)
20
22
  /** Folder of a capture under steps/: the node, or steps/<node>/items/<item> for a page of a stack step. */
21
23
  export const stepIdOf = (e) => (e.item ? `${e.node}/items/${e.item}` : e.node)
22
24
  export const evaluationSource = (node, capture) => sha256({ capture, criteria: node.criteria || [], purpose: node.purpose || '' })
@@ -26,8 +28,9 @@ export const evaluationSource = (node, capture) => sha256({ capture, criteria: n
26
28
  * asked, the vision evaluator. Every semantic verdict with a score carries a one-sentence `reason`: the vision
27
29
  * model's own, or a sentence saying what the text evaluator read (it returns a probability only).
28
30
  */
29
- export function combine ({ node, capture, jev, vision = null, thresholds }) {
31
+ export function combine ({ node, capture, jev, vision = null, thresholds, environment = null }) {
30
32
  return (node.criteria || []).map((c) => {
33
+ if (!appliesOn(c, environment)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies on ${c.environments.join(', ')} only; this walk ran on ${environment}.`, na: true, not_applicable: 'environment' }
31
34
  if (c.check.type === 'deterministic') {
32
35
  const r = capture.checks?.[c.id]
33
36
  const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
@@ -64,6 +67,8 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
64
67
  const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
65
68
  const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
66
69
  const paths = runPaths(run.dir)
70
+ // criteria limited to other environments (environments: [production]) are reported as not applicable here
71
+ const environment = run.meta?.environment?.name || null
67
72
  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: [] }
68
73
  // the vision evaluator (#17): flagged and uncertain criteria, a bounded number of calls per evaluation
69
74
  const vset = visionSettings(config)
@@ -101,7 +106,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
101
106
  const current = capture.criteria_hash === criteriaHash(node)
102
107
  // console triage needs the evaluator only when the capture has console errors
103
108
  const outOfSample = entry.item && !sampled.get(entry.node)?.has(entry.item)
104
- const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && !(c.check.about === 'console' && !(capture.console_errors || []).length))
109
+ const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && appliesOn(c, environment) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
105
110
  // quick signals ride along on every captured page state (also steps without semantic criteria)
106
111
  const signals = settings.signals !== false && !outOfSample && !!capture.state
107
112
  // an imported screen without page text (#11) has nothing to ask about: a person decides on the canvas
@@ -126,7 +131,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
126
131
  const source = evaluationSource(node, capture)
127
132
  if (drift && current && needsDrift(node)) { capture.checks = { ...capture.checks, ...await driftForCapture({ node, entry, capture, runDir: run.dir, ctx: drift }) }; report.drift++ }
128
133
  if (styleTokens?.tokens && current && tokenCriteria(node).length) capture.checks = { ...capture.checks, ...storedValueChecks(node, capture, { ...styleTokens, recorded: !!capture.rechecked_at, only: (c) => !!c.check.token && STYLE_ASSERTS.has(c.check.assert) }) }
129
- let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds })
134
+ let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds, environment })
130
135
  // pages outside the semantic sample carry only the deterministic results
131
136
  if (outOfSample) criteria = criteria.filter((c) => c.type !== 'semantic' || c.evidence === 'no console errors')
132
137
  const evaluation = {
@@ -175,7 +180,7 @@ function signalsOf (jev) {
175
180
  return Object.keys(out).length ? out : null
176
181
  }
177
182
 
178
- export function semanticPrefetch ({ scenario, config, root }) {
183
+ export function semanticPrefetch ({ scenario, config, root, environment = null }) {
179
184
  const settings = { ...DEFAULTS, ...(config.evaluators?.jev || {}), thresholds: { ...DEFAULTS.thresholds, ...(config.evaluators?.jev?.thresholds || {}) } }
180
185
  const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
181
186
  const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
@@ -197,7 +202,7 @@ export function semanticPrefetch ({ scenario, config, root }) {
197
202
  for (const e of [].concat(entries || [])) {
198
203
  if (e?.type !== 'capture' || e.item) continue
199
204
  const node = nodes.get(e.node)
200
- const semantic = (node?.criteria || []).filter((c) => c.check.type === 'semantic')
205
+ const semantic = (node?.criteria || []).filter((c) => c.check.type === 'semantic' && appliesOn(c, environment))
201
206
  if (!semantic.length && settings.signals === false) continue
202
207
  queue.push(async () => {
203
208
  const capture = await readJson(join(runDir, e.file))
@@ -283,7 +288,7 @@ function hostedSender (config, report) {
283
288
  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' } }
284
289
  try { return { response: await client.request('POST', `/orgs/${config.org}/semantic`, JSON.parse(body)) } } catch (e) {
285
290
  const m = String(e.message || e)
286
- if (/HTTP (402|403|503)/.test(m)) { report.hostedStop = m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.hostedStop } }
291
+ if (/HTTP (402|403|503)/.test(m)) { report.hostedStop = e.serviceMessage ?? m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.hostedStop } }
287
292
  return { error: m }
288
293
  }
289
294
  }
package/src/lib/figma.mjs CHANGED
@@ -63,7 +63,7 @@ export function figmaClient ({ token, fetch: f = globalThis.fetch, base = figmaA
63
63
  variables: (key) => get(`/v1/files/${key}/variables/local`, { optional: true }),
64
64
  async download (url) {
65
65
  const res = await f(url)
66
- if (!res.ok) throw new UserError(`Downloading a frame image failed: HTTP ${res.status}.`)
66
+ if (!res.ok) throw new UserError(`Downloading a frame image from Figma failed (HTTP ${res.status}). Figma's image links expire after a while; run the import again.`)
67
67
  return Buffer.from(await res.arrayBuffer())
68
68
  }
69
69
  }
@@ -4,7 +4,7 @@
4
4
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
5
5
  import { existsSync } from 'node:fs'
6
6
  import { resolve, relative, dirname, join } from 'node:path'
7
- import { UserError } from './util.mjs'
7
+ import { UserError, NO_ORG } from './util.mjs'
8
8
  import { gateOf, accountFor } from './access.mjs'
9
9
  import { secret } from './secrets.mjs'
10
10
  import { runner } from './ci.mjs'
@@ -23,7 +23,7 @@ const issueLine = (i) => `${i.key.padEnd(10)} ${String(i.state).padEnd(8)} step
23
23
  export async function issueCmd ({ args, config, client, runId, say }) {
24
24
  const sub = args._[0]
25
25
  const org = config.org
26
- if (!org) throw new UserError('Set org: in the config first.')
26
+ if (!org) throw new UserError(NO_ORG)
27
27
  if (sub === 'list') {
28
28
  const q = new URLSearchParams(Object.entries({ run: typeof args.run === 'string' ? args.run : null, scenario: typeof args.scenario === 'string' ? args.scenario : null, state: typeof args.state === 'string' ? args.state : null }).filter(([, v]) => v))
29
29
  const items = await client.request('GET', `/orgs/${org}/issues${q.size ? `?${q}` : ''}`)
@@ -169,7 +169,7 @@ export async function scheduleCmd ({ args, config, root, say, deps }) {
169
169
  say(`Wrote ${relative(process.cwd(), out)}: ${scenario.id} on ${envName} ${every === 'week' ? 'every week' : 'every day'} at ${String(atHour(args.at)).padStart(2, '0')}:00 UTC with the full walk (clicks, forms, every criterion).\nNext: add the repository secret KALOKO_TOKEN${secretNames(env, scenario).length ? ` and ${secretNames(env, scenario).join(', ')}` : ''}, then commit the workflow.`)
170
170
  return { file: out }
171
171
  }
172
- if (!org) throw new UserError('Set org: in the config first.')
172
+ if (!org) throw new UserError(NO_ORG)
173
173
  if (sub === 'list' || !sub) {
174
174
  const data = await client().request('GET', `/orgs/${org}/schedules`)
175
175
  if (args.json) { say(JSON.stringify(data, null, 2)); return data }
package/src/lib/login.mjs CHANGED
@@ -34,7 +34,7 @@ export async function login (base, { org = null, print = false, open = true, tok
34
34
  base = base.replace(/\/$/, '')
35
35
  const post = async (path, body) => {
36
36
  let res
37
- try { res = await fetchImpl(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }) } catch (e) { throw new UserError(`Cannot reach ${base}: ${e.message}`) }
37
+ try { res = await fetchImpl(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }) } catch (e) { throw new UserError(`Cannot reach ${base} (${e.cause?.code || e.message}). Check your network or VPN and run kaloko login again; --url or KALOKO_URL points it to another service.`) }
38
38
  return { status: res.status, body: await res.json().catch(() => ({})) }
39
39
  }
40
40
  const start = await post('/cli/device', { client: `kaloko${version ? ` ${version}` : ''} on ${hostname()}`.slice(0, 60) })
package/src/lib/mail.mjs CHANGED
@@ -13,7 +13,7 @@ const address = (a) => (a?.Address ? (a.Name ? `${a.Name} <${a.Address}>` : a.Ad
13
13
 
14
14
  /** Mailpit REST client (https://mailpit.axllent.org/docs/api-v1/). */
15
15
  export function mailpit (mail) {
16
- if (!mail || mail.provider !== 'mailpit' || !mail.url) throw new UserError('Environment has no mail.provider: mailpit with url.')
16
+ if (!mail || mail.provider !== 'mailpit' || !mail.url) throw new UserError('This environment has no Mailpit mailbox. Add mail: { provider: mailpit, url: <Mailpit address> } to it in kaloko.config.yml.')
17
17
  const headers = {}
18
18
  // `auth.basic` (older configs: user_env / password_env next to url), plus any headers of a gate in front of it
19
19
  const basic = mail.auth?.basic || (mail.user_env ? { user_env: mail.user_env, password_env: mail.password_env } : null)
@@ -99,24 +99,29 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
99
99
  // semantic answers carry the thresholds they were judged by and where they were evaluated (own key or Kaloko)
100
100
  // semantic answers carry their one-sentence reason (#17) and whether the evaluator saw the screenshot
101
101
  const seen = r.provider && /-vision$/.test(r.provider)
102
- loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(r.na ? { na: true } : {}), ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && (seen ? ev.vision?.via : ev.semantic?.via) ? { via: seen ? ev.vision.via : ev.semantic.via } : {}), ...(r.reason ? { reason: r.reason, ...(r.reason_source === 'model' ? { reason_by: 'model' } : {}) } : {}), ...(seen ? { vision: true, ...(r.text_score != null ? { text_score: r.text_score } : {}) } : {}) }
102
+ loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(r.na ? { na: true } : {}), ...(r.not_applicable ? { not_applicable: r.not_applicable } : {}), ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && (seen ? ev.vision?.via : ev.semantic?.via) ? { via: seen ? ev.vision.via : ev.semantic.via } : {}), ...(r.reason ? { reason: r.reason, ...(r.reason_source === 'model' ? { reason_by: 'model' } : {}) } : {}), ...(seen ? { vision: true, ...(r.text_score != null ? { text_score: r.text_score } : {}) } : {}) }
103
103
  if (r.evidence && (!agg.evidence || r.verdict !== 'pass')) agg.evidence = r.evidence
104
104
  if (r.reason && (!agg.reason || (r.verdict !== 'pass' && agg.reason_verdict === 'pass'))) { agg.reason = r.reason; agg.reason_verdict = r.verdict }
105
105
  }
106
106
  }
107
107
  const criteria = [...perCriterion.values()].map(({ reason_verdict: _rv, ...agg }) => {
108
108
  for (const l of applicable) for (const vp of viewportNames) ((agg.by_locale[l] ||= {})[vp] ||= { verdict: 'not_reached' })
109
- const verdicts = counted(applicable.flatMap((l) => viewportNames.map((vp) => agg.by_locale[l][vp])))
110
- return { ...agg, verdict: aggregate(verdicts) }
109
+ const entries = applicable.flatMap((l) => viewportNames.map((vp) => agg.by_locale[l][vp]))
110
+ const verdicts = counted(entries)
111
+ // a criterion limited to other environments (environments: [production]) is not applicable on this walk:
112
+ // reported as such, and it does not make the step partial
113
+ const offEnv = entries.some((e) => e.not_applicable) && entries.every((e) => e.not_applicable || e.verdict === 'not_reached')
114
+ return { ...agg, verdict: aggregate(verdicts), ...(offEnv ? { not_applicable: 'environment' } : {}) }
111
115
  })
116
+ const counting = criteria.filter((c) => !c.not_applicable)
112
117
  const byLocale = {}
113
118
  for (const l of locales) {
114
119
  const captured = !!capturesOut[l]
115
- const vs = criteria.map((c) => aggregate(counted(Object.values(c.by_locale[l] || {}))))
120
+ const vs = counting.map((c) => aggregate(counted(Object.values(c.by_locale[l] || {}))))
116
121
  if (applicable.includes(l) && viewportNames.some((vp) => !capturesOut[l]?.[vp])) vs.push('not_reached')
117
122
  byLocale[l] = statusOf(vs, captured)
118
123
  }
119
- const verdicts = criteria.map((c) => c.verdict)
124
+ const verdicts = counting.map((c) => c.verdict)
120
125
  if (applicable.some((l) => viewportNames.some((vp) => !capturesOut[l]?.[vp]))) verdicts.push('not_reached')
121
126
  return {
122
127
  ...base,
@@ -137,7 +142,7 @@ export function summarize (nodes, sessions = null) {
137
142
  const screens = nodes.filter((n) => n.kind !== 'decision')
138
143
  const summary = { pass: 0, partial: 0, fail: 0, skip: 0, criteria: {} }
139
144
  for (const n of screens) summary[n.status]++
140
- for (const n of screens) for (const c of n.criteria) summary.criteria[c.verdict] = (summary.criteria[c.verdict] || 0) + 1
145
+ for (const n of screens) for (const c of n.criteria) if (!c.not_applicable) summary.criteria[c.verdict] = (summary.criteria[c.verdict] || 0) + 1
141
146
  if (sessions) {
142
147
  summary.by_session = {}
143
148
  for (const key of Object.keys(sessions)) summary.by_session[key] = { pass: 0, partial: 0, fail: 0, skip: 0 }
@@ -18,6 +18,11 @@ export const PACK_HELP = {
18
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
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
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
+ AC205: { cs: 'Z nadpisu je jasné, k čemu obrazovka je', why: ['People switch between screens all day; a vague heading makes them check where they are.', 'Lidé mezi obrazovkami přepínají celý den; s mlhavým nadpisem musí hledat, kde vlastně jsou.'], fix: ['Name the screen after what it shows or does: "Runs of the checkout flow", not "Dashboard".', 'Pojmenujte obrazovku podle toho, co ukazuje nebo dělá: „Běhy flow pokladny“, ne „Nástěnka“.'] },
22
+ AC206: { cs: 'Je jasné, jak formulář odeslat a co bude dál', why: ['When the send button or the next step is unclear, people submit twice or give up.', 'Když není jasné odesílací tlačítko nebo další krok, lidé odešlou dvakrát nebo to vzdají.'], fix: ['Label the button with the result ("Send the link") and say in one sentence what happens next.', 'Tlačítko pojmenujte podle výsledku („Poslat odkaz“) a jednou větou řekněte, co bude dál.'] },
23
+ AC207: { cs: 'Každá položka seznamu říká, co je zač a jak ji otevřít', why: ['A row with only an id or a date makes people open items one by one to find the right one.', 'Řádek jen s identifikátorem nebo datem nutí lidi otevírat položky jednu po druhé.'], fix: ['Show a name and a state in every row and make the name the link.', 'V každém řádku ukažte název a stav a z názvu udělejte odkaz.'] },
24
+ AC208: { cs: 'Každé nastavení říká, co změní a jak se uloží', why: ['People leave settings unsure whether the change took effect.', 'Lidé odcházejí z nastavení a nevědí, jestli se změna projevila.'], fix: ['Add a short hint under each setting and either a save button or a visible "Saved".', 'Pod každé nastavení přidejte krátkou nápovědu a buď tlačítko Uložit, nebo viditelné „Uloženo“.'] },
25
+ AC209: { cs: 'Text jde rychle projít: nadpisy a krátké odstavce', why: ['Readers of documentation scan for their question; a wall of text hides the answer.', 'Čtenář dokumentace hledá svou otázku očima; souvislý text odpověď schová.'], fix: ['Split the text under headings that name the question and keep paragraphs to a few sentences.', 'Rozdělte text pod nadpisy, které pojmenují otázku, a odstavce držte na pár větách.'] },
21
26
  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
27
  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
28
  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.'] },
package/src/lib/packs.mjs CHANGED
@@ -33,13 +33,23 @@ export const PACKS = {
33
33
  },
34
34
  usability: {
35
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
- ]
36
+ title: 'Usability by page type (`page` on the step or scenario): purpose, a primary action on landing pages, forms, lists, settings, link texts',
37
+ criteria: ({ page = 'landing' } = {}) => {
38
+ const sem = (question) => ({ type: 'semantic', question, scope: 'main, body' })
39
+ const on = (...types) => types.includes(page)
40
+ return [
41
+ ...(on('landing', 'doc') ? [{ id: 'AC201', text: 'The purpose of the page is clear within the first screen', check: sem('Is the purpose of this page clear from its heading and first paragraph alone?') }] : []),
42
+ ...(on('work', 'list', 'settings', 'form') ? [{ id: 'AC205', text: 'The heading says what the screen is for', check: sem('Is it clear from the heading and the content of this screen what it is for?') }] : []),
43
+ // one loud call to action is a landing-page rule; work screens, lists and settings rightly offer several actions
44
+ ...(on('landing') ? [{ id: 'AC202', text: 'One clear primary action', check: sem('Is there exactly one clearly primary call to action on the page?') }] : []),
45
+ ...(on('form') ? [{ id: 'AC206', text: 'It is clear how to send the form and what happens next', check: sem('Is it clear which button sends the form and what happens after it is sent?') }] : []),
46
+ ...(on('list') ? [{ id: 'AC207', text: 'Each item in the list says what it is and how to open it', check: sem('Does each item in the list show what it is (for example a name and its state) and how to open it?') }] : []),
47
+ ...(on('settings') ? [{ id: 'AC208', text: 'Each setting says what it changes and how it is saved', check: sem('Does each setting say what it changes, and is it clear whether a change is saved by itself or with a button?') }] : []),
48
+ ...(on('doc') ? [{ id: 'AC209', text: 'The text can be scanned: headings and short paragraphs', check: sem('Is the text split into headings and short paragraphs that a reader can scan?') }] : []),
49
+ { 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 }) },
50
+ { id: 'AC204', text: 'Links and buttons say what they do', check: sem('Do the link and button texts describe what happens when they are used, rather than generic words like "click here"?') }
51
+ ]
52
+ }
43
53
  },
44
54
  a11y: {
45
55
  base: 300,
@@ -103,6 +113,20 @@ export const PACKS = {
103
113
  }
104
114
  }
105
115
 
116
+ /** Page types a step (or the whole scenario) declares with `page`; the usability pack asks per type. */
117
+ export const PAGE_TYPES = ['landing', 'work', 'list', 'settings', 'form', 'doc']
118
+
119
+ /**
120
+ * The page type of a node: its own `page`, then the scenario's `page`, else inferred. A step with the seo pack or a
121
+ * scenario allowed on production is a public page (landing); anything else is an app screen (work).
122
+ */
123
+ export function pageTypeOf (node, scenario = {}) {
124
+ if (node?.page) return node.page
125
+ if (scenario?.page) return scenario.page
126
+ if ((node?.packs || []).includes('seo') || (scenario?.environments || []).includes('production')) return 'landing'
127
+ return 'work'
128
+ }
129
+
106
130
  /**
107
131
  * Adds the criteria of each pack listed on a node (`node.packs`) that the node does not already define.
108
132
  * Mutates and returns the scenario; safe to call twice.
@@ -117,7 +141,7 @@ export function expandPacks (scenario) {
117
141
  for (const name of node.packs) {
118
142
  const pack = PACKS[name]
119
143
  if (!pack) throw new Error(`node "${node.id}": unknown pack "${name}" (known: ${Object.keys(PACKS).join(', ')})`)
120
- for (const c of pack.criteria({ locales })) {
144
+ for (const c of pack.criteria({ locales, page: pageTypeOf(node, scenario) })) {
121
145
  if (have.has(c.id)) continue
122
146
  node.criteria.push({ ...c, pack: name })
123
147
  have.add(c.id)
package/src/lib/run.mjs CHANGED
@@ -64,7 +64,7 @@ export async function resolveRun ({ root, output, run }) {
64
64
  dir = join(base, id)
65
65
  }
66
66
  const meta = await readJson(join(dir, 'meta.json'), null)
67
- if (!meta) throw new UserError(`Run directory ${dir} has no meta.json`)
67
+ if (!meta) throw new UserError(`${dir} is not a complete run (meta.json is missing). Start a new one with kaloko start, or delete the folder if it is left over.`)
68
68
  return { dir, meta, paths: runPaths(dir) }
69
69
  }
70
70
 
@@ -5,8 +5,31 @@ import { UserError } from './util.mjs'
5
5
  import { expandPacks } from './packs.mjs'
6
6
  import { jsGuard } from './assertions.mjs'
7
7
 
8
+ // a scenario holds thousands of values, not millions: aliases (&a, *a) can expand a few kilobytes of YAML into
9
+ // billions that validation and its JSON copy would walk ("billion laughs"), so the expanded size has a ceiling
10
+ const MAX_VALUES = 1_000_000
11
+ const MAX_DEPTH = 64
12
+
13
+ /** The data of a scenario's YAML text; a UserError when it is no YAML or expands beyond MAX_VALUES or MAX_DEPTH. */
14
+ export function parseScenario (text, file = 'scenario') {
15
+ let data
16
+ try { data = yaml.load(text) } catch (e) { throw new UserError(`Scenario ${file} is not valid YAML: ${String(e?.message || e).split('\n')[0]}`) }
17
+ let n = 0
18
+ const stack = [[data, 0]]
19
+ while (stack.length) {
20
+ const [v, depth] = stack.pop()
21
+ if (!v || typeof v !== 'object') continue
22
+ if (depth >= MAX_DEPTH) throw new UserError(`Scenario ${file} is nested deeper than ${MAX_DEPTH} levels.`)
23
+ const kids = Object.values(v)
24
+ // counted before they are pushed, so a shared alias cannot grow the stack past the ceiling either
25
+ if ((n += kids.length) > MAX_VALUES) throw new UserError(`Scenario ${file} expands to more than ${MAX_VALUES} values (YAML aliases?).`)
26
+ for (const k of kids) stack.push([k, depth + 1])
27
+ }
28
+ return data
29
+ }
30
+
8
31
  export async function loadScenario (file) {
9
- const data = yaml.load(await readFile(file, 'utf8'))
32
+ const data = parseScenario(await readFile(file, 'utf8'), file)
10
33
  const problems = validateScenario(data)
11
34
  if (!problems.length) expandPacks(data)
12
35
  if (problems.length) throw new UserError(`Scenario ${file} is invalid:\n - ${problems.join('\n - ')}`)
@@ -54,6 +77,45 @@ export function validateScenario (data) {
54
77
  return problems
55
78
  }
56
79
 
80
+ const TEXT_ASSERTS = new Set(['text_matches', 'text_absent', 'title_matches', 'meta_matches'])
81
+ const ACCENTED = /[áčďéěíňóřšťúůýžäöüßąćęłńśźżàâçèêëîïôûùÿœñ]/i
82
+ const EN_WORDS = new Set(['the', 'and', 'of', 'to', 'in', 'for', 'with', 'your', 'you', 'is', 'are', 'on', 'by', 'or', 'not', 'no', 'an', 'this', 'what', 'how'])
83
+ const CS_WORDS = new Set(['a', 'i', 'v', 've', 'na', 'do', 'se', 'je', 'jsou', 'pro', 's', 'z', 'k', 'od', 'po', 'jak', 'co', 'nebo', 'ani', 'už', 'vaše', 'váš'])
84
+
85
+ /** Language of one alternative of a text pattern: 'other' (diacritics or a Czech word), 'en' (an English word) or null. */
86
+ function branchLanguage (branch) {
87
+ const text = branch.replace(/\\./g, ' ').replace(/\[[^\]]*\]/g, ' ')
88
+ if (ACCENTED.test(text)) return 'other'
89
+ const words = (text.match(/\p{L}+/gu) || []).map((w) => w.toLowerCase())
90
+ if (words.some((w) => CS_WORDS.has(w) && w.length > 1)) return 'other'
91
+ if (words.some((w) => EN_WORDS.has(w))) return 'en'
92
+ return null
93
+ }
94
+
95
+ /**
96
+ * Warnings that do not make a scenario invalid. A text criterion in a scenario with several locales whose pattern
97
+ * reads in one language only fails (or passes by accident) in the others. The heuristic is deliberately simple:
98
+ * the pattern is split at `|` and each alternative gets a language: a letter with a diacritic or a common Czech
99
+ * word means not English, a common English word ("the", "and", "your") means English, anything else (a brand,
100
+ * one plain word, "SOC 2") is unknown. It warns only when every alternative has a language and they all agree, so
101
+ * "Plans and payment" or "Vaše data" warn and "Your data|Vaše data", "Slack" or "Design drafts" do not.
102
+ * Pack criteria are left out.
103
+ */
104
+ export function scenarioWarnings (data) {
105
+ const out = []
106
+ if (!data || (data.locales || []).length < 2 || !Array.isArray(data.nodes)) return out
107
+ for (const node of data.nodes) {
108
+ for (const c of node.criteria || []) {
109
+ const k = c.check || {}
110
+ if (c.pack || k.type !== 'deterministic' || !TEXT_ASSERTS.has(k.assert) || typeof k.pattern !== 'string') continue
111
+ const langs = k.pattern.split('|').map(branchLanguage)
112
+ if (langs.some((l) => !l) || new Set(langs).size !== 1) continue
113
+ out.push(`node "${node.id}": ${c.id} pattern /${k.pattern}/ reads in one language only, but the scenario walks ${data.locales.join(', ')}; add the other language as an alternative (English|Czech) or move the check to a per-locale step`)
114
+ }
115
+ }
116
+ return out
117
+ }
118
+
57
119
  /**
58
120
  * Layered layout: column = longest path from a root, lane = explicit `lane`
59
121
  * or inherited from the parent (+1 for each extra `alt` branch).
@@ -19,19 +19,49 @@ export function ciName (vars = process.env) {
19
19
  return vars.CI && vars.CI !== 'false' ? 'ci' : null
20
20
  }
21
21
 
22
+ /** What to do after the service answered `status`; '' when its own message says enough. */
23
+ export function nextStepFor (status, { tokenEnv = 'KALOKO_TOKEN', url = 'https://kaloko.app' } = {}) {
24
+ if (status === 401) return `The token in ${tokenEnv} is invalid, expired or revoked. Run kaloko login for a new one (or put a fresh token from Settings → API tokens into .env).`
25
+ if (status === 403) return 'Your role or the token\'s scopes do not allow this; ask an admin of the organization, or use a token that may do it.'
26
+ if (status === 413) return 'The file is larger than the service takes; leave it out of the run or make it smaller.'
27
+ if (status === 429) return 'The service is limiting requests from this token. Wait a minute and run the command again.'
28
+ if (status >= 500) return `The service had a problem on its side. Try again in a few minutes; ${url}/status says whether it is a known outage.`
29
+ return ''
30
+ }
31
+
32
+ /** A UserError for an HTTP answer: what failed, the service's message, what to do next; `status` kept for callers. */
33
+ export function httpError (what, status, message, opts) {
34
+ const err = new UserError([`${what} → HTTP ${status}: ${message}`, nextStepFor(status, opts)].filter(Boolean).join(' '))
35
+ err.status = status
36
+ err.serviceMessage = message
37
+ return err
38
+ }
39
+
40
+ /** A UserError for a request that got no answer at all (offline, DNS, refused, TLS). */
41
+ export function unreachable (url, e) {
42
+ const code = e?.cause?.code || e?.code || e?.message || 'no answer'
43
+ const err = new UserError(`Cannot reach ${url} (${code}). Check your network or VPN and run the command again; KALOKO_URL points the CLI to another service.`)
44
+ err.offline = true
45
+ return err
46
+ }
47
+
22
48
  export function service (config) {
23
- const base = serviceUrl(config) + '/api/v1'
49
+ const root = serviceUrl(config)
50
+ const base = root + '/api/v1'
24
51
  const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
25
52
  const token = process.env[tokenEnv]
26
- if (!token) throw new UserError(`Set ${tokenEnv} (create a token in Kaloko → Settings).`)
53
+ if (!token) throw new UserError(`${tokenEnv} is not set, so the CLI cannot reach your organization on ${root}. Run kaloko login, or create a token in Settings → API tokens and put ${tokenEnv}=… into .env.`)
54
+ const opts = { tokenEnv, url: root }
27
55
  // an agent names itself (KALOKO_AGENT, e.g. "claude-code"); the service shows it in presence, history and audit (#42)
28
56
  const ci = ciName()
29
57
  const headers = { Authorization: `Bearer ${token}`, ...(process.env.KALOKO_AGENT ? { 'X-QAwalk-Agent': process.env.KALOKO_AGENT.slice(0, 60) } : {}), ...(ci ? { 'X-Kaloko-CI': ci } : {}) }
30
58
  const check = async (res, what) => {
31
59
  if (res.ok) return res.json()
32
60
  const body = await res.json().catch(() => ({}))
33
- throw new UserError(`${what} → HTTP ${res.status}: ${body.error || res.statusText}`)
61
+ throw httpError(what, res.status, body.error || res.statusText, opts)
34
62
  }
63
+ // fetch rejects without an answer (offline, refused, DNS): say so instead of a stack trace
64
+ const call = (send) => send().catch((e) => { if (e instanceof UserError) throw e; throw unreachable(root, e) })
35
65
  // the service limits requests per token (HTTP 429, nothing was processed): wait as told and try again
36
66
  // read-your-writes across requests: the service answers with a database bookmark the next request sends back
37
67
  let bookmark = null
@@ -46,18 +76,18 @@ export function service (config) {
46
76
  }
47
77
  return {
48
78
  async request (method, path, body) {
49
- const res = await limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }))
79
+ const res = await call(() => limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })))
50
80
  return check(res, `${method} ${path}`)
51
81
  },
52
82
  /** A request whose answer is not JSON (an export): the response, or an error with the service's message. */
53
83
  async raw (method, path, body) {
54
- const res = await limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }))
55
- if (!res.ok) { const b = await res.json().catch(() => ({})); throw new UserError(`${method} ${path} → HTTP ${res.status}: ${b.error || res.statusText}`) }
84
+ const res = await call(() => limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })))
85
+ if (!res.ok) { const b = await res.json().catch(() => ({})); throw httpError(`${method} ${path}`, res.status, b.error || res.statusText, opts) }
56
86
  return res
57
87
  },
58
88
  async fetch (path) {
59
- const res = await fetch(base + path, { headers })
60
- if (!res.ok) { const body = await res.json().catch(() => ({})); throw new UserError(`GET ${path} → HTTP ${res.status}: ${body.error || res.statusText}`) }
89
+ const res = await call(() => limited(() => fetch(base + path, { headers })))
90
+ if (!res.ok) { const body = await res.json().catch(() => ({})); throw httpError(`GET ${path}`, res.status, body.error || res.statusText, opts) }
61
91
  return res
62
92
  },
63
93
  async upload (path, data, name, extra = {}) {
@@ -65,7 +95,7 @@ export function service (config) {
65
95
  // a dropped connection (flaky Wi-Fi, CI runner hiccup) is tried again like a 5xx; the next share resumes otherwise
66
96
  let res
67
97
  try { res = await limited(() => fetch(base + path, { method: 'PUT', headers: { ...headers, ...extra }, body: data })) } catch (e) {
68
- if (attempt >= 3) throw new UserError(`upload ${name}: ${e.cause?.code || e.message}. Run kaloko share again: files already stored are not sent twice.`)
98
+ if (attempt >= 3) throw new UserError(`The upload of ${name} broke off (${e.cause?.code || e.message}). Check your network and run kaloko share again: files already stored are not sent twice.`)
69
99
  await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
70
100
  continue
71
101
  }
@@ -129,7 +129,7 @@ export async function detectUnstable ({ root, config, run, scenario, last = 4, r
129
129
  const others = runs ? (await Promise.all(runs.map(async (id) => {
130
130
  const dir = id.includes('/') ? resolve(id) : join(resolve(root, config.output), id)
131
131
  const meta = await readJson(join(dir, 'meta.json'), null)
132
- if (!meta) throw new UserError(`Run ${id} not found in ${config.output}.`)
132
+ if (!meta) throw new UserError(`Run ${id} is not in ${config.output}. kaloko runs lists the runs here.`)
133
133
  return { dir, meta }
134
134
  }))) : (await siblingRuns({ root, config, run, sameCommit })).slice(0, Math.max(0, last - 1))
135
135
  const all = [{ dir: run.dir, meta: run.meta }, ...others]
@@ -292,7 +292,7 @@ export async function pullDrawn ({ root, config, run, args, say, client }) {
292
292
  const scenarioId = typeof args.scenario === 'string' ? args.scenario : run.meta.scenario.id
293
293
  const data = await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(scenarioId)}/ignore-regions`)
294
294
  const regions = data.regions || []
295
- if (!regions.length) { say(`No drawn regions wait for ${scenarioId}. Draw them on a screenshot on kaloko.app (step detail → Page → Draw a region).`); return { applied: [], skipped: [], regions: [] } }
295
+ if (!regions.length) { say(`No drawn regions wait for ${scenarioId}. Draw them on a screenshot on kaloko.app (step detail → Mark a changing region).`); return { applied: [], skipped: [], regions: [] } }
296
296
  const byNode = new Map()
297
297
  for (const r of regions) (byNode.get(r.node_id) || byNode.set(r.node_id, []).get(r.node_id)).push(r.scenario_entry)
298
298
  const file = resolve(root, run.meta.scenario_file)