kaloko 0.18.0 → 0.20.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 (44) hide show
  1. package/package.json +1 -1
  2. package/schemas/config.v1.json +47 -1
  3. package/schemas/run.v1.json +57 -1
  4. package/schemas/scenario.v1.json +100 -14
  5. package/skills/kaloko/reference/acceptance.md +18 -4
  6. package/skills/kaloko/reference/evaluation.md +44 -1
  7. package/skills/kaloko/reference/integrations.md +30 -1
  8. package/skills/kaloko/reference/mcp.md +3 -0
  9. package/skills/kaloko/reference/stacks.md +181 -16
  10. package/src/cli.mjs +191 -37
  11. package/src/lib/build.mjs +37 -7
  12. package/src/lib/capture.mjs +31 -9
  13. package/src/lib/config.mjs +15 -1
  14. package/src/lib/crawler.mjs +242 -0
  15. package/src/lib/draft.mjs +2 -0
  16. package/src/lib/evaluate.mjs +124 -38
  17. package/src/lib/findings.mjs +98 -0
  18. package/src/lib/health-core.mjs +28 -5
  19. package/src/lib/health-findings.mjs +116 -0
  20. package/src/lib/health-index.mjs +202 -0
  21. package/src/lib/health.mjs +4 -0
  22. package/src/lib/integrations-cli.mjs +6 -4
  23. package/src/lib/jev.mjs +34 -14
  24. package/src/lib/linkcheck.mjs +59 -22
  25. package/src/lib/local-evaluator.mjs +270 -0
  26. package/src/lib/manifest-core.mjs +115 -14
  27. package/src/lib/pack-help.mjs +26 -0
  28. package/src/lib/packs.mjs +41 -3
  29. package/src/lib/pii.mjs +2 -0
  30. package/src/lib/pr.mjs +5 -1
  31. package/src/lib/probe.mjs +62 -1
  32. package/src/lib/recheck.mjs +5 -1
  33. package/src/lib/scenario.mjs +16 -3
  34. package/src/lib/seo-core.mjs +400 -0
  35. package/src/lib/seo-init.mjs +272 -0
  36. package/src/lib/service.mjs +31 -2
  37. package/src/lib/site.mjs +362 -0
  38. package/src/lib/stack-core.mjs +244 -0
  39. package/src/lib/stack.mjs +291 -51
  40. package/src/lib/traffic-core.mjs +289 -0
  41. package/src/lib/traffic.mjs +149 -0
  42. package/src/lib/upload.mjs +3 -2
  43. package/src/lib/variants-core.mjs +198 -0
  44. package/viewer/index.html +49 -24
package/src/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url'
5
5
  import { AsyncLocalStorage } from 'node:async_hooks'
6
6
  import { availableParallelism } from 'node:os'
7
7
  import yaml from 'js-yaml'
8
- import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FILE } from './lib/config.mjs'
8
+ import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FILE, projectRequirement } from './lib/config.mjs'
9
9
  import { loadScenario, parseScenario, validateScenario, scenarioWarnings } from './lib/scenario.mjs'
10
10
  import { createRun, resolveRun, readCaptures, listRuns } from './lib/run.mjs'
11
11
  import { startRefs, normalizePurpose, RUN_PURPOSES } from './lib/run-refs.mjs'
@@ -15,13 +15,13 @@ import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/c
15
15
  import { observeConsole } from './lib/console.mjs'
16
16
  import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.mjs'
17
17
  import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher, gateForRequests } from './lib/access.mjs'
18
- import { configureGate } from './lib/linkcheck.mjs'
18
+ import { configureGate, disposeShared } from './lib/linkcheck.mjs'
19
19
  import { rememberEnvironment } from './lib/secrets.mjs'
20
20
  import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
21
- import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
21
+ import { buildManifest, writePreview, attentionLines, runTraffic } from './lib/build.mjs'
22
22
  import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
23
23
  import { signoffCmd, evidenceCmd } from './lib/signoff.mjs'
24
- import { localized, readJson, UserError, UsageError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG, EXIT, parseDuration } from './lib/util.mjs'
24
+ import { localized, readJson, writeJson, UserError, UsageError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG, EXIT, parseDuration } from './lib/util.mjs'
25
25
  import { noteLegacy, legacyHint, resetLegacy, legacyInUse } from './lib/legacy.mjs'
26
26
  import { packageRoot } from './lib/schema.mjs'
27
27
  import { compareRuns, formatComparison } from './lib/compare.mjs'
@@ -32,14 +32,16 @@ import { pageFacts, draftScenario, scenarioYaml } from './lib/draft.mjs'
32
32
  import { detectProject, initScenario, skeletonScenario, scenarioHeader, formatDetection } from './lib/init-detect.mjs'
33
33
  import { checkSlug, checkName, setConfigKeys, createProject, renameProject, syncProject, shareProjectNote } from './lib/projects-cli.mjs'
34
34
  import { recheckCaptures } from './lib/recheck.mjs'
35
- import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft } from './lib/stack.mjs'
35
+ import { runFindings, findingsText } from './lib/findings.mjs'
36
+ import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage, sourceLocaleOf, variantReader } from './lib/stack.mjs'
37
+ import { walkSiteChecks, siteChecksOn } from './lib/site.mjs'
36
38
  import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
37
39
  import { expandPacks } from './lib/packs.mjs'
38
40
  import { upsertPrComment } from './lib/pr.mjs'
39
41
  import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
40
42
  import { healthCmd } from './lib/health.mjs'
41
43
  import { htmlViewAllowed } from './lib/htmlview.mjs'
42
- import { service, serviceUrl } from './lib/service.mjs'
44
+ import { service, serviceUrl, shareNotFound } from './lib/service.mjs'
43
45
  import { parseArgs, helpFor, checkOptions, closest, catalogue, unknownOptions, subcommandsFor, takesWords } from './lib/cli-args.mjs'
44
46
  import { issueCmd, scheduleCmd } from './lib/integrations-cli.mjs'
45
47
  import { login } from './lib/login.mjs'
@@ -114,6 +116,11 @@ Project
114
116
  listed); without it no routes are guessed, and --skeleton writes a scenario with
115
117
  a TODO instead. --base-url reads the pages from the running site (sitemap.xml,
116
118
  then the links of its home page) instead of the code; only that host is asked
119
+ [--seo <site> [--env production] [--families 4] [--limit 50] [--packs seo,aeo,a11y,perf] [--out file] [--force]]
120
+ A weekly SEO and answer-engine monitor of a public site: reads robots.txt and its
121
+ sitemaps, finds the languages and the biggest content families (first path segment,
122
+ translated per language) and writes qa/flows/seo-<site>.yml with one rotating stack
123
+ step per family (packs seo and aeo, a11y and perf on request)
117
124
  ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected]
118
125
  [--out file] [--print] [--force]
119
126
  Write a workflow that walks what each pull request affects on its preview
@@ -200,8 +207,11 @@ Run
200
207
  --devices walks only those; dark mode and reduced motion are set per capture
201
208
  Semantic questions start as each step is captured; the walk ends with the
202
209
  verdicts and what failed (--no-evaluate skips that; kaloko evaluate redoes it)
210
+ [--budget N] At most N uncached evaluator calls for the walk and its evaluation (default
211
+ evaluate.budget.calls); semantic criteria past it stay not evaluated
203
212
  [--items a,b] [--limit N] Stack steps (kind: stack, many pages of one template): only pages whose id or
204
- path contains a, b; at most N pages (default items.limit, 500)
213
+ path contains a, b, from every matching page (also outside this week's rotation,
214
+ which such a walk leaves as it was); at most N pages (default items.limit, 500)
205
215
  [--stability N] Take N screenshots of each capture a moment apart and note regions that move
206
216
  (clocks, carousels); kaloko stability turns them into ignore suggestions
207
217
  [--concurrency N] Locales at the same time, each in its own browser context (default: by CPU in an
@@ -231,6 +241,8 @@ Run
231
241
 
232
242
  Results
233
243
  evaluate [--json] Deterministic checks + semantic evaluator (default JEV) for all captures
244
+ evaluate --budget N At most N uncached evaluator calls (default evaluate.budget.calls): steps first,
245
+ then stack pages failing a check or checked longest ago; the rest not evaluated
234
246
  evaluate --recheck [--steps a,b] After a scenario change: re-run deterministic checks on the stored HTML and ask
235
247
  the semantic questions again on the stored captures (no walk);
236
248
  console, overflow and performance keep their live result
@@ -304,6 +316,12 @@ Results
304
316
  Newest capture of every step as steps/<step>/<locale>/<viewport>.<ext> plus
305
317
  index.json (documentation layer, Business plan); unchanged files are skipped
306
318
  (also kaloko export --scenario <id>)
319
+ export findings [<run>] [--format csv|json] [--locale l] [--step id] [--out file]
320
+ The fix list of a local run: every failing criterion and health check per page and
321
+ locale with its evidence, the page's edit_url and content_ref (from the item script
322
+ or items.resolve) and the walk that checks it again (kaloko walk --steps s --items p);
323
+ with traffic: in the config, also clicks, impressions, sessions and an impact
324
+ score, sorted by it
307
325
  public [--run id] [--off] Make a shared run readable without sign-in (admin; testers may refresh an already public scenario)
308
326
  stability [--last 4 | --runs a,b] [--same-commit] [--apply] [--json]
309
327
  Regions that change on every run or move while the page is idle (clocks, ads,
@@ -472,10 +490,10 @@ async function warnRetired (args) {
472
490
  }
473
491
 
474
492
  /** The end of a walk: evaluate (the semantic answers are mostly cached by now) and say what did not pass. */
475
- async function walkVerdicts ({ prefetch, run, scenario, config, root }) {
493
+ async function walkVerdicts ({ prefetch, run, scenario, config, root, budget = null }) {
476
494
  const t0 = Date.now()
477
495
  await prefetch.done()
478
- const report = await evaluateRun({ run, scenario, config, root })
496
+ const report = await evaluateRun({ run, scenario, config, root, budget })
479
497
  // health checks (advisory) are counted apart; they never make a step fail
480
498
  const health = report.findings.filter((f) => f.health); const accept = report.findings.filter((f) => !f.health)
481
499
  const fails = accept.filter((f) => f.verdict === 'fail'); const unsure = accept.filter((f) => f.verdict === 'needs_review')
@@ -487,9 +505,17 @@ async function walkVerdicts ({ prefetch, run, scenario, config, root }) {
487
505
  if (fails.length) say(` ✕ ${fails.slice(0, 15).map(withFix).join('\n ✕ ')}${fails.length > 15 ? `\n … ${fails.length - 15} more (kaloko build lists them all)` : ''}`)
488
506
  if (unsure.length) say(` ? ${unsure.slice(0, 8).map(line).join('\n ? ')}${unsure.length > 8 ? `\n … ${unsure.length - 8} more` : ''}`)
489
507
  if (report.skipped.length) say(` Semantic criteria not evaluated: ${[...new Set(report.skipped.map((x) => x.split(': ').slice(1).join(': ')))].slice(0, 2).join('; ')}`)
508
+ if (report.budget) say(budgetLine(report))
490
509
  if (health.length) say(healthLine(health))
491
510
  }
492
511
 
512
+ /** One line on the evaluator budget (#158): calls used, and how many captures and uncertain answers it left out. */
513
+ function budgetLine (report) {
514
+ const b = report.budget
515
+ const out = report.evaluator?.escalation_skipped ? `, ${report.evaluator.escalation_skipped} uncertain answer(s) not escalated` : ''
516
+ return `Evaluator budget: ${b.used} of ${b.calls} call(s) used${b.skipped ? `; semantic criteria of ${b.skipped} capture(s) not evaluated (raise evaluate.budget.calls or pass --budget)` : ''}${out}.`
517
+ }
518
+
493
519
  /** One line on the health findings of an evaluation: counts by severity (one per check and step), never a verdict. */
494
520
  function healthLine (findings) {
495
521
  const worst = new Map()
@@ -783,24 +809,48 @@ async function exportScenario ({ args, config, root, client }) {
783
809
  * Walks a stack step: resolves its pages, then opens and captures each one with the step's criteria and index
784
810
  * fields (HTTP status of the main document included). A step script, when given, runs per page with `item`.
785
811
  */
786
- async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, args, keys = null, tag = '' }) {
787
- const { items, total, truncated } = await resolveItems({ node, base: baseUrl(env, locale), page, scenarioDir, env, locale, limit: Number(args.limit) || undefined })
788
- const only = list(args.items)
789
- const chosen = only ? items.filter((it) => only.some((q) => it.id === q || it.path.includes(q))) : items
812
+ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, args, keys = null, tag = '', cache = null, gate = null }) {
813
+ // a rotating sample (items.rotate) remembers between runs how many runs walked it and when each page was checked
814
+ const statePath = (l) => join(run.dir, '..', '.cache', 'stack', scenario.id, `${node.id}.${l}.json`)
815
+ const state = node.items?.rotate ? await rotationState(statePath(locale), run.meta.id) : null
816
+ // locale variants (#176): every locale walks the window of the source list, so a rotation by runs counts the runs of
817
+ // the step once for all locales (whichever of them a run walks), not per locale
818
+ const sourceLocale = node.items?.variants ? sourceLocaleOf(node, scenario, locale) : locale
819
+ const counter = state && node.items.variants && node.items.rotate === 'run' ? await rotationState(statePath('variants'), run.meta.id) : null
820
+ const round = counter ? counter.run : state?.run || 0
821
+ // locale variants read the source pages for their hreflang with the environment's access (variantReader)
822
+ const reader = node.items?.variants ? await variantReader({ page, gate, origins: envOrigins(env, scenario, locale) }) : null
823
+ let resolved
824
+ try { resolved = await resolveItems({ node, base: baseUrl(env, locale), sourceBase: baseUrl(env, sourceLocale), page, scenarioDir, env, locale, scenario, limit: Number(args.limit) || undefined, rotation: { run: round }, checked: state?.checked || null, only: list(args.items), cache, readPage: reader?.read || null }) } finally { await reader?.dispose() }
825
+ const { items, total, truncated, all, rotation, partial, variants, resolve_failed: unresolved, resolve_error: unresolvedWhy } = resolved
826
+ if (unresolved) say(`! ${node.id} ${locale}${tag}: items.resolve failed for ${unresolved} page(s), walked without an edit link (${unresolvedWhy})`)
827
+ if (variants) {
828
+ // the build marks the pages without a version in this locale; a partial walk (--items) updates its pages only
829
+ const file = join(run.dir, 'steps', node.id, `variants.${locale}.json`)
830
+ const before = partial ? await readJson(file, null) : null
831
+ const asked = new Set([...items.map((it) => it.id), ...variants.missing.map((m) => m.id)])
832
+ const keep = (list) => (before?.[list] || []).filter((m) => !asked.has(m.id) && !(m.ids || []).some((id) => asked.has(id)))
833
+ await writeJson(file, before ? { ...variants, pages: before.pages, missing: [...keep('missing'), ...variants.missing], shared: [...keep('shared'), ...variants.shared] } : variants)
834
+ if (variants.missing.length) say(`! ${node.id} ${locale}${tag}: ${variants.missing.length} of ${variants.pages} ${variants.source_locale} page(s) have no ${locale} version (hreflang), e.g. ${variants.missing[0].path}: ${variants.missing[0].reason}`)
835
+ if (variants.shared.length) say(`! ${node.id} ${locale}${tag}: ${variants.shared.length} ${locale} page(s) stand for several ${variants.source_locale} pages, e.g. ${variants.shared[0].url}`)
836
+ }
837
+ // --items picked the pages from the whole list (resolveItems only); a partial walk leaves the rotation state alone
838
+ const chosen = items
790
839
  const passKeys = keys || engineKeys(run.meta.scope, 'chromium')
791
840
  const bases = baseViewports(run.meta.scope)
792
841
  const vpNames = (node.items.viewports || []).filter((v) => bases.includes(v) || run.meta.scope.viewports[v])
793
842
  const passBases = baseViewports({ ...run.meta.scope, viewports: Object.fromEntries(passKeys.map((k) => [k, run.meta.scope.viewports[k]])) })
794
843
  const viewports = scopeKeys(run.meta.scope, passKeys, vpNames.length ? vpNames : [passBases[0] || bases[0]])
795
844
  if (!Object.keys(viewports).length) { say(`▦ ${node.id} ${locale}${tag}: no viewport of this step in this pass`); return [] }
796
- 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)` : ''}`)
845
+ if (rotation) say(`▦ ${node.id} ${locale}${tag}: rotating sample, ${rotation.week ? `week ${rotation.week}` : `run ${rotation.run}`} — pages ${rotation.offset + 1}…${((rotation.offset + (variants?.pages ?? items.length) - 1) % Math.max(1, total)) + 1} of ${total}, all covered in ${rotation.rounds} ${rotation.week ? 'weeks' : 'runs'}`)
846
+ say(`▦ ${node.id} ${locale}${tag}: ${chosen.length} page(s)${rotation ? '' : truncated ? ` — limit ${Number(args.limit) || node.items.limit || DEFAULT_LIMIT} reached (${total}+ found; raise items.limit or pass --limit)` : ''}`)
797
847
  const fields = fieldsOf(node)
798
848
  const script = node.execution?.script ? await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href) : null
799
849
  let status = null
800
850
  const onResponse = (r) => { if (r.request().isNavigationRequest() && r.frame() === page.mainFrame()) status = r.status() }
801
851
  page.on('response', onResponse)
802
852
  const streak = failureStreak(5)
803
- const failed = []; let ok = 0
853
+ const failed = []; let ok = 0; const checkedNow = []
804
854
  const started = Date.now(); let told = started
805
855
  try {
806
856
  for (const [i, item] of chosen.entries()) {
@@ -813,7 +863,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
813
863
  if (script) await script.default({ page, item, url: () => item.url, locale, env, run: run.meta, capture, acceptDialogs: () => acceptDialogs(page) })
814
864
  else await page.goto(item.url, { waitUntil: 'domcontentloaded', timeout: 30000 })
815
865
  if (!captured) await capture()
816
- ok++; streak.ok('stack')
866
+ ok++; streak.ok('stack'); checkedNow.push(item.source_path || item.path)
817
867
  } catch (error) {
818
868
  failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
819
869
  if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
@@ -822,10 +872,19 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
822
872
  if (i + 1 < chosen.length && ((i + 1) % 25 === 0 || Date.now() - told > 30000)) { told = Date.now(); const left = timeLeft(i + 1, chosen.length, told - started); say(` ${node.id} ${locale}: ${i + 1}/${chosen.length}${left ? ` · ${left}` : ''}`) }
823
873
  }
824
874
  } finally { page.off('response', onResponse) }
875
+ // the rotation of this locale goes into the run (the stack index shows it) and the pages checked into the state
876
+ if (state && rotation) {
877
+ const checked = await state.save(checkedNow, all)
878
+ if (counter) await counter.save([], [])
879
+ await writeJson(join(run.dir, 'steps', node.id, `rotation.${locale}.json`), { ...rotation, ...rotationCoverage(all, checked) })
880
+ }
825
881
  say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}${tag}: ${ok}/${chosen.length} page(s) captured`)
826
882
  return failed
827
883
  }
828
884
 
885
+ /** The origins of the environment for the scenario's locales: where the gate and the walk's cookies may go. */
886
+ const envOrigins = (env, scenario, locale) => [...new Set([...(scenario.locales || []), locale].map((l) => { try { return new URL(baseUrl(env, l)).origin } catch { return null } }).filter(Boolean))]
887
+
829
888
  /** Deep link of an app step: a URL scheme as written, a path under the environment's base URL (universal links). */
830
889
  function linkFor (env, node, locale) {
831
890
  const raw = typeof node.path === 'string' ? node.path : (node.path?.[locale] ?? node.path?.default)
@@ -1029,21 +1088,66 @@ export function exportSubcommand (args) {
1029
1088
  const sub = args._[0]
1030
1089
  if (sub === undefined) return null
1031
1090
  // the flags of the other kinds (live and design exports take --scenario too)
1032
- const clashes = { run: ['live', 'design', 'format', 'scenario'], steps: ['live', 'design', 'format'], docs: ['live', 'design', 'scenario'], live: ['design', 'format'], design: ['live', 'format'] }
1091
+ const clashes = { run: ['live', 'design', 'format', 'scenario'], steps: ['live', 'design', 'format'], findings: ['live', 'design', 'scenario'], docs: ['live', 'design', 'scenario'], live: ['design', 'format'], design: ['live', 'format'] }
1033
1092
  const owner = { live: 'live', design: 'design', format: 'docs', scenario: 'steps' }
1034
1093
  // kaloko export <run-id>: the run named as a word, as people type it
1035
1094
  if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(sub) && args.run === undefined) { args._.shift(); args.run = sub; return 'run' }
1036
- if (!(sub in clashes)) throw new UsageError(`kaloko export ${sub}: there is no such export. Use run, steps, docs, live or design (kaloko help export).`)
1095
+ if (!(sub in clashes)) throw new UsageError(`kaloko export ${sub}: there is no such export. Use run, steps, findings, docs, live or design (kaloko help export).`)
1037
1096
  args._.shift()
1038
1097
  const clash = clashes[sub].find((f) => args[f] !== undefined)
1039
1098
  if (clash) throw new UsageError(`kaloko export ${sub} does not take --${clash}; that is kaloko export ${owner[clash]}.`)
1040
1099
  if (sub === 'live') args.live = true
1041
1100
  if (sub === 'design') args.design = typeof args.design === 'string' ? args.design : true
1042
1101
  if (sub === 'docs' && typeof args.format !== 'string') args.format = 'md'
1102
+ // kaloko export findings <run-id>: the run named as a word
1103
+ if (sub === 'findings' && args._[0] && args.run === undefined) args.run = args._.shift()
1104
+ if (sub === 'findings' && args.format !== undefined && !['csv', 'json'].includes(args.format)) throw new UsageError('kaloko export findings takes --format csv or --format json.')
1043
1105
  if (sub === 'steps' && typeof args.scenario !== 'string') throw new UsageError('kaloko export steps needs --scenario <id>: the flow whose steps to export.')
1044
1106
  return sub
1045
1107
  }
1046
1108
 
1109
+ /** kaloko init --seo: writes the drafted monitor and says whether the config's environment points at the site. */
1110
+ async function writeSeoScenario ({ seo, cwd }) {
1111
+ const { scenarioYaml } = await import('./lib/draft.mjs')
1112
+ const { seoNextSteps } = await import('./lib/seo-init.mjs')
1113
+ await mkdir(dirname(seo.out), { recursive: true })
1114
+ await writeFile(seo.out, `${seo.header}${scenarioYaml(seo.scenario)}`)
1115
+ const file = relative(cwd, seo.out)
1116
+ say(`Drafted ${file}: ${seo.scenario.nodes.length} stack step(s) in ${seo.locales.length} language(s).`)
1117
+ for (const n of seo.notes) say(` ${n}`)
1118
+ let configNote = null
1119
+ try {
1120
+ const { config } = await loadConfig(cwd)
1121
+ const env = config.environments?.[seo.env]
1122
+ const base = typeof env?.base_url === 'string' ? env.base_url : env?.base_url?.default
1123
+ let host = null; try { host = new URL(base).host } catch {}
1124
+ if (!env) configNote = `! ${CONFIG_FILE} has no environment ${seo.env}. Add it under environments:\n ${seo.env}:\n base_url: ${seo.origin}\n readonly: true`
1125
+ else if (host !== new URL(seo.origin).host) configNote = `! The ${seo.env} environment points at ${base || 'nothing'}, not ${seo.origin}. Set its base_url, or pass --env with the environment of this site.`
1126
+ } catch {}
1127
+ say(seoNextSteps({ file, env: seo.env, origin: seo.origin, configNote }).join('\n'))
1128
+ return { file, steps: seo.scenario.nodes.map((n) => n.id), locales: seo.locales }
1129
+ }
1130
+
1131
+ /** kaloko export findings: the fix list of a local run, per page and locale (src/lib/findings.mjs). */
1132
+ async function exportFindings (args) {
1133
+ const { config, root, run, scenario } = await runContext(args)
1134
+ // read-only: the manifest and the stack indexes are assembled in memory from the captures, nothing is written
1135
+ const indexes = new Map()
1136
+ const traffic = await runTraffic({ run, scenario, config, root })
1137
+ const manifest = await buildManifest({ run, scenario, config, root, catalog: [], indexes, traffic })
1138
+ const step = typeof args.step === 'string' ? args.step : null
1139
+ if (step && !(manifest.nodes || []).some((n) => n.id === step)) throw new UsageError(`No step ${step} in this run. Steps: ${(manifest.nodes || []).filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}.`)
1140
+ const rows = await runFindings(manifest, { stackIndex: (n) => indexes.get(n.id) || null, locale: typeof args.locale === 'string' ? args.locale : null, step, traffic })
1141
+ const format = typeof args.format === 'string' ? args.format : args.json ? 'json' : 'csv'
1142
+ const text = findingsText(rows, format, { run: manifest.id, scenario: manifest.scenario?.id || null })
1143
+ if (typeof args.out === 'string') {
1144
+ await writeFile(resolve(args.out), text)
1145
+ const pages = new Set(rows.map((r) => `${r.step}|${r.page || ''}|${r.locale}`)).size
1146
+ say(args.json ? JSON.stringify({ file: resolve(args.out), count: rows.length, pages }) : `${rows.length} finding(s) on ${pages} page(s) → ${relative(process.cwd(), resolve(args.out)) || args.out}${rows.some((r) => r.edit_url) ? ' (with edit links)' : ''}${rows.some((r) => r.impact) ? ', sorted by impact (search and GA4 traffic of the page)' : ''}`)
1147
+ } else process.stdout.write(format === 'json' ? `${text}\n` : text)
1148
+ return { count: rows.length }
1149
+ }
1150
+
1047
1151
  async function affectedSelection (args, { config, root, scenario, scenarioFile }) {
1048
1152
  let client = null
1049
1153
  try { if (!args.offline) client = service(config) } catch {}
@@ -1073,6 +1177,14 @@ async function projectsWrite (sub, args) {
1073
1177
  if (sub !== 'sync' && slug === config.project && name && config.project_name && name !== config.project_name) say(`Note: kaloko.config.yml still says project_name: ${config.project_name}; change it there too, or kaloko projects sync would rename the project back.`)
1074
1178
  }
1075
1179
 
1180
+ /** The steps that take most of a run, as stored: " Largest steps: products 212 MB (1 390 files), …" or ''. */
1181
+ function biggestSteps (entries, n = 3) {
1182
+ const by = new Map()
1183
+ for (const e of entries) { const k = e.path.match(/^steps\/([^/]+)\//)?.[1]; if (!k) continue; const x = by.get(k) || { bytes: 0, files: 0 }; x.bytes += e.stored ?? e.bytes; x.files++; by.set(k, x) }
1184
+ const top = [...by].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, n)
1185
+ return top.length ? ` Largest steps: ${top.map(([k, x]) => `${k} ${fmtBytes(x.bytes)} (${x.files} files)`).join(', ')}.` : ''
1186
+ }
1187
+
1076
1188
  const commands = {
1077
1189
  async init (args) {
1078
1190
  const cwd = process.cwd()
@@ -1088,13 +1200,29 @@ const commands = {
1088
1200
  product: args.product !== undefined ? checkSlug(args.product === true ? '' : String(args.product), '--product slug') : null
1089
1201
  }
1090
1202
  const projectAsked = Object.values(projectKeys).some((v) => v != null)
1203
+ // --seo <site> (#162): the monitor is drafted from the live site before anything is written, so a site that
1204
+ // cannot be reached leaves the project as it was
1205
+ let seo = null
1206
+ if (args.seo !== undefined) {
1207
+ const { seoDraft, SEO_DEFAULTS } = await import('./lib/seo-init.mjs')
1208
+ const num = (k, d, max) => { if (args[k] === undefined) return d; const n = Number(args[k]); if (!Number.isInteger(n) || n < 1 || n > max) throw new UsageError(`--${k} takes a whole number from 1 to ${max}.`); return n }
1209
+ const env = typeof args.env === 'string' ? args.env : 'production'
1210
+ // the name goes into kaloko.config.yml as a key: a plain slug only
1211
+ if (!/^[a-z0-9][a-z0-9_-]{0,39}$/i.test(env)) throw new UsageError(`--env takes an environment name (letters, digits, - and _), like production; got ${env}.`)
1212
+ const draft = await seoDraft(String(args.seo), { families: num('families', SEO_DEFAULTS.families, 12), limit: num('limit', SEO_DEFAULTS.limit, 10000), packs: typeof args.packs === 'string' ? list(args.packs) : SEO_DEFAULTS.packs, env })
1213
+ const out = typeof args.out === 'string' ? resolve(cwd, args.out) : join(cwd, 'qa', 'flows', `seo-${new URL(draft.origin).hostname.replace(/^www\./, '').replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.yml`)
1214
+ if (existsSync(out) && !args.force) throw new UserError(`${relative(cwd, out)} already exists; kept. --force replaces it, --out writes another file.`)
1215
+ const problems = validateScenario(JSON.parse(JSON.stringify(draft.scenario)))
1216
+ if (problems.length) throw new UserError(`The drafted scenario does not validate: ${problems.join('; ')}`)
1217
+ seo = { ...draft, out, env }
1218
+ }
1091
1219
  // what the project is (#26): framework, dev server, locales, main routes; --no-detect keeps the plain template
1092
1220
  // several sites or apps (--site picks one) and pages read from the running site (--base-url, or the dev URL)
1093
1221
  let baseUrl = null
1094
1222
  if (args['base-url'] !== undefined) {
1095
1223
  try { baseUrl = new URL(String(args['base-url'])); if (!/^https?:$/.test(baseUrl.protocol)) throw new Error() } catch { throw new UsageError('--base-url must be the http(s) address the site runs on, e.g. http://localhost:3000.') }
1096
1224
  }
1097
- const detected = args['no-detect'] ? null : await detectProject(cwd, { site: args.site ?? null, baseUrl: baseUrl?.href || null, probe: true })
1225
+ const detected = args['no-detect'] || seo ? null : await detectProject(cwd, { site: args.site ?? null, baseUrl: baseUrl?.href || null, probe: true })
1098
1226
  if (detected) say(formatDetection(detected))
1099
1227
  if (!exists) {
1100
1228
  let tpl = await readFile(join(packageRoot, 'templates', 'kaloko.config.yml'), 'utf8')
@@ -1107,9 +1235,15 @@ const commands = {
1107
1235
  if (project && !projectKeys.project) tpl = tpl.replace(/^project: .*$/m, `project: ${project}`)
1108
1236
  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}`)
1109
1237
  }
1238
+ // the monitored site is production (or --env): its pages are only read
1239
+ if (seo) {
1240
+ if (seo.env === 'production') tpl = tpl.replace(/^( production:\n(?: #.*\n)* base_url: ).*$/m, `$1${seo.origin}`)
1241
+ else tpl = tpl.replace(/^environments:\n/m, `environments:\n ${seo.env}:\n base_url: ${seo.origin}\n readonly: true\n`)
1242
+ if (!projectKeys.project) tpl = tpl.replace(/^project: .*$/m, `project: ${new URL(seo.origin).hostname.replace(/^www\./, '').replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`)
1243
+ }
1110
1244
  if (projectAsked) tpl = setConfigKeys(tpl, projectKeys).replace(/^# The name people see on Kaloko.*\n# project_name: .*\n/m, projectKeys.project_name ? '' : '$&')
1111
1245
  await writeFile(file, tpl)
1112
- say(`Created ${CONFIG_FILE}${detected ? ` (local: ${detected.url})` : ''}. Edit org and the staging and production environments.`)
1246
+ say(`Created ${CONFIG_FILE}${detected ? ` (local: ${detected.url})` : seo ? ` (${seo.env}: ${seo.origin})` : ''}. Edit org${seo ? '' : ' and the staging and production environments'}.`)
1113
1247
  } else if (projectAsked) {
1114
1248
  // an existing config keeps everything else, comments included
1115
1249
  const cfgFile = existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE)
@@ -1129,7 +1263,7 @@ const commands = {
1129
1263
  // the visual changelog (docs/DOCS-CHANGELOG.md): asked once; --docs=off|changelog|full for scripts and agents
1130
1264
  let docsMode = typeof args.docs === 'string' ? args.docs : null
1131
1265
  if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new UsageError('--docs must be off, changelog or full.')
1132
- if (!docsMode && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !process.env.NODE_TEST_CONTEXT) {
1266
+ if (!docsMode && !seo && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !process.env.NODE_TEST_CONTEXT) {
1133
1267
  const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
1134
1268
  const a = (await rl.question('Keep a visual changelog? (recommended) [Y/n] ')).trim().toLowerCase(); rl.close()
1135
1269
  docsMode = a === 'n' || a === 'no' ? 'off' : 'changelog'
@@ -1157,7 +1291,7 @@ const commands = {
1157
1291
  }
1158
1292
  say('Docs folders in docs/ (tutorials, how-to, reference, explanation) with an index and a template in each; kaloko docs publish sends them, kaloko docs check checks them')
1159
1293
  }
1160
- } else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
1294
+ } else if (!docsMode && !seo) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
1161
1295
  const agent = typeof args.agent === 'string' ? args.agent : 'none'
1162
1296
  if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UsageError('--agent must be claude, codex, all or none.')
1163
1297
  const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
@@ -1189,6 +1323,7 @@ const commands = {
1189
1323
  await cp(join(packageRoot, 'templates', 'steps', 'consent.mjs'), consentStep)
1190
1324
  say('Added qa/flows/steps/consent.mjs (accepts the consent dialog, if the site has one)')
1191
1325
  }
1326
+ if (seo) return writeSeoScenario({ seo, cwd })
1192
1327
  // a first scenario that walks as it is, when the project has none yet
1193
1328
  let first = null
1194
1329
  if (detected && !args['no-scenario']) {
@@ -1228,13 +1363,16 @@ const commands = {
1228
1363
  let failed = 0
1229
1364
  // --json: one entry per file with its problems, warnings and coverage; the lines below go to stderr
1230
1365
  const results = []
1366
+ // the config, when there is one, says whether every scenario must name its project (kaloko share answers 404 else)
1367
+ let projectRequired = null
1368
+ try { const { config, file } = await loadConfig(process.cwd(), args.config); projectRequired = projectRequirement(config, await readFile(file, 'utf8')) } catch {}
1231
1369
  for (const file of args._) {
1232
1370
  let scenario
1233
1371
  try { scenario = parseScenario(await readFile(file, 'utf8'), file) } catch (e) { if (!(e instanceof UserError)) throw e; failed++; say(`✕ ${e.message}`); results.push({ file, ok: false, problems: [e.message], warnings: [] }); continue }
1234
1372
  const problems = validateScenario(scenario)
1235
1373
  if (!problems.length) expandPacks(scenario)
1236
1374
  if (problems.length) { failed++; say(`✕ ${file}\n - ${problems.join('\n - ')}`) } else say(`✓ ${file}`)
1237
- const warnings = problems.length ? [] : scenarioWarnings(scenario)
1375
+ const warnings = problems.length ? [] : scenarioWarnings(scenario, { projectRequired })
1238
1376
  for (const w of warnings) say(` ! ${w}`)
1239
1377
  const entry = { file, id: scenario.id || null, ok: !problems.length, problems, warnings }
1240
1378
  results.push(entry)
@@ -1264,13 +1402,15 @@ const commands = {
1264
1402
  },
1265
1403
 
1266
1404
  async start (args) {
1267
- const { config, root } = await context(args)
1405
+ const { config, root, file: configFile } = await context(args)
1268
1406
  if (!args.scenario || !args.env) throw new UsageError('Usage: kaloko start --scenario <file> --env <name>')
1269
1407
  // the records the run serves and why it was walked (#126): checked before anything is created
1270
1408
  const { refs, purpose, problems: refIssues } = startRefs(args, process.env)
1271
1409
  if (refIssues.length) throw new UserError(refIssues.join('\n'))
1272
1410
  const scenarioFile = resolve(args.scenario)
1273
1411
  const scenario = await loadScenario(scenarioFile)
1412
+ // warnings of kaloko validate (errors pack on a stack, a project the config asks for): said, not blocking
1413
+ for (const w of scenarioWarnings(scenario, { projectRequired: projectRequirement(config, await readFile(configFile, 'utf8').catch(() => '')) }).filter((x) => !/reads in one language only/.test(x))) (args.json ? console.error : say)(`! ${w}`)
1274
1414
  const env = environment(config, args.env)
1275
1415
  if (scenario.environments && !scenario.environments.includes(env.name)) throw new UserError(`Scenario ${scenario.id} is not allowed on ${env.name} (allowed: ${scenario.environments.join(', ')}).`)
1276
1416
  if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
@@ -1464,10 +1604,13 @@ const commands = {
1464
1604
  if (isApp(scenario.platform)) {
1465
1605
  await walkApp({ config, root, run, scenario, env, args })
1466
1606
  // an app walk ends with the verdicts like a web walk (--no-evaluate skips them; kaloko evaluate redoes them)
1467
- if (!args['no-evaluate']) await walkVerdicts({ prefetch: { done: async () => {} }, run, scenario, config, root })
1607
+ if (!args['no-evaluate']) await walkVerdicts({ prefetch: { done: async () => {} }, run, scenario, config, root, budget: args.budget ?? null })
1468
1608
  return
1469
1609
  }
1470
1610
  const pick = list(args.steps)
1611
+ // stacks with locale variants read their source list and each page's hreflang once per walk, for every locale
1612
+ const stackCache = new Map()
1613
+ const stackGate = await gateForRequests(env, { root }) // the gate for requests outside the browser: link checks, hreflang of stack pages
1471
1614
  const locales = list(args.locales) || run.meta.scope.locales
1472
1615
  const scope = run.meta.scope
1473
1616
  // browser engines and device profiles (batch E): one pass each, capturing only its own keys; a run without
@@ -1477,7 +1620,7 @@ const commands = {
1477
1620
  if (!passes.length) throw new UserError(`This run has no captures in ${only.join(', ')} (its browsers: ${enginesOf(scope).join(', ')}).`)
1478
1621
  const needChromium = passes.some((p) => p.engine === 'chromium')
1479
1622
  // --ephemeral: an own browser (own cookies and tabs) even when the shared one runs, so runs can walk in parallel
1480
- configureGate(await gateForRequests(env, { root }))
1623
+ configureGate(stackGate)
1481
1624
  const conn = args.ephemeral || !needChromium ? null : await connectBrowser({ root, config, env })
1482
1625
  const releaseLock = conn ? await acquireWalkLock({ root, config, env, run: run.meta.id }).catch(async (e) => { await conn.browser.close(); throw e }) : null
1483
1626
  const ephemeral = conn || !needChromium ? null : await launchEphemeral(config)
@@ -1488,7 +1631,7 @@ const commands = {
1488
1631
  const parallel = concurrency > 1 && locales.length > 1
1489
1632
  const scenarioDir = resolve(root, run.meta.scenario_file, '..')
1490
1633
  // semantic questions of every new capture start right away; the walk ends with the verdicts (--no-evaluate: skip)
1491
- const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root, environment: env?.name || null })
1634
+ const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root, environment: env?.name || null, budget: args.budget ?? null })
1492
1635
  const walked = await readCaptures(run.dir)
1493
1636
  // recordings and traces (batch D, #12): opt-in recordings, traces unless turned off; encoded in an own headless Chrome
1494
1637
  const recSettings = recordSettings({ args, config, scenario })
@@ -1573,7 +1716,7 @@ const commands = {
1573
1716
  const consoleFile = shared && !sess ? consoleLog(root, config.output, env.name) : null
1574
1717
  if (node.kind === 'stack') {
1575
1718
  try {
1576
- const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, args, keys: pass.keys, tag })
1719
+ const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, args, keys: pass.keys, tag, cache: stackCache, gate: stackGate })
1577
1720
  for (const b of bad) failed.push(`${node.id} (${locale}${tag}) ${b}`)
1578
1721
  } catch (error) {
1579
1722
  failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
@@ -1664,7 +1807,14 @@ const commands = {
1664
1807
  if (failed.length) { say(`\nSteps that failed (fix and rerun with --steps):\n - ${failed.join('\n - ')}`); process.exitCode = 1 }
1665
1808
  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.`)
1666
1809
  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 }
1667
- if (prefetch) await walkVerdicts({ prefetch, run, scenario, config, root })
1810
+ // site checks (#152, health.site): robots.txt, sitemaps, llms.txt and redirects, once per site and run
1811
+ // health.site.environments limits them (e.g. [production]); off production, HC1102 is a warning and HC1103 skipped
1812
+ const siteOpts = scenario.health?.site === true ? {} : scenario.health?.site
1813
+ if (siteOpts && env.base_url && siteChecksOn(siteOpts, env.name)) {
1814
+ say('')
1815
+ await walkSiteChecks({ runDir: run.dir, bases: locales.map((l) => baseUrl(env, l)).filter(Boolean), options: siteOpts, gate: await gateForRequests(env, { root }), production: env.name === 'production', say }).catch((error) => say(`site checks: ${error.message.split('\n')[0]}`))
1816
+ }
1817
+ if (prefetch) await walkVerdicts({ prefetch, run, scenario, config, root, budget: args.budget ?? null })
1668
1818
  },
1669
1819
 
1670
1820
  async affected (args) {
@@ -1979,9 +2129,11 @@ const commands = {
1979
2129
  say(`Rechecked deterministic criteria of ${r.rechecked} capture(s) from the stored HTML (console, overflow and performance keep their live result).`)
1980
2130
  if (r.skipped.length) say(` skipped: ${r.skipped.join('; ')}`)
1981
2131
  }
1982
- const report = await evaluateRun({ run, scenario, config, root })
1983
- const via = report.via.qawalk ? ` via Kaloko${report.hosted?.limit != null ? ` (${report.hosted.questions} of ${report.hosted.limit} questions this month)` : ''}` : report.via['own-key'] ? ' with your key' : ''
2132
+ const report = await evaluateRun({ run, scenario, config, root, budget: args.budget ?? null })
2133
+ const via = report.evaluator ? ` at ${report.evaluator.base_url}` : report.via.qawalk ? ` via Kaloko${report.hosted?.limit != null ? ` (${report.hosted.questions} of ${report.hosted.limit} questions this month)` : ''}` : report.via['own-key'] ? ' with your key' : ''
1984
2134
  say(`Evaluated ${report.evaluated} capture(s); semantic evaluator ${report.model}${via}: ${report.semantic_calls} call(s), ${report.semantic_cached} cached.`)
2135
+ if (report.evaluator?.escalate_to) say(`Uncertain answers escalated to ${report.evaluator.escalate_to}: ${report.evaluator.escalated}${report.evaluator.escalation_skipped ? `; ${report.evaluator.escalation_skipped} not escalated` : ''}.`)
2136
+ if (report.budget) say(budgetLine(report))
1985
2137
  if (report.vision?.asked) say(`Vision evaluator ${report.vision.model}: ${report.vision.asked} capture(s) with flagged or uncertain criteria, ${report.vision.calls} call(s), ${report.vision.cached} cached${report.vision.skipped.length ? `; not asked: ${[...new Set(report.vision.skipped.map((x) => x.split(': ').slice(1).join(': ')))].slice(0, 2).join('; ')}` : ''}.`)
1986
2138
  if (report.triage) say(report.triage.screens ? `Triage (experimental, ${report.triage.model}): ${report.triage.screens} screen(s)${report.triage.frames ? ` and ${report.triage.frames} recording frame(s)` : ''}, ${report.triage.shown} finding(s) to look at${report.triage.findings > report.triage.shown ? ` and ${report.triage.findings - report.triage.shown} less likely` : ''}; they never change a verdict.${report.triage.skipped ? ` Not every request was answered: ${report.triage.skipped}` : ''}` : `Triage (experimental): not run${report.triage.skipped ? `: ${report.triage.skipped}` : ' (no screenshot to look at)'}`)
1987
2139
  if (report.skipped.length) {
@@ -1995,7 +2147,7 @@ const commands = {
1995
2147
  // --json: the counts and every verdict a person or agent has to act on (fail, needs_review)
1996
2148
  if (args.json) {
1997
2149
  const pick = (f) => ({ step: f.node, ...(f.item ? { item: f.item } : {}), locale: f.locale, viewport: f.viewport, criterion: f.id, verdict: f.verdict, why: f.why || null, fix: f.fix || null })
1998
- say(JSON.stringify({ run: run.meta.id, evaluated: report.evaluated, semantic: { model: report.model, calls: report.semantic_calls, cached: report.semantic_cached, via: report.via.qawalk ? 'kaloko' : report.via['own-key'] ? 'own-key' : null }, vision: report.vision || null, triage: report.triage || null, findings: (report.findings || []).filter((f) => !f.health && (f.verdict === 'fail' || f.verdict === 'needs_review')).map(pick), health: (report.findings || []).filter((f) => f.health).map((f) => ({ ...pick(f), pack: f.pack, severity: f.severity })), skipped: report.skipped, outdated: report.outdated }, null, 2))
2150
+ say(JSON.stringify({ run: run.meta.id, evaluated: report.evaluated, semantic: { model: report.model, calls: report.semantic_calls, cached: report.semantic_cached, via: report.via.qawalk ? 'kaloko' : report.via['own-key'] ? 'own-key' : report.via['own-model'] ? 'own-model' : null, ...(report.evaluator ? { evaluator: report.evaluator } : {}) }, budget: report.budget || null, vision: report.vision || null, triage: report.triage || null, findings: (report.findings || []).filter((f) => !f.health && (f.verdict === 'fail' || f.verdict === 'needs_review')).map(pick), health: (report.findings || []).filter((f) => f.health).map((f) => ({ ...pick(f), pack: f.pack, severity: f.severity })), skipped: report.skipped, outdated: report.outdated }, null, 2))
1999
2151
  }
2000
2152
  },
2001
2153
 
@@ -2086,17 +2238,18 @@ const commands = {
2086
2238
  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.`)
2087
2239
  }
2088
2240
  const missing = await missingHashes(client, config.org, entries.map((e) => e.hash))
2089
- const total = entries.reduce((n, e) => n + e.bytes, 0)
2090
- 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
2241
+ // sizes as the service stores them (HTML and JSON gzip-compressed): what the per-run limit and the quota count
2242
+ const total = entries.reduce((n, e) => n + (e.stored ?? e.bytes), 0)
2243
+ const fresh = missing ? [...new Map(entries.filter((e) => missing.has(e.hash)).map((e) => [e.hash, e.stored ?? e.bytes])).values()].reduce((n, b) => n + b, 0) : total
2091
2244
  const resumed = (await readJson(join(run.dir, PROGRESS_FILE), null).catch(() => null))?.run === manifest.id
2092
2245
  try {
2093
2246
  if (!u) throw new Error('usage unavailable')
2094
- 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.`)
2247
+ 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)}.${biggestSteps(entries)} Capture fewer viewports or locales; for a stack, lower items.limit, walk a rotating sample (items.rotate: week) or keep thumbnails only (capture: { screenshot: thumb }). An admin can also ask for a higher limit.`)
2095
2248
  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.`)
2096
2249
  const recBytes = entries.filter((e) => /\.rec\./.test(e.path)).reduce((n, e) => n + e.bytes, 0)
2097
2250
  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)})`)
2098
2251
  } catch (e) { if (e instanceof UserError && /limit|quota/.test(e.message)) throw e }
2099
- const created = await client.request('POST', `/orgs/${config.org}/runs?expires_days=${days}`, manifest)
2252
+ const created = await client.request('POST', `/orgs/${config.org}/runs?expires_days=${days}`, manifest).catch(async (e) => { throw e.status === 404 ? await shareNotFound(e, { client, org: config.org, project: run.meta.project }) : e })
2100
2253
  await writeFile(join(run.dir, PROGRESS_FILE), JSON.stringify({ run: manifest.id, started_at: new Date().toISOString() }) + '\n').catch(() => {})
2101
2254
  const up = await uploadRunFiles({ client, org: config.org, runId: manifest.id, dir: run.dir, entries, missing, say, ...(args.json ? { write: (t) => process.stderr.write(t) } : {}) })
2102
2255
  if (up.mode === 'blobs') say(` ${up.uploaded} new file(s) sent (${fmtBytes(up.bytes)}), ${up.reused} unchanged file(s) reused`)
@@ -2578,7 +2731,7 @@ const commands = {
2578
2731
  },
2579
2732
 
2580
2733
  async export (args) {
2581
- exportSubcommand(args)
2734
+ if (exportSubcommand(args) === 'findings') return exportFindings(args)
2582
2735
  const { config, root } = await context(args)
2583
2736
  const client = service(config)
2584
2737
  if (args.design) {
@@ -2849,6 +3002,7 @@ export async function main (argv, { bin = 'kaloko' } = {}) {
2849
3002
  try {
2850
3003
  await runCommand(args, json)
2851
3004
  } finally {
3005
+ await disposeShared() // the crawler checks' request context, when a walk or capture made one
2852
3006
  if (json) console.log = stdout
2853
3007
  const hint = legacyHint()
2854
3008
  if (hint) console.error(hint)