kaloko 0.8.0 → 0.10.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 (43) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +2 -0
  4. package/schemas/revision.v1.json +11 -0
  5. package/schemas/run.v1.json +12 -3
  6. package/schemas/scenario.v1.json +14 -0
  7. package/skills/kaloko/reference/design.md +93 -1
  8. package/skills/kaloko/reference/execution.md +6 -0
  9. package/skills/kaloko/reference/portable-html.md +12 -3
  10. package/skills/kaloko/reference/stacks.md +6 -0
  11. package/src/cli.mjs +119 -47
  12. package/src/lib/access.mjs +1 -1
  13. package/src/lib/apps/drivers.mjs +1 -1
  14. package/src/lib/apps/webview.mjs +3 -3
  15. package/src/lib/build.mjs +9 -46
  16. package/src/lib/capture.mjs +1 -1
  17. package/src/lib/changelog.mjs +2 -2
  18. package/src/lib/design-cli.mjs +121 -47
  19. package/src/lib/design-core.mjs +26 -4
  20. package/src/lib/design-export.mjs +28 -8
  21. package/src/lib/design-lint.mjs +45 -11
  22. package/src/lib/design-origins.mjs +86 -0
  23. package/src/lib/design.mjs +112 -8
  24. package/src/lib/dimensions.mjs +66 -12
  25. package/src/lib/doctor.mjs +72 -10
  26. package/src/lib/drift.mjs +1 -1
  27. package/src/lib/evaluate.mjs +7 -3
  28. package/src/lib/figma.mjs +1 -1
  29. package/src/lib/integrations-cli.mjs +3 -3
  30. package/src/lib/login.mjs +1 -1
  31. package/src/lib/mail.mjs +1 -1
  32. package/src/lib/manifest-core.mjs +90 -5
  33. package/src/lib/run.mjs +1 -1
  34. package/src/lib/service.mjs +39 -9
  35. package/src/lib/spec-cli.mjs +7 -4
  36. package/src/lib/stability.mjs +1 -1
  37. package/src/lib/stack.mjs +16 -9
  38. package/src/lib/tokens.mjs +16 -0
  39. package/src/lib/util.mjs +6 -0
  40. package/src/lib/video.mjs +11 -2
  41. package/src/lib/vision.mjs +1 -1
  42. package/src/lib/walk-dimensions.mjs +31 -6
  43. package/viewer/index.html +249 -48
package/src/cli.mjs CHANGED
@@ -20,7 +20,7 @@ import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
20
20
  import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
21
21
  import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
22
22
  import { signoffCmd } from './lib/signoff.mjs'
23
- import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote } from './lib/util.mjs'
23
+ import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG } from './lib/util.mjs'
24
24
  import { packageRoot } from './lib/schema.mjs'
25
25
  import { compareRuns, formatComparison } from './lib/compare.mjs'
26
26
  import { doctor, formatDoctor } from './lib/doctor.mjs'
@@ -29,7 +29,7 @@ import { calibrate, formatCalibration, casesFromGolden, casesFromRun } from './l
29
29
  import { pageFacts, draftScenario, scenarioYaml } from './lib/draft.mjs'
30
30
  import { detectProject, initScenario, formatDetection } from './lib/init-detect.mjs'
31
31
  import { recheckCaptures } from './lib/recheck.mjs'
32
- import { resolveItems, fieldsOf, DEFAULT_LIMIT } from './lib/stack.mjs'
32
+ import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft } from './lib/stack.mjs'
33
33
  import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
34
34
  import { expandPacks } from './lib/packs.mjs'
35
35
  import { upsertPrComment } from './lib/pr.mjs'
@@ -50,6 +50,7 @@ import { walkDesign, syncDesign, publishVersion, previewNotes, historyCmd, refer
50
50
  import { screensCmd } from './lib/screens-cli.mjs'
51
51
  import { designDir, readBase, headKey } from './lib/design.mjs'
52
52
  import { lintDesign, formatLint } from './lib/design-lint.mjs'
53
+ import { cleanAssetOrigins } from './lib/design-origins.mjs'
53
54
  import { exportDesign } from './lib/design-export.mjs'
54
55
  import { driverFor, isApp, pngSize, ocr as ocrImage } from './lib/apps/drivers.mjs'
55
56
  import { captureApp } from './lib/apps/capture.mjs'
@@ -57,7 +58,7 @@ import { readTree, flatten } from './lib/apps/tree.mjs'
57
58
  import { recordSettings, recordingSession, parseRecord, recordingFiles, traceFiles, stripRecordings, withoutRecordings } from './lib/recording.mjs'
58
59
  import { nativeSession } from './lib/apps/recording-native.mjs'
59
60
  import { videoCmd } from './lib/video-cli.mjs'
60
- import { passesOf, enginesOf, contextOptionsOf, launchEngine, pickKeys, variantFields, variantOf, dimensionValues, BROWSERS } from './lib/dimensions.mjs'
61
+ import { passesOf, enginesOf, contextOptionsOf, launchEngine, pickKeys, variantFields, variantOf, dimensionValues, stateKeys, nodeStates, BROWSERS } from './lib/dimensions.mjs'
61
62
  import { startScope, scopeKeys, dimensionLine, baseViewports, engineKeys, appDeviceGroups } from './lib/walk-dimensions.mjs'
62
63
  import { deviceConfig, prepareAndroid, readyAndroid, finishAndroid, prepareSimulator, applyAppAppearance } from './lib/apps/devices.mjs'
63
64
  import { attachAndroidWebView } from './lib/apps/webview.mjs'
@@ -94,7 +95,9 @@ Project
94
95
  the organization and what the token may do; the token (at most 30 days) goes to
95
96
  ~/.config/kaloko/.env, or is printed with --print
96
97
  doctor [--json] [--fix] Check Node, Chrome, config, environments, browser ports, token, evaluator key
97
- and app tools; --fix asks macOS for the desktop capture permissions
98
+ and app tools; --fix installs missing Playwright browsers, creates the run and
99
+ scenario folders, writes org: from the token and asks macOS for the desktop
100
+ capture permissions
98
101
  validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]]
99
102
  Validate scenarios (schema + references); --coverage compares criteria with the plan
100
103
  With a token it also warns about scenarios the service still lists whose file is gone (--offline skips it)
@@ -111,6 +114,9 @@ Run
111
114
  More dimensions: every step is captured in each combination (keys like
112
115
  mobile.webkit.dark); default from the scenario, then walk: in the config
113
116
  [--devices pixel-8-api-34,iphone-15-ios-18] App scenarios: the emulators, simulators or devices to walk on
117
+ [--states empty,error] Only these step states (states: on a step; keys like mobile.state-empty)
118
+ [--token-modes brand-a,brand-b | --token-modes none]
119
+ Design environments: the token modes to render (default every mode of the set)
114
120
  browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
115
121
  Shared Chromium with CDP (agents, scripts and people drive it)
116
122
  browser install [--with-deps] [--browsers chromium,webkit,firefox]
@@ -119,8 +125,8 @@ Run
119
125
  devices [--json] Device presets for viewports: (phones, foldables, tablets, laptop, desktop, TV)
120
126
  open --step <id> [--locale l] Navigate the current page to the step URL
121
127
  session reset New visitor: clear cookies and storage in the shared browser
122
- capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b]
123
- Capture the current page for a step in all viewports
128
+ capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b] [--state <name>]
129
+ Capture the current page for a step in all viewports (--state: as that step state)
124
130
  capture --step <id> --crop <css> [--title <text>]
125
131
  Attach an element screenshot to a step
126
132
  capture --step <id> --image <shot.png> [--tree <dump.xml|json> | --ocr] [--platform android|ios|desktop]
@@ -305,9 +311,11 @@ Design (environments with kind: design; live HTML steps in a folder)
305
311
  sync [--pull|--push] [--branch b] [--mine a,b|--theirs a,b] [--no-lint]
306
312
  Exchange revisions with the shared draft; only changed steps and missing files travel
307
313
  Lints the folder first: errors (things that break on the live step) stop the push
308
- design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--json]
314
+ design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]
309
315
  Check the folder against the portable HTML rules (relative URLs, no CDNs, no storage,
310
- deterministic content, viewport, lang, links that also work from disk); --strict fails on warnings
316
+ deterministic content, viewport, lang, links that also work from disk); --strict fails on warnings.
317
+ Fonts, styles and images from origins of the environment are compared with the
318
+ organization's allowed origins (--offline skips that)
311
319
  share [--name <text>] [--branch b] [--notes file|-] [--notes-preview]
312
320
  Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged",
313
321
  with "what changed" notes per step (screens per viewport, text, tokens); --notes-preview
@@ -319,7 +327,7 @@ Design (environments with kind: design; live HTML steps in a folder)
319
327
  a folder exported from Figma works without a token. One way: no prototype links
320
328
  history [--scenario id] [--step id] [--locale l] [--json]
321
329
  Versions of the flow; revisions of a step with who (person or agent) and when
322
- spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]
330
+ spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--state s] [--local] [--json]
323
331
  What to build for a step as approved: elements with role, name and selector, styles
324
332
  mapped to DTCG tokens (or the nearest one), :hover/:focus/:active/:disabled, contrast
325
333
  drift [step] [--ref design|vN] [--remote <run id|latest>] [--json]
@@ -449,12 +457,27 @@ async function runContext (args) {
449
457
  }
450
458
 
451
459
  function stepOf (scenario, id) {
452
- if (!id) throw new UserError('--step <id> is required.')
460
+ if (!id) throw new UserError(`Which step? Pass --step <id>; this scenario has: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}.`)
453
461
  const node = scenario.nodes.find((n) => n.id === id)
454
462
  if (!node) throw new UserError(`Unknown step "${id}". Steps: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}`)
455
463
  return node
456
464
  }
457
465
 
466
+ /**
467
+ * Font and asset origins for `kaloko design lint` (#46): `wanted` from `origins` of the design environment, and with
468
+ * `online` the organization's list from the service (GET /me design_origins; null when not signed in or unreachable).
469
+ */
470
+ async function lintOrigins (config, env, { online = false } = {}) {
471
+ let wanted = []
472
+ try { wanted = cleanAssetOrigins(env?.origins || []) } catch (e) { throw new UserError(`Environment ${env.name}, origins: ${e.message}`) }
473
+ if (!online || !wanted.length) return { wanted, allowed: null }
474
+ let timer
475
+ try {
476
+ const me = await Promise.race([service(config).request('GET', '/me'), new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 8000) })])
477
+ return Array.isArray(me?.design_origins) ? { wanted, allowed: me.design_origins } : { wanted, allowed: null, unchecked: true }
478
+ } catch { return { wanted, allowed: null, unchecked: true } } finally { clearTimeout(timer) }
479
+ }
480
+
458
481
  /** Scenario of --scenario (id or file) or of the current run. */
459
482
  /**
460
483
  * kaloko design import figma <file-url|key|folder>: Figma frames (or a folder exported from Figma) become the steps of
@@ -486,7 +509,7 @@ async function designImport (args) {
486
509
  if (args.json) say(JSON.stringify(r, null, 2))
487
510
  else say(formatImport(r, { dir: relative(process.cwd(), dir) || '.' }))
488
511
  if (args['dry-run']) return r
489
- const lint = await lintDesign({ dir, scenario })
512
+ const lint = await lintDesign({ dir, scenario, origins: await lintOrigins(config, env) })
490
513
  if (lint.errors) say(`\n${formatLint(lint, { max: 10 })}`)
491
514
  if (!args.publish) {
492
515
  say(`\nNext: kaloko start --scenario ${relative(process.cwd(), scenarioFile)} --env ${env.name} && kaloko walk && kaloko share (or rerun with --publish).\nAccept the version on the canvas and set it as the design reference (kaloko reference --design --version <n>): implementation runs with packs: [design] are then checked against these frames.`)
@@ -519,6 +542,24 @@ function manualKeys (run, pick) {
519
542
  return scopeKeys(scope, keys.length ? keys : engineKeys(scope, 'chromium'), null)
520
543
  }
521
544
 
545
+ /** Modes of a design environment's token set (tokens.resolver.json or tokens.json); [] without one. */
546
+ async function designTokenModesOf (config, root, env) {
547
+ try {
548
+ const { loadTokens } = await import('./lib/drift.mjs')
549
+ const { modesOf } = await import('./lib/tokens.mjs')
550
+ const set = await loadTokens({ client: null, config, root, designDir: designDir(env, root) })
551
+ return set ? modesOf(set) : []
552
+ } catch { return [] }
553
+ }
554
+
555
+ /** --state <name> (#46): the keys of that state of the step instead of the default ones. */
556
+ function inState (run, node, keys, state) {
557
+ if (!state || state === true || state === 'default') return keys
558
+ const have = nodeStates(run.meta.scope, node)
559
+ if (!have.includes(state)) throw new UserError(`Step ${node.id} has no state "${state}" in this run (${have.length > 1 ? `its states: ${have.join(', ')}` : 'add states: to the step and start a new run'}).`)
560
+ return Object.fromEntries(stateKeys(run.meta.scope, Object.keys(keys), state).map((k) => [k, run.meta.scope.viewports[k]]))
561
+ }
562
+
522
563
  /** { key: [w, h] } a capture on a Chromium page takes: every Chromium key of the run, or those of `pick`. */
523
564
  function scopeViewports (run, pick) {
524
565
  return scopeKeys(run.meta.scope, engineKeys(run.meta.scope, 'chromium'), pick)
@@ -655,6 +696,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
655
696
  page.on('response', onResponse)
656
697
  const streak = failureStreak(5)
657
698
  const failed = []; let ok = 0
699
+ const started = Date.now(); let told = started
658
700
  try {
659
701
  for (const [i, item] of chosen.entries()) {
660
702
  if (streak.stopped('stack')) { failed.push(`${item.id}: not walked after repeated failures`); continue }
@@ -671,7 +713,8 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
671
713
  failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
672
714
  if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
673
715
  }
674
- if ((i + 1) % 25 === 0 && i + 1 < chosen.length) say(` ${node.id} ${locale}: ${i + 1}/${chosen.length}`)
716
+ // long stacks: progress every 25 pages (or 30 s) with the time left, estimated from the pages so far
717
+ if (i + 1 < chosen.length && ((i + 1) % 25 === 0 || Date.now() - told > 30000)) { told = Date.now(); const left = timeLeft(i + 1, chosen.length, told - started); say(` ${node.id} ${locale}: ${i + 1}/${chosen.length}${left ? ` · ${left}` : ''}`) }
675
718
  }
676
719
  } finally { page.off('response', onResponse) }
677
720
  say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}${tag}: ${ok}/${chosen.length} page(s) captured`)
@@ -768,15 +811,17 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
768
811
  if (!scripted && !(link && ['playwright', 'script'].includes(node.execution?.strategy))) { pending.push(`${node.id} (${locale}): ${node.execution?.strategy || 'agent'} — ${(node.execution?.instructions || '').split('\n')[0]}`); continue }
769
812
  const streakKey = `${group.device || ''}|${locale}`
770
813
  if (streak.stopped(streakKey)) { skipped.push(`${node.id} (${locale}${tag})`); continue }
771
- const records = []
814
+ let records = []
815
+ // step states (#46): the script (or the deep link) runs once per state; `state` tells the script which
816
+ let state = 'default'
772
817
  try {
773
818
  let captured = false
774
819
  await rec?.beforeStep(node)
775
820
  const capture = async (o = {}) => {
776
821
  captured = true
777
- await rec?.beforeCapture(node)
822
+ if (state === 'default') await rec?.beforeCapture(node)
778
823
  // a named viewport: within this device, or any of the run when it has no devices (as before)
779
- const keys = o.viewport ? pickKeys(scope, group.device ? group.keys : Object.keys(scope.viewports), [o.viewport]) : group.keys
824
+ const keys = stateKeys(scope, o.viewport ? pickKeys(scope, group.device ? group.keys : Object.keys(scope.viewports), [o.viewport]) : group.keys, state)
780
825
  if (!keys.length) throw new UserError(`Unknown viewport "${o.viewport}" for ${group.device || platform}.`)
781
826
  const out = []
782
827
  for (const key of keys) {
@@ -799,14 +844,25 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
799
844
  records.push(...out)
800
845
  return out.length === 1 ? out[0] : out
801
846
  }
802
- if (scripted) {
803
- const mod = await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href)
804
- await mod.default({ app: driver, page: driver.page || null, webview: driver.webview || null, locale, node: { id: node.id, title: localized(node.title, locale), kind: node.kind }, device: group.device, env, run: run.meta, capture, link: (id = node.id) => linkFor(env, stepOf(scenario, id), locale), url: (id = node.id) => linkFor(env, stepOf(scenario, id), locale) })
805
- } else await driver.link(link)
806
- if (!captured) await capture()
847
+ const mod = scripted ? await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href) : null
848
+ const once = async () => {
849
+ captured = false
850
+ if (mod) await mod.default({ app: driver, page: driver.page || null, webview: driver.webview || null, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(scope, node) } : {}) }, device: group.device, env, run: run.meta, capture, link: (id = node.id) => linkFor(env, stepOf(scenario, id), locale), url: (id = node.id) => linkFor(env, stepOf(scenario, id), locale) })
851
+ else await driver.link(link)
852
+ if (!captured) await capture()
853
+ }
854
+ await once()
807
855
  await rec?.afterStep(node, driver.web ? { records } : { record: records[0] })
808
856
  say(`✓ ${node.id} ${locale}${tag}`)
809
857
  streak.ok(streakKey)
858
+ for (const s of nodeStates(scope, node).slice(1)) {
859
+ state = s; records = []
860
+ try { await once(); say(`✓ ${node.id} ${locale}${tag} · ${s}`) } catch (error) {
861
+ failed.push(`${node.id} (${locale}${tag} · ${s}): ${error.message.split('\n')[0]}`)
862
+ say(`✗ ${node.id} ${locale}${tag} · ${s}: ${error.message.split('\n')[0]}`)
863
+ }
864
+ }
865
+ state = 'default'
810
866
  } catch (error) {
811
867
  await rec?.afterStep(node, driver.web ? { error, records } : { error, record: records[0] }).catch(() => {})
812
868
  failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
@@ -1022,7 +1078,9 @@ const commands = {
1022
1078
  if (scenario.environments && !scenario.environments.includes(env.name)) throw new UserError(`Scenario ${scenario.id} is not allowed on ${env.name} (allowed: ${scenario.environments.join(', ')}).`)
1023
1079
  if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
1024
1080
  const locales = list(args.locales) || scenario.locales || ['default']
1025
- const scope = { ...startScope({ args, scenario, config, env }), ...(args.note ? { note: args.note } : {}) }
1081
+ // design environments render every mode of their token set (#46): the modes come from the folder's set
1082
+ const tokenModes = env.kind === 'design' ? await designTokenModesOf(config, root, env) : []
1083
+ const scope = { ...startScope({ args, scenario, config, env, tokenModes }), ...(args.note ? { note: args.note } : {}) }
1026
1084
  scope.locales = locales
1027
1085
  const vpNames = baseViewports(scope)
1028
1086
  const git = await gitMeta(root)
@@ -1101,7 +1159,8 @@ const commands = {
1101
1159
  }
1102
1160
  if (isApp(scenario.platform)) {
1103
1161
  // the app's current screen, through the environment's driver (no navigation); --viewport may name a device key
1104
- const key = typeof args.viewport === 'string' ? args.viewport : Object.keys(run.meta.scope.viewports || {})[0]
1162
+ const key0 = typeof args.viewport === 'string' ? args.viewport : Object.keys(run.meta.scope.viewports || {})[0]
1163
+ const key = Object.keys(inState(run, node, { [key0]: run.meta.scope.viewports[key0] }, args.state))[0] || key0
1105
1164
  const device = run.meta.scope.devices?.[variantOf(run.meta.scope, key).viewport]?.app ? variantOf(run.meta.scope, key).viewport : null
1106
1165
  const cfg = deviceConfig(scenario.platform, env.app?.[scenario.platform] || {}, device)
1107
1166
  const driver = driverFor(scenario.platform, { ...env, app: { ...(env.app || {}), [scenario.platform]: cfg } }, { root, scenarioDir: resolve(root, run.meta.scenario_file, '..'), locale })
@@ -1135,7 +1194,7 @@ const commands = {
1135
1194
  return
1136
1195
  }
1137
1196
  const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
1138
- const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: manualKeys(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 })
1197
+ const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: inState(run, node, manualKeys(run, list(args.viewports)), args.state), 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 })
1139
1198
  for (const r of results) {
1140
1199
  const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
1141
1200
  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}` : ''}`)
@@ -1193,8 +1252,8 @@ const commands = {
1193
1252
  }
1194
1253
  }
1195
1254
  if (env.kind === 'design') {
1196
- await walkDesign({ config, root, run, scenario, env, pick: list(args.steps), locales: list(args.locales) || run.meta.scope.locales, viewports: scopeViewports(run, list(args.viewports)), say, noCache: !!args['no-cache'] })
1197
- const lint = await lintDesign({ dir: designDir(env, root), scenario })
1255
+ await walkDesign({ config, root, run, scenario, env, pick: list(args.steps), locales: list(args.locales) || run.meta.scope.locales, viewports: scopeKeys(run.meta.scope, engineKeys(run.meta.scope, 'chromium', '*'), list(args.viewports)), say, noCache: !!args['no-cache'] })
1256
+ const lint = await lintDesign({ dir: designDir(env, root), scenario, origins: await lintOrigins(config, env) })
1198
1257
  if (lint.errors || lint.warnings) say(`\nPortable HTML: ${lint.errors} error(s), ${lint.warnings} warning(s); kaloko design lint lists them${lint.errors ? ' (errors stop kaloko sync: they break on the live step)' : ''}.`)
1199
1258
  return
1200
1259
  }
@@ -1337,22 +1396,32 @@ const commands = {
1337
1396
  const scriptFile = resolve(scenarioDir, node.execution.script)
1338
1397
  // one invocation of the step script; the automatic capture runs unless the script captured the step itself:
1339
1398
  // a crop is an attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
1340
- const rec = await recorderFor(sPage)
1341
- const runStep = async (mod) => {
1399
+ const rec0 = await recorderFor(sPage)
1400
+ // step states (#46): the script runs once per state with `state`; each run captures that state's keys
1401
+ // (the recording follows the default state)
1402
+ const runStep = async (mod, state = 'default') => {
1342
1403
  let captured = false
1404
+ const rec = state === 'default' ? rec0 : null
1343
1405
  rec?.beforeStep(node)
1344
1406
  const records = []
1345
- const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeKeys(run.meta.scope, pass.keys, opts.viewports || null), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, 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); records.push(...r); return r }) }
1407
+ const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeKeys(run.meta.scope, stateKeys(run.meta.scope, Object.keys(scopeKeys(run.meta.scope, pass.keys, opts.viewports || null)), state)), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, 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); records.push(...r); return r }) }
1346
1408
  try {
1347
- 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) })
1409
+ await mod.default({ page: sPage, context: sCtx, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(run.meta.scope, node) } : {}) }, 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) })
1348
1410
  if (!captured) await capture()
1349
1411
  } catch (error) { await rec?.afterStep(node, { error }); throw error }
1350
1412
  await rec?.afterStep(node, { records })
1351
1413
  }
1352
1414
  try {
1353
- await runStep(await import(pathToFileURL(scriptFile).href))
1415
+ const mod = await import(pathToFileURL(scriptFile).href)
1416
+ await runStep(mod)
1354
1417
  say(`✓ ${node.id} ${locale}${tag}`)
1355
1418
  streak.ok(streakKey)
1419
+ for (const state of nodeStates(run.meta.scope, node).slice(1)) {
1420
+ try { await runStep(mod, state); say(`✓ ${node.id} ${locale}${tag} · ${state}`) } catch (error) {
1421
+ failed.push(`${node.id} (${locale}${tag} · ${state}): ${error.message.split('\n')[0]}`)
1422
+ say(`✗ ${node.id} ${locale}${tag} · ${state}: ${error.message.split('\n')[0]}`)
1423
+ }
1424
+ }
1356
1425
  } catch (error) {
1357
1426
  // a selector the page no longer has: propose one from the page (#13); --heal applies it once the step passes
1358
1427
  const healed = args['no-heal'] ? null : await healStep({ error, page: sPage, scriptFile, root, output: config.output, run, node, locale, apply: !!args.heal, rerun: runStep }).catch(() => null)
@@ -1792,7 +1861,7 @@ const commands = {
1792
1861
  const client = service(config)
1793
1862
  if (env.kind === 'design' && !args.pull && !args['no-lint']) {
1794
1863
  // what renders locally may still break on the live step (CSP, sandbox): say so before anything is pushed
1795
- const lint = await lintDesign({ dir: designDir(env, root), scenario })
1864
+ const lint = await lintDesign({ dir: designDir(env, root), scenario, origins: await lintOrigins(config, env, { online: true }) })
1796
1865
  if (lint.errors || lint.warnings) say(formatLint(lint, { max: 15 }) + '\n')
1797
1866
  if (lint.errors) throw new UserError(`${lint.errors} error(s) above break the design on the live step; fix them, or sync with --no-lint to push anyway.`)
1798
1867
  }
@@ -1801,19 +1870,19 @@ const commands = {
1801
1870
 
1802
1871
  async design (args) {
1803
1872
  if (args._[0] === 'import') return designImport(args)
1804
- if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--json]\n kaloko design import figma <file-url|key|export folder> [--frames a,b] [--map frames.json] [--token-env FIGMA_TOKEN] [--publish]')
1873
+ if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]\n kaloko design import figma <file-url|key|export folder> [--frames a,b] [--map frames.json] [--token-env FIGMA_TOKEN] [--publish]')
1805
1874
  const { config, root } = await context(args)
1806
1875
  let run = null
1807
1876
  try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
1808
1877
  let dir = typeof args.dir === 'string' ? resolve(args.dir) : null
1878
+ const runEnv = run ? environment(config, run.meta.environment.name) : null
1879
+ const env = typeof args.env === 'string' ? environment(config, args.env) : runEnv?.kind === 'design' ? runEnv : Object.keys(config.environments || {}).map((n) => environment(config, n)).find((e) => e.kind === 'design')
1809
1880
  if (!dir) {
1810
- const runEnv = run ? environment(config, run.meta.environment.name) : null
1811
- const env = typeof args.env === 'string' ? environment(config, args.env) : runEnv?.kind === 'design' ? runEnv : Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design')
1812
1881
  if (!env) throw new UserError('No design environment: pass --dir <folder> or --env <design env>.')
1813
1882
  dir = designDir(env, root)
1814
1883
  }
1815
1884
  const scenario = typeof args.scenario === 'string' && /\.ya?ml$/.test(args.scenario) ? await loadScenario(resolve(args.scenario)) : run ? await loadScenario(resolve(root, run.meta.scenario_file)).catch(() => null) : null
1816
- const report = await lintDesign({ dir, scenario })
1885
+ const report = await lintDesign({ dir, scenario, origins: env?.kind === 'design' ? await lintOrigins(config, env, { online: !args.offline }) : null })
1817
1886
  say(args.json ? JSON.stringify(report, null, 2) : formatLint(report, { all: !!args.all, max: args.all ? 500 : 60 }))
1818
1887
  if (report.errors || (args.strict && report.warnings)) process.exitCode = 1
1819
1888
  },
@@ -2106,9 +2175,9 @@ const commands = {
2106
2175
  }
2107
2176
  let runId = args.run
2108
2177
  if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
2109
- if (!runId) throw new UserError('--run <id> is required when there is no current run.')
2178
+ if (!runId) throw new UserError('No current run here. Pass --run <id>, or walk the scenario first (kaloko start, then kaloko walk).')
2110
2179
  const run = await load(runId)
2111
- if (!run) throw new UserError(`Run ${runId} not found locally or on the service.`)
2180
+ if (!run) throw new UserError(`Run ${runId} is neither in ${config.output} nor on the service. Check the id (kaloko runs lists the local ones; the run's address on Kaloko shows a shared one).`)
2112
2181
  let baseId = typeof args.baseline === 'string' ? args.baseline : null
2113
2182
  // --design: against the design reference of the scenario (#42)
2114
2183
  if (args.design && run.scenario?.id) { client ||= service(config); baseId = (await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(run.scenario.id)}/references`)).design; if (!baseId) throw new UserError('This scenario has no design reference yet (kaloko reference --design --version <n>).') }
@@ -2120,7 +2189,7 @@ const commands = {
2120
2189
  }
2121
2190
  if (!baseId) throw new UserError('No baseline or earlier run of this scenario to compare with; pass --baseline <id>.')
2122
2191
  const base = await load(baseId)
2123
- if (!base) throw new UserError(`Run ${baseId} not found locally or on the service.`)
2192
+ if (!base) throw new UserError(`Baseline run ${baseId} is neither in ${config.output} nor on the service. Check the id, or pass another --baseline <id>.`)
2124
2193
  const d = compareRuns(run, base)
2125
2194
  if (args.json) { say(JSON.stringify(d, null, 2)); return }
2126
2195
  say(formatComparison(d))
@@ -2199,7 +2268,7 @@ const commands = {
2199
2268
  if (typeof args.scenario === 'string') return exportScenario({ args, config, root, client })
2200
2269
  if (typeof args.format === 'string') {
2201
2270
  // living docs and the changelog as files (docs/DOCS-CHANGELOG.md → Exports)
2202
- if (!config.org) throw new UserError('Set org: in the config first.')
2271
+ if (!config.org) throw new UserError(NO_ORG)
2203
2272
  const { pickProduct } = await import('./lib/changelog.mjs')
2204
2273
  const { exportDocs } = await import('./lib/docs.mjs')
2205
2274
  const product = pickProduct(config, root, args.product)
@@ -2220,7 +2289,7 @@ const commands = {
2220
2289
  }
2221
2290
  let runId = args.run
2222
2291
  if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
2223
- if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id> is required (a shared run id).')
2292
+ if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('Which run? Pass --run <id> with the id of a shared run (kaloko share prints it), or run this where kaloko start made a current run.')
2224
2293
  const out = typeof args.out === 'string' ? resolve(args.out) : resolve(root, config.output, `${runId}.zip`)
2225
2294
  const { createWriteStream } = await import('node:fs')
2226
2295
  const { Readable } = await import('node:stream')
@@ -2236,7 +2305,7 @@ const commands = {
2236
2305
  const client = service(config)
2237
2306
  let runId = args.run
2238
2307
  if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
2239
- if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id> is required (a shared run id).')
2308
+ if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('Which run? Pass --run <id> with the id of a shared run (kaloko share prints it), or run this where kaloko start made a current run.')
2240
2309
  const r = await client.request('POST', `/orgs/${config.org}/runs/${runId}/public`, { public: !args.off })
2241
2310
  say(r.public ? `Public: ${r.url} (readable without sign-in; only one public run per scenario)` : `Private again: ${runId}`)
2242
2311
  },
@@ -2284,7 +2353,7 @@ const commands = {
2284
2353
  const product = pickProduct(config, root, args.product)
2285
2354
  const version = typeof args.version === 'string' ? args.version : 'latest'
2286
2355
  if (sub === 'publish') {
2287
- if (!config.org && !args['dry-run']) throw new UserError('Set org: in the config first.')
2356
+ if (!config.org && !args['dry-run']) throw new UserError(NO_ORG)
2288
2357
  const r = await docs.publishDocs({ root, config, product, client: args['dry-run'] ? null : service(config), version, reviewed: !!args.reviewed, dryRun: !!args['dry-run'] })
2289
2358
  if (args.json) { say(JSON.stringify(r, null, 2)); return }
2290
2359
  if (r.dry_run) { say(`${product.slug}: ${r.pages} page(s) from ${r.dir} would go to Kaloko as ${r.version} (${r.languages.join(', ')}); dry run, nothing sent.${r.broken.length ? `\n ! broken step references: ${r.broken.join('; ')}` : ''}`); return }
@@ -2312,12 +2381,12 @@ const commands = {
2312
2381
  const { config, root } = await context(args)
2313
2382
  const lib = await import('./lib/changelog.mjs')
2314
2383
  if (sub === 'list') {
2315
- if (!config.org) throw new UserError('Set org: in the config first.')
2384
+ if (!config.org) throw new UserError(NO_ORG)
2316
2385
  const r = await lib.listProducts({ root, config, client: service(config) })
2317
2386
  say(args.json ? JSON.stringify(r, null, 2) : lib.formatList(r))
2318
2387
  return
2319
2388
  }
2320
- if (!config.org && !args['dry-run']) throw new UserError('Set org: in the config first.')
2389
+ if (!config.org && !args['dry-run']) throw new UserError(NO_ORG)
2321
2390
  const r = await lib.syncProducts({ root, config, client: args['dry-run'] ? null : service(config), only: typeof args.product === 'string' ? args.product : null, dryRun: !!args['dry-run'] })
2322
2391
  say(args.json ? JSON.stringify(r, null, 2) : lib.formatSync(r))
2323
2392
  if (r.products.some((p) => p.ok === false)) process.exitCode = 1
@@ -2326,7 +2395,7 @@ const commands = {
2326
2395
  /** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
2327
2396
  async release (args) {
2328
2397
  const { config, root } = await context(args)
2329
- if (!config.org) throw new UserError('Set org: in the config first.')
2398
+ if (!config.org) throw new UserError(NO_ORG)
2330
2399
  const { pickProduct, releaseProduct } = await import('./lib/changelog.mjs')
2331
2400
  const product = pickProduct(config, root, args.product)
2332
2401
  const client = args['dry-run'] ? null : service(config)
@@ -2360,10 +2429,13 @@ export async function main (argv) {
2360
2429
  const args = parse(argv)
2361
2430
  const name = args._.shift() || 'help'
2362
2431
  const cmd = commands[name] || (args.help ? commands.help : null)
2363
- if (!cmd) { console.error(`Unknown command "${name}".\n`); say(HELP); process.exitCode = 2; return }
2432
+ if (!cmd) { console.error(`There is no command "${name}". The commands are below; kaloko help <command> explains one.\n`); say(HELP); process.exitCode = 2; return }
2364
2433
  try {
2365
2434
  await cmd(args)
2366
2435
  } catch (error) {
2367
- if (error instanceof UserError) { console.error(error.message); process.exitCode = 1 } else throw error
2436
+ if (error instanceof UserError) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = 1 } else {
2437
+ console.error(`Kaloko stopped on an unexpected error (a bug on our side). Send the output below to ${SUPPORT_EMAIL} with the command you ran.`)
2438
+ throw error
2439
+ }
2368
2440
  }
2369
2441
  }
@@ -167,7 +167,7 @@ export function totp (base32, { step = 30, digits = 6, now = Date.now() } = {})
167
167
  let bits = ''
168
168
  for (const ch of base32.replace(/[\s=]/g, '').toUpperCase()) {
169
169
  const v = alphabet.indexOf(ch)
170
- if (v < 0) throw new UserError('The TOTP secret is not base32.')
170
+ if (v < 0) throw new UserError('The TOTP secret is not base32 (letters A–Z and digits 2–7). Copy the secret again from the setup screen of the account, not the six-digit code.')
171
171
  bits += v.toString(2).padStart(5, '0')
172
172
  }
173
173
  const key = Buffer.from((bits.match(/.{8}/g) || []).map((b) => parseInt(b, 2)))
@@ -78,7 +78,7 @@ function android (cfg, ctx) {
78
78
  platform: 'android',
79
79
  async open ({ locale, reset, attach = false } = {}) {
80
80
  const { stdout } = await adb(['get-state']).catch((e) => { throw new UserError(`No Android device: ${e.message}. Start an emulator or connect a device (adb devices).`) })
81
- if (!/device/.test(stdout)) throw new UserError('The Android device is not ready (adb get-state).')
81
+ if (!/device/.test(stdout)) throw new UserError('The Android device is connected but not ready (adb get-state). Unlock it and allow USB debugging, or wait until the emulator has booted, then run the command again.')
82
82
  const dens = (await shell('wm', 'density')).stdout.match(/(\d+)\s*$/m)
83
83
  density = dens ? Number(dens[1]) / 160 : 1
84
84
  if (!cfg.device) device = (await shell('getprop', 'ro.product.model')).stdout.trim() || 'android'
@@ -27,7 +27,7 @@ export function webviewSockets (procNetUnix) {
27
27
  * local port and connects Playwright over CDP. Returns { browser, page, pages, close }.
28
28
  */
29
29
  export async function attachAndroidWebView (cfg, { timeout = 15000 } = {}) {
30
- const adb = (args) => run(cfg.adb || 'adb', [...(cfg.serial ? ['-s', cfg.serial] : []), ...args], { timeout: 30000 }).then((r) => r.stdout).catch((e) => { if (e.code === 'ENOENT') throw new UserError('adb is not installed or not on PATH.'); throw e })
30
+ const adb = (args) => run(cfg.adb || 'adb', [...(cfg.serial ? ['-s', cfg.serial] : []), ...args], { timeout: 30000 }).then((r) => r.stdout).catch((e) => { if (e.code === 'ENOENT') throw new UserError('adb is not installed or not on PATH. Install Android platform-tools (kaloko doctor names the command for this system) and open a new terminal.'); throw e })
31
31
  const end = Date.now() + timeout
32
32
  let socket = null
33
33
  while (!socket && Date.now() < end) {
@@ -43,12 +43,12 @@ export async function attachAndroidWebView (cfg, { timeout = 15000 } = {}) {
43
43
  let browser
44
44
  try { browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout }) } catch (e) {
45
45
  await adb(['forward', '--remove', `tcp:${port}`]).catch(() => {})
46
- throw new UserError(`Could not connect to the WebView (${socket.socket}): ${e.message.split('\n')[0]}`)
46
+ throw new UserError(`Could not connect to the WebView (${socket.socket}): ${e.message.split('\n')[0]}. Bring the app to the front and run the command again.`)
47
47
  }
48
48
  const pages = browser.contexts().flatMap((c) => c.pages())
49
49
  // the visible page first: a WebView app often keeps hidden ones around
50
50
  let page = pages[0] || null
51
51
  for (const p of pages) { if (await p.evaluate(() => document.visibilityState === 'visible').catch(() => false)) { page = p; break } }
52
- if (!page) { await browser.close().catch(() => {}); throw new UserError('The WebView has no page open.') }
52
+ if (!page) { await browser.close().catch(() => {}); throw new UserError('The WebView has no page open. Open the screen with the WebView in the app, then run the command again.') }
53
53
  return { browser, page, pages, socket: socket.socket, close: async () => { await browser.close().catch(() => {}); await adb(['forward', '--remove', `tcp:${port}`]).catch(() => {}) } }
54
54
  }
package/src/lib/build.mjs CHANGED
@@ -4,10 +4,9 @@ import { existsSync } from 'node:fs'
4
4
  import { join, resolve } from 'node:path'
5
5
  import { readJson, writeJson } from './util.mjs'
6
6
  import { readCaptures, runPaths, listRuns } from './run.mjs'
7
- import { latestCaptures, evaluationSource, stepIdOf, appliesOn } from './evaluate.mjs'
8
- import { fieldsOf, DEFAULT_SEMANTIC_SAMPLE } from './stack.mjs'
7
+ import { latestCaptures, evaluationSource, stepIdOf } from './evaluate.mjs'
9
8
  import { layout } from './scenario.mjs'
10
- import { assembleNode, summarize, statusOf, worst } from './manifest-core.mjs'
9
+ import { assembleNode, assembleStack, summarize } from './manifest-core.mjs'
11
10
  import { packageRoot } from './schema.mjs'
12
11
  import { variantOf } from './dimensions.mjs'
13
12
 
@@ -148,53 +147,17 @@ async function runMedia ({ run, entries }) {
148
147
  */
149
148
  async function buildStack ({ run, node, base, captures, attachments }) {
150
149
  const paths = runPaths(run.dir)
151
- const fields = fieldsOf(node).map((f) => ({ id: f.id, label: f.label || f.id }))
152
- const environment = run.meta?.environment?.name || null
153
- const crit = (node.criteria || []).map((c) => ({ id: c.id, text: c.text, type: c.check.type, pack: c.pack || null, ...(appliesOn(c, environment) ? {} : { not_applicable: 'environment' }) }))
154
- // criteria limited to other environments do not decide the status of a page
155
- const live = new Set(crit.filter((c) => !c.not_applicable).map((c) => c.id))
156
- const byItem = new Map()
150
+ const list = []
157
151
  for (const c of captures.filter((x) => x.node === node.id && x.item)) {
158
152
  const cap = await readJson(join(run.dir, c.file))
159
- const evPath = paths.step(stepIdOf(c), c.locale, c.viewport, 'eval.json')
160
- const stored = await readJson(evPath, null)
153
+ const stored = await readJson(paths.step(stepIdOf(c), c.locale, c.viewport, 'eval.json'), null)
161
154
  const ev = stored?.source === evaluationSource(node, cap) ? stored : null
162
- let it = byItem.get(c.item)
163
- if (!it) { it = { id: c.item, path: cap.item?.path || null, url: cap.url, title: cap.item?.title || cap.fields?.title || cap.metadata?.title || null, fields: cap.fields || {}, captures: {}, results: {}, order: byItem.size }; byItem.set(c.item, it) }
164
- ;(it.captures[c.locale] ||= {})[c.viewport] = { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html, url: cap.url, size: cap.size ? [cap.size.width, cap.size.height] : null, boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 10), overflow: (cap.overflow || []).length, meta: cap.meta, ...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.capture?.full_page === false ? { first_screen: true } : {}), eval: ev ? `steps/${stepIdOf(c)}/${c.locale}/${c.viewport}.eval.json` : null }
165
- it.results[`${c.locale}|${c.viewport}`] = ev ? Object.fromEntries(ev.criteria.map((r) => [r.id, [r.verdict, r.evidence ? String(r.evidence).slice(0, 200) : null, r.score ?? null]])) : Object.fromEntries(crit.filter((x) => x.type !== 'semantic').map((x) => [x.id, ['not_evaluated', null, null]]))
166
- if (!ev) it.stale = true
167
- }
168
- const items = [...byItem.values()].sort((a, b) => a.order - b.order).map(({ order, ...it }) => {
169
- const per = Object.values(it.results)
170
- const verdictOf = (id) => worst(per.map((r) => r[id]?.[0]).filter(Boolean))
171
- const verdicts = crit.filter((c) => live.has(c.id)).map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
172
- const status = statusOf(verdicts, per.length > 0)
173
- const by_locale = Object.fromEntries(Object.keys(it.captures).map((l) => [l, statusOf(Object.entries(it.results).filter(([k]) => k.startsWith(`${l}|`)).flatMap(([, r]) => Object.entries(r).filter(([id]) => live.has(id)).map(([, x]) => x[0])), true)]))
174
- return { ...it, status, by_locale, fails: crit.filter((c) => live.has(c.id) && verdictOf(c.id) === 'fail').map((c) => c.id), review: crit.filter((c) => live.has(c.id) && ['needs_review', 'not_evaluated'].includes(verdictOf(c.id))).map((c) => c.id) }
175
- })
176
- const summary = { pass: 0, partial: 0, fail: 0, skip: 0 }
177
- for (const it of items) summary[it.status]++
178
- const criteria = crit.map((c) => {
179
- const counts = {}
180
- for (const it of items) { const v = worst(Object.values(it.results).map((r) => r[c.id]?.[0]).filter(Boolean)); if (v !== 'not_reached') counts[v] = (counts[v] || 0) + 1 }
181
- return { ...c, counts, verdict: counts.fail ? 'fail' : counts.needs_review ? 'needs_review' : counts.not_evaluated ? 'not_evaluated' : counts.pass ? 'pass' : 'not_reached', by_locale: {} }
182
- })
183
- const semanticSample = node.items?.semantic_sample ?? DEFAULT_SEMANTIC_SAMPLE
184
- const file = `steps/${node.id}/items.json`
185
- await writeJson(join(run.dir, file), { schema: 'qawalk.stack.v1', node: node.id, fields, criteria: crit, semantic_sample: semanticSample, items })
186
- const thumbOf = (it) => { const c = Object.values(it.captures)[0]; const v = c && Object.values(c)[0]; return v ? v.thumb || v.png : null }
187
- const sample = [...items.filter((it) => it.status === 'fail'), ...items.filter((it) => it.status !== 'fail')].slice(0, 4).map((it) => ({ id: it.id, title: it.title, path: it.path, status: it.status, thumb: thumbOf(it) }))
188
- const status = !items.length ? 'skip' : summary.fail ? 'fail' : summary.partial || summary.skip ? 'partial' : 'pass'
189
- const locales = [...new Set(items.flatMap((it) => Object.keys(it.by_locale)))]
190
- const by_locale = Object.fromEntries(locales.map((l) => { const s = items.map((it) => it.by_locale[l]).filter(Boolean); return [l, s.includes('fail') ? 'fail' : s.some((x) => x !== 'pass') ? 'partial' : 'pass'] }))
191
- const src = node.items || {}
192
- return {
193
- ...base, status, by_locale, captures: {}, criteria, semantic: {}, metadata: {}, session: node.session || null,
194
- execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : { strategy: 'playwright', instructions: 'Open every page of the stack and capture it.' },
195
- 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 })),
196
- 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 }
155
+ // a variant of a design stack (#61) opens live in the lightbox: its page in the step's revision
156
+ list.push({ item: c.item, locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html, eval: `steps/${stepIdOf(c)}/${c.locale}/${c.viewport}.eval.json` }, ...(cap.live ? { live: cap.live } : {}) })
197
157
  }
158
+ const { node: out, index, file } = assembleStack({ node, base, environment: run.meta?.environment?.name || null, captures: list, attachments })
159
+ await writeJson(join(run.dir, file), index)
160
+ return out
198
161
  }
199
162
 
200
163
  /**
@@ -165,7 +165,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
165
165
  }
166
166
  }
167
167
  // the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
168
- if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens, scope?.variants?.[viewport]?.color_scheme || null)
168
+ if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens, scope?.variants?.[viewport]?.token_mode || scope?.variants?.[viewport]?.color_scheme || null)
169
169
  // regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
170
170
  const ignore = await resolveIgnore(page, node, locale, viewport)
171
171
  // what moves while the page sits still (walk --stability N): repeated screenshots, compared here
@@ -8,7 +8,7 @@ import { promisify } from 'node:util'
8
8
  import { join, resolve, relative, dirname, basename } from 'node:path'
9
9
  import { UserError, sha256 } from './util.mjs'
10
10
  import { repoSlug } from './git.mjs'
11
- import { cleanVersion, compareVersions, tagFor, versionOfTag, latestVersionIn, sectionOf, applyDraft, finalizeRelease, draftEntry, groupsMarkdown, parseEntry, findStepRefs, CHANGELOG_SKELETON } from './changelog-core.mjs'
11
+ import { cleanVersion, compareVersions, tagFor, versionOfTag, latestVersionIn, sectionOf, applyDraft, finalizeRelease, draftEntry, parseEntry, findStepRefs, CHANGELOG_SKELETON } from './changelog-core.mjs'
12
12
 
13
13
  const run = promisify(execFile)
14
14
  async function out (cmd, args, cwd, timeout = 20000) {
@@ -236,7 +236,7 @@ export async function releaseProduct ({ root, config, product, version = null, d
236
236
  const src = await detectVersionSource(root, product)
237
237
  const v = cleanVersion(version || src.version || '')
238
238
  if (!v) throw new UserError(`Which version? Pass it (kaloko release 2.4.0); ${src.source === 'git-tag' ? `no tag matches ${src.pattern}` : `${src.path || src.source} names none`}.`)
239
- if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,79}$/.test(v)) throw new UserError(`Not a version: ${v}`)
239
+ if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,79}$/.test(v)) throw new UserError(`${v} is not a version Kaloko can use: letters, digits, dots, + and -, at most 80 characters (e.g. 2.4.0).`)
240
240
  const notes = []
241
241
  if (src.version && src.version !== v && src.source !== 'git-tag' && src.source !== 'semantic-release') notes.push(`${src.tool || src.source} (${src.path}) says ${src.version}; recording ${v} as you asked.`)
242
242
  const file = changelogFile(root, product)