kaloko 0.9.0 → 0.11.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.
package/src/cli.mjs CHANGED
@@ -9,7 +9,7 @@ import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FI
9
9
  import { loadScenario, parseScenario, validateScenario, scenarioWarnings } from './lib/scenario.mjs'
10
10
  import { createRun, resolveRun, readCaptures, listRuns } from './lib/run.mjs'
11
11
  import { gitMeta, projectAndIssue, appVersion } from './lib/git.mjs'
12
- import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
12
+ import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, launchWithRetry, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
13
13
  import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
14
14
  import { observeConsole } from './lib/console.mjs'
15
15
  import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.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'
@@ -113,6 +114,9 @@ Run
113
114
  More dimensions: every step is captured in each combination (keys like
114
115
  mobile.webkit.dark); default from the scenario, then walk: in the config
115
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)
116
120
  browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
117
121
  Shared Chromium with CDP (agents, scripts and people drive it)
118
122
  browser install [--with-deps] [--browsers chromium,webkit,firefox]
@@ -121,8 +125,8 @@ Run
121
125
  devices [--json] Device presets for viewports: (phones, foldables, tablets, laptop, desktop, TV)
122
126
  open --step <id> [--locale l] Navigate the current page to the step URL
123
127
  session reset New visitor: clear cookies and storage in the shared browser
124
- capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b]
125
- 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)
126
130
  capture --step <id> --crop <css> [--title <text>]
127
131
  Attach an element screenshot to a step
128
132
  capture --step <id> --image <shot.png> [--tree <dump.xml|json> | --ocr] [--platform android|ios|desktop]
@@ -307,9 +311,11 @@ Design (environments with kind: design; live HTML steps in a folder)
307
311
  sync [--pull|--push] [--branch b] [--mine a,b|--theirs a,b] [--no-lint]
308
312
  Exchange revisions with the shared draft; only changed steps and missing files travel
309
313
  Lints the folder first: errors (things that break on the live step) stop the push
310
- design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--json]
314
+ design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]
311
315
  Check the folder against the portable HTML rules (relative URLs, no CDNs, no storage,
312
- 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)
313
319
  share [--name <text>] [--branch b] [--notes file|-] [--notes-preview]
314
320
  Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged",
315
321
  with "what changed" notes per step (screens per viewport, text, tokens); --notes-preview
@@ -321,7 +327,7 @@ Design (environments with kind: design; live HTML steps in a folder)
321
327
  a folder exported from Figma works without a token. One way: no prototype links
322
328
  history [--scenario id] [--step id] [--locale l] [--json]
323
329
  Versions of the flow; revisions of a step with who (person or agent) and when
324
- 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]
325
331
  What to build for a step as approved: elements with role, name and selector, styles
326
332
  mapped to DTCG tokens (or the nearest one), :hover/:focus/:active/:disabled, contrast
327
333
  drift [step] [--ref design|vN] [--remote <run id|latest>] [--json]
@@ -457,6 +463,21 @@ function stepOf (scenario, id) {
457
463
  return node
458
464
  }
459
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
+
460
481
  /** Scenario of --scenario (id or file) or of the current run. */
461
482
  /**
462
483
  * kaloko design import figma <file-url|key|folder>: Figma frames (or a folder exported from Figma) become the steps of
@@ -488,7 +509,7 @@ async function designImport (args) {
488
509
  if (args.json) say(JSON.stringify(r, null, 2))
489
510
  else say(formatImport(r, { dir: relative(process.cwd(), dir) || '.' }))
490
511
  if (args['dry-run']) return r
491
- const lint = await lintDesign({ dir, scenario })
512
+ const lint = await lintDesign({ dir, scenario, origins: await lintOrigins(config, env) })
492
513
  if (lint.errors) say(`\n${formatLint(lint, { max: 10 })}`)
493
514
  if (!args.publish) {
494
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.`)
@@ -521,6 +542,24 @@ function manualKeys (run, pick) {
521
542
  return scopeKeys(scope, keys.length ? keys : engineKeys(scope, 'chromium'), null)
522
543
  }
523
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
+
524
563
  /** { key: [w, h] } a capture on a Chromium page takes: every Chromium key of the run, or those of `pick`. */
525
564
  function scopeViewports (run, pick) {
526
565
  return scopeKeys(run.meta.scope, engineKeys(run.meta.scope, 'chromium'), pick)
@@ -772,15 +811,17 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
772
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 }
773
812
  const streakKey = `${group.device || ''}|${locale}`
774
813
  if (streak.stopped(streakKey)) { skipped.push(`${node.id} (${locale}${tag})`); continue }
775
- 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'
776
817
  try {
777
818
  let captured = false
778
819
  await rec?.beforeStep(node)
779
820
  const capture = async (o = {}) => {
780
821
  captured = true
781
- await rec?.beforeCapture(node)
822
+ if (state === 'default') await rec?.beforeCapture(node)
782
823
  // a named viewport: within this device, or any of the run when it has no devices (as before)
783
- 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)
784
825
  if (!keys.length) throw new UserError(`Unknown viewport "${o.viewport}" for ${group.device || platform}.`)
785
826
  const out = []
786
827
  for (const key of keys) {
@@ -803,14 +844,25 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
803
844
  records.push(...out)
804
845
  return out.length === 1 ? out[0] : out
805
846
  }
806
- if (scripted) {
807
- const mod = await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href)
808
- 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) })
809
- } else await driver.link(link)
810
- 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()
811
855
  await rec?.afterStep(node, driver.web ? { records } : { record: records[0] })
812
856
  say(`✓ ${node.id} ${locale}${tag}`)
813
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'
814
866
  } catch (error) {
815
867
  await rec?.afterStep(node, driver.web ? { error, records } : { error, record: records[0] }).catch(() => {})
816
868
  failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
@@ -1026,7 +1078,9 @@ const commands = {
1026
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(', ')}).`)
1027
1079
  if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
1028
1080
  const locales = list(args.locales) || scenario.locales || ['default']
1029
- 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 } : {}) }
1030
1084
  scope.locales = locales
1031
1085
  const vpNames = baseViewports(scope)
1032
1086
  const git = await gitMeta(root)
@@ -1105,7 +1159,8 @@ const commands = {
1105
1159
  }
1106
1160
  if (isApp(scenario.platform)) {
1107
1161
  // the app's current screen, through the environment's driver (no navigation); --viewport may name a device key
1108
- 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
1109
1164
  const device = run.meta.scope.devices?.[variantOf(run.meta.scope, key).viewport]?.app ? variantOf(run.meta.scope, key).viewport : null
1110
1165
  const cfg = deviceConfig(scenario.platform, env.app?.[scenario.platform] || {}, device)
1111
1166
  const driver = driverFor(scenario.platform, { ...env, app: { ...(env.app || {}), [scenario.platform]: cfg } }, { root, scenarioDir: resolve(root, run.meta.scenario_file, '..'), locale })
@@ -1139,7 +1194,7 @@ const commands = {
1139
1194
  return
1140
1195
  }
1141
1196
  const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
1142
- 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 })
1143
1198
  for (const r of results) {
1144
1199
  const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
1145
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}` : ''}`)
@@ -1197,8 +1252,8 @@ const commands = {
1197
1252
  }
1198
1253
  }
1199
1254
  if (env.kind === 'design') {
1200
- 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'] })
1201
- 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) })
1202
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)' : ''}.`)
1203
1258
  return
1204
1259
  }
@@ -1341,22 +1396,32 @@ const commands = {
1341
1396
  const scriptFile = resolve(scenarioDir, node.execution.script)
1342
1397
  // one invocation of the step script; the automatic capture runs unless the script captured the step itself:
1343
1398
  // a crop is an attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
1344
- const rec = await recorderFor(sPage)
1345
- 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') => {
1346
1403
  let captured = false
1404
+ const rec = state === 'default' ? rec0 : null
1347
1405
  rec?.beforeStep(node)
1348
1406
  const records = []
1349
- 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 }) }
1350
1408
  try {
1351
- 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) })
1352
1410
  if (!captured) await capture()
1353
1411
  } catch (error) { await rec?.afterStep(node, { error }); throw error }
1354
1412
  await rec?.afterStep(node, { records })
1355
1413
  }
1356
1414
  try {
1357
- await runStep(await import(pathToFileURL(scriptFile).href))
1415
+ const mod = await import(pathToFileURL(scriptFile).href)
1416
+ await runStep(mod)
1358
1417
  say(`✓ ${node.id} ${locale}${tag}`)
1359
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
+ }
1360
1425
  } catch (error) {
1361
1426
  // a selector the page no longer has: propose one from the page (#13); --heal applies it once the step passes
1362
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)
@@ -1598,7 +1663,7 @@ const commands = {
1598
1663
  if (!account.configured) throw new UserError(`Add the account first: environments.${env.name}.accounts.${args.account} in ${CONFIG_FILE} (it may be empty: {}).`)
1599
1664
  if (!process.stdin.isTTY) throw new UserError('kaloko auth save needs a person at the terminal: they sign in in the browser window and press Enter.')
1600
1665
  const { chromium } = await import('playwright')
1601
- const browser = await chromium.launch({ headless: false, channel: config.browser.channel || undefined })
1666
+ const browser = await launchWithRetry(chromium, { headless: false, channel: config.browser.channel || undefined })
1602
1667
  try {
1603
1668
  const page = await (await newContext(browser, env, { root, config })).newPage()
1604
1669
  await page.goto(typeof args.url === 'string' ? args.url : baseUrl(env), { waitUntil: 'domcontentloaded' })
@@ -1796,7 +1861,7 @@ const commands = {
1796
1861
  const client = service(config)
1797
1862
  if (env.kind === 'design' && !args.pull && !args['no-lint']) {
1798
1863
  // what renders locally may still break on the live step (CSP, sandbox): say so before anything is pushed
1799
- 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 }) })
1800
1865
  if (lint.errors || lint.warnings) say(formatLint(lint, { max: 15 }) + '\n')
1801
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.`)
1802
1867
  }
@@ -1805,19 +1870,19 @@ const commands = {
1805
1870
 
1806
1871
  async design (args) {
1807
1872
  if (args._[0] === 'import') return designImport(args)
1808
- 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]')
1809
1874
  const { config, root } = await context(args)
1810
1875
  let run = null
1811
1876
  try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
1812
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')
1813
1880
  if (!dir) {
1814
- const runEnv = run ? environment(config, run.meta.environment.name) : null
1815
- 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')
1816
1881
  if (!env) throw new UserError('No design environment: pass --dir <folder> or --env <design env>.')
1817
1882
  dir = designDir(env, root)
1818
1883
  }
1819
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
1820
- 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 })
1821
1886
  say(args.json ? JSON.stringify(report, null, 2) : formatLint(report, { all: !!args.all, max: args.all ? 500 : 60 }))
1822
1887
  if (report.errors || (args.strict && report.warnings)) process.exitCode = 1
1823
1888
  },
@@ -2209,7 +2274,8 @@ const commands = {
2209
2274
  const product = pickProduct(config, root, args.product)
2210
2275
  const printPdf = async (htmlFile, outFile) => {
2211
2276
  const { chromium } = await import('playwright')
2212
- const browser = await chromium.launch(config.browser?.channel ? { channel: config.browser.channel } : {}).catch(() => chromium.launch())
2277
+ // the configured channel when it is installed, else Playwright's Chromium; a browser that stalls is not retried twice over
2278
+ const browser = await launchWithRetry(chromium, config.browser?.channel ? { channel: config.browser.channel } : {}).catch((e) => e?.name === 'TimeoutError' || !config.browser?.channel ? Promise.reject(e) : launchWithRetry(chromium, {}))
2213
2279
  try {
2214
2280
  const page = await browser.newPage()
2215
2281
  await page.goto(pathToFileURL(htmlFile).href, { waitUntil: 'load', timeout: 60000 })
@@ -200,10 +200,12 @@ export async function jsValues (page, checks, timeout = JS_TIMEOUT) {
200
200
  return { type, value }
201
201
  })()`
202
202
  try {
203
+ // the page's own timer limits the expression; this one only guards against a page that stopped answering, with
204
+ // room for a slow round trip on a busy machine (a 1 s margin failed true checks under load)
203
205
  let timer
204
206
  const result = await Promise.race([
205
207
  page.evaluate(wrapped),
206
- new Promise((_, no) => { timer = setTimeout(() => no(new Error(`did not finish within ${timeout} ms`)), timeout + 1000) })
208
+ new Promise((_, no) => { timer = setTimeout(() => no(new Error(`the page did not answer within ${Math.round((timeout + 15000) / 1000)} s`)), timeout + 15000) })
207
209
  ]).finally(() => clearTimeout(timer))
208
210
  const text = JSON.stringify(result.value)
209
211
  out[id] = { expression, result: text && text.length > 2000 ? { type: result.type, value: `${text.slice(0, 2000)}…` } : result }
@@ -5,10 +5,12 @@
5
5
  import { chromium } from 'playwright'
6
6
  import { writeJson, appendJsonl } from './util.mjs'
7
7
  import { prepareContext, clientCertificates } from './access.mjs'
8
+ import { launchPersistentWithRetry } from './browser.mjs'
8
9
 
9
10
  const opts = JSON.parse(process.argv[2])
10
11
  const certs = opts.env ? clientCertificates(opts.env, opts.root) : []
11
- const context = await chromium.launchPersistentContext(opts.profile, {
12
+ // a time limit and one more try (KALOKO_LAUNCH_TIMEOUT): a start that stalls on a busy machine does not hang here
13
+ const context = await launchPersistentWithRetry(chromium, opts.profile, {
12
14
  headless: !opts.headed,
13
15
  channel: opts.channel || undefined,
14
16
  viewport: { width: 1280, height: 800 },
@@ -69,12 +69,16 @@ export async function startBrowser ({ root, config, env, headed }) {
69
69
  })
70
70
  const child = spawn(process.execPath, [join(here, 'browser-daemon.mjs'), args], { detached: true, stdio: 'ignore' })
71
71
  child.unref()
72
- for (let i = 0; i < 100; i++) {
72
+ // the daemon launches with launchLimit() and tries once more after a timeout
73
+ const wait = 2 * launchLimit() + 10000
74
+ for (const until = Date.now() + wait; Date.now() < until;) {
73
75
  await new Promise((r) => setTimeout(r, 200))
74
76
  const state = await readJson(stateFile, null)
75
77
  if (state && state.pid === child.pid) return state
78
+ if (!await alive(child.pid)) throw new UserError('Browser did not start (its process ended). Try "kaloko browser start --headed" to see what happens, or "kaloko doctor".')
76
79
  }
77
- throw new UserError('Browser did not start within 20 s. Try "kaloko browser start --headed" to see what happens.')
80
+ try { process.kill(child.pid, 'SIGTERM') } catch {} // Playwright ends the browser it was starting
81
+ throw new UserError(`Browser did not start within ${Math.round(wait / 1000)} s. Try "kaloko browser start --headed" to see what happens; on a busy machine raise KALOKO_LAUNCH_TIMEOUT (seconds).`)
78
82
  }
79
83
 
80
84
  /** Stops the browser of one environment, or every shared browser of the project. */
@@ -102,7 +106,7 @@ export async function connectBrowser ({ root, config, env }) {
102
106
  if (!state) return null
103
107
  assertPolicy(state, env)
104
108
  const { chromium } = await import('playwright')
105
- const browser = await chromium.connectOverCDP(state.cdp)
109
+ const browser = await connectWithRetry(chromium, state.cdp)
106
110
  return { browser, context: browser.contexts()[0], state }
107
111
  }
108
112
 
@@ -138,17 +142,82 @@ export async function currentPage (context, match) {
138
142
  return real[real.length - 1] || pages[pages.length - 1] || context.newPage()
139
143
  }
140
144
 
145
+ /** The time limit of a browser launch or connection: KALOKO_LAUNCH_TIMEOUT seconds, default 45. */
146
+ export const launchLimit = () => (Number(process.env.KALOKO_LAUNCH_TIMEOUT) || 45) * 1000
147
+ /** The time limit of the first context of a new browser and of every later newContext/newPage: 20 s, or KALOKO_LAUNCH_TIMEOUT when set. */
148
+ export const answerLimit = () => (Number(process.env.KALOKO_LAUNCH_TIMEOUT) > 0 ? launchLimit() : 20000)
149
+
150
+ const isTimeout = (e) => /Timeout|did not (finish|answer)/i.test(String(e?.name) + ' ' + String(e?.message))
151
+
152
+ /** `promise`, or a TimeoutError naming `what` when it has not settled within `ms`. */
153
+ export function withTimeout (promise, ms, what) {
154
+ let timer
155
+ const limit = new Promise((resolve, reject) => {
156
+ timer = setTimeout(() => reject(Object.assign(new Error(`${what} did not answer within ${Math.round(ms / 1000)} s (KALOKO_LAUNCH_TIMEOUT)`), { name: 'TimeoutError' })), ms)
157
+ })
158
+ return Promise.race([promise, limit]).finally(() => clearTimeout(timer))
159
+ }
160
+
161
+ /** Runs `attempt` and, when it timed out, once more; any other error reaches the caller. */
162
+ export async function retryOnTimeout (attempt) {
163
+ try { return await attempt() } catch (e) {
164
+ if (!isTimeout(e)) throw e
165
+ return attempt()
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Puts a time limit on newContext and newPage of a browser: a browser that stopped answering (seen on loaded
171
+ * machines: launched fine, then Target.createBrowserContext never returned) fails the command instead of holding it
172
+ * forever. A context that arrives after the limit is closed.
173
+ */
174
+ export function limitBrowser (browser, ms = answerLimit()) {
175
+ if (!browser || typeof browser.newContext !== 'function' || browser.__kalokoLimited) return browser
176
+ for (const name of ['newContext', 'newPage']) {
177
+ const call = browser[name].bind(browser)
178
+ browser[name] = (...a) => {
179
+ const pending = call(...a)
180
+ return withTimeout(pending, ms, `browser.${name}`).catch((e) => {
181
+ if (isTimeout(e)) pending.then((x) => x?.close?.().catch(() => {}), () => {})
182
+ throw e
183
+ })
184
+ }
185
+ }
186
+ browser.__kalokoLimited = true
187
+ return browser
188
+ }
189
+
141
190
  /**
142
191
  * Launches a browser with a time limit and one more try: on a busy machine a browser now and then starts but never
143
- * answers, and Playwright would wait for it forever. Playwright ends the process of a launch that timed out.
144
- * KALOKO_LAUNCH_TIMEOUT (seconds, default 45) sets the limit.
192
+ * answers, and Playwright would wait for it forever. Playwright ends the process of a launch that timed out. A
193
+ * launched browser must open a context within answerLimit(); one that does not is closed (Playwright kills it when it
194
+ * does not close within 30 s) and the launch is tried once more. KALOKO_LAUNCH_TIMEOUT (seconds, default 45).
145
195
  */
146
196
  export async function launchWithRetry (browserType, options = {}) {
147
- const timeout = (Number(process.env.KALOKO_LAUNCH_TIMEOUT) || 45) * 1000
148
- try { return await browserType.launch({ timeout, ...options }) } catch (e) {
149
- if (!/Timeout/i.test(String(e?.name) + String(e?.message))) throw e
150
- return browserType.launch({ timeout, ...options })
151
- }
197
+ const timeout = launchLimit()
198
+ return retryOnTimeout(async () => {
199
+ const browser = await browserType.launch({ timeout, ...options })
200
+ if (typeof browser?.newContext !== 'function') return browser
201
+ try {
202
+ await withTimeout(browser.newContext().then((c) => c.close()), answerLimit(), 'the new browser')
203
+ } catch (e) {
204
+ browser.close().catch(() => {}) // not awaited: the next try starts while Playwright closes or kills this one
205
+ throw e
206
+ }
207
+ return limitBrowser(browser)
208
+ })
209
+ }
210
+
211
+ /** chromium.connectOverCDP with a time limit and one more try; the browser's newContext/newPage are limited too. */
212
+ export async function connectWithRetry (browserType, endpoint, options = {}) {
213
+ const timeout = launchLimit()
214
+ return limitBrowser(await retryOnTimeout(() => browserType.connectOverCDP(endpoint, { timeout, ...options })))
215
+ }
216
+
217
+ /** chromium.launchPersistentContext with a time limit and one more try (the shared browser's daemon). */
218
+ export async function launchPersistentWithRetry (browserType, profile, options = {}) {
219
+ const timeout = launchLimit()
220
+ return retryOnTimeout(() => browserType.launchPersistentContext(profile, { timeout, ...options }))
152
221
  }
153
222
 
154
223
  export async function launchEphemeral (config) {
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