kaloko 0.4.9 → 0.4.11

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 (39) hide show
  1. package/README.md +88 -4
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +35 -0
  4. package/schemas/scenario.v1.json +30 -1
  5. package/skills/kaloko/SKILL.md +22 -4
  6. package/skills/kaloko/reference/acceptance.md +16 -0
  7. package/skills/kaloko/reference/ci.md +70 -0
  8. package/skills/kaloko/reference/collaboration.md +9 -1
  9. package/skills/kaloko/reference/environments.md +5 -0
  10. package/skills/kaloko/reference/execution.md +56 -0
  11. package/skills/kaloko/reference/import.md +66 -0
  12. package/skills/kaloko/reference/mcp.md +2 -0
  13. package/skills/kaloko/reference/recording.md +95 -0
  14. package/src/cli.mjs +467 -100
  15. package/src/lib/affected-core.mjs +242 -0
  16. package/src/lib/affected.mjs +233 -0
  17. package/src/lib/anim.mjs +125 -0
  18. package/src/lib/apps/recording-native.mjs +212 -0
  19. package/src/lib/build.mjs +60 -4
  20. package/src/lib/ci.mjs +207 -0
  21. package/src/lib/config.mjs +2 -0
  22. package/src/lib/doctor.mjs +5 -1
  23. package/src/lib/evaluate.mjs +4 -2
  24. package/src/lib/git.mjs +6 -2
  25. package/src/lib/heal.mjs +207 -0
  26. package/src/lib/import.mjs +374 -0
  27. package/src/lib/init-detect.mjs +219 -0
  28. package/src/lib/onboarding.mjs +15 -8
  29. package/src/lib/pr.mjs +24 -2
  30. package/src/lib/recording.mjs +373 -0
  31. package/src/lib/scenario.mjs +2 -1
  32. package/src/lib/service.mjs +7 -1
  33. package/src/lib/trace.mjs +109 -0
  34. package/src/lib/unzip.mjs +31 -0
  35. package/src/lib/upload.mjs +114 -0
  36. package/src/lib/video-cli.mjs +209 -0
  37. package/src/lib/video.mjs +427 -0
  38. package/src/lib/webm.mjs +144 -0
  39. package/viewer/index.html +186 -7
package/src/cli.mjs CHANGED
@@ -1,9 +1,9 @@
1
- import { writeFile, access, readFile, mkdir } from 'node:fs/promises'
1
+ import { writeFile, access, readFile, mkdir, rm } from 'node:fs/promises'
2
2
  import { existsSync } from 'node:fs'
3
- import { gzipSync } from 'node:zlib'
4
- import { createHash } from 'node:crypto'
5
3
  import { join, resolve, relative, dirname } from 'node:path'
6
- import { pathToFileURL } from 'node:url'
4
+ import { pathToFileURL, fileURLToPath } from 'node:url'
5
+ import { AsyncLocalStorage } from 'node:async_hooks'
6
+ import { availableParallelism } from 'node:os'
7
7
  import yaml from 'js-yaml'
8
8
  import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FILE } from './lib/config.mjs'
9
9
  import { loadScenario, validateScenario } from './lib/scenario.mjs'
@@ -26,6 +26,7 @@ import { doctor, formatDoctor } from './lib/doctor.mjs'
26
26
  import { parsePlan, coverage, formatCoverage } from './lib/coverage.mjs'
27
27
  import { calibrate, formatCalibration, casesFromGolden, casesFromRun } from './lib/calibrate.mjs'
28
28
  import { pageFacts, draftScenario, scenarioYaml } from './lib/draft.mjs'
29
+ import { detectProject, initScenario, formatDetection } from './lib/init-detect.mjs'
29
30
  import { recheckCaptures } from './lib/recheck.mjs'
30
31
  import { resolveItems, fieldsOf, DEFAULT_LIMIT } from './lib/stack.mjs'
31
32
  import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
@@ -37,6 +38,12 @@ import { service, serviceUrl } from './lib/service.mjs'
37
38
  import { login } from './lib/login.mjs'
38
39
  import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
39
40
  import { specCmd, driftCmd } from './lib/spec-cli.mjs'
41
+ import { hashFiles, missingHashes, uploadRunFiles, PROGRESS_FILE, fmtBytes } from './lib/upload.mjs'
42
+ import { affectedFor, carryCaptures } from './lib/affected.mjs'
43
+ import { formatAffected, selectedIds } from './lib/affected-core.mjs'
44
+ import { healStep, formatHeal } from './lib/heal.mjs'
45
+ import { readSource, stepFor, viewportFor, adhocScenario, writeImported, attachImported, keepVideos, slug } from './lib/import.mjs'
46
+ import { findPreview, pullRequestOf, envLines, writeEnvFile, githubWorkflow, gitlabJob, addPreviewEnvironment } from './lib/ci.mjs'
40
47
  import { walkDesign, syncDesign, publishVersion, historyCmd, referenceCmd, tokensCmd, exportLive, localRevisions, branchCmd, uploadBlobs } from './lib/design-cli.mjs'
41
48
  import { designDir, readBase, headKey } from './lib/design.mjs'
42
49
  import { lintDesign, formatLint } from './lib/design-lint.mjs'
@@ -44,8 +51,10 @@ import { exportDesign } from './lib/design-export.mjs'
44
51
  import { driverFor, isApp, pngSize, ocr as ocrImage } from './lib/apps/drivers.mjs'
45
52
  import { captureApp } from './lib/apps/capture.mjs'
46
53
  import { readTree, flatten } from './lib/apps/tree.mjs'
54
+ import { recordSettings, recordingSession, recordingFiles, traceFiles, stripRecordings, withoutRecordings } from './lib/recording.mjs'
55
+ import { nativeSession } from './lib/apps/recording-native.mjs'
56
+ import { videoCmd } from './lib/video-cli.mjs'
47
57
 
48
- const fmtBytes = (n) => { n = Number(n) || 0; const u = ['B', 'kB', 'MB', 'GB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++ } return `${n.toFixed(i ? 1 : 0)} ${u[i]}` }
49
58
 
50
59
  const HELP = `kaloko — walk, assess and approve what your agents built, on one flow canvas
51
60
 
@@ -55,8 +64,18 @@ Project
55
64
  draft <url> [url…] [--id SITE-X] [--title t] [--out qa/flows/site.yml] [--force]
56
65
  First plan + read-only production scenario for public pages, drafted from the
57
66
  pages (title, description, H1) with the seo, a11y, perf and console packs
58
- init [--agent claude|codex|all] [--org <slug>]
59
- Create ${CONFIG_FILE} and install the kaloko skill for your agent
67
+ init [--agent claude|codex|all] [--org <slug>] [--no-detect] [--no-scenario]
68
+ Create ${CONFIG_FILE} and install the kaloko skill for your agent; detects the
69
+ framework, dev server URL, locales and main routes, and drafts a first scenario
70
+ (qa/flows/main-pages.yml) when the project has none
71
+ ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected]
72
+ [--out file] [--print] [--force]
73
+ Write a workflow that walks what each pull request affects on its preview
74
+ deployment (Vercel, Netlify, Cloudflare Pages, review apps) and comments on it;
75
+ adds the preview environment (base_url \${KALOKO_PREVIEW_URL}) to the config
76
+ ci preview-url [--wait 600] [--github-env] [--dotenv file]
77
+ In CI: the preview URL (KALOKO_PREVIEW_URL, the deployment event, provider
78
+ variables or the commit's GitHub deployments), the pull request and its base
60
79
  login [--org <slug>] [--url <service>] [--print] [--no-open]
61
80
  Sign this machine in through the browser: approve the code on kaloko.app, choose
62
81
  the organization and what the token may do; the token (at most 30 days) goes to
@@ -74,6 +93,7 @@ Run
74
93
  Create a run directory and make it current
75
94
  browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
76
95
  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)
77
97
  open --step <id> [--locale l] Navigate the current page to the step URL
78
98
  session reset New visitor: clear cookies and storage in the shared browser
79
99
  capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b]
@@ -105,6 +125,27 @@ Run
105
125
  path contains a, b; at most N pages (default items.limit, 500)
106
126
  [--stability N] Take N screenshots of each capture a moment apart and note regions that move
107
127
  (clocks, carousels); kaloko stability turns them into ignore suggestions
128
+ [--concurrency N] Locales at the same time, each in its own browser context (default: by CPU in an
129
+ own browser, 1 on the shared browser; walk.concurrency in the config)
130
+ [--affected [--base ref] [--no-upstream] [--if-unmapped all] [--carry]]
131
+ Only the steps the changed files reach (see kaloko affected), plus steps on the
132
+ main path before them and steps with affects: always; --carry copies the other
133
+ steps' captures from the previous local run
134
+ [--heal | --no-heal] A selector the page lost gets a proposal from the page (test id, role and name,
135
+ text) with a patch; --heal applies it to the step script once the step passes
136
+ [--record [step|flow|failed]] [--record-quality low|medium|high] [--no-trace]
137
+ Record each step from the previous screen to its capture (step), the whole walk
138
+ per locale with a chapter per step (flow), or only steps that fail (failed);
139
+ also capture.record in the config or scenario, record: true on a step.
140
+ A trace (actions, requests, console) is kept per step unless --no-trace
141
+ affected [--scenario file | --all] [--base ref] [--files a,b] [--json] [--check] [--offline]
142
+ Which steps changed files reach, and why: framework route files against step paths
143
+ and URLs captured earlier, imports, global files, i18n messages, step scripts and
144
+ affects: globs; --check exits 1 when nothing is affected
145
+ import <folder | playwright-report/ | results.json | trace.zip | walkthrough.md> [--scenario file | --adhoc]
146
+ [--env e] [--locale l] [--viewport v] [--map "Title=step,…"] [--format f] [--out file]
147
+ Screenshots, recordings and traces from agents and tools as a run: into the
148
+ current run, a new run of --scenario, or an ad-hoc flow of the screens
108
149
  status Show what the current run has captured
109
150
  Scenarios may declare "sessions" (people in the flow); walk keeps one browser context per session and locale
110
151
 
@@ -114,9 +155,16 @@ Results
114
155
  the semantic questions again on the stored captures (no walk);
115
156
  console, overflow and performance keep their live result
116
157
  build Write run.json and the local preview (index.html)
158
+ video export [--format mp4|webm|gif] [--locale l] [--steps a,b] [--lang en|cs] [--out file]
159
+ The run's recordings in flow order with a title card per step (title, verdict);
160
+ shared with the run on Business and up
161
+ video list | trace --step <id> | tools
162
+ Recordings and traces of the run; a step's trace as a timeline; ffmpeg, adb, simctl
117
163
  preview Build and print the preview path
118
- share [--expires 30] [--pr] Upload the run to kaloko.app (KALOKO_TOKEN, tester role);
119
- --pr adds or updates one comment on the branch's pull request via gh
164
+ share [--expires 30] [--pr] Upload the run to kaloko.app (KALOKO_TOKEN, tester role); files travel by content
165
+ hash, so unchanged captures are not sent again and a broken-off share resumes;
166
+ --pr adds or updates one comment on the branch's pull request via gh (KALOKO_PR in
167
+ CI), or a note on the GitLab merge request (KALOKO_GITLAB_TOKEN)
120
168
  feedback [--run id] [--scenario id] [--json]
121
169
  Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies;
122
170
  returned steps first, with their notes
@@ -237,7 +285,40 @@ function parse (argv) {
237
285
  return args
238
286
  }
239
287
  const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : null)
240
- const say = (...m) => console.log(...m)
288
+ // parallel walks (#27) print each locale's lines together, in locale order: `say` writes into the running unit's buffer
289
+ const output = new AsyncLocalStorage()
290
+ const say = (...m) => { const buf = output.getStore(); if (buf) buf.push(m.join(' ')); else console.log(...m) }
291
+
292
+ /**
293
+ * Runs `job` over `items`, `n` at a time, and returns the results in item order. The first item prints live; the
294
+ * others print in item order once every earlier one is done, so the log reads the same however the work interleaved.
295
+ */
296
+ export async function inOrder (items, n, job, print = (l) => console.log(l)) {
297
+ const results = new Array(items.length)
298
+ if (n <= 1) { for (const [i, it] of items.entries()) results[i] = await job(it); return results }
299
+ const bufs = items.map(() => []); const finished = items.map(() => false)
300
+ let printed = 1
301
+ const flush = () => { while (printed < items.length && finished[printed - 1] && finished[printed]) { for (const l of bufs[printed]) print(l); printed++ } }
302
+ const queue = items.map((_, i) => i)
303
+ const worker = async () => {
304
+ for (let i = queue.shift(); i !== undefined; i = queue.shift()) {
305
+ results[i] = await (i === 0 ? job(items[i]) : output.run(bufs[i], () => job(items[i])))
306
+ finished[i] = true
307
+ flush()
308
+ }
309
+ }
310
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker))
311
+ flush()
312
+ return results
313
+ }
314
+
315
+ /** Locales walked at once: --concurrency, walk.concurrency, else by CPU (one at a time on the shared browser). */
316
+ export function walkConcurrency (args, config, units, shared, cpus = availableParallelism()) {
317
+ const asked = Number(args.concurrency) || Number(config.walk?.concurrency)
318
+ if (asked) return Math.max(1, Math.min(16, Math.floor(asked)))
319
+ if (units < 2 || shared) return 1
320
+ return Math.max(1, Math.min(units, 4, Math.floor(cpus / 2)))
321
+ }
241
322
 
242
323
  async function context (args) {
243
324
  const { config, root, file } = await loadConfig(process.cwd(), args.config)
@@ -437,6 +518,14 @@ function linkFor (env, node, locale) {
437
518
  if (/^[a-z][a-z0-9+.-]*:/i.test(p)) return p
438
519
  return env.base_url ? urlFor(env, { path: p }, locale) : null
439
520
  }
521
+ /** A headless Chrome of its own for encoding recordings, started on first use (one per walk). */
522
+ function lazyEncoder (config) {
523
+ let started = null
524
+ return {
525
+ page: () => (started ||= launchEphemeral(config).then(async (b) => ({ b, p: await b.newPage() })).catch(() => ({ b: null, p: null }))).then((x) => x.p),
526
+ close: async () => { if (started) await (await started).b?.close().catch(() => {}) }
527
+ }
528
+ }
440
529
  const appViewport = (run) => Object.keys(run.meta.scope.viewports || {})[0] || null
441
530
 
442
531
  /**
@@ -454,6 +543,8 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
454
543
  const encoder = enc ? await enc.newPage().catch(() => null) : null
455
544
  const streak = failureStreak(args['keep-going'] ? 0 : 3)
456
545
  const maxChars = config.evaluators?.jev?.max_state_chars
546
+ // recordings and traces (batch D): Electron windows use the screencast recorder, devices record themselves
547
+ const recSettings = recordSettings({ args, config, scenario })
457
548
  try {
458
549
  for (const locale of locales) {
459
550
  const driver = driverFor(platform, env, { root, scenarioDir, locale })
@@ -462,6 +553,9 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
462
553
  if (driver.web) observeConsole(driver.page)
463
554
  say(`${platform} ${locale}: ${driver.web ? 'Electron window' : 'app'} ready`)
464
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)
465
559
  try {
466
560
  for (const node of scenario.nodes) {
467
561
  if (node.kind === 'decision' || (pick && !pick.includes(node.id))) continue
@@ -474,30 +568,42 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
474
568
  const link = linkFor(env, node, locale)
475
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 }
476
570
  if (streak.stopped(locale)) { skipped.push(`${node.id} (${locale})`); continue }
571
+ const records = []
477
572
  try {
478
573
  let captured = false
574
+ await rec?.beforeStep(node)
479
575
  const capture = async (o = {}) => {
480
576
  captured = true
577
+ await rec?.beforeCapture(node)
481
578
  if (driver.web) {
482
579
  const [w, h] = await driver.page.evaluate(() => [innerWidth, innerHeight])
483
- return 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' } })
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
484
583
  }
485
- return captureApp({ snapshot: await driver.snapshot(), node, locale, viewport: o.viewport || appViewport(run), run, platform, encoder, maxChars, extra: { url: link } })
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
486
587
  }
487
588
  if (scripted) {
488
589
  const mod = await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href)
489
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) })
490
591
  } else await driver.link(link)
491
592
  if (!captured) await capture()
593
+ await rec?.afterStep(node, driver.web ? { records } : { record: records[0] })
492
594
  say(`✓ ${node.id} ${locale}`)
493
595
  streak.ok(locale)
494
596
  } catch (error) {
597
+ await rec?.afterStep(node, driver.web ? { error, records } : { error, record: records[0] }).catch(() => {})
495
598
  failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
496
599
  say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
497
600
  if (streak.record(locale, error.message)) say(` stopping ${locale}: the last 3 steps failed with the same error`)
498
601
  }
499
602
  }
500
- } finally { await driver.close().catch(() => {}) }
603
+ } finally {
604
+ for (const n of (await rec?.finish().catch((e) => [`recording: ${e.message}`])) || []) say(` ${n}`)
605
+ await driver.close().catch(() => {})
606
+ }
501
607
  }
502
608
  } finally { await enc?.close().catch(() => {}) }
503
609
  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 - ')}`)
@@ -536,6 +642,13 @@ async function captureImage ({ config, run, scenario, node, locale, args }) {
536
642
  } finally { await enc?.close().catch(() => {}) }
537
643
  }
538
644
 
645
+ /** walk --affected / kaloko affected: the selection for one scenario (a token adds URLs of the newest shared run). */
646
+ async function affectedSelection (args, { config, root, scenario, scenarioFile }) {
647
+ let client = null
648
+ try { if (!args.offline) client = service(config) } catch {}
649
+ return affectedFor({ root, config, scenario, scenarioFile, base: typeof args.base === 'string' ? args.base : null, files: list(args.files), client, upstream: !args['no-upstream'] })
650
+ }
651
+
539
652
  const commands = {
540
653
  async init (args) {
541
654
  const cwd = process.cwd()
@@ -544,14 +657,22 @@ const commands = {
544
657
  try { await access(file); exists = true } catch {}
545
658
  // a project from before the rename keeps its qawalk.config.yml (it is still read)
546
659
  try { await access(join(cwd, LEGACY_CONFIG_FILE)); exists = true; say(`${LEGACY_CONFIG_FILE} found; kept (still read; rename it to ${CONFIG_FILE} when convenient).`) } catch {}
660
+ // what the project is (#26): framework, dev server, locales, main routes; --no-detect keeps the plain template
661
+ const detected = args['no-detect'] ? null : await detectProject(cwd)
662
+ if (detected) say(formatDetection(detected))
547
663
  if (!exists) {
548
664
  let tpl = await readFile(join(packageRoot, 'templates', 'kaloko.config.yml'), 'utf8')
549
665
  if (typeof args.org === 'string') {
550
666
  if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(args.org)) throw new UserError('--org must be the organization slug from kaloko.app (a-z, 0-9, -).')
551
667
  tpl = tpl.replace(/^org: .*$/m, `org: ${args.org}`)
552
668
  }
669
+ if (detected) {
670
+ const project = (detected.name || cwd.split('/').pop() || '').toLowerCase().replace(/^@[^/]+\//, '').replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
671
+ if (project) tpl = tpl.replace(/^project: .*$/m, `project: ${project}`)
672
+ tpl = tpl.replace(' local:\n base_url:\n default: http://localhost:3000', ` local:\n # dev server: ${detected.command || 'start your app'}${detected.framework ? ` (${detected.framework.name}, found by kaloko init)` : ''}\n base_url:\n default: ${detected.url}`)
673
+ }
553
674
  await writeFile(file, tpl)
554
- say(`Created ${CONFIG_FILE}. Edit org and environments.`)
675
+ say(`Created ${CONFIG_FILE}${detected ? ` (local: ${detected.url})` : ''}. Edit org and the staging and production environments.`)
555
676
  } else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
556
677
  const agent = typeof args.agent === 'string' ? args.agent : 'none'
557
678
  if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UserError('--agent must be claude, codex, all or none.')
@@ -584,12 +705,32 @@ const commands = {
584
705
  await cp(join(packageRoot, 'templates', 'steps', 'consent.mjs'), consentStep)
585
706
  say('Added qa/flows/steps/consent.mjs (accepts the consent dialog, if the site has one)')
586
707
  }
708
+ // a first scenario that walks as it is, when the project has none yet
709
+ let first = null
710
+ if (detected && !args['no-scenario']) {
711
+ try {
712
+ const { config, root } = await loadConfig(cwd)
713
+ if (!(await scenarioFiles(config, root)).length) {
714
+ const scenario = initScenario(detected)
715
+ const problems = validateScenario(JSON.parse(JSON.stringify(scenario)))
716
+ if (problems.length) throw new Error(problems.join('; '))
717
+ first = join(cwd, 'qa', 'flows', 'main-pages.yml')
718
+ await mkdir(join(cwd, 'qa', 'flows', 'steps'), { recursive: true })
719
+ await writeFile(first, `# First scenario drafted by kaloko init from ${detected.routes.length ? 'the route files' : 'the home page'}; sharpen purposes and criteria, add the flows that matter.\n${scenarioYaml(scenario)}`)
720
+ const open = join(cwd, 'qa', 'flows', 'steps', 'open.mjs')
721
+ if (!existsSync(open)) await writeFile(open, "// Opens the step's page (kaloko init).\nexport default async ({ page, url }) => { await page.goto(url(), { waitUntil: 'domcontentloaded' }) }\n")
722
+ say(`Drafted ${relative(cwd, first)}: ${scenario.nodes.length} page(s) in ${scenario.locales.join(', ')}`)
723
+ }
724
+ } catch (e) { say(`No scenario drafted: ${e.message.split('\n')[0]}`) }
725
+ }
726
+ let running = false
727
+ if (detected) { try { const r = await fetch(detected.url, { signal: AbortSignal.timeout(1500) }); running = r.status < 500 } catch {} }
587
728
  // the organization's sample run and the way to a first real flow (src/lib/onboarding.mjs)
588
729
  let written = ''
589
730
  try { written = await readFile(existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE), 'utf8') } catch {}
590
731
  const org = typeof args.org === 'string' ? args.org : (written.match(/^org:\s*["']?([a-z0-9-]+)["']?\s*$/m) || [])[1]
591
732
  const base = serviceUrl({ share: { url: (written.match(/^share:[ \t]*\n(?:[ \t]+.*\n)*?[ \t]+url:[ \t]*["']?(https?:\/\/[^\s"']+)/m) || [])[1] } })
592
- say(initNextSteps({ base, org }).join('\n'))
733
+ say(initNextSteps({ base, org, loggedIn: !!process.env.KALOKO_TOKEN, devServer: detected && !running ? detected : null, scenario: first && relative(cwd, first) }).join('\n'))
593
734
  say(`Scenario template: ${join(packageRoot, 'templates', 'scenario.yml')}`)
594
735
  },
595
736
 
@@ -647,6 +788,14 @@ const commands = {
647
788
  async browser (args) {
648
789
  const { config, root } = await context(args)
649
790
  const action = args._[0] || 'status'
791
+ if (action === 'install') {
792
+ // the Chromium of the Playwright version kaloko itself uses (CI: --with-deps adds the system libraries)
793
+ const { spawn } = await import('node:child_process')
794
+ 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}).`)
797
+ return
798
+ }
650
799
  let env = null
651
800
  if (typeof args.env === 'string') env = environment(config, args.env)
652
801
  else { try { const run = await resolveRun({ root, output: config.output, run: args.run }); env = environment(config, run.meta.environment.name) } catch {} }
@@ -750,8 +899,31 @@ const commands = {
750
899
  say(`✓ ${node.id} ${locale}: "${results.subject}" → ${results.count} viewport(s)`)
751
900
  },
752
901
 
902
+ async video (args) {
903
+ const { config, root } = await context(args)
904
+ await videoCmd({ config, root, args, say })
905
+ },
906
+
753
907
  async walk (args) {
754
908
  const { config, root, run, scenario, env } = await runContext(args)
909
+ if (args.affected) {
910
+ // only the steps the changes reach (#14), explained; the rest stays as it was (or is carried with --carry)
911
+ const r = await affectedSelection(args, { config, root, scenario, scenarioFile: resolve(root, run.meta.scenario_file) })
912
+ say(formatAffected(r.sel, { scenario, base: r.base, changed: r.files.length }))
913
+ if (args['if-unmapped'] === 'all' && r.sel.unmapped.length) say('Unmapped changes: walking every step (--if-unmapped all).\n')
914
+ else {
915
+ let ids = selectedIds(scenario, r.sel)
916
+ if (list(args.steps)) ids = ids.filter((id) => list(args.steps).includes(id))
917
+ if (!ids.length || r.sel.none) { say('Nothing to walk: no change reaches a step of this scenario.'); return }
918
+ args.steps = ids.join(',')
919
+ if (r.sel.locales && !args.locales) { const ls = r.sel.locales.filter((l) => run.meta.scope.locales.includes(l)); if (ls.length) args.locales = ls.join(',') }
920
+ if (args.carry) {
921
+ const c = await carryCaptures({ root, output: config.output, run, scenario, walked: new Set(ids) })
922
+ say(c ? `Carried ${c.steps.length} step(s) not walked from run ${c.from}: ${c.steps.join(', ')}` : 'Nothing to carry: no earlier local run of this scenario on this environment.')
923
+ }
924
+ say('')
925
+ }
926
+ }
755
927
  if (env.kind === 'design') {
756
928
  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'] })
757
929
  const lint = await lintDesign({ dir: designDir(env, root), scenario })
@@ -766,17 +938,23 @@ const commands = {
766
938
  const conn = args.ephemeral ? null : await connectBrowser({ root, config, env })
767
939
  const releaseLock = conn ? await acquireWalkLock({ root, config, env, run: run.meta.id }).catch(async (e) => { await conn.browser.close(); throw e }) : null
768
940
  const ephemeral = conn ? null : await launchEphemeral(config)
769
- const ctx = conn ? conn.context : await newContext(ephemeral, env, { root, config })
941
+ const browser = conn ? conn.browser : ephemeral
942
+ // locales are independent: with --concurrency > 1 each walks in its own browser context at the same time (#27);
943
+ // one locale at a time keeps the shared browser's own context, as before
944
+ const concurrency = walkConcurrency(args, config, locales.length, !!conn)
945
+ const parallel = concurrency > 1 && locales.length > 1
946
+ const ctx = parallel ? null : conn ? conn.context : await newContext(ephemeral, env, { root, config })
770
947
  const scenarioDir = resolve(root, run.meta.scenario_file, '..')
771
948
  // semantic questions of every new capture start right away; the walk ends with the verdicts (--no-evaluate: skip)
772
949
  const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root })
773
- const pending = []
774
- const failed = []
775
- const skipped = []
776
950
  const walked = await readCaptures(run.dir)
951
+ // recordings and traces (batch D, #12): opt-in recordings, traces unless turned off; encoded in an own headless Chrome
952
+ const recSettings = recordSettings({ args, config, scenario })
953
+ const encoder = lazyEncoder(config)
777
954
  const sessions = scenario.sessions || null
778
955
  // named sessions: one isolated browser context per person and locale (cookies, storage, sign-in), created lazily
779
956
  const sessionContexts = new Map()
957
+ const ownContexts = []
780
958
  // `account` stays the scenario's key; `credentials` resolves it in this environment (config accounts)
781
959
  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
782
960
  const contextFor = async (key, locale) => {
@@ -785,7 +963,7 @@ const commands = {
785
963
  if (!sessionContexts.has(id)) {
786
964
  // every context gets the environment's gate and readonly policy; an account's saved sign-in is loaded
787
965
  const account = accountFor(env, sessions[key].account, { root, config })
788
- const c = await newContext(conn ? conn.browser : ephemeral, env, { root, config, account })
966
+ const c = await newContext(browser, env, { root, config, account })
789
967
  const p = await c.newPage()
790
968
  observeConsole(p)
791
969
  sessionContexts.set(id, { context: c, page: p })
@@ -797,76 +975,265 @@ const commands = {
797
975
  // stop a session after the same failure repeats (shared precondition such as a failed sign-in);
798
976
  // --keep-going walks every step regardless
799
977
  const streak = failureStreak(args['keep-going'] ? 0 : 3)
800
- try {
801
- for (const locale of locales) {
802
- // the walk owns its tab; agents and people keep theirs in the same shared browser
803
- const page = await ctx.newPage()
804
- if (!conn) observeConsole(page)
805
- try {
806
- // every locale starts as a new visitor on its first walk in the run
807
- if (conn && !walked.some((c) => c.locale === locale)) { await resetSession(ctx, page, baseUrl(env, locale)); say(`session reset (new visitor, ${locale})`) }
808
- for (const node of scenario.nodes) {
809
- if (node.kind === 'decision' || (pick && !pick.includes(node.id))) continue
810
- if (node.kind !== 'stack' && node.path && !urlFor(env, node, locale)) continue // step does not exist in this locale
811
- 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 }
812
- const sess = await contextFor(node.session, locale)
813
- const sPage = sess ? sess.page : page
814
- const sCtx = sess ? sess.context : ctx
815
- if (node.kind === 'stack') {
816
- try {
817
- const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile: conn && !sess ? consoleLog(root, config.output, env.name) : null, args })
818
- for (const b of bad) failed.push(`${node.id} (${locale}) ${b}`)
819
- } catch (error) {
820
- failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
821
- say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
822
- }
823
- continue
978
+ 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]}`)
824
1012
  }
825
- if (node.kind === 'email' && (node.mail?.to || sessionInfo(node.session)?.mailbox)) {
826
- // an e-mail node with a recipient is walked like a step: wait for the message and capture it
827
- try {
828
- const to = expandRun(node.mail?.to || sessionInfo(node.session).mailbox, run.meta.short)
829
- const results = await captureMail({ config, root, run, env, node, locale, to, subject: localized(node.mail?.subject, locale), timeoutMs: 45000 })
830
- say(`✓ ${node.id} ${locale}: "${results.subject}"`)
831
- } catch (error) {
832
- failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
833
- say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
834
- }
835
- continue
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]}`)
836
1024
  }
837
- 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 }
838
- const streakKey = `${node.session || ''}|${locale}`
839
- if (streak.stopped(streakKey)) { skipped.push(`${node.id} (${locale})`); continue }
1025
+ continue
1026
+ }
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 }) }
840
1039
  try {
841
- const mod = await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href)
842
- // the automatic capture runs unless this script invocation captured the step itself: a crop is an
843
- // attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
844
- let captured = false
845
- const capture = (opts = {}) => { captured = true; return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, opts.viewports), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile: conn && !sess ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); return r }) }
846
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) })
847
1041
  if (!captured) await capture()
848
- say(`✓ ${node.id} ${locale}`)
1042
+ } catch (error) { await rec?.afterStep(node, { error }); throw error }
1043
+ await rec?.afterStep(node, { records })
1044
+ }
1045
+ 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 })}`)
849
1054
  streak.ok(streakKey)
850
- } catch (error) {
851
- failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
852
- say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
853
- 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`)
1055
+ continue
854
1056
  }
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`)
855
1061
  }
856
- } finally { await page.close().catch(() => {}) }
1062
+ }
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(() => {})
857
1066
  }
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: [] })))
858
1073
  } finally {
859
1074
  for (const { context } of sessionContexts.values()) await context.close().catch(() => {})
1075
+ for (const c of ownContexts) await c.close().catch(() => {})
860
1076
  if (conn) await conn.browser.close() // over CDP this only disconnects; the shared browser keeps running
861
1077
  if (ephemeral) await ephemeral.close()
1078
+ await encoder.close()
862
1079
  await releaseLock?.()
863
1080
  }
1081
+ const pending = results.flatMap((r) => r.pending); const failed = results.flatMap((r) => r.failed); const skipped = results.flatMap((r) => r.skipped)
864
1082
  if (pending.length) say(`\nSteps for an agent or a person (capture them with kaloko capture --step <id>):\n - ${[...new Set(pending)].join('\n - ')}`)
865
1083
  if (failed.length) { say(`\nSteps that failed (fix and rerun with --steps):\n - ${failed.join('\n - ')}`); process.exitCode = 1 }
1084
+ if (proposals.length) say(`\nSelector proposals above: review the patch, then kaloko walk --steps ${[...new Set(proposals)].join(',')} --heal applies it once the step passes.`)
866
1085
  if (skipped.length) { say(`\nSteps not walked after repeated failures (rerun with --steps once the cause is fixed):\n - ${skipped.join('\n - ')}`); process.exitCode = 1 }
867
1086
  if (prefetch) await walkVerdicts({ prefetch, run, scenario, config, root })
868
1087
  },
869
1088
 
1089
+ async affected (args) {
1090
+ const { config, root } = await context(args)
1091
+ let targets = []
1092
+ if (typeof args.scenario === 'string') targets = [resolve(args.scenario)]
1093
+ else {
1094
+ try { const r = await resolveRun({ root, output: config.output, run: args.run }); targets = [resolve(root, r.meta.scenario_file)] } catch {}
1095
+ if (!targets.length || args.all) targets = await scenarioFiles(config, root)
1096
+ }
1097
+ if (!targets.length) throw new UserError('No scenario: pass --scenario <file>, start a run, or list scenarios in the config.')
1098
+ const out = []
1099
+ for (const file of targets) {
1100
+ const scenario = await loadScenario(file)
1101
+ const r = await affectedSelection(args, { config, root, scenario, scenarioFile: file })
1102
+ out.push({ scenario: scenario.id, file: relative(root, file), base: r.base, changed: r.files, runs: r.runs, ...r.sel, walk: selectedIds(scenario, r.sel) })
1103
+ if (!args.json) say(formatAffected(r.sel, { scenario, base: r.base, changed: r.files.length }) + '\n')
1104
+ }
1105
+ if (args.json) say(JSON.stringify(targets.length === 1 && typeof args.scenario === 'string' ? out[0] : out, null, 2))
1106
+ else if (!args.check) say(`Walk them with: kaloko walk --affected${args.base ? ` --base ${args.base}` : ''} (after kaloko start)`)
1107
+ // --check: exit code 1 when nothing is affected (CI skips the scenario)
1108
+ if (args.check && !out.some((o) => o.walk.length && !o.none)) process.exitCode = 1
1109
+ },
1110
+
1111
+ /**
1112
+ * `kaloko import <path>` (#11): screenshots, recordings and traces from agents and tools as a run: into the current
1113
+ * run, a new run of --scenario, or an ad-hoc flow made from the screens themselves.
1114
+ */
1115
+ async import (args) {
1116
+ const from = args._[0]
1117
+ if (!from) throw new UserError('Usage: kaloko import <folder | playwright-report/ | results.json | trace.zip | walkthrough.md> [--scenario file | --adhoc] [--env e] [--locale l] [--viewport v] [--map "Title=step,…"]')
1118
+ const { config, root } = await context(args)
1119
+ let source
1120
+ try { source = await readSource(resolve(from), typeof args.format === 'string' ? args.format : 'auto') } catch (e) { throw new UserError(`kaloko import: ${e.message}`) }
1121
+ if (!source.items.length) throw new UserError(`No screenshots found in ${from} (${source.format}). A folder needs .png, .jpg or .webp files, a steps.json or a Markdown walkthrough with images; a Playwright report needs screenshot attachments or a trace.`)
1122
+ say(`Read ${source.items.length} screen(s)${source.videos.length ? ` and ${source.videos.length} recording(s)` : ''} from ${from} (${source.format}${source.tool !== source.format ? `, ${source.tool}` : ''})`)
1123
+ const envName = () => {
1124
+ if (typeof args.env === 'string') return args.env
1125
+ const names = Object.keys(config.environments).filter((n) => config.environments[n].kind !== 'design')
1126
+ for (const n of ['local', ...names]) { try { if (config.environments[n]) { environment(config, n); return n } } catch {} }
1127
+ throw new UserError('Pass --env <name>: no environment of the config resolves here.')
1128
+ }
1129
+ let run = null
1130
+ let scenarioFile = null
1131
+ if (typeof args.scenario === 'string') scenarioFile = resolve(args.scenario)
1132
+ else if (!args.adhoc) { try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {} }
1133
+ if (!run && !scenarioFile) {
1134
+ // an ad-hoc flow: one manual screen per imported item, in order; read-only, it never touched the environment
1135
+ const scenario = adhocScenario(source, { id: typeof args.id === 'string' ? args.id : undefined, title: typeof args.title === 'string' ? args.title : undefined, locale: typeof args.locale === 'string' ? args.locale : 'en' })
1136
+ scenario.readonly = true
1137
+ const problems = validateScenario(JSON.parse(JSON.stringify(scenario)))
1138
+ if (problems.length) throw new UserError(`The ad-hoc flow did not validate:\n - ${problems.join('\n - ')}`)
1139
+ scenarioFile = resolve(root, typeof args.out === 'string' ? args.out : join('qa', 'flows', `${slug(scenario.id)}.yml`))
1140
+ if (existsSync(scenarioFile) && !args.force) throw new UserError(`${relative(process.cwd(), scenarioFile)} exists; pass --out <file> or --force.`)
1141
+ await mkdir(dirname(scenarioFile), { recursive: true })
1142
+ await writeFile(scenarioFile, `# Ad-hoc flow from kaloko import ${from}: rename steps, add purposes and criteria, or map the screens onto a real scenario.\n${scenarioYaml(scenario)}`)
1143
+ say(`Wrote the ad-hoc flow ${relative(process.cwd(), scenarioFile)} (${scenario.nodes.length} steps)`)
1144
+ }
1145
+ if (!run) {
1146
+ await commands.start({ scenario: scenarioFile, env: envName(), locales: args.locales, viewports: args.viewports, executor: args.executor || `import:${source.tool}`, config: args.config, _: [] })
1147
+ run = await resolveRun({ root, output: config.output })
1148
+ } else say(`Importing into run ${run.meta.id} (${run.meta.scenario.id}); --adhoc or --scenario starts a new one`)
1149
+ const scenario = await loadScenario(resolve(root, run.meta.scenario_file))
1150
+ const map = Object.fromEntries((list(args.map) || []).map((p) => p.split('=')).filter((p) => p.length === 2 && p[0] && p[1]).map(([a, b]) => [a.trim(), b.trim()]))
1151
+ const locale = typeof args.locale === 'string' && run.meta.scope.locales.includes(args.locale) ? args.locale : run.meta.scope.locales[0]
1152
+ const enc = await launchEphemeral(config).catch(() => null)
1153
+ const encoder = enc ? await enc.newPage().catch(() => null) : null
1154
+ const used = new Set(); const unmatched = []; let captured = 0; let attached = 0
1155
+ try {
1156
+ for (const item of source.items) {
1157
+ const hit = stepFor(item, scenario.nodes, map)
1158
+ if (!hit) { unmatched.push(item.title); say(`? ${item.title}: no step matches`); continue }
1159
+ const loc = item.locale && run.meta.scope.locales.includes(item.locale) ? item.locale : locale
1160
+ const vp = viewportFor(item.size, run.meta.scope.viewports, item.viewport || (typeof args.viewport === 'string' ? args.viewport : null))
1161
+ const key = `${hit.node.id}|${loc}|${vp}`
1162
+ if (used.has(key)) { await attachImported({ run, node: hit.node, locale: loc, item }); attached++; say(`+ ${item.title} → attached to ${hit.node.id}`); continue }
1163
+ used.add(key)
1164
+ await writeImported({ run, node: hit.node, locale: loc, viewport: vp, item, tool: source.tool, encoder })
1165
+ captured++
1166
+ say(`✓ ${item.title} → ${hit.node.id} ${loc}/${vp} (${hit.how})`)
1167
+ }
1168
+ } finally { await enc?.close().catch(() => {}) }
1169
+ const kept = await keepVideos(run, source.videos)
1170
+ say(`\nImported ${captured} screen(s)${attached ? `, ${attached} more as attachments` : ''} into ${run.meta.id}.${encoder ? '' : ' (No Chrome: images kept as they are, without thumbnails.)'}`)
1171
+ if (unmatched.length) say(`Not matched to a step (${unmatched.length}): name the step in steps.json ("step": "<id>"), or pass --map "${unmatched[0]}=<step id>". Steps: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}`)
1172
+ if (kept.length) say(`Recordings kept in the run folder, not attached to a step and not uploaded: ${kept.join(', ')}. kaloko walk --record records steps for the canvas.`)
1173
+ say('Next: kaloko evaluate, then kaloko preview (local canvas) or kaloko share. Imported screens have no page to check: their deterministic criteria wait for a walk, semantic ones for a person.')
1174
+ },
1175
+
1176
+ /**
1177
+ * `kaloko ci github|gitlab` writes the workflow that walks pull request previews (#15); `kaloko ci preview-url`
1178
+ * runs inside it and finds the preview deployment, the pull request and its base.
1179
+ */
1180
+ async ci (args) {
1181
+ const action = args._[0]
1182
+ if (action === 'preview-url') {
1183
+ const err = (m) => console.error(m)
1184
+ const preview = await findPreview({ wait: Number(args.wait) || 0, say: err })
1185
+ const pr = await pullRequestOf().catch(() => null)
1186
+ if (!preview) throw new UserError('No preview URL found. Set KALOKO_PREVIEW_URL, or let Vercel, Netlify or Cloudflare Pages report deployments to GitHub (the workflow needs deployments: read).')
1187
+ const lines = envLines({ preview, pr })
1188
+ if (args['github-env']) {
1189
+ if (!process.env.GITHUB_ENV) throw new UserError('--github-env works inside GitHub Actions (GITHUB_ENV is not set).')
1190
+ await writeEnvFile(process.env.GITHUB_ENV, lines)
1191
+ }
1192
+ if (typeof args.dotenv === 'string') await writeEnvFile(resolve(args.dotenv), lines)
1193
+ err(`Preview ${preview.url} (from ${preview.source})${pr?.number ? `, pull request #${pr.number}${pr.base ? ` into ${pr.base}` : ''}` : ''}`)
1194
+ console.log(preview.url)
1195
+ return
1196
+ }
1197
+ if (!['github', 'gitlab'].includes(action)) throw new UserError('Usage: kaloko ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected] [--out file] [--print] [--force]\n kaloko ci preview-url [--wait 600] [--github-env] [--dotenv file]')
1198
+ const { config, root, file } = await context(args)
1199
+ const envName = typeof args.env === 'string' ? args.env : 'preview'
1200
+ const all = (await scenarioFiles(config, root)).map((f) => relative(root, f))
1201
+ const scenarios = list(args.scenario) || all
1202
+ if (!scenarios.length) throw new UserError('No scenarios to walk: pass --scenario <file>, or list scenario globs in the config.')
1203
+ // the environment the workflow walks: base_url from KALOKO_PREVIEW_URL, which preview-url sets
1204
+ if (!config.environments[envName]) {
1205
+ const text = await readFile(file, 'utf8')
1206
+ const next = addPreviewEnvironment(text, envName)
1207
+ if (next && !args.print) {
1208
+ const problems = (await import('./lib/schema.mjs')).schemaErrors('config.v1', yaml.load(next))
1209
+ if (problems.length) throw new UserError(`Adding ${envName} would make ${CONFIG_FILE} invalid: ${problems.join('; ')}`)
1210
+ await writeFile(file, next)
1211
+ 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
+ } else say(`Add to environments: in ${CONFIG_FILE}:\n ${envName}:\n base_url: \${KALOKO_PREVIEW_URL}`)
1213
+ }
1214
+ for (const f of scenarios) {
1215
+ let sc = null
1216
+ try { sc = yaml.load(await readFile(resolve(root, f), 'utf8')) } catch { throw new UserError(`${f} cannot be read.`) }
1217
+ if (sc?.environments && !sc.environments.includes(envName)) say(`! ${f} allows environments [${sc.environments.join(', ')}]: add ${envName}, or kaloko start refuses it in CI.`)
1218
+ }
1219
+ let pkg = null
1220
+ try { pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) } catch {}
1221
+ 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
+ const dependency = !!(pkg && ({ ...pkg.dependencies, ...pkg.devDependencies }).kaloko)
1223
+ 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' }
1225
+ const content = action === 'github' ? githubWorkflow(opts) : gitlabJob(opts)
1226
+ if (args.print) { say(content); return }
1227
+ const out = resolve(root, typeof args.out === 'string' ? args.out : action === 'github' ? '.github/workflows/kaloko.yml' : 'kaloko.gitlab-ci.yml')
1228
+ if (existsSync(out) && !args.force) throw new UserError(`${relative(process.cwd(), out)} exists; pass --force to replace it (or --print to compare).`)
1229
+ await mkdir(dirname(out), { recursive: true })
1230
+ await writeFile(out, content)
1231
+ say(`Wrote ${relative(process.cwd(), out)}: ${scenarios.length} scenario(s) on ${envName}${opts.affected ? ', only what each pull request affects' : ''}${action === 'github' ? `, ${opts.trigger === 'deployment' ? 'when a preview deployment succeeds' : 'on every pull request'}` : ''}.`)
1232
+ say(action === 'github'
1233
+ ? 'Next:\n add the repository secret KALOKO_TOKEN: a token with the tester role and the upload scope (Kaloko → Settings → API tokens; on Enterprise, a service account token)\n commit the workflow; the first preview deployment of a pull request walks it and comments on the PR\n Vercel deployment protection: add VERCEL_AUTOMATION_BYPASS_SECRET and uncomment access.headers in the preview environment'
1234
+ : 'Next:\n include it from .gitlab-ci.yml: include: { local: kaloko.gitlab-ci.yml }\n add the masked CI/CD variables KALOKO_TOKEN and KALOKO_GITLAB_TOKEN (api scope, for the merge request comment)\n make the job wait for your review app deploy (needs:), or set KALOKO_PREVIEW_URL in its variables')
1235
+ },
1236
+
870
1237
  async auth (args) {
871
1238
  const { config, root } = await context(args)
872
1239
  const action = args._[0]
@@ -1006,9 +1373,16 @@ const commands = {
1006
1373
  await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
1007
1374
  return
1008
1375
  }
1009
- const manifest = await buildManifest({ run, scenario, config, root, catalog: [] })
1376
+ let manifest = await buildManifest({ run, scenario, config, root, catalog: [] })
1010
1377
  await writePreview({ run, manifest })
1011
- const files = new Set()
1378
+ // the plan and storage before anything is sent; recordings travel on plans with the recordings feature only
1379
+ const u = await client.request('GET', `/orgs/${config.org}/usage`).catch(() => null)
1380
+ const recs = recordingFiles(manifest)
1381
+ if (recs.length && u?.limits?.features && !u.limits.features.recordings) {
1382
+ manifest = stripRecordings(manifest)
1383
+ say(`Recordings stay in the local preview (${recs.length} file(s)): sharing them is part of the Business plan and up, and this organization is on ${u.limits.plan_name}. Screenshots and traces are shared as usual.`)
1384
+ }
1385
+ const files = new Set([...traceFiles(manifest), ...recordingFiles(manifest)])
1012
1386
  for (const n of manifest.nodes) {
1013
1387
  for (const byVp of Object.values(n.captures || {})) for (const c of Object.values(byVp)) [c.image || c.png, c.thumb, c.html, c.eval, c.tree, c.spec].filter(Boolean).forEach((p) => files.add(p))
1014
1388
  for (const a of n.attachments || []) files.add(a.file)
@@ -1020,39 +1394,31 @@ const commands = {
1020
1394
  }
1021
1395
  }
1022
1396
  const days = Number(args.expires) || config.share?.expires_days || 30
1023
- const { stat } = await import('node:fs/promises')
1024
- let total = 0
1025
- for (const p of files) total += (await stat(join(run.dir, p))).size
1397
+ // by content hash (#28): files the organization already stores (earlier runs, an interrupted share) are not sent
1398
+ let entries = await hashFiles(run.dir, [...files])
1399
+ // a recording above the service's file limit stays local (with its poster and preview), the rest is shared
1400
+ const tooBig = new Set(entries.filter((e) => /\.rec\./.test(e.path) && u?.limits?.max_file_bytes && e.bytes > u.limits.max_file_bytes).map((e) => e.path))
1401
+ if (tooBig.size) {
1402
+ manifest = withoutRecordings(manifest, tooBig)
1403
+ const keep = new Set([...traceFiles(manifest), ...recordingFiles(manifest)])
1404
+ entries = entries.filter((e) => !/\.rec\./.test(e.path) || keep.has(e.path))
1405
+ say(`${tooBig.size} recording(s) larger than ${fmtBytes(u.limits.max_file_bytes)} stay in the local preview: ${[...tooBig].slice(0, 3).join(', ')}${tooBig.size > 3 ? ', …' : ''}. Record them with record.quality: low or a lower max_seconds to share them.`)
1406
+ }
1407
+ const missing = await missingHashes(client, config.org, entries.map((e) => e.hash))
1408
+ const total = entries.reduce((n, e) => n + e.bytes, 0)
1409
+ const fresh = missing ? [...new Map(entries.filter((e) => missing.has(e.hash)).map((e) => [e.hash, e.bytes])).values()].reduce((n, b) => n + b, 0) : total
1410
+ const resumed = (await readJson(join(run.dir, PROGRESS_FILE), null).catch(() => null))?.run === manifest.id
1026
1411
  try {
1027
- const u = await client.request('GET', `/orgs/${config.org}/usage`)
1412
+ if (!u) throw new Error('usage unavailable')
1028
1413
  if (total > u.limits.max_run_bytes) throw new UserError(`This run is ${fmtBytes(total)}; the limit per run is ${fmtBytes(u.limits.max_run_bytes)}. Capture fewer viewports or locales, or ask an admin for a higher limit.`)
1029
- if (u.usage.bytes + total > u.limits.quota_bytes) throw new UserError(`Storage quota would be exceeded: ${fmtBytes(u.usage.bytes)} used + ${fmtBytes(total)} > ${fmtBytes(u.limits.quota_bytes)}. Delete old runs in Settings or ask for a higher quota.`)
1030
- say(`Uploading ${files.size} files, ${fmtBytes(total)} (organization uses ${fmtBytes(u.usage.bytes)} of ${fmtBytes(u.limits.quota_bytes)})`)
1414
+ if (u.usage.bytes + fresh > u.limits.quota_bytes) throw new UserError(`Storage quota would be exceeded: ${fmtBytes(u.usage.bytes)} used + ${fmtBytes(fresh)} > ${fmtBytes(u.limits.quota_bytes)}. Delete old runs in Settings or ask for a higher quota.`)
1415
+ const recBytes = entries.filter((e) => /\.rec\./.test(e.path)).reduce((n, e) => n + e.bytes, 0)
1416
+ say(`${resumed ? 'Resuming the interrupted share: ' : ''}${files.size} files, ${fmtBytes(total)}${recBytes ? ` (recordings ${fmtBytes(recBytes)})` : ''}${missing ? `; ${fmtBytes(fresh)} new, the rest already stored` : ''} (organization uses ${fmtBytes(u.usage.bytes)} of ${fmtBytes(u.limits.quota_bytes)})`)
1031
1417
  } catch (e) { if (e instanceof UserError && /limit|quota/.test(e.message)) throw e }
1032
1418
  const created = await client.request('POST', `/orgs/${config.org}/runs?expires_days=${days}`, manifest)
1033
- // text files travel and rest gzip-compressed; the service serves them with Content-Encoding: gzip
1034
- const body = async (p) => { const raw = await readFile(join(run.dir, p)); const gz = /\.(html|json)$/.test(p) && raw.length > 512; return { data: gz ? gzipSync(raw, { level: 6 }) : raw, gz } }
1035
- // the file list goes first: the service checks limits once and each upload is then a plain write
1036
- // (an older service without the plan answers 404/405 and every upload checks itself, as before)
1037
- // screenshots name their SHA-256: the service checks it on arrival and compares unchanged steps with it
1038
- if (files.size) {
1039
- const plan = []
1040
- for (const p of files) { const { data } = await body(p); plan.push({ path: p, bytes: data.length, ...(/\.(png|webp|jpe?g)$/i.test(p) ? { sha256: createHash('sha256').update(data).digest('hex') } : {}) }) }
1041
- await client.request('POST', `/orgs/${config.org}/runs/${manifest.id}/files`, { files: plan }).catch((e) => { if (!/HTTP (404|405)/.test(e.message)) throw e })
1042
- }
1043
- const queue = [...files]
1044
- let done = 0
1045
- const worker = async () => {
1046
- while (queue.length) {
1047
- const p = queue.shift()
1048
- const { data, gz } = await body(p)
1049
- await client.upload(`/orgs/${config.org}/runs/${manifest.id}/files/${p.split('/').map(encodeURIComponent).join('/')}`, data, p, gz ? { 'Content-Encoding': 'gzip' } : {})
1050
- done++
1051
- if (done % 10 === 0 || done === files.size) process.stdout.write(`\r uploaded ${done}/${files.size}`)
1052
- }
1053
- }
1054
- await Promise.all(Array.from({ length: files.size > 200 ? 12 : 6 }, worker))
1055
- if (files.size) process.stdout.write('\n')
1419
+ await writeFile(join(run.dir, PROGRESS_FILE), JSON.stringify({ run: manifest.id, started_at: new Date().toISOString() }) + '\n').catch(() => {})
1420
+ const up = await uploadRunFiles({ client, org: config.org, runId: manifest.id, dir: run.dir, entries, missing, say })
1421
+ if (up.mode === 'blobs') say(` ${up.uploaded} new file(s) sent (${fmtBytes(up.bytes)}), ${up.reused} unchanged file(s) reused`)
1056
1422
  // experimental HTML views (#42): their bundles travel as blobs and attach to the run as revisions
1057
1423
  const viewIds = new Set(manifest.nodes.flatMap((n) => Object.values(n.captures || {}).flatMap((vps) => Object.values(vps).map((c) => c.view?.revision).filter(Boolean))))
1058
1424
  if (viewIds.size) {
@@ -1067,6 +1433,7 @@ const commands = {
1067
1433
  } catch (e) { say(`HTML views not attached: ${e.message.split('\n')[0]}`) }
1068
1434
  }
1069
1435
  const fin = await client.request('POST', `/orgs/${config.org}/runs/${manifest.id}/finalize`)
1436
+ await rm(join(run.dir, PROGRESS_FILE), { force: true })
1070
1437
  const meta = await readJson(join(run.dir, 'meta.json'))
1071
1438
  meta.shared = { url: fin.url, at: new Date().toISOString(), expires_days: created.expires_days }
1072
1439
  await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')