kaloko 0.4.7 → 0.4.9

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.
@@ -23,13 +23,18 @@ file.
23
23
  | Tool | Use it for |
24
24
  |---|---|
25
25
  | `list_runs` | published runs, newest first; filters `scenario`, `project`, `verdict`, `author`, `environment`, `branch`, `limit` |
26
- | `get_run_summary` | one run: statuses per step and locale, criteria that need attention with evidence, screenshot URLs, verdict, baseline, canvas URL, open feedback |
26
+ | `get_run_summary` | one run: statuses per step and locale, criteria that need attention with evidence (semantic ones with score and the evaluator's one-sentence `reason` per locale and viewport), regions ignored in pixel diffs and regions seen moving (`ignored_regions`, `unstable_regions`), screenshot URLs, verdict, baseline, canvas URL, open feedback |
27
27
  | `get_feedback` | run verdict with note, approved steps, unresolved rejections and comments, failed manual criteria (`run_id`, or `scenario` for its latest run) |
28
28
  | `list_notifications` | the token owner's inbox (`unread_only`, `limit`) |
29
29
  | `list_projects` | projects of the organization with counts; the slug is the `project` filter of `list_runs` and the value for `project:` in a scenario |
30
30
  | `get_criteria_verdicts` | reviewer verdicts on manual or overridden criteria of a run |
31
+ | `get_review_queue` | what a person still has to decide in a run (`run_id`, or `scenario` / nothing for the newest run): steps in review order with reasons, decisions, approvals carried over unchanged steps with their origin, counts and `review_url` to give the person |
32
+ | `decide_step` | records your (the agent's) decision on a step: `approve`, or `return` with a `note`; marked as an agent's and not counted as a person's. Never use it to approve for a person |
33
+ | `revoke_carried_approval` | revokes an approval carried over to a step that looks unchanged but should be looked at again (`run_id`, `step`) |
31
34
  | `get_step_assets` | documentation layer (Business): newest capture of every step of a scenario with stable image, HTML and metadata URLs (`scenario`, `step`, `locale`, `viewport`, `accepted`) |
32
35
  | `list_step_changes` | documentation layer: steps whose capture or status changed since the previous accepted run or `since` (`scenario`, `since`, `accepted`); see [documentation.md](documentation.md) |
36
+ | `get_started` | where the organization stands (`empty`, `sample`, `active`), its sample run, the next steps and a prompt; call it in a new project, see [first-run.md](first-run.md) |
37
+ | `add_sample_run` | adds the sample run (or returns the one there is); needs an upload or full token and the Developer / Tester role |
33
38
 
34
39
  ## Comment tools
35
40
 
@@ -60,6 +65,8 @@ The same data and permissions as the REST API; each tool is its REST route.
60
65
  |---|---|
61
66
  | `get_draft` | which revision each step shows, step status, who changed it (person or agent) |
62
67
  | `get_step` | the handoff: live files, signed entry URL, token set, page structure, open comments (`ref`: draft, design, vN, run id) |
68
+ | `get_step_spec` | what to build for a step as approved: elements with role, name and selector, styles mapped to tokens, states, contrast (`ref`: design by default, draft, vN, run id; `format` text or json) |
69
+ | `get_step_drift` | how an implementation run (`run`: id or `latest`) differs from the design reference, per element: expected token against the actual value |
63
70
  | `get_step_history` | versions, and revisions of a step with authors |
64
71
  | `list_step_changes` | steps that differ between two references (default design reference → draft) |
65
72
  | `get_references`, `get_tokens` | design reference and baseline; the project's token set |
package/src/cli.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { writeFile, access, readFile, mkdir } from 'node:fs/promises'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { gzipSync } from 'node:zlib'
4
+ import { createHash } from 'node:crypto'
4
5
  import { join, resolve, relative, dirname } from 'node:path'
5
6
  import { pathToFileURL } from 'node:url'
6
7
  import yaml from 'js-yaml'
@@ -16,7 +17,8 @@ import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher, gateFo
16
17
  import { configureGate } from './lib/linkcheck.mjs'
17
18
  import { rememberEnvironment } from './lib/secrets.mjs'
18
19
  import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
19
- import { buildManifest, writePreview } from './lib/build.mjs'
20
+ import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
21
+ import { stabilityCmd } from './lib/stability.mjs'
20
22
  import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote } from './lib/util.mjs'
21
23
  import { packageRoot } from './lib/schema.mjs'
22
24
  import { compareRuns, formatComparison } from './lib/compare.mjs'
@@ -29,9 +31,12 @@ import { resolveItems, fieldsOf, DEFAULT_LIMIT } from './lib/stack.mjs'
29
31
  import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
30
32
  import { expandPacks } from './lib/packs.mjs'
31
33
  import { upsertPrComment } from './lib/pr.mjs'
34
+ import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
32
35
  import { htmlViewAllowed } from './lib/htmlview.mjs'
33
36
  import { service, serviceUrl } from './lib/service.mjs'
34
37
  import { login } from './lib/login.mjs'
38
+ import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
39
+ import { specCmd, driftCmd } from './lib/spec-cli.mjs'
35
40
  import { walkDesign, syncDesign, publishVersion, historyCmd, referenceCmd, tokensCmd, exportLive, localRevisions, branchCmd, uploadBlobs } from './lib/design-cli.mjs'
36
41
  import { designDir, readBase, headKey } from './lib/design.mjs'
37
42
  import { lintDesign, formatLint } from './lib/design-lint.mjs'
@@ -98,6 +103,8 @@ Run
98
103
  verdicts and what failed (--no-evaluate skips that; kaloko evaluate redoes it)
99
104
  [--items a,b] [--limit N] Stack steps (kind: stack, many pages of one template): only pages whose id or
100
105
  path contains a, b; at most N pages (default items.limit, 500)
106
+ [--stability N] Take N screenshots of each capture a moment apart and note regions that move
107
+ (clocks, carousels); kaloko stability turns them into ignore suggestions
101
108
  status Show what the current run has captured
102
109
  Scenarios may declare "sessions" (people in the flow); walk keeps one browser context per session and locale
103
110
 
@@ -111,7 +118,11 @@ Results
111
118
  share [--expires 30] [--pr] Upload the run to kaloko.app (KALOKO_TOKEN, tester role);
112
119
  --pr adds or updates one comment on the branch's pull request via gh
113
120
  feedback [--run id] [--scenario id] [--json]
114
- Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies
121
+ Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies;
122
+ returned steps first, with their notes
123
+ review [--run id] [--scenario id] [--json]
124
+ What a person still has to decide on a shared run (failed, unsure, changed, returned,
125
+ commented steps), approvals carried over unchanged steps, and the review-mode link
115
126
  comment [--step id | --flow] --note "…" [--status rejected|approved] [--run id] [--scenario id]
116
127
  comment --reply|--resolve|--reopen|--edit|--delete <id> [--note "…"] [--flow]
117
128
  Comment, answer and close feedback from the CLI (resolve with "Fixed in <commit>")
@@ -136,6 +147,10 @@ Results
136
147
  Newest capture of every step as steps/<step>/<locale>/<viewport>.<ext> plus
137
148
  index.json (documentation layer, Business plan); unchanged files are skipped
138
149
  public [--run id] [--off] Make a shared run readable without sign-in (admin; testers may refresh an already public scenario)
150
+ stability [--last 4 | --runs a,b] [--same-commit] [--apply] [--json]
151
+ Regions that change on every run or move while the page is idle (clocks, ads,
152
+ carousels): ignore suggestions per step in <run>/stability.yml; --apply adds
153
+ them to the scenario (ignore:), and pixel diffs then skip them
139
154
  compare [--run id] [--baseline id | --design] [--json]
140
155
  Text diff of two runs: step statuses, criterion verdicts, scores and evidence
141
156
  (default: current run against the scenario baseline, else the previous run)
@@ -153,6 +168,12 @@ Design (environments with kind: design; live HTML steps in a folder)
153
168
  share [--name <text>] [--branch b] Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged"
154
169
  history [--scenario id] [--step id] [--locale l] [--json]
155
170
  Versions of the flow; revisions of a step with who (person or agent) and when
171
+ spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]
172
+ What to build for a step as approved: elements with role, name and selector, styles
173
+ mapped to DTCG tokens (or the nearest one), :hover/:focus/:active/:disabled, contrast
174
+ drift [step] [--ref design|vN] [--remote <run id|latest>] [--json]
175
+ How this implementation run differs from the approved design (expected token vs actual
176
+ value, raw values, missing elements and states); exit code 1 when something differs
156
177
  reference [--scenario id] (--design|--baseline) (--run id|--version n|--off) [--branch b]
157
178
  Show or set the design reference (accepted version) and the baseline
158
179
  tokens pull|push|check|validate [--project p] [--file tokens.json|tokens.resolver.json] [--mode m] [--json]
@@ -563,7 +584,13 @@ const commands = {
563
584
  await cp(join(packageRoot, 'templates', 'steps', 'consent.mjs'), consentStep)
564
585
  say('Added qa/flows/steps/consent.mjs (accepts the consent dialog, if the site has one)')
565
586
  }
566
- say(`Next: run kaloko login to approve a token in the browser (or create one in the app under Settings → API tokens and put KALOKO_TOKEN into .env).\nScenario template: ${join(packageRoot, 'templates', 'scenario.yml')}`)
587
+ // the organization's sample run and the way to a first real flow (src/lib/onboarding.mjs)
588
+ let written = ''
589
+ try { written = await readFile(existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE), 'utf8') } catch {}
590
+ const org = typeof args.org === 'string' ? args.org : (written.match(/^org:\s*["']?([a-z0-9-]+)["']?\s*$/m) || [])[1]
591
+ const base = serviceUrl({ share: { url: (written.match(/^share:[ \t]*\n(?:[ \t]+.*\n)*?[ \t]+url:[ \t]*["']?(https?:\/\/[^\s"']+)/m) || [])[1] } })
592
+ say(initNextSteps({ base, org }).join('\n'))
593
+ say(`Scenario template: ${join(packageRoot, 'templates', 'scenario.yml')}`)
567
594
  },
568
595
 
569
596
  async validate (args) {
@@ -691,7 +718,7 @@ const commands = {
691
718
  return
692
719
  }
693
720
  const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
694
- const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, list(args.viewports)), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null })
721
+ const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, list(args.viewports)), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, stability: Number(args.stability) || 0 })
695
722
  for (const r of results) {
696
723
  const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
697
724
  say(`✓ ${node.id} ${locale} ${r.viewport} ${r.size.width}×${r.size.height}px ${failed.length ? `failing checks: ${failed.join(', ')}` : 'checks ok'}${r.blocked_requests.length ? ` blocked: ${r.blocked_requests.length}` : ''}`)
@@ -815,7 +842,7 @@ const commands = {
815
842
  // the automatic capture runs unless this script invocation captured the step itself: a crop is an
816
843
  // attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
817
844
  let captured = false
818
- const capture = (opts = {}) => { captured = true; return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, opts.viewports), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile: conn && !sess ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {} }).then((r) => { prefetch?.add(r, run.dir); return r }) }
845
+ const capture = (opts = {}) => { captured = true; return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, opts.viewports), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile: conn && !sess ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); return r }) }
819
846
  await mod.default({ page: sPage, context: sCtx, locale, node: { id: node.id, title: localized(node.title, locale), kind: node.kind }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0 }), acceptDialogs: () => acceptDialogs(sPage) })
820
847
  if (!captured) await capture()
821
848
  say(`✓ ${node.id} ${locale}`)
@@ -936,6 +963,7 @@ const commands = {
936
963
  const report = await evaluateRun({ run, scenario, config, root })
937
964
  const via = report.via.qawalk ? ` via Kaloko${report.hosted?.limit != null ? ` (${report.hosted.questions} of ${report.hosted.limit} questions this month)` : ''}` : report.via['own-key'] ? ' with your key' : ''
938
965
  say(`Evaluated ${report.evaluated} capture(s); semantic evaluator ${report.model}${via}: ${report.semantic_calls} call(s), ${report.semantic_cached} cached.`)
966
+ if (report.vision?.asked) say(`Vision evaluator ${report.vision.model}: ${report.vision.asked} capture(s) with flagged or uncertain criteria, ${report.vision.calls} call(s), ${report.vision.cached} cached${report.vision.skipped.length ? `; not asked: ${[...new Set(report.vision.skipped.map((x) => x.split(': ').slice(1).join(': ')))].slice(0, 2).join('; ')}` : ''}.`)
939
967
  if (report.skipped.length) {
940
968
  // one line per reason: "12 captures: TYPESAFE_API_KEY is not set …"
941
969
  const byReason = new Map()
@@ -951,9 +979,15 @@ const commands = {
951
979
  const file = await writePreview({ run, manifest })
952
980
  const s = manifest.summary
953
981
  say(`${manifest.id}: ${s.pass} pass · ${s.partial} partial · ${s.fail} fail · ${s.skip} not checked\nPreview: ${file}`)
982
+ const why = attentionLines(manifest); if (why.length) say(`AI verdicts to act on (score and why):\n${why.join('\n')}`)
954
983
  return file
955
984
  },
956
985
 
986
+ async stability (args) {
987
+ const { config, root, run, scenario } = await runContext(args)
988
+ await stabilityCmd({ root, config, run, scenario, args, say })
989
+ },
990
+
957
991
  async preview (args) {
958
992
  const file = await commands.build(args)
959
993
  say(pathToFileURL(file).href)
@@ -976,7 +1010,7 @@ const commands = {
976
1010
  await writePreview({ run, manifest })
977
1011
  const files = new Set()
978
1012
  for (const n of manifest.nodes) {
979
- for (const byVp of Object.values(n.captures || {})) for (const c of Object.values(byVp)) [c.image || c.png, c.thumb, c.html, c.eval, c.tree].filter(Boolean).forEach((p) => files.add(p))
1013
+ for (const byVp of Object.values(n.captures || {})) for (const c of Object.values(byVp)) [c.image || c.png, c.thumb, c.html, c.eval, c.tree, c.spec].filter(Boolean).forEach((p) => files.add(p))
980
1014
  for (const a of n.attachments || []) files.add(a.file)
981
1015
  // stack steps: the items index and every page's files
982
1016
  if (n.stack?.items) {
@@ -1000,9 +1034,10 @@ const commands = {
1000
1034
  const body = async (p) => { const raw = await readFile(join(run.dir, p)); const gz = /\.(html|json)$/.test(p) && raw.length > 512; return { data: gz ? gzipSync(raw, { level: 6 }) : raw, gz } }
1001
1035
  // the file list goes first: the service checks limits once and each upload is then a plain write
1002
1036
  // (an older service without the plan answers 404/405 and every upload checks itself, as before)
1003
- if (files.size > 20) {
1037
+ // screenshots name their SHA-256: the service checks it on arrival and compares unchanged steps with it
1038
+ if (files.size) {
1004
1039
  const plan = []
1005
- for (const p of files) plan.push({ path: p, bytes: (await body(p)).data.length })
1040
+ for (const p of files) { const { data } = await body(p); plan.push({ path: p, bytes: data.length, ...(/\.(png|webp|jpe?g)$/i.test(p) ? { sha256: createHash('sha256').update(data).digest('hex') } : {}) }) }
1006
1041
  await client.request('POST', `/orgs/${config.org}/runs/${manifest.id}/files`, { files: plan }).catch((e) => { if (!/HTTP (404|405)/.test(e.message)) throw e })
1007
1042
  }
1008
1043
  const queue = [...files]
@@ -1036,6 +1071,9 @@ const commands = {
1036
1071
  meta.shared = { url: fin.url, at: new Date().toISOString(), expires_days: created.expires_days }
1037
1072
  await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
1038
1073
  say(`Shared ${manifest.id} (${files.size} files, expires in ${created.expires_days} days)\n${fin.url}`)
1074
+ // what waits for a person, with the review-mode link to give them (approvals of unchanged steps carry over)
1075
+ const review = shareReviewLine(fin.review)
1076
+ if (review) say(review)
1039
1077
  const capped = expiryNote(days, created.expires_days)
1040
1078
  if (capped) say(`Note: ${capped}`)
1041
1079
  if (args.pr) {
@@ -1080,6 +1118,25 @@ const commands = {
1080
1118
  await historyCmd({ config, scenarioId, client: service(config), args, say })
1081
1119
  },
1082
1120
 
1121
+ async spec (args) {
1122
+ const { config, root } = await context(args)
1123
+ let run = null
1124
+ try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
1125
+ const scenario = run ? await loadScenario(resolve(root, run.meta.scenario_file)).catch(() => null) : typeof args.scenario === 'string' && /\.ya?ml$/.test(args.scenario) ? await loadScenario(resolve(args.scenario)) : null
1126
+ const scenarioId = await scenarioIdOf(args, config, root)
1127
+ let client = null
1128
+ if (!args.local) { try { client = service(config) } catch (e) { if (!run) throw e } }
1129
+ await specCmd({ config, client, run, scenario, scenarioId, args, say })
1130
+ },
1131
+
1132
+ async drift (args) {
1133
+ const { config, root } = await context(args)
1134
+ let run = null
1135
+ try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
1136
+ const scenarioId = await scenarioIdOf(args, config, root)
1137
+ await driftCmd({ config, client: service(config), run, scenarioId, args, say })
1138
+ },
1139
+
1083
1140
  async reference (args) {
1084
1141
  const { config, root } = await context(args)
1085
1142
  const scenarioId = await scenarioIdOf(args, config, root)
@@ -1132,12 +1189,38 @@ const commands = {
1132
1189
  for (const x of r.replies || []) say(` ↳ ${x.email}${x.agent ? ` via ${x.agent}` : ''}: ${x.deleted ? '(deleted)' : x.note}`)
1133
1190
  }
1134
1191
  const flow = data.flow_comments || []
1192
+ // how many steps still wait for a person (review mode); an older service answers 404 and the line is left out
1193
+ const queue = await client.request('GET', `/orgs/${config.org}/review-queue?run=${encodeURIComponent(data.run.id)}`).catch(() => null)
1194
+ if (queue?.counts) say(` review: ${queue.counts.pending} of ${queue.counts.queue} step(s) wait for a person${queue.counts.carried ? `, ${queue.counts.carried} unchanged kept their approval` : ''} (kaloko review; review mode: ${queue.review_url})`)
1135
1195
  if (!data.items.length && !flow.length) { say(' No open rejections or comments.'); return }
1136
- for (const r of data.items) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
1196
+ // returned steps first: a reviewer said what should change
1197
+ const returned = data.items.filter((r) => r.status === 'rejected')
1198
+ if (returned.length) say(`Returned steps (${returned.length}), fix these first:`)
1199
+ for (const r of returned) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
1200
+ if (returned.length && data.items.length > returned.length) say('Comments:')
1201
+ for (const r of data.items.filter((x) => x.status !== 'rejected')) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
1137
1202
  for (const r of flow) thread(r, 'whole flow')
1138
1203
  say(`\nAnswer with: kaloko comment --reply <id> --note "…"; close with: kaloko comment --resolve <id> --note "Fixed in <commit>"${flow.length ? ' (add --flow for flow comments)' : ''}`)
1139
1204
  },
1140
1205
 
1206
+ /**
1207
+ * What a person still has to decide on a shared run: the service's review queue (failed, unsure, changed since the
1208
+ * accepted run, returned, commented), the approvals carried over unchanged steps, and the review-mode link. For
1209
+ * agents: report it to the person; never approve on their behalf.
1210
+ */
1211
+ async review (args) {
1212
+ const { config, root } = await context(args)
1213
+ const client = service(config)
1214
+ let runId = typeof args.run === 'string' ? args.run : null
1215
+ let scenarioId = typeof args.scenario === 'string' ? args.scenario : null
1216
+ if (!runId && !scenarioId) {
1217
+ try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id; else scenarioId = r.meta.scenario.id } catch {}
1218
+ }
1219
+ const q = new URLSearchParams({ run: runId || 'latest', ...(scenarioId ? { scenario: scenarioId } : {}) })
1220
+ const data = await client.request('GET', `/orgs/${config.org}/review-queue?${q}`)
1221
+ say(args.json ? JSON.stringify(data, null, 2) : formatReviewQueue(data))
1222
+ },
1223
+
1141
1224
  /**
1142
1225
  * Comments from the CLI: on a step (--step), the whole run (default) or the whole flow (--flow); reply, resolve
1143
1226
  * (with a closing note), reopen, edit and delete by id. Targets the shared run (--run, default the current one).
@@ -1236,6 +1319,7 @@ const commands = {
1236
1319
  if (args.print) return
1237
1320
  say(`✓ Signed in to ${r.org_name || r.org} as ${r.email} (${r.scope}; expires ${String(r.expires_at || '').slice(0, 10)}). ${tokenEnv} saved to ${r.file}.`)
1238
1321
  if (config.org && r.org && config.org !== r.org) say(`! ${CONFIG_FILE} names the organization ${config.org}; the token is for ${r.org}.`)
1322
+ for (const line of loginNextSteps({ base, org: r.org })) say(line)
1239
1323
  let projectEnv = ''
1240
1324
  try { projectEnv = await readFile(join(process.cwd(), '.env'), 'utf8') } catch {}
1241
1325
  if (inShell || new RegExp(`^\\s*(export\\s+)?${tokenEnv}\\s*=`, 'm').test(projectEnv)) say(`! ${tokenEnv} is also set ${inShell ? 'in your shell' : 'in this project\'s .env'}, which wins over ~/.config/kaloko/.env. Remove it there to use the new token.`)
package/src/lib/build.mjs CHANGED
@@ -29,9 +29,14 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
29
29
  const cap = await readJson(join(run.dir, c.file))
30
30
  const stored = await readJson(runPaths(run.dir).step(c.node, c.locale, c.viewport, 'eval.json'), null)
31
31
  const ev = stored?.source === evaluationSource(node, cap) ? stored : null
32
- caps.push({ locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html || null, tree: cap.files.tree || null, eval: `steps/${c.node}/${c.locale}/${c.viewport}.eval.json` }, ...(cap.live ? { live: cap.live } : {}) })
32
+ // the step spec travels as its own file (Dev mode on screenshots, `kaloko spec`, drift on the service)
33
+ const spec = cap.spec ? `steps/${c.node}/${c.locale}/${c.viewport}.spec.json` : null
34
+ if (spec) await writeJson(join(run.dir, spec), cap.spec)
35
+ caps.push({ locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html || null, tree: cap.files.tree || null, eval: `steps/${c.node}/${c.locale}/${c.viewport}.eval.json`, spec }, ...(cap.live ? { live: cap.live } : {}) })
33
36
  }
34
- nodes.push(assembleNode({ node, pos: pos.get(node.id), locales, viewportNames, captures: caps, attachments: attachments.filter((a) => a.node === node.id) }))
37
+ // focus screenshots of the keyboard walk (#19): only those of the current captures, not of earlier walks
38
+ const shots = new Set(caps.flatMap((c) => (c.cap.keyboard?.shots || []).map((s) => s.file)))
39
+ nodes.push(assembleNode({ node, pos: pos.get(node.id), locales, viewportNames, captures: caps, attachments: attachments.filter((a) => a.node === node.id && (a.kind !== 'focus' || shots.has(a.file))) }))
35
40
  }
36
41
  const summary = summarize(nodes, scenario.sessions)
37
42
 
@@ -126,19 +131,39 @@ async function buildStack ({ run, node, base, captures, attachments }) {
126
131
  return {
127
132
  ...base, status, by_locale, captures: {}, criteria, semantic: {}, metadata: {}, session: node.session || null,
128
133
  execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : { strategy: 'playwright', instructions: 'Open every page of the stack and capture it.' },
129
- attachments: attachments.filter((a) => a.node === node.id).map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
134
+ attachments: attachments.filter((a) => a.node === node.id && a.kind !== 'focus').map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
130
135
  stack: { count: items.length, items: file, summary, fields, sample, semantic_sample: semanticSample, source: ['urls', 'file', 'sitemap', 'crawl', 'script'].find((k) => src[k]) || null, stale: items.filter((it) => it.stale).length }
131
136
  }
132
137
  }
133
138
 
139
+ /**
140
+ * Lines for `kaloko build`: semantic criteria that failed or need a person, per step, locale and viewport, with the
141
+ * score and the one-sentence reason (#17). At most `max` lines.
142
+ */
143
+ export function attentionLines (manifest, max = 20) {
144
+ const out = []
145
+ for (const n of manifest.nodes || []) {
146
+ for (const c of n.criteria || []) {
147
+ if (c.type !== 'semantic' || !['fail', 'needs_review'].includes(c.verdict)) continue
148
+ for (const [l, vps] of Object.entries(c.by_locale || {})) {
149
+ for (const [vp, r] of Object.entries(vps)) {
150
+ if (!['fail', 'needs_review'].includes(r.verdict)) continue
151
+ out.push(` ${r.verdict === 'fail' ? '✕' : '?'} ${n.id} ${l}/${vp} ${c.id}${r.score != null ? ` ${Math.round(r.score * 100)} %${r.vision ? ' (with screenshot)' : ''}` : ''}${r.reason ? `: ${r.reason}` : r.note ? `: ${r.note}` : ''}`)
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return out.length > max ? [...out.slice(0, max), ` … ${out.length - max} more on the canvas`] : out
157
+ }
158
+
134
159
  export async function writePreview ({ run, manifest }) {
135
160
  const template = await readFile(join(packageRoot, 'viewer', 'index.html'), 'utf8')
136
161
  // the local preview opens from file://, where fetch cannot read files: stack indexes go inline
137
162
  // file:// cannot fetch: stack indexes and app UI trees travel inside the preview
138
163
  const withTrees = async (n) => {
139
- if (!Object.values(n.captures || {}).some((vps) => Object.values(vps).some((c) => c?.tree))) return n
164
+ if (!Object.values(n.captures || {}).some((vps) => Object.values(vps).some((c) => c?.tree || c?.spec))) return n
140
165
  const captures = {}
141
- for (const [l, vps] of Object.entries(n.captures)) { captures[l] = {}; for (const [vp, c] of Object.entries(vps)) captures[l][vp] = c?.tree ? { ...c, tree_inline: await readJson(join(run.dir, c.tree), null) } : c }
166
+ for (const [l, vps] of Object.entries(n.captures)) { captures[l] = {}; for (const [vp, c] of Object.entries(vps)) captures[l][vp] = c?.tree || c?.spec ? { ...c, ...(c.tree ? { tree_inline: await readJson(join(run.dir, c.tree), null) } : {}), ...(c.spec ? { spec_inline: await readJson(join(run.dir, c.spec), null) } : {}) } : c }
142
167
  return { ...n, captures }
143
168
  }
144
169
  // design runs: live steps open from the merged tree, where links between steps resolve (design.mjs copyLive)
@@ -2,6 +2,7 @@ import { writeFile, mkdir, rm } from 'node:fs/promises'
2
2
  import { dirname, relative, join } from 'node:path'
3
3
  import { buildHtmlView } from './htmlview.mjs'
4
4
  import { probe } from './probe.mjs'
5
+ import { pageSpecScript } from './devspec.mjs'
5
6
  import { writeJson, readJsonl } from './util.mjs'
6
7
  import { addCapture, runPaths } from './run.mjs'
7
8
 
@@ -37,6 +38,15 @@ export const toWebp = (page, png, opts = {}) => encodeImage(page, png, { ...opts
37
38
  * Capture options: scenario-wide `capture:` merged with the node's. Documentation runs use
38
39
  * { format: png, scale: 2, mask: [selectors], annotate: true }; daily acceptance keeps the WebP default.
39
40
  */
41
+ /** The page spec (src/lib/devspec.mjs), kept under ~250 kB so it fits a design revision's capture record. */
42
+ export async function stepSpec (page, tokens = null) {
43
+ try {
44
+ const spec = await page.evaluate(pageSpecScript(tokens))
45
+ while (spec.elements.length > 20 && JSON.stringify(spec).length > 250000) spec.elements = spec.elements.slice(0, Math.floor(spec.elements.length * 0.8))
46
+ return spec
47
+ } catch { return null }
48
+ }
49
+
40
50
  export function captureOptions (scenario, node) {
41
51
  const o = { ...(scenario?.capture || {}), ...(node?.capture || {}) }
42
52
  const q = Number(o.quality)
@@ -56,6 +66,8 @@ import { consoleEntriesFor } from './console.mjs'
56
66
  import { redact, redactDeep } from './secrets.mjs'
57
67
  import { readFields } from './stack.mjs'
58
68
  import { checkLinks, checkHreflang, requester, walkCache, NETWORK_ASSERTS, isolatedRequest, cacheScope } from './linkcheck.mjs'
69
+ import { keyboardChecks, needsKeyboard, KEYBOARD_ASSERTS } from './keyboard.mjs'
70
+ import { repeatRegions, resolveIgnore } from './stability.mjs'
59
71
 
60
72
  /** Probe arguments derived from the node's criteria. */
61
73
  export function probeArgs (node, maxChars, design = false) {
@@ -74,6 +86,9 @@ export function probeArgs (node, maxChars, design = false) {
74
86
  // md-menu, listboxes, tooltips). A step that leaves one open wants it on the capture.
75
87
  const OVERLAY = '[role=menu], [role=listbox], [role=tooltip], .MuiPopover-root, .MuiPopper-root, .MuiMenu-root, md-menu-content, .md-open-menu-container.md-active, .md-panel-outer-wrapper.md-panel-is-showing'
76
88
 
89
+ const STACK_KEYBOARD = new Map()
90
+ const STACK_KEYBOARD_PAGES = 5
91
+
77
92
  export async function settle (page) {
78
93
  try { await page.waitForLoadState('networkidle', { timeout: 5000 }) } catch {}
79
94
  try { await page.evaluate(() => document.fonts?.ready) } catch {}
@@ -95,7 +110,7 @@ export async function settle (page) {
95
110
  * Captures the current state of `page` for one step and locale in every
96
111
  * requested viewport: full-page PNG, rendered HTML, probe results.
97
112
  */
98
- export async function captureStep ({ page, node, locale, viewports, run, consoleFile, maxChars = 90000, since, extra = {}, format = null, options = null, designSummary = false, htmlView = null, item = null, fields = null }) {
113
+ export async function captureStep ({ page, node, locale, viewports, run, consoleFile, maxChars = 90000, since, extra = {}, format = null, options = null, designSummary = false, htmlView = null, item = null, fields = null, tokens = null, stability = 0 }) {
99
114
  const opts = { ...(options || captureOptions(null, node)), ...(format ? { format } : {}) }
100
115
  // a page of a stack step lives under steps/<node>/items/<item>/…; its capture record names node and item
101
116
  const stepId = item ? `${node.id}/items/${item.id}` : node.id
@@ -132,6 +147,12 @@ export async function captureStep ({ page, node, locale, viewports, run, console
132
147
  throw error
133
148
  }
134
149
  }
150
+ // the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
151
+ if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens)
152
+ // regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
153
+ const ignore = await resolveIgnore(page, node, locale, viewport)
154
+ // what moves while the page sits still (walk --stability N): repeated screenshots, compared here
155
+ const unstable = stability > 1 ? await repeatRegions(page, { n: Math.min(6, stability), fullPage: opts.fullPage }).catch((error) => ({ error: String(error.message).slice(0, 200) })) : null
135
156
  // numbered marks on the highlighted elements, in criteria order (first box of each)
136
157
  const annotations = opts.annotate ? Object.entries(result.boxes || {}).filter(([, list]) => list?.length).map(([criterion, list], i) => ({ n: i + 1, criterion, x: list[0].x, y: list[0].y })) : []
137
158
  let image = paths.step(stepId, locale, viewport, opts.format)
@@ -168,6 +189,24 @@ export async function captureStep ({ page, node, locale, viewports, run, console
168
189
  : { ok: errors.length === 0, evidence: errors.length ? errors.slice(0, 3).join(' | ') : 'no console errors' }
169
190
  }
170
191
  }
192
+ // keyboard and screen-reader checks (#19): a Tab walk and the accessibility tree, after the screenshot and after
193
+ // the console was read (focus handlers of the page must not count as its errors)
194
+ let keyboard = null
195
+ // stack steps walk the keyboard on their first few pages only (one template, hundreds of pages)
196
+ const kbKey = `${run.dir}|${node.id}|${viewport}`
197
+ const kbSampled = !item || (STACK_KEYBOARD.set(kbKey, (STACK_KEYBOARD.get(kbKey) || 0) + 1).get(kbKey) <= STACK_KEYBOARD_PAGES)
198
+ if (needsKeyboard(node) && !kbSampled) {
199
+ for (const c of node.criteria) if (KEYBOARD_ASSERTS.has(c.check.assert)) result.checks[c.id] = { ok: null, evidence: `keyboard walk runs on the first ${STACK_KEYBOARD_PAGES} pages of a stack step` }
200
+ }
201
+ if (needsKeyboard(node) && kbSampled) {
202
+ const kb = await keyboardChecks(page, node.criteria, {
203
+ saveShot: async (n, png) => { const f = paths.step(stepId, locale, viewport, `focus-${n}.png`); await writeFile(f, png); return relative(run.dir, f) }
204
+ })
205
+ Object.assign(result.checks, kb.checks)
206
+ result.boxes = { ...result.boxes, ...kb.boxes }
207
+ keyboard = kb.keyboard
208
+ for (const s of keyboard?.shots || []) await addCapture(run.dir, { type: 'attachment', kind: 'focus', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, title: `Focus ${s.n}: ${s.name}`, file: s.file, at: new Date().toISOString() })
209
+ }
171
210
  // experimental HTML view (#42): a scrubbed, script-free static copy with rehosted assets
172
211
  let view = null
173
212
  if (htmlView) {
@@ -189,6 +228,9 @@ export async function captureStep ({ page, node, locale, viewports, run, console
189
228
  ...extra,
190
229
  http_status: extra.http_status ?? result.http_status ?? null,
191
230
  capture: { format: String(image).split('.').pop(), scale: opts.scale, masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
231
+ ...(ignore.length ? { ignore } : {}),
232
+ ...(unstable ? { unstable } : {}),
233
+ ...(keyboard ? { keyboard } : {}),
192
234
  ...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) } } : {}),
193
235
  ...(fields ? { fields: { ...(await page.evaluate(readFields, fields).catch(() => ({}))), ...(fields.some((f) => (f.from || f.id) === 'http') ? Object.fromEntries(fields.filter((f) => (f.from || f.id) === 'http').map((f) => [f.id, extra.http_status ?? result.http_status ?? null])) : {}) } } : {}),
194
236
  files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
@@ -7,6 +7,7 @@ import { readJson, writeJson, UserError } from './util.mjs'
7
7
  import { readCaptures, addCapture, runPaths } from './run.mjs'
8
8
  import { latestCaptures, criteriaHash, evaluationSource } from './evaluate.mjs'
9
9
  import { captureStep } from './capture.mjs'
10
+ import { loadTokens } from './drift.mjs'
10
11
  import { layout } from './scenario.mjs'
11
12
  import { launchEphemeral } from './browser.mjs'
12
13
  import { observeConsole } from './console.mjs'
@@ -45,6 +46,8 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
45
46
  const browser = await launchEphemeral(config)
46
47
  const paths = runPaths(run.dir)
47
48
  const mode = env.tokens_mode || ''
49
+ // the design folder's own set names the values in the step spec (the one its live pages reference)
50
+ const tokens = await loadTokens({ client: null, config, root, designDir: dir })
48
51
  let rendered = 0, reused = 0
49
52
  try {
50
53
  const page = await (await browser.newContext()).newPage()
@@ -58,7 +61,8 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
58
61
  for (const [vp, size] of Object.entries(viewports)) {
59
62
  const ck = captureKey(rev.id, vp, mode)
60
63
  const cached = noCache ? null : await fromCache({ root, output: config.output, key: ck })
61
- if (cached && cached.record.criteria_hash === criteriaHash(node)) {
64
+ // a capture from before the step spec existed renders again, so Dev mode and `kaloko spec` have it
65
+ if (cached && cached.record.criteria_hash === criteriaHash(node) && cached.record.spec) {
62
66
  const record = await restoreCached({ cached, runDir: run.dir, paths, node: node.id, locale: rev.locale, viewport: vp })
63
67
  const file = paths.step(node.id, rev.locale, vp, 'capture.json')
64
68
  await writeJson(file, record)
@@ -69,7 +73,7 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
69
73
  if (Object.keys(todo).length) {
70
74
  const since = new Date().toISOString()
71
75
  await page.goto(`${server.url}/${rev.entry}`, { waitUntil: 'load' })
72
- const results = await captureStep({ page, node, locale: rev.locale, viewports: todo, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live }, designSummary: true })
76
+ const results = await captureStep({ page, node, locale: rev.locale, viewports: todo, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live }, designSummary: true, tokens })
73
77
  for (const r of results) { await toCache({ root, output: config.output, key: captureKey(rev.id, r.viewport, mode), runDir: run.dir, record: r }); rendered++ }
74
78
  }
75
79
  say(`✓ ${node.id} ${rev.locale} revision ${rev.id.slice(0, 8)}${Object.keys(todo).length ? '' : ' (unchanged, reused)'}`)
@@ -231,7 +235,7 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
231
235
  out[k] = h
232
236
  }
233
237
  const stored = await readJson(runPaths(run.dir).step(rev.node, rev.locale, vp, 'eval.json'), null)
234
- out.record = slimCapture(rec)
238
+ out.record = { ...slimCapture(rec), ...(rec.spec ? { spec: rec.spec } : {}) }
235
239
  out.evaluation = stored?.source === evaluationSource(node, rec) ? stored : null
236
240
  out.mode = env.tokens_mode || ''
237
241
  out.renderer = RENDERER