kaloko 0.4.11 → 0.5.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 +45 -4
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +42 -2
  4. package/schemas/run.v1.json +33 -2
  5. package/schemas/scenario.v1.json +107 -16
  6. package/skills/kaloko/SKILL.md +8 -2
  7. package/skills/kaloko/reference/acceptance.md +70 -1
  8. package/skills/kaloko/reference/apps.md +103 -8
  9. package/skills/kaloko/reference/ci.md +3 -1
  10. package/skills/kaloko/reference/design.md +39 -0
  11. package/skills/kaloko/reference/devices.md +109 -0
  12. package/skills/kaloko/reference/environments.md +6 -0
  13. package/skills/kaloko/reference/execution.md +6 -0
  14. package/src/cli.mjs +326 -190
  15. package/src/lib/access.mjs +5 -2
  16. package/src/lib/apps/devices.mjs +187 -0
  17. package/src/lib/apps/drivers.mjs +11 -3
  18. package/src/lib/apps/ios-device.mjs +78 -0
  19. package/src/lib/apps/tree.mjs +9 -1
  20. package/src/lib/apps/webview.mjs +54 -0
  21. package/src/lib/apps/windows.mjs +108 -0
  22. package/src/lib/apps/windows.ps1 +78 -0
  23. package/src/lib/assertions.mjs +300 -0
  24. package/src/lib/build.mjs +4 -1
  25. package/src/lib/capture.mjs +146 -119
  26. package/src/lib/ci.mjs +8 -4
  27. package/src/lib/design-cli.mjs +35 -6
  28. package/src/lib/design.mjs +3 -1
  29. package/src/lib/devices.mjs +85 -0
  30. package/src/lib/devspec.mjs +3 -2
  31. package/src/lib/dimensions.mjs +326 -0
  32. package/src/lib/doctor.mjs +31 -6
  33. package/src/lib/drift.mjs +8 -4
  34. package/src/lib/evaluate.mjs +25 -7
  35. package/src/lib/import.mjs +2 -1
  36. package/src/lib/jev.mjs +3 -1
  37. package/src/lib/manifest-core.mjs +12 -3
  38. package/src/lib/probe.mjs +60 -0
  39. package/src/lib/recheck.mjs +6 -1
  40. package/src/lib/scenario.mjs +3 -0
  41. package/src/lib/stability-core.mjs +4 -2
  42. package/src/lib/walk-dimensions.mjs +83 -0
  43. package/viewer/index.html +282 -19
package/src/cli.mjs CHANGED
@@ -51,9 +51,13 @@ import { exportDesign } from './lib/design-export.mjs'
51
51
  import { driverFor, isApp, pngSize, ocr as ocrImage } from './lib/apps/drivers.mjs'
52
52
  import { captureApp } from './lib/apps/capture.mjs'
53
53
  import { readTree, flatten } from './lib/apps/tree.mjs'
54
- import { recordSettings, recordingSession, recordingFiles, traceFiles, stripRecordings, withoutRecordings } from './lib/recording.mjs'
54
+ import { recordSettings, recordingSession, parseRecord, recordingFiles, traceFiles, stripRecordings, withoutRecordings } from './lib/recording.mjs'
55
55
  import { nativeSession } from './lib/apps/recording-native.mjs'
56
56
  import { videoCmd } from './lib/video-cli.mjs'
57
+ import { passesOf, enginesOf, contextOptionsOf, launchEngine, pickKeys, variantFields, variantOf, dimensionValues, BROWSERS } from './lib/dimensions.mjs'
58
+ import { startScope, scopeKeys, dimensionLine, baseViewports, engineKeys, appDeviceGroups } from './lib/walk-dimensions.mjs'
59
+ import { deviceConfig, prepareAndroid, readyAndroid, finishAndroid, prepareSimulator, applyAppAppearance } from './lib/apps/devices.mjs'
60
+ import { attachAndroidWebView } from './lib/apps/webview.mjs'
57
61
 
58
62
 
59
63
  const HELP = `kaloko — walk, assess and approve what your agents built, on one flow canvas
@@ -91,9 +95,19 @@ Run
91
95
  start --scenario <file> --env <name> [--locales cs,en] [--viewports desktop,mobile] [--executor <name>]
92
96
  [--app-version <v>] (default: the git tag on HEAD; documentation captures carry it)
93
97
  Create a run directory and make it current
98
+ [--viewports iphone-15,ipad-landscape,desktop] Viewport names of the scenario or device presets
99
+ (kaloko devices lists them; <preset>-landscape turns one sideways)
100
+ [--browsers chromium,webkit,firefox] [--color-schemes light,dark] [--reduced-motion no-preference,reduce]
101
+ [--forced-colors none,active] [--display-modes browser,standalone]
102
+ More dimensions: every step is captured in each combination (keys like
103
+ mobile.webkit.dark); default from the scenario, then walk: in the config
104
+ [--devices pixel-8-api-34,iphone-15-ios-18] App scenarios: the emulators, simulators or devices to walk on
94
105
  browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
95
106
  Shared Chromium with CDP (agents, scripts and people drive it)
96
- browser install [--with-deps] Download the Chromium of kaloko's Playwright (CI; KALOKO_BROWSER_CHANNEL=chromium uses it)
107
+ browser install [--with-deps] [--browsers chromium,webkit,firefox]
108
+ Download the engines of kaloko's Playwright (default Chromium; CI:
109
+ KALOKO_BROWSER_CHANNEL=chromium uses it); WebKit and Firefox for cross-browser runs
110
+ devices [--json] Device presets for viewports: (phones, foldables, tablets, laptop, desktop, TV)
97
111
  open --step <id> [--locale l] Navigate the current page to the step URL
98
112
  session reset New visitor: clear cookies and storage in the shared browser
99
113
  capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b]
@@ -115,10 +129,14 @@ Run
115
129
  A person signs in once (SSO, MFA) in a visible browser; the session is saved
116
130
  for that account and walk reuses it (never uploaded)
117
131
  auth list Saved sessions per environment and account, with their age
118
- walk [--steps a,b] [--locales l] [--keep-going] [--ephemeral]
132
+ walk [--steps a,b] [--locales l] [--keep-going] [--ephemeral] [--browsers webkit]
119
133
  Run playwright steps (execution.script) and capture them; a session stops
120
134
  after 3 steps in a row fail with the same error unless --keep-going
121
135
  One walk at a time per shared browser; --ephemeral walks in an own browser
136
+ A run with several browsers or device presets walks once per engine and device
137
+ profile; --browsers walks only those engines of the run
138
+ [--devices pixel-8-api-34] App runs walk once per device of devices: (emulators start, phones wake up);
139
+ --devices walks only those; dark mode and reduced motion are set per capture
122
140
  Semantic questions start as each step is captured; the walk ends with the
123
141
  verdicts and what failed (--no-evaluate skips that; kaloko evaluate redoes it)
124
142
  [--items a,b] [--limit N] Stack steps (kind: stack, many pages of one template): only pages whose id or
@@ -353,10 +371,20 @@ async function scenarioIdOf (args, config, root) {
353
371
  throw new UserError('--scenario <id|file> is required when there is no current run.')
354
372
  }
355
373
 
374
+ /**
375
+ * Keys a capture of the current page takes (kaloko capture): the Chromium keys without a device preset, whose
376
+ * pixel ratio, touch and agent the page does not have; a preset key only when --viewports names it.
377
+ */
378
+ function manualKeys (run, pick) {
379
+ const scope = run.meta.scope
380
+ if (pick) return scopeViewports(run, pick)
381
+ const keys = engineKeys(scope, 'chromium').filter((k) => !scope.devices?.[variantOf(scope, k).viewport]?.emulation)
382
+ return scopeKeys(scope, keys.length ? keys : engineKeys(scope, 'chromium'), null)
383
+ }
384
+
385
+ /** { key: [w, h] } a capture on a Chromium page takes: every Chromium key of the run, or those of `pick`. */
356
386
  function scopeViewports (run, pick) {
357
- const all = run.meta.scope.viewports
358
- const names = pick || Object.keys(all)
359
- return Object.fromEntries(names.map((n) => { if (!all[n]) throw new UserError(`Unknown viewport "${n}".`); return [n, all[n]] }))
387
+ return scopeKeys(run.meta.scope, engineKeys(run.meta.scope, 'chromium'), pick)
360
388
  }
361
389
 
362
390
  async function scenarioFiles (config, root) {
@@ -472,14 +500,17 @@ async function exportScenario ({ args, config, root, client }) {
472
500
  * Walks a stack step: resolves its pages, then opens and captures each one with the step's criteria and index
473
501
  * fields (HTTP status of the main document included). A step script, when given, runs per page with `item`.
474
502
  */
475
- async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, args }) {
503
+ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, args, keys = null, tag = '' }) {
476
504
  const { items, total, truncated } = await resolveItems({ node, base: baseUrl(env, locale), page, scenarioDir, env, locale, limit: Number(args.limit) || undefined })
477
505
  const only = list(args.items)
478
506
  const chosen = only ? items.filter((it) => only.some((q) => it.id === q || it.path.includes(q))) : items
479
- const scope = run.meta.scope.viewports
480
- const vpNames = (node.items.viewports || []).filter((v) => scope[v])
481
- const viewports = Object.fromEntries((vpNames.length ? vpNames : [Object.keys(scope)[0]]).map((v) => [v, scope[v]]))
482
- say(`▦ ${node.id} ${locale}: ${chosen.length} page(s)${truncated ? ` — limit ${Number(args.limit) || node.items.limit || DEFAULT_LIMIT} reached (${total}+ found; raise items.limit or pass --limit)` : ''}`)
507
+ const passKeys = keys || engineKeys(run.meta.scope, 'chromium')
508
+ const bases = baseViewports(run.meta.scope)
509
+ const vpNames = (node.items.viewports || []).filter((v) => bases.includes(v) || run.meta.scope.viewports[v])
510
+ const passBases = baseViewports({ ...run.meta.scope, viewports: Object.fromEntries(passKeys.map((k) => [k, run.meta.scope.viewports[k]])) })
511
+ const viewports = scopeKeys(run.meta.scope, passKeys, vpNames.length ? vpNames : [passBases[0] || bases[0]])
512
+ if (!Object.keys(viewports).length) { say(`▦ ${node.id} ${locale}${tag}: no viewport of this step in this pass`); return [] }
513
+ say(`▦ ${node.id} ${locale}${tag}: ${chosen.length} page(s)${truncated ? ` — limit ${Number(args.limit) || node.items.limit || DEFAULT_LIMIT} reached (${total}+ found; raise items.limit or pass --limit)` : ''}`)
483
514
  const fields = fieldsOf(node)
484
515
  const script = node.execution?.script ? await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href) : null
485
516
  let status = null
@@ -493,7 +524,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
493
524
  status = null
494
525
  const since = Date.now()
495
526
  let captured = false
496
- const capture = (opts = {}) => { captured = true; return captureStep({ page, node, locale, viewports: opts.viewports ? Object.fromEntries(opts.viewports.map((v) => [v, scope[v]])) : viewports, run, consoleFile, since, item, fields, maxChars: config.evaluators?.jev?.max_state_chars, options: captureOptions(scenario, node), extra: { http_status: status } }) }
527
+ const capture = (opts = {}) => { captured = true; return captureStep({ page, node, locale, viewports: opts.viewports ? scopeKeys(run.meta.scope, passKeys, opts.viewports) : viewports, run, consoleFile, since, item, fields, maxChars: config.evaluators?.jev?.max_state_chars, options: captureOptions(scenario, node), extra: { http_status: status } }) }
497
528
  try {
498
529
  if (script) await script.default({ page, item, url: () => item.url, locale, env, run: run.meta, capture, acceptDialogs: () => acceptDialogs(page) })
499
530
  else await page.goto(item.url, { waitUntil: 'domcontentloaded', timeout: 30000 })
@@ -501,12 +532,12 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
501
532
  ok++; streak.ok('stack')
502
533
  } catch (error) {
503
534
  failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
504
- if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}): the last 5 pages failed with the same error`)
535
+ if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
505
536
  }
506
537
  if ((i + 1) % 25 === 0 && i + 1 < chosen.length) say(` ${node.id} ${locale}: ${i + 1}/${chosen.length}`)
507
538
  }
508
539
  } finally { page.off('response', onResponse) }
509
- say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}: ${ok}/${chosen.length} page(s) captured`)
540
+ say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}${tag}: ${ok}/${chosen.length} page(s) captured`)
510
541
  return failed
511
542
  }
512
543
 
@@ -529,14 +560,17 @@ function lazyEncoder (config) {
529
560
  const appViewport = (run) => Object.keys(run.meta.scope.viewports || {})[0] || null
530
561
 
531
562
  /**
532
- * Walks an app scenario (platform android, ios, electron, desktop) with the environment's driver: per locale the
533
- * app starts fresh, each step runs its script (with `app`, the driver) or opens its deep link, then is captured.
563
+ * Walks an app scenario (platform android, ios, electron, desktop, windows) with the environment's driver: per
564
+ * device of the run (devices:, batch E) and locale the app starts fresh, each step runs its script (with `app`, the
565
+ * driver) or opens its deep link, then is captured in each of the device's keys (dark mode, reduced motion set on
566
+ * the device). `capture({ webview: true })` (or app.android.webview) captures an Android WebView's page instead.
534
567
  */
535
568
  async function walkApp ({ config, root, run, scenario, env, args }) {
536
569
  const platform = scenario.platform
537
570
  const pick = list(args.steps)
538
571
  const locales = list(args.locales) || run.meta.scope.locales
539
572
  const scenarioDir = resolve(root, run.meta.scenario_file, '..')
573
+ const scope = run.meta.scope
540
574
  const pending = []; const failed = []; const skipped = []
541
575
  // WebP and thumbnails are encoded in a headless Chrome page (no native image dependency); PNG without Chrome
542
576
  const enc = await launchEphemeral(config).catch(() => null)
@@ -545,65 +579,111 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
545
579
  const maxChars = config.evaluators?.jev?.max_state_chars
546
580
  // recordings and traces (batch D): Electron windows use the screencast recorder, devices record themselves
547
581
  const recSettings = recordSettings({ args, config, scenario })
582
+ const groups = appDeviceGroups(scope, list(args.devices))
583
+ // app captures carry the scheme and motion of their key (no browser: the app draws itself)
584
+ const fieldsOf = (key) => { const { browser: _b, ...f } = variantFields(scope, key); return f }
548
585
  try {
549
- for (const locale of locales) {
550
- const driver = driverFor(platform, env, { root, scenarioDir, locale })
551
- try {
552
- await driver.open({ locale, reset: true })
553
- if (driver.web) observeConsole(driver.page)
554
- say(`${platform} ${locale}: ${driver.web ? 'Electron window' : 'app'} ready`)
555
- } catch (error) { failed.push(`${platform} (${locale}): ${error.message.split('\n')[0]}`); say(`✗ ${platform} ${locale}: ${error.message.split('\n')[0]}`); await driver.close().catch(() => {}); if (error instanceof UserError) break; continue }
556
- const rec = driver.web
557
- ? await recordingSession({ page: driver.page, run, scenario, locale, settings: recSettings, viewports: run.meta.scope.viewports, encoder: async () => encoder, say, label: `${platform} ${locale}` }).catch(() => null)
558
- : await nativeSession({ platform, env, run, scenario, locale, settings: recSettings, driver, encoder: async () => encoder, say }).catch(() => null)
586
+ for (const group of groups) {
587
+ const tag = group.device && groups.length > 1 ? ` · ${group.title}` : ''
588
+ if (groups.length > 1) say(`\n${group.title}: ${group.keys.join(', ')}`)
589
+ // the device: its entry in app.<platform>.devices or what its name says; an emulator starts, a phone wakes up
590
+ let cfg = deviceConfig(platform, env.app?.[platform] || {}, group.device)
559
591
  try {
560
- for (const node of scenario.nodes) {
561
- if (node.kind === 'decision' || (pick && !pick.includes(node.id))) continue
562
- if (node.kind === 'stack') { pending.push(`${node.id} (${locale}): stack steps list web pages; not walked on ${platform}`); continue }
563
- if (node.kind === 'email' && node.mail?.to) {
564
- try { const r = await captureMail({ config, root, run, env, node, locale, to: expandRun(node.mail.to, run.meta.short), subject: localized(node.mail?.subject, locale), timeoutMs: 45000 }); say(`✓ ${node.id} ${locale}: "${r.subject}"`) } catch (error) { failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`); say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`) }
565
- continue
566
- }
567
- const scripted = ['playwright', 'script'].includes(node.execution?.strategy) && node.execution?.script
568
- const link = linkFor(env, node, locale)
569
- 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 }
570
- if (streak.stopped(locale)) { skipped.push(`${node.id} (${locale})`); continue }
571
- const records = []
572
- try {
573
- let captured = false
574
- await rec?.beforeStep(node)
575
- const capture = async (o = {}) => {
576
- captured = true
577
- await rec?.beforeCapture(node)
578
- if (driver.web) {
579
- const [w, h] = await driver.page.evaluate(() => [innerWidth, innerHeight])
580
- const r = await captureStep({ page: driver.page, node, locale, options: captureOptions(scenario, node), viewports: { [o.viewport || appViewport(run) || 'window']: [w, h] }, run, consoleFile: null, maxChars, extra: { platform, device: 'Electron' } })
581
- records.push(...r)
582
- return r
592
+ if (platform === 'android') { cfg = await prepareAndroid(cfg, { say }); await readyAndroid(cfg) }
593
+ if (platform === 'ios' && group.device) cfg = await prepareSimulator(cfg)
594
+ } catch (error) { failed.push(`${group.device || platform}: ${error.message.split('\n')[0]}`); say(`✗ ${group.device || platform}: ${error.message.split('\n')[0]}`); continue }
595
+ const denv = { ...env, app: { ...(env.app || {}), [platform]: cfg } }
596
+ // recordings and traces land on this device's keys (a failed step without a capture: its first key)
597
+ const grun = { ...run, meta: { ...run.meta, scope: { ...scope, viewports: scopeKeys(scope, group.keys) } } }
598
+ let look = { key: null, undo: async () => {} } // the system appearance the device is in now
599
+ const appearance = async (driver, key) => {
600
+ const v = scope.variants?.[key] || null
601
+ const want = JSON.stringify([v?.color_scheme || null, v?.reduced_motion || null])
602
+ if (look.key === want) return look.notes || []
603
+ await look.undo()
604
+ const r = await applyAppAppearance(platform, cfg, driver, v)
605
+ look = { key: want, undo: r.undo, notes: r.notes }
606
+ return r.notes
607
+ }
608
+ for (const locale of locales) {
609
+ const driver = driverFor(platform, denv, { root, scenarioDir, locale })
610
+ let webview = null
611
+ if (platform === 'android') driver.webview = async () => (webview ||= await attachAndroidWebView(cfg)).page
612
+ try {
613
+ await driver.open({ locale, reset: true })
614
+ if (driver.web) observeConsole(driver.page)
615
+ say(`${platform} ${locale}${tag}: ${driver.web ? 'Electron window' : 'app'} ready`)
616
+ } catch (error) { failed.push(`${platform} (${locale}${tag}): ${error.message.split('\n')[0]}`); say(`✗ ${platform} ${locale}${tag}: ${error.message.split('\n')[0]}`); await driver.close().catch(() => {}); if (error instanceof UserError) break; continue }
617
+ const rec = driver.web
618
+ ? await recordingSession({ page: driver.page, run: grun, scenario, locale, settings: recSettings, viewports: grun.meta.scope.viewports, encoder: async () => encoder, say, label: `${platform} ${locale}${tag}` }).catch(() => null)
619
+ : await nativeSession({ platform, env: denv, run: grun, scenario, locale, settings: recSettings, driver, encoder: async () => encoder, say }).catch(() => null)
620
+ try {
621
+ for (const node of scenario.nodes) {
622
+ if (node.kind === 'decision' || (pick && !pick.includes(node.id))) continue
623
+ if (node.kind === 'stack') { pending.push(`${node.id} (${locale}): stack steps list web pages; not walked on ${platform}`); continue }
624
+ if (node.kind === 'email' && group !== groups[0]) continue // one message, captured on the first device
625
+ if (node.kind === 'email' && node.mail?.to) {
626
+ try { const r = await captureMail({ config, root, run, env, node, locale, to: expandRun(node.mail.to, run.meta.short), subject: localized(node.mail?.subject, locale), timeoutMs: 45000 }); say(`✓ ${node.id} ${locale}: "${r.subject}"`) } catch (error) { failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`); say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`) }
627
+ continue
628
+ }
629
+ const scripted = ['playwright', 'script'].includes(node.execution?.strategy) && node.execution?.script
630
+ const link = linkFor(env, node, locale)
631
+ 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 }
632
+ const streakKey = `${group.device || ''}|${locale}`
633
+ if (streak.stopped(streakKey)) { skipped.push(`${node.id} (${locale}${tag})`); continue }
634
+ const records = []
635
+ try {
636
+ let captured = false
637
+ await rec?.beforeStep(node)
638
+ const capture = async (o = {}) => {
639
+ captured = true
640
+ await rec?.beforeCapture(node)
641
+ // a named viewport: within this device, or any of the run when it has no devices (as before)
642
+ const keys = o.viewport ? pickKeys(scope, group.device ? group.keys : Object.keys(scope.viewports), [o.viewport]) : group.keys
643
+ if (!keys.length) throw new UserError(`Unknown viewport "${o.viewport}" for ${group.device || platform}.`)
644
+ const out = []
645
+ for (const key of keys) {
646
+ if (driver.web) {
647
+ // Electron: a web capture; captureStep emulates the key's scheme and motion itself
648
+ const [w, h] = await driver.page.evaluate(() => [innerWidth, innerHeight])
649
+ out.push(...await captureStep({ page: driver.page, node, locale, options: captureOptions(scenario, node), viewports: { [key]: [w, h] }, run, consoleFile: null, maxChars, extra: { platform, device: 'Electron' } }))
650
+ continue
651
+ }
652
+ const notes = await appearance(driver, key)
653
+ if (o.webview || (cfg.webview && o.webview !== false)) {
654
+ if (!driver.webview) throw new UserError(`WebView captures need an Android app; on ${platform} capture the screen (tree or OCR).`)
655
+ const page = await driver.webview()
656
+ const [w, h] = await page.evaluate(() => [innerWidth, innerHeight])
657
+ out.push(...await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: { [key]: [w, h] }, run, consoleFile: null, maxChars, extra: { platform, device: cfg.device || null, webview: true, ...fieldsOf(key) } }))
658
+ continue
659
+ }
660
+ out.push(await captureApp({ snapshot: await driver.snapshot(), node, locale, viewport: key, run, platform, encoder, maxChars, extra: { url: link, ...fieldsOf(key), ...(notes.length ? { notes } : {}) } }))
661
+ }
662
+ records.push(...out)
663
+ return out.length === 1 ? out[0] : out
583
664
  }
584
- const r = await captureApp({ snapshot: await driver.snapshot(), node, locale, viewport: o.viewport || appViewport(run), run, platform, encoder, maxChars, extra: { url: link } })
585
- records.push(r)
586
- return r
665
+ if (scripted) {
666
+ const mod = await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href)
667
+ 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) })
668
+ } else await driver.link(link)
669
+ if (!captured) await capture()
670
+ await rec?.afterStep(node, driver.web ? { records } : { record: records[0] })
671
+ say(`✓ ${node.id} ${locale}${tag}`)
672
+ streak.ok(streakKey)
673
+ } catch (error) {
674
+ await rec?.afterStep(node, driver.web ? { error, records } : { error, record: records[0] }).catch(() => {})
675
+ failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
676
+ say(`✗ ${node.id} ${locale}${tag}: ${error.message.split('\n')[0]}`)
677
+ if (streak.record(streakKey, error.message)) say(` stopping ${locale}${tag}: the last 3 steps failed with the same error`)
587
678
  }
588
- if (scripted) {
589
- const mod = await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href)
590
- await mod.default({ app: driver, page: driver.page || null, locale, node: { id: node.id, title: localized(node.title, locale), kind: node.kind }, 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) })
591
- } else await driver.link(link)
592
- if (!captured) await capture()
593
- await rec?.afterStep(node, driver.web ? { records } : { record: records[0] })
594
- say(`✓ ${node.id} ${locale}`)
595
- streak.ok(locale)
596
- } catch (error) {
597
- await rec?.afterStep(node, driver.web ? { error, records } : { error, record: records[0] }).catch(() => {})
598
- failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
599
- say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
600
- if (streak.record(locale, error.message)) say(` stopping ${locale}: the last 3 steps failed with the same error`)
601
679
  }
680
+ } finally {
681
+ for (const n of (await rec?.finish().catch((e) => [`recording: ${e.message}`])) || []) say(` ${n}`)
682
+ await webview?.close(); await driver.close().catch(() => {})
602
683
  }
603
- } finally {
604
- for (const n of (await rec?.finish().catch((e) => [`recording: ${e.message}`])) || []) say(` ${n}`)
605
- await driver.close().catch(() => {})
606
684
  }
685
+ await look.undo().catch(() => {})
686
+ if (platform === 'android') await finishAndroid(cfg)
607
687
  }
608
688
  } finally { await enc?.close().catch(() => {}) }
609
689
  if (pending.length) say(`\nSteps for an agent or a person (drive the app, then kaloko capture --step <id> --image shot.png [--tree dump.xml|json]):\n - ${[...new Set(pending)].join('\n - ')}`)
@@ -771,29 +851,40 @@ const commands = {
771
851
  if (scenario.environments && !scenario.environments.includes(env.name)) throw new UserError(`Scenario ${scenario.id} is not allowed on ${env.name} (allowed: ${scenario.environments.join(', ')}).`)
772
852
  if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
773
853
  const locales = list(args.locales) || scenario.locales || ['default']
774
- const vpAll = scenario.viewports || { desktop: [1280, 800], mobile: [390, 844] }
775
- const vpNames = list(args.viewports) || Object.keys(vpAll)
776
- const scope = { locales, viewports: Object.fromEntries(vpNames.map((n) => [n, vpAll[n]])), ...(args.note ? { note: args.note } : {}) }
854
+ const scope = { ...startScope({ args, scenario, config, env }), ...(args.note ? { note: args.note } : {}) }
855
+ scope.locales = locales
856
+ const vpNames = baseViewports(scope)
777
857
  const git = await gitMeta(root)
778
858
  const executor = { kind: args.executor ? 'agent' : 'cli', name: args.executor || 'kaloko-cli' }
779
859
  const { project, issue } = await projectAndIssue({ cwd: root, config, scenario, branch: git?.branch })
780
860
  const appVer = typeof args['app-version'] === 'string' ? args['app-version'].slice(0, 80) : await appVersion(root)
781
861
  const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer })
782
- say(`Run ${meta.id}\n dir: ${relative(process.cwd(), dir)}\n env: ${env.name}${env.readonly ? ' (readonly)' : ''}\n locales: ${locales.join(', ')} viewports: ${vpNames.join(', ')}\n project: ${project.slug}${issue ? ` issue: ${issue.repo ? issue.repo + '#' : '#'}${issue.number}` : ''}`)
862
+ say(`Run ${meta.id}\n dir: ${relative(process.cwd(), dir)}\n env: ${env.name}${env.readonly ? ' (readonly)' : ''}\n locales: ${locales.join(', ')} viewports: ${vpNames.length > 12 ? `${vpNames.slice(0, 12).join(', ')} … (${vpNames.length})` : vpNames.join(', ')}${dimensionLine(scope)}\n project: ${project.slug}${issue ? ` issue: ${issue.repo ? issue.repo + '#' : '#'}${issue.number}` : ''}`)
783
863
  const addr = recipient(env.mail, meta.short)
784
864
  if (addr) say(` test e-mail: ${addr}`)
785
865
  say(` other agents may start runs here too: pin this one with --run ${meta.id} or KALOKO_RUN=${meta.id}`)
786
866
  },
787
867
 
868
+ /** `kaloko devices`: the device presets a scenario's viewports may name. */
869
+ async devices (args) {
870
+ const { DEVICES, devicePreset } = await import('./lib/devices.mjs')
871
+ const all = Object.keys(DEVICES).map((n) => devicePreset(n))
872
+ if (args.json) { say(JSON.stringify(all, null, 2)); return }
873
+ for (const d of all) say(`${d.name.padEnd(26)} ${d.title.padEnd(28)} ${`${d.size[0]}×${d.size[1]}`.padEnd(10)} screen ${`${d.screen[0]}×${d.screen[1]}`.padEnd(10)} ${String(d.dpr).padEnd(6)}× ${d.touch ? 'touch' : ''}`)
874
+ say('\nUse them in viewports: (a list of names, or name: preset), or kaloko start --viewports iphone-15,desktop-hd.\n<preset>-landscape turns a phone, foldable or tablet sideways; Design environments use the whole screen.')
875
+ },
876
+
788
877
  async browser (args) {
789
878
  const { config, root } = await context(args)
790
879
  const action = args._[0] || 'status'
791
880
  if (action === 'install') {
792
- // the Chromium of the Playwright version kaloko itself uses (CI: --with-deps adds the system libraries)
881
+ // the engines of the Playwright version kaloko itself uses (CI: --with-deps adds the system libraries)
882
+ const engines = list(args.browsers) || ['chromium']
883
+ for (const e of engines) if (!BROWSERS.includes(e)) throw new UserError(`Unknown browser engine ${e}. Use: ${BROWSERS.join(', ')}.`)
793
884
  const { spawn } = await import('node:child_process')
794
885
  const cli = join(dirname(fileURLToPath(import.meta.resolve('playwright/package.json'))), 'cli.js')
795
- const code = await new Promise((done) => spawn(process.execPath, [cli, 'install', ...(args['with-deps'] ? ['--with-deps'] : []), 'chromium'], { stdio: 'inherit' }).on('close', done))
796
- if (code) throw new UserError(`Installing Chromium failed (exit ${code}).`)
886
+ const code = await new Promise((done) => spawn(process.execPath, [cli, 'install', ...(args['with-deps'] ? ['--with-deps'] : []), ...engines], { stdio: 'inherit' }).on('close', done))
887
+ if (code) throw new UserError(`Installing ${engines.join(', ')} failed (exit ${code}).`)
797
888
  return
798
889
  }
799
890
  let env = null
@@ -836,13 +927,17 @@ const commands = {
836
927
  return
837
928
  }
838
929
  if (isApp(scenario.platform)) {
839
- // the app's current screen, through the environment's driver (no navigation)
840
- const driver = driverFor(scenario.platform, env, { root, scenarioDir: resolve(root, run.meta.scenario_file, '..'), locale })
930
+ // the app's current screen, through the environment's driver (no navigation); --viewport may name a device key
931
+ const key = typeof args.viewport === 'string' ? args.viewport : Object.keys(run.meta.scope.viewports || {})[0]
932
+ const device = run.meta.scope.devices?.[variantOf(run.meta.scope, key).viewport]?.app ? variantOf(run.meta.scope, key).viewport : null
933
+ const cfg = deviceConfig(scenario.platform, env.app?.[scenario.platform] || {}, device)
934
+ const driver = driverFor(scenario.platform, { ...env, app: { ...(env.app || {}), [scenario.platform]: cfg } }, { root, scenarioDir: resolve(root, run.meta.scenario_file, '..'), locale })
841
935
  if (driver.web) throw new UserError('For Electron, run kaloko walk --steps <id>, or pass --image.')
842
936
  await driver.open({ locale, attach: true })
843
937
  const enc = await launchEphemeral(config).catch(() => null)
844
938
  try {
845
- const r = await captureApp({ snapshot: await driver.snapshot(), node, locale, viewport: typeof args.viewport === 'string' ? args.viewport : Object.keys(run.meta.scope.viewports || {})[0], run, platform: scenario.platform, encoder: enc ? await enc.newPage() : null, maxChars: config.evaluators?.jev?.max_state_chars })
939
+ const { browser: _b, ...dims } = variantFields(run.meta.scope, key)
940
+ const r = await captureApp({ snapshot: await driver.snapshot(), node, locale, viewport: key, run, platform: scenario.platform, encoder: enc ? await enc.newPage() : null, maxChars: config.evaluators?.jev?.max_state_chars, extra: dims })
846
941
  say(`✓ ${node.id} ${locale} ${r.viewport} ${r.device || scenario.platform} ${r.files.tree ? `UI tree (${r.capture.tree})` : 'no UI tree'}`)
847
942
  } finally { await enc?.close().catch(() => {}) }
848
943
  return
@@ -867,7 +962,7 @@ const commands = {
867
962
  return
868
963
  }
869
964
  const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
870
- const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, list(args.viewports)), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, stability: Number(args.stability) || 0 })
965
+ 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 })
871
966
  for (const r of results) {
872
967
  const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
873
968
  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}` : ''}`)
@@ -933,17 +1028,23 @@ const commands = {
933
1028
  if (isApp(scenario.platform)) return walkApp({ config, root, run, scenario, env, args })
934
1029
  const pick = list(args.steps)
935
1030
  const locales = list(args.locales) || run.meta.scope.locales
1031
+ const scope = run.meta.scope
1032
+ // browser engines and device profiles (batch E): one pass each, capturing only its own keys; a run without
1033
+ // dimensions has a single pass, the walk of before
1034
+ const only = args.browsers ? dimensionValues('browsers', args.browsers) : null
1035
+ const passes = passesOf(scope, { only })
1036
+ if (!passes.length) throw new UserError(`This run has no captures in ${only.join(', ')} (its browsers: ${enginesOf(scope).join(', ')}).`)
1037
+ const needChromium = passes.some((p) => p.engine === 'chromium')
936
1038
  // --ephemeral: an own browser (own cookies and tabs) even when the shared one runs, so runs can walk in parallel
937
1039
  configureGate(await gateForRequests(env, { root }))
938
- const conn = args.ephemeral ? null : await connectBrowser({ root, config, env })
1040
+ const conn = args.ephemeral || !needChromium ? null : await connectBrowser({ root, config, env })
939
1041
  const releaseLock = conn ? await acquireWalkLock({ root, config, env, run: run.meta.id }).catch(async (e) => { await conn.browser.close(); throw e }) : null
940
- const ephemeral = conn ? null : await launchEphemeral(config)
941
- const browser = conn ? conn.browser : ephemeral
1042
+ const ephemeral = conn || !needChromium ? null : await launchEphemeral(config)
1043
+ const chromium = conn ? conn.browser : ephemeral
942
1044
  // locales are independent: with --concurrency > 1 each walks in its own browser context at the same time (#27);
943
1045
  // one locale at a time keeps the shared browser's own context, as before
944
1046
  const concurrency = walkConcurrency(args, config, locales.length, !!conn)
945
1047
  const parallel = concurrency > 1 && locales.length > 1
946
- const ctx = parallel ? null : conn ? conn.context : await newContext(ephemeral, env, { root, config })
947
1048
  const scenarioDir = resolve(root, run.meta.scenario_file, '..')
948
1049
  // semantic questions of every new capture start right away; the walk ends with the verdicts (--no-evaluate: skip)
949
1050
  const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root })
@@ -952,127 +1053,156 @@ const commands = {
952
1053
  const recSettings = recordSettings({ args, config, scenario })
953
1054
  const encoder = lazyEncoder(config)
954
1055
  const sessions = scenario.sessions || null
955
- // named sessions: one isolated browser context per person and locale (cookies, storage, sign-in), created lazily
956
- const sessionContexts = new Map()
957
1056
  const ownContexts = []
1057
+ const engines = new Map() // WebKit and Firefox, launched for their passes
958
1058
  // `account` stays the scenario's key; `credentials` resolves it in this environment (config accounts)
959
1059
  const sessionInfo = (key) => key && sessions?.[key] ? { key, title: localized(sessions[key].title, locales[0]), mailbox: sessions[key].mailbox ? expandRun(sessions[key].mailbox, run.meta.short) : null, account: sessions[key].account || null, credentials: accountFor(env, sessions[key].account, { root, config }) } : null
960
- const contextFor = async (key, locale) => {
961
- if (!key || !sessions?.[key]) return null
962
- const id = `${key}|${locale}`
963
- if (!sessionContexts.has(id)) {
964
- // every context gets the environment's gate and readonly policy; an account's saved sign-in is loaded
965
- const account = accountFor(env, sessions[key].account, { root, config })
966
- const c = await newContext(browser, env, { root, config, account })
967
- const p = await c.newPage()
968
- observeConsole(p)
969
- sessionContexts.set(id, { context: c, page: p })
970
- const saved = account && await access(account.storage_state).then(() => true, () => false)
971
- say(`session ${key} (${locale}): new browser context${saved ? ' with the saved sign-in' : ''}`)
972
- }
973
- return sessionContexts.get(id)
974
- }
975
1060
  // stop a session after the same failure repeats (shared precondition such as a failed sign-in);
976
1061
  // --keep-going walks every step regardless
977
1062
  const streak = failureStreak(args['keep-going'] ? 0 : 3)
978
1063
  const proposals = []
979
- // one locale: its own tab (and, in parallel, its own context); returns what it could not walk
980
- const walkLocale = async (locale) => {
981
- const pending = []; const failed = []; const skipped = []
982
- let lctx = ctx
983
- if (parallel) { lctx = await newContext(browser, env, { root, config }); ownContexts.push(lctx) }
984
- // the walk owns its tab; agents and people keep theirs in the same shared browser
985
- const page = await lctx.newPage()
986
- const shared = conn && !parallel // the shared browser's own context logs its console to a file
987
- if (!shared) observeConsole(page)
988
- // one recorder per page (the walk's tab and each named session's), started on its first step
989
- const recorders = new Map()
990
- const recorderFor = async (p) => {
991
- if (!recorders.has(p)) recorders.set(p, recordingSession({ page: p, run, scenario, locale, settings: recSettings, viewports: run.meta.scope.viewports, encoder: encoder.page, say, label: locale }).catch(() => null))
992
- return recorders.get(p)
993
- }
994
- try {
995
- // every locale starts as a new visitor on its first walk in the run (an own context is new already)
996
- if (shared && !walked.some((c) => c.locale === locale)) { await resetSession(lctx, page, baseUrl(env, locale)); say(`session reset (new visitor, ${locale})`) }
997
- for (const node of scenario.nodes) {
998
- if (node.kind === 'decision' || (pick && !pick.includes(node.id))) continue
999
- if (node.kind !== 'stack' && node.path && !urlFor(env, node, locale)) continue // step does not exist in this locale
1000
- if (node.session && sessions?.[node.session]?.blocked_on?.includes(env.name)) { pending.push(`${node.id} (${locale}): session ${node.session} is blocked on ${env.name}`); continue }
1001
- const sess = await contextFor(node.session, locale)
1002
- const sPage = sess ? sess.page : page
1003
- const sCtx = sess ? sess.context : lctx
1004
- const consoleFile = shared && !sess ? consoleLog(root, config.output, env.name) : null
1005
- if (node.kind === 'stack') {
1006
- try {
1007
- const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, args })
1008
- for (const b of bad) failed.push(`${node.id} (${locale}) ${b}`)
1009
- } catch (error) {
1010
- failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
1011
- say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
1012
- }
1013
- continue
1014
- }
1015
- if (node.kind === 'email' && (node.mail?.to || sessionInfo(node.session)?.mailbox)) {
1016
- // an e-mail node with a recipient is walked like a step: wait for the message and capture it
1017
- try {
1018
- const to = expandRun(node.mail?.to || sessionInfo(node.session).mailbox, run.meta.short)
1019
- const results = await captureMail({ config, root, run, env, node, locale, to, subject: localized(node.mail?.subject, locale), timeoutMs: 45000 })
1020
- say(`✓ ${node.id} ${locale}: "${results.subject}"`)
1021
- } catch (error) {
1022
- failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
1023
- say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
1024
- }
1025
- continue
1064
+ const results = []
1065
+ const recSkipped = new Set() // engines whose passes are not recorded (once per engine)
1066
+ try {
1067
+ for (const [passIndex, pass] of passes.entries()) {
1068
+ const first = passIndex === 0
1069
+ const plain = pass.engine === 'chromium' && !pass.emulation
1070
+ let browser = chromium
1071
+ if (pass.engine !== 'chromium') {
1072
+ if (!engines.has(pass.engine)) engines.set(pass.engine, await launchEngine(pass.engine))
1073
+ browser = engines.get(pass.engine)
1074
+ }
1075
+ const extra = contextOptionsOf(pass)
1076
+ const tag = passes.length > 1 && pass.label ? ` · ${pass.label}` : ''
1077
+ if (passes.length > 1) say(`\n${pass.label || 'chromium'}: ${pass.keys.join(', ')}`)
1078
+ // recordings and traces (batch D) in Chromium passes; a flow recording is one per locale, made in the first
1079
+ let passRec = recSettings
1080
+ if (pass.engine !== 'chromium') {
1081
+ passRec = null
1082
+ const wanted = !!recSettings.record || scenario.nodes.some((n) => parseRecord(n.record ?? n.capture?.record))
1083
+ if (wanted && !recSkipped.has(pass.engine)) { recSkipped.add(pass.engine); say(`Recording and traces skipped in ${pass.engine}: they need Chromium (the Chromium keys of this run are recorded).`) }
1084
+ } else if (passes.slice(0, passIndex).some((p) => p.engine === 'chromium') && recSettings.record?.mode === 'flow') passRec = { ...recSettings, record: null }
1085
+ const ctx = parallel ? null : plain && conn ? conn.context : await newContext(browser, env, { root, config, extra })
1086
+ if (ctx && ctx !== conn?.context) ownContexts.push(ctx)
1087
+ // named sessions: one isolated browser context per person and locale (cookies, storage, sign-in), created lazily
1088
+ const sessionContexts = new Map()
1089
+ const contextFor = async (key, locale) => {
1090
+ if (!key || !sessions?.[key]) return null
1091
+ const id = `${key}|${locale}`
1092
+ if (!sessionContexts.has(id)) {
1093
+ // every context gets the environment's gate and readonly policy; an account's saved sign-in is loaded
1094
+ const account = accountFor(env, sessions[key].account, { root, config })
1095
+ const c = await newContext(browser, env, { root, config, account, extra })
1096
+ ownContexts.push(c)
1097
+ const p = await c.newPage()
1098
+ observeConsole(p)
1099
+ sessionContexts.set(id, { context: c, page: p })
1100
+ const saved = account && await access(account.storage_state).then(() => true, () => false)
1101
+ say(`session ${key} (${locale}${tag}): new browser context${saved ? ' with the saved sign-in' : ''}`)
1026
1102
  }
1027
- if (!['playwright', 'script'].includes(node.execution.strategy) || !node.execution.script) { pending.push(`${node.id} (${locale}): ${node.execution.strategy} — ${node.execution.instructions.split('\n')[0]}`); continue }
1028
- const streakKey = `${node.session || ''}|${locale}`
1029
- if (streak.stopped(streakKey)) { skipped.push(`${node.id} (${locale})`); continue }
1030
- const scriptFile = resolve(scenarioDir, node.execution.script)
1031
- // one invocation of the step script; the automatic capture runs unless the script captured the step itself:
1032
- // a crop is an attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
1033
- const rec = await recorderFor(sPage)
1034
- const runStep = async (mod) => {
1035
- let captured = false
1036
- rec?.beforeStep(node)
1037
- const records = []
1038
- const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, opts.viewports), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, 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 }) }
1039
- try {
1040
- 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) })
1041
- if (!captured) await capture()
1042
- } catch (error) { await rec?.afterStep(node, { error }); throw error }
1043
- await rec?.afterStep(node, { records })
1103
+ return sessionContexts.get(id)
1104
+ }
1105
+ // one locale: its own tab (and, in parallel, its own context); returns what it could not walk
1106
+ const walkLocale = async (locale) => {
1107
+ const pending = []; const failed = []; const skipped = []
1108
+ let lctx = ctx
1109
+ if (parallel) { lctx = await newContext(browser, env, { root, config, extra }); ownContexts.push(lctx) }
1110
+ // the walk owns its tab; agents and people keep theirs in the same shared browser
1111
+ const page = await lctx.newPage()
1112
+ const shared = conn && !parallel && plain // the shared browser's own context logs its console to a file
1113
+ if (!shared) observeConsole(page)
1114
+ // one recorder per page (the walk's tab and each named session's), started on its first step;
1115
+ // recordings and traces need CDP: Chromium passes only
1116
+ const recorders = new Map()
1117
+ const recorderFor = async (p) => {
1118
+ if (!passRec) return null
1119
+ if (!recorders.has(p)) recorders.set(p, recordingSession({ page: p, run, scenario, locale, settings: passRec, viewports: scopeKeys(scope, pass.keys), encoder: encoder.page, say, label: `${locale}${tag}` }).catch(() => null))
1120
+ return recorders.get(p)
1044
1121
  }
1045
1122
  try {
1046
- await runStep(await import(pathToFileURL(scriptFile).href))
1047
- say(`✓ ${node.id} ${locale}`)
1048
- streak.ok(streakKey)
1049
- } catch (error) {
1050
- // a selector the page no longer has: propose one from the page (#13); --heal applies it once the step passes
1051
- 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)
1052
- if (healed?.applied) {
1053
- say(`✓ ${node.id} ${locale} (healed)\n${formatHeal(healed, { stepArg: node.id })}`)
1054
- streak.ok(streakKey)
1055
- continue
1123
+ // every locale starts as a new visitor on its first walk in the run (an own context is new already)
1124
+ if (shared && !walked.some((c) => c.locale === locale)) { await resetSession(lctx, page, baseUrl(env, locale)); say(`session reset (new visitor, ${locale}${tag})`) }
1125
+ for (const node of scenario.nodes) {
1126
+ if (node.kind === 'decision' || (pick && !pick.includes(node.id))) continue
1127
+ if (node.kind !== 'stack' && node.path && !urlFor(env, node, locale)) continue // step does not exist in this locale
1128
+ if (node.session && sessions?.[node.session]?.blocked_on?.includes(env.name)) { pending.push(`${node.id} (${locale}${tag}): session ${node.session} is blocked on ${env.name}`); continue }
1129
+ const sess = await contextFor(node.session, locale)
1130
+ const sPage = sess ? sess.page : page
1131
+ const sCtx = sess ? sess.context : lctx
1132
+ const consoleFile = shared && !sess ? consoleLog(root, config.output, env.name) : null
1133
+ if (node.kind === 'stack') {
1134
+ try {
1135
+ const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, args, keys: pass.keys, tag })
1136
+ for (const b of bad) failed.push(`${node.id} (${locale}${tag}) ${b}`)
1137
+ } catch (error) {
1138
+ failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
1139
+ say(`✗ ${node.id} ${locale}${tag}: ${error.message.split('\n')[0]}`)
1140
+ }
1141
+ continue
1142
+ }
1143
+ if (node.kind === 'email' && !first) continue // one message, captured in the first pass
1144
+ if (node.kind === 'email' && (node.mail?.to || sessionInfo(node.session)?.mailbox)) {
1145
+ // an e-mail node with a recipient is walked like a step: wait for the message and capture it
1146
+ try {
1147
+ const to = expandRun(node.mail?.to || sessionInfo(node.session).mailbox, run.meta.short)
1148
+ const results = await captureMail({ config, root, run, env, node, locale, to, subject: localized(node.mail?.subject, locale), timeoutMs: 45000 })
1149
+ say(`✓ ${node.id} ${locale}${tag}: "${results.subject}"`)
1150
+ } catch (error) {
1151
+ failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
1152
+ say(`✗ ${node.id} ${locale}${tag}: ${error.message.split('\n')[0]}`)
1153
+ }
1154
+ continue
1155
+ }
1156
+ if (!['playwright', 'script'].includes(node.execution.strategy) || !node.execution.script) { pending.push(`${node.id} (${locale}${tag}): ${node.execution.strategy} — ${node.execution.instructions.split('\n')[0]}`); continue }
1157
+ const streakKey = `${pass.engine}|${pass.emulation?.key || ''}|${node.session || ''}|${locale}`
1158
+ if (streak.stopped(streakKey)) { skipped.push(`${node.id} (${locale}${tag})`); continue }
1159
+ const scriptFile = resolve(scenarioDir, node.execution.script)
1160
+ // one invocation of the step script; the automatic capture runs unless the script captured the step itself:
1161
+ // a crop is an attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
1162
+ const rec = await recorderFor(sPage)
1163
+ const runStep = async (mod) => {
1164
+ let captured = false
1165
+ rec?.beforeStep(node)
1166
+ const records = []
1167
+ 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 }) }
1168
+ try {
1169
+ 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) })
1170
+ if (!captured) await capture()
1171
+ } catch (error) { await rec?.afterStep(node, { error }); throw error }
1172
+ await rec?.afterStep(node, { records })
1173
+ }
1174
+ try {
1175
+ await runStep(await import(pathToFileURL(scriptFile).href))
1176
+ say(`✓ ${node.id} ${locale}${tag}`)
1177
+ streak.ok(streakKey)
1178
+ } catch (error) {
1179
+ // a selector the page no longer has: propose one from the page (#13); --heal applies it once the step passes
1180
+ 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)
1181
+ if (healed?.applied) {
1182
+ say(`✓ ${node.id} ${locale}${tag} (healed)\n${formatHeal(healed, { stepArg: node.id })}`)
1183
+ streak.ok(streakKey)
1184
+ continue
1185
+ }
1186
+ failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
1187
+ say(`✗ ${node.id} ${locale}${tag}: ${error.message.split('\n')[0]}`)
1188
+ if (healed) { say(formatHeal(healed, { stepArg: node.id })); if (healed.patch) proposals.push(node.id) }
1189
+ if (streak.record(streakKey, error.message)) say(` stopping ${node.session ? `session ${node.session}` : 'this walk'} (${locale}${tag}): the last 3 steps failed with the same error; fix the shared cause (sign-in, environment) and rerun, or pass --keep-going`)
1190
+ }
1056
1191
  }
1057
- failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
1058
- say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
1059
- if (healed) { say(formatHeal(healed, { stepArg: node.id })); if (healed.patch) proposals.push(node.id) }
1060
- if (streak.record(streakKey, error.message)) say(` stopping ${node.session ? `session ${node.session}` : 'this walk'} (${locale}): the last 3 steps failed with the same error; fix the shared cause (sign-in, environment) and rerun, or pass --keep-going`)
1192
+ } finally {
1193
+ for (const r of recorders.values()) { const notes = await (await r)?.finish().catch((e) => [`recording: ${e.message}`]); for (const n of notes || []) say(` ${n}`) }
1194
+ await page.close().catch(() => {})
1061
1195
  }
1196
+ return { pending, failed, skipped }
1062
1197
  }
1063
- } finally {
1064
- for (const r of recorders.values()) { const notes = await (await r)?.finish().catch((e) => [`recording: ${e.message}`]); for (const n of notes || []) say(` ${n}`) }
1065
- await page.close().catch(() => {})
1198
+ if (parallel) say(`Walking ${locales.length} locales, ${concurrency} at a time (each in its own browser context); output in locale order.`)
1199
+ results.push(...await inOrder(locales, parallel ? concurrency : 1, (locale) => walkLocale(locale).catch((error) => ({ pending: [], failed: [`${locale}${tag}: ${error.message.split('\n')[0]}`], skipped: [] }))))
1200
+ // the next pass has its own contexts: close this one's now
1201
+ for (const c of ownContexts.splice(0)) await c.close().catch(() => {})
1066
1202
  }
1067
- return { pending, failed, skipped }
1068
- }
1069
- let results
1070
- try {
1071
- if (parallel) say(`Walking ${locales.length} locales, ${concurrency} at a time (each in its own browser context); output in locale order.`)
1072
- results = await inOrder(locales, parallel ? concurrency : 1, (locale) => walkLocale(locale).catch((error) => ({ pending: [], failed: [`${locale}: ${error.message.split('\n')[0]}`], skipped: [] })))
1073
1203
  } finally {
1074
- for (const { context } of sessionContexts.values()) await context.close().catch(() => {})
1075
1204
  for (const c of ownContexts) await c.close().catch(() => {})
1205
+ for (const b of engines.values()) await b.close().catch(() => {})
1076
1206
  if (conn) await conn.browser.close() // over CDP this only disconnects; the shared browser keeps running
1077
1207
  if (ephemeral) await ephemeral.close()
1078
1208
  await encoder.close()
@@ -1211,17 +1341,20 @@ const commands = {
1211
1341
  say(`Added the environment ${envName} to ${relative(process.cwd(), file) || CONFIG_FILE}: base_url \${KALOKO_PREVIEW_URL}, set by kaloko ci preview-url in CI.`)
1212
1342
  } else say(`Add to environments: in ${CONFIG_FILE}:\n ${envName}:\n base_url: \${KALOKO_PREVIEW_URL}`)
1213
1343
  }
1344
+ // the engines the job installs: Chromium and whatever the scenarios and walk.browsers ask for (batch E)
1345
+ const engines = new Set(['chromium', ...(config.walk?.browsers || [])])
1214
1346
  for (const f of scenarios) {
1215
1347
  let sc = null
1216
1348
  try { sc = yaml.load(await readFile(resolve(root, f), 'utf8')) } catch { throw new UserError(`${f} cannot be read.`) }
1217
1349
  if (sc?.environments && !sc.environments.includes(envName)) say(`! ${f} allows environments [${sc.environments.join(', ')}]: add ${envName}, or kaloko start refuses it in CI.`)
1350
+ for (const b of sc?.browsers || []) engines.add(b)
1218
1351
  }
1219
1352
  let pkg = null
1220
1353
  try { pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) } catch {}
1221
1354
  const pm = existsSync(join(root, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(root, 'yarn.lock')) ? 'yarn' : existsSync(join(root, 'bun.lockb')) || existsSync(join(root, 'bun.lock')) ? 'bun' : 'npm'
1222
1355
  const dependency = !!(pkg && ({ ...pkg.dependencies, ...pkg.devDependencies }).kaloko)
1223
1356
  const version = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version
1224
- const opts = { scenarios, env: envName, affected: !args['no-affected'], pm, dependency, version: `^${version}`, trigger: args.trigger === 'pull_request' ? 'pull_request' : 'deployment' }
1357
+ const opts = { scenarios, env: envName, affected: !args['no-affected'], pm, dependency, version: `^${version}`, trigger: args.trigger === 'pull_request' ? 'pull_request' : 'deployment', browsers: [...engines] }
1225
1358
  const content = action === 'github' ? githubWorkflow(opts) : gitlabJob(opts)
1226
1359
  if (args.print) { say(content); return }
1227
1360
  const out = resolve(root, typeof args.out === 'string' ? args.out : action === 'github' ? '.github/workflows/kaloko.yml' : 'kaloko.gitlab-ci.yml')
@@ -1846,7 +1979,10 @@ const commands = {
1846
1979
 
1847
1980
  async doctor (args) {
1848
1981
  const { config, root, file } = await context(args)
1849
- const r = await doctor({ config, root, file, fix: !!args.fix })
1982
+ // engines the scenarios ask for (browsers: [webkit, firefox]), so doctor checks they are installed
1983
+ const engines = []
1984
+ for (const f of await scenarioFiles(config, root).catch(() => [])) { try { engines.push(...((yaml.load(await readFile(f, 'utf8')) || {}).browsers || [])) } catch {} }
1985
+ const r = await doctor({ config, root, file, fix: !!args.fix, engines })
1850
1986
  say(args.json ? JSON.stringify(r, null, 2) : formatDoctor(r))
1851
1987
  if (!r.ok) process.exitCode = 1
1852
1988
  },