kaloko 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.mjs CHANGED
@@ -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
21
  import { buildManifest, writePreview, attentionLines } 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 } 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,10 @@ 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)
307
323
  public [--run id] [--off] Make a shared run readable without sign-in (admin; testers may refresh an already public scenario)
308
324
  stability [--last 4 | --runs a,b] [--same-commit] [--apply] [--json]
309
325
  Regions that change on every run or move while the page is idle (clocks, ads,
@@ -472,10 +488,10 @@ async function warnRetired (args) {
472
488
  }
473
489
 
474
490
  /** 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 }) {
491
+ async function walkVerdicts ({ prefetch, run, scenario, config, root, budget = null }) {
476
492
  const t0 = Date.now()
477
493
  await prefetch.done()
478
- const report = await evaluateRun({ run, scenario, config, root })
494
+ const report = await evaluateRun({ run, scenario, config, root, budget })
479
495
  // health checks (advisory) are counted apart; they never make a step fail
480
496
  const health = report.findings.filter((f) => f.health); const accept = report.findings.filter((f) => !f.health)
481
497
  const fails = accept.filter((f) => f.verdict === 'fail'); const unsure = accept.filter((f) => f.verdict === 'needs_review')
@@ -487,9 +503,17 @@ async function walkVerdicts ({ prefetch, run, scenario, config, root }) {
487
503
  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
504
  if (unsure.length) say(` ? ${unsure.slice(0, 8).map(line).join('\n ? ')}${unsure.length > 8 ? `\n … ${unsure.length - 8} more` : ''}`)
489
505
  if (report.skipped.length) say(` Semantic criteria not evaluated: ${[...new Set(report.skipped.map((x) => x.split(': ').slice(1).join(': ')))].slice(0, 2).join('; ')}`)
506
+ if (report.budget) say(budgetLine(report))
490
507
  if (health.length) say(healthLine(health))
491
508
  }
492
509
 
510
+ /** One line on the evaluator budget (#158): calls used, and how many captures and uncertain answers it left out. */
511
+ function budgetLine (report) {
512
+ const b = report.budget
513
+ const out = report.evaluator?.escalation_skipped ? `, ${report.evaluator.escalation_skipped} uncertain answer(s) not escalated` : ''
514
+ 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}.`
515
+ }
516
+
493
517
  /** One line on the health findings of an evaluation: counts by severity (one per check and step), never a verdict. */
494
518
  function healthLine (findings) {
495
519
  const worst = new Map()
@@ -784,23 +808,27 @@ async function exportScenario ({ args, config, root, client }) {
784
808
  * fields (HTTP status of the main document included). A step script, when given, runs per page with `item`.
785
809
  */
786
810
  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
811
+ // a rotating sample (items.rotate) remembers between runs how many runs walked it and when each page was checked
812
+ const state = node.items?.rotate ? await rotationState(join(run.dir, '..', '.cache', 'stack', scenario.id, `${node.id}.${locale}.json`), run.meta.id) : null
813
+ const { items, total, truncated, all, rotation, resolve_failed: unresolved, resolve_error: unresolvedWhy } = await resolveItems({ node, base: baseUrl(env, locale), page, scenarioDir, env, locale, scenario, limit: Number(args.limit) || undefined, rotation: { run: state?.run || 0 }, checked: state?.checked || null, only: list(args.items) })
814
+ if (unresolved) say(`! ${node.id} ${locale}${tag}: items.resolve failed for ${unresolved} page(s), walked without an edit link (${unresolvedWhy})`)
815
+ // --items picked the pages from the whole list (resolveItems only); a partial walk leaves the rotation state alone
816
+ const chosen = items
790
817
  const passKeys = keys || engineKeys(run.meta.scope, 'chromium')
791
818
  const bases = baseViewports(run.meta.scope)
792
819
  const vpNames = (node.items.viewports || []).filter((v) => bases.includes(v) || run.meta.scope.viewports[v])
793
820
  const passBases = baseViewports({ ...run.meta.scope, viewports: Object.fromEntries(passKeys.map((k) => [k, run.meta.scope.viewports[k]])) })
794
821
  const viewports = scopeKeys(run.meta.scope, passKeys, vpNames.length ? vpNames : [passBases[0] || bases[0]])
795
822
  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)` : ''}`)
823
+ if (rotation) say(`▦ ${node.id} ${locale}${tag}: rotating sample, ${rotation.week ? `week ${rotation.week}` : `run ${rotation.run}`} — pages ${rotation.offset + 1}…${((rotation.offset + items.length - 1) % Math.max(1, total)) + 1} of ${total}, all covered in ${rotation.rounds} ${rotation.week ? 'weeks' : 'runs'}`)
824
+ 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
825
  const fields = fieldsOf(node)
798
826
  const script = node.execution?.script ? await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href) : null
799
827
  let status = null
800
828
  const onResponse = (r) => { if (r.request().isNavigationRequest() && r.frame() === page.mainFrame()) status = r.status() }
801
829
  page.on('response', onResponse)
802
830
  const streak = failureStreak(5)
803
- const failed = []; let ok = 0
831
+ const failed = []; let ok = 0; const checkedNow = []
804
832
  const started = Date.now(); let told = started
805
833
  try {
806
834
  for (const [i, item] of chosen.entries()) {
@@ -813,7 +841,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
813
841
  if (script) await script.default({ page, item, url: () => item.url, locale, env, run: run.meta, capture, acceptDialogs: () => acceptDialogs(page) })
814
842
  else await page.goto(item.url, { waitUntil: 'domcontentloaded', timeout: 30000 })
815
843
  if (!captured) await capture()
816
- ok++; streak.ok('stack')
844
+ ok++; streak.ok('stack'); checkedNow.push(item.path)
817
845
  } catch (error) {
818
846
  failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
819
847
  if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
@@ -822,6 +850,11 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
822
850
  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
851
  }
824
852
  } finally { page.off('response', onResponse) }
853
+ // the rotation of this locale goes into the run (the stack index shows it) and the pages checked into the state
854
+ if (state && rotation) {
855
+ const checked = await state.save(checkedNow, all)
856
+ await writeJson(join(run.dir, 'steps', node.id, `rotation.${locale}.json`), { ...rotation, ...rotationCoverage(all, checked) })
857
+ }
825
858
  say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}${tag}: ${ok}/${chosen.length} page(s) captured`)
826
859
  return failed
827
860
  }
@@ -1029,21 +1062,65 @@ export function exportSubcommand (args) {
1029
1062
  const sub = args._[0]
1030
1063
  if (sub === undefined) return null
1031
1064
  // 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'] }
1065
+ 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
1066
  const owner = { live: 'live', design: 'design', format: 'docs', scenario: 'steps' }
1034
1067
  // kaloko export <run-id>: the run named as a word, as people type it
1035
1068
  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).`)
1069
+ 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
1070
  args._.shift()
1038
1071
  const clash = clashes[sub].find((f) => args[f] !== undefined)
1039
1072
  if (clash) throw new UsageError(`kaloko export ${sub} does not take --${clash}; that is kaloko export ${owner[clash]}.`)
1040
1073
  if (sub === 'live') args.live = true
1041
1074
  if (sub === 'design') args.design = typeof args.design === 'string' ? args.design : true
1042
1075
  if (sub === 'docs' && typeof args.format !== 'string') args.format = 'md'
1076
+ // kaloko export findings <run-id>: the run named as a word
1077
+ if (sub === 'findings' && args._[0] && args.run === undefined) args.run = args._.shift()
1078
+ if (sub === 'findings' && args.format !== undefined && !['csv', 'json'].includes(args.format)) throw new UsageError('kaloko export findings takes --format csv or --format json.')
1043
1079
  if (sub === 'steps' && typeof args.scenario !== 'string') throw new UsageError('kaloko export steps needs --scenario <id>: the flow whose steps to export.')
1044
1080
  return sub
1045
1081
  }
1046
1082
 
1083
+ /** kaloko init --seo: writes the drafted monitor and says whether the config's environment points at the site. */
1084
+ async function writeSeoScenario ({ seo, cwd }) {
1085
+ const { scenarioYaml } = await import('./lib/draft.mjs')
1086
+ const { seoNextSteps } = await import('./lib/seo-init.mjs')
1087
+ await mkdir(dirname(seo.out), { recursive: true })
1088
+ await writeFile(seo.out, `${seo.header}${scenarioYaml(seo.scenario)}`)
1089
+ const file = relative(cwd, seo.out)
1090
+ say(`Drafted ${file}: ${seo.scenario.nodes.length} stack step(s) in ${seo.locales.length} language(s).`)
1091
+ for (const n of seo.notes) say(` ${n}`)
1092
+ let configNote = null
1093
+ try {
1094
+ const { config } = await loadConfig(cwd)
1095
+ const env = config.environments?.[seo.env]
1096
+ const base = typeof env?.base_url === 'string' ? env.base_url : env?.base_url?.default
1097
+ let host = null; try { host = new URL(base).host } catch {}
1098
+ 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`
1099
+ 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.`
1100
+ } catch {}
1101
+ say(seoNextSteps({ file, env: seo.env, origin: seo.origin, configNote }).join('\n'))
1102
+ return { file, steps: seo.scenario.nodes.map((n) => n.id), locales: seo.locales }
1103
+ }
1104
+
1105
+ /** kaloko export findings: the fix list of a local run, per page and locale (src/lib/findings.mjs). */
1106
+ async function exportFindings (args) {
1107
+ const { config, root, run, scenario } = await runContext(args)
1108
+ // read-only: the manifest and the stack indexes are assembled in memory from the captures, nothing is written
1109
+ const indexes = new Map()
1110
+ const manifest = await buildManifest({ run, scenario, config, root, catalog: [], indexes })
1111
+ const step = typeof args.step === 'string' ? args.step : null
1112
+ 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(', ')}.`)
1113
+ const rows = await runFindings(manifest, { stackIndex: (n) => indexes.get(n.id) || null, locale: typeof args.locale === 'string' ? args.locale : null, step })
1114
+ const format = typeof args.format === 'string' ? args.format : args.json ? 'json' : 'csv'
1115
+ const text = findingsText(rows, format, { run: manifest.id, scenario: manifest.scenario?.id || null })
1116
+ if (typeof args.out === 'string') {
1117
+ await writeFile(resolve(args.out), text)
1118
+ const pages = new Set(rows.map((r) => `${r.step}|${r.page || ''}|${r.locale}`)).size
1119
+ 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)' : ''}`)
1120
+ } else process.stdout.write(format === 'json' ? `${text}\n` : text)
1121
+ return { count: rows.length }
1122
+ }
1123
+
1047
1124
  async function affectedSelection (args, { config, root, scenario, scenarioFile }) {
1048
1125
  let client = null
1049
1126
  try { if (!args.offline) client = service(config) } catch {}
@@ -1073,6 +1150,14 @@ async function projectsWrite (sub, args) {
1073
1150
  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
1151
  }
1075
1152
 
1153
+ /** The steps that take most of a run, as stored: " Largest steps: products 212 MB (1 390 files), …" or ''. */
1154
+ function biggestSteps (entries, n = 3) {
1155
+ const by = new Map()
1156
+ 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) }
1157
+ const top = [...by].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, n)
1158
+ return top.length ? ` Largest steps: ${top.map(([k, x]) => `${k} ${fmtBytes(x.bytes)} (${x.files} files)`).join(', ')}.` : ''
1159
+ }
1160
+
1076
1161
  const commands = {
1077
1162
  async init (args) {
1078
1163
  const cwd = process.cwd()
@@ -1088,13 +1173,29 @@ const commands = {
1088
1173
  product: args.product !== undefined ? checkSlug(args.product === true ? '' : String(args.product), '--product slug') : null
1089
1174
  }
1090
1175
  const projectAsked = Object.values(projectKeys).some((v) => v != null)
1176
+ // --seo <site> (#162): the monitor is drafted from the live site before anything is written, so a site that
1177
+ // cannot be reached leaves the project as it was
1178
+ let seo = null
1179
+ if (args.seo !== undefined) {
1180
+ const { seoDraft, SEO_DEFAULTS } = await import('./lib/seo-init.mjs')
1181
+ 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 }
1182
+ const env = typeof args.env === 'string' ? args.env : 'production'
1183
+ // the name goes into kaloko.config.yml as a key: a plain slug only
1184
+ 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}.`)
1185
+ 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 })
1186
+ 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`)
1187
+ if (existsSync(out) && !args.force) throw new UserError(`${relative(cwd, out)} already exists; kept. --force replaces it, --out writes another file.`)
1188
+ const problems = validateScenario(JSON.parse(JSON.stringify(draft.scenario)))
1189
+ if (problems.length) throw new UserError(`The drafted scenario does not validate: ${problems.join('; ')}`)
1190
+ seo = { ...draft, out, env }
1191
+ }
1091
1192
  // what the project is (#26): framework, dev server, locales, main routes; --no-detect keeps the plain template
1092
1193
  // several sites or apps (--site picks one) and pages read from the running site (--base-url, or the dev URL)
1093
1194
  let baseUrl = null
1094
1195
  if (args['base-url'] !== undefined) {
1095
1196
  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
1197
  }
1097
- const detected = args['no-detect'] ? null : await detectProject(cwd, { site: args.site ?? null, baseUrl: baseUrl?.href || null, probe: true })
1198
+ const detected = args['no-detect'] || seo ? null : await detectProject(cwd, { site: args.site ?? null, baseUrl: baseUrl?.href || null, probe: true })
1098
1199
  if (detected) say(formatDetection(detected))
1099
1200
  if (!exists) {
1100
1201
  let tpl = await readFile(join(packageRoot, 'templates', 'kaloko.config.yml'), 'utf8')
@@ -1107,9 +1208,15 @@ const commands = {
1107
1208
  if (project && !projectKeys.project) tpl = tpl.replace(/^project: .*$/m, `project: ${project}`)
1108
1209
  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
1210
  }
1211
+ // the monitored site is production (or --env): its pages are only read
1212
+ if (seo) {
1213
+ if (seo.env === 'production') tpl = tpl.replace(/^( production:\n(?: #.*\n)* base_url: ).*$/m, `$1${seo.origin}`)
1214
+ else tpl = tpl.replace(/^environments:\n/m, `environments:\n ${seo.env}:\n base_url: ${seo.origin}\n readonly: true\n`)
1215
+ if (!projectKeys.project) tpl = tpl.replace(/^project: .*$/m, `project: ${new URL(seo.origin).hostname.replace(/^www\./, '').replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`)
1216
+ }
1110
1217
  if (projectAsked) tpl = setConfigKeys(tpl, projectKeys).replace(/^# The name people see on Kaloko.*\n# project_name: .*\n/m, projectKeys.project_name ? '' : '$&')
1111
1218
  await writeFile(file, tpl)
1112
- say(`Created ${CONFIG_FILE}${detected ? ` (local: ${detected.url})` : ''}. Edit org and the staging and production environments.`)
1219
+ say(`Created ${CONFIG_FILE}${detected ? ` (local: ${detected.url})` : seo ? ` (${seo.env}: ${seo.origin})` : ''}. Edit org${seo ? '' : ' and the staging and production environments'}.`)
1113
1220
  } else if (projectAsked) {
1114
1221
  // an existing config keeps everything else, comments included
1115
1222
  const cfgFile = existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE)
@@ -1129,7 +1236,7 @@ const commands = {
1129
1236
  // the visual changelog (docs/DOCS-CHANGELOG.md): asked once; --docs=off|changelog|full for scripts and agents
1130
1237
  let docsMode = typeof args.docs === 'string' ? args.docs : null
1131
1238
  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) {
1239
+ if (!docsMode && !seo && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !process.env.NODE_TEST_CONTEXT) {
1133
1240
  const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
1134
1241
  const a = (await rl.question('Keep a visual changelog? (recommended) [Y/n] ')).trim().toLowerCase(); rl.close()
1135
1242
  docsMode = a === 'n' || a === 'no' ? 'off' : 'changelog'
@@ -1157,7 +1264,7 @@ const commands = {
1157
1264
  }
1158
1265
  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
1266
  }
1160
- } else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
1267
+ } else if (!docsMode && !seo) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
1161
1268
  const agent = typeof args.agent === 'string' ? args.agent : 'none'
1162
1269
  if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UsageError('--agent must be claude, codex, all or none.')
1163
1270
  const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
@@ -1189,6 +1296,7 @@ const commands = {
1189
1296
  await cp(join(packageRoot, 'templates', 'steps', 'consent.mjs'), consentStep)
1190
1297
  say('Added qa/flows/steps/consent.mjs (accepts the consent dialog, if the site has one)')
1191
1298
  }
1299
+ if (seo) return writeSeoScenario({ seo, cwd })
1192
1300
  // a first scenario that walks as it is, when the project has none yet
1193
1301
  let first = null
1194
1302
  if (detected && !args['no-scenario']) {
@@ -1228,13 +1336,16 @@ const commands = {
1228
1336
  let failed = 0
1229
1337
  // --json: one entry per file with its problems, warnings and coverage; the lines below go to stderr
1230
1338
  const results = []
1339
+ // the config, when there is one, says whether every scenario must name its project (kaloko share answers 404 else)
1340
+ let projectRequired = null
1341
+ try { const { config, file } = await loadConfig(process.cwd(), args.config); projectRequired = projectRequirement(config, await readFile(file, 'utf8')) } catch {}
1231
1342
  for (const file of args._) {
1232
1343
  let scenario
1233
1344
  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
1345
  const problems = validateScenario(scenario)
1235
1346
  if (!problems.length) expandPacks(scenario)
1236
1347
  if (problems.length) { failed++; say(`✕ ${file}\n - ${problems.join('\n - ')}`) } else say(`✓ ${file}`)
1237
- const warnings = problems.length ? [] : scenarioWarnings(scenario)
1348
+ const warnings = problems.length ? [] : scenarioWarnings(scenario, { projectRequired })
1238
1349
  for (const w of warnings) say(` ! ${w}`)
1239
1350
  const entry = { file, id: scenario.id || null, ok: !problems.length, problems, warnings }
1240
1351
  results.push(entry)
@@ -1264,13 +1375,15 @@ const commands = {
1264
1375
  },
1265
1376
 
1266
1377
  async start (args) {
1267
- const { config, root } = await context(args)
1378
+ const { config, root, file: configFile } = await context(args)
1268
1379
  if (!args.scenario || !args.env) throw new UsageError('Usage: kaloko start --scenario <file> --env <name>')
1269
1380
  // the records the run serves and why it was walked (#126): checked before anything is created
1270
1381
  const { refs, purpose, problems: refIssues } = startRefs(args, process.env)
1271
1382
  if (refIssues.length) throw new UserError(refIssues.join('\n'))
1272
1383
  const scenarioFile = resolve(args.scenario)
1273
1384
  const scenario = await loadScenario(scenarioFile)
1385
+ // warnings of kaloko validate (errors pack on a stack, a project the config asks for): said, not blocking
1386
+ 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
1387
  const env = environment(config, args.env)
1275
1388
  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
1389
  if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
@@ -1464,7 +1577,7 @@ const commands = {
1464
1577
  if (isApp(scenario.platform)) {
1465
1578
  await walkApp({ config, root, run, scenario, env, args })
1466
1579
  // 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 })
1580
+ if (!args['no-evaluate']) await walkVerdicts({ prefetch: { done: async () => {} }, run, scenario, config, root, budget: args.budget ?? null })
1468
1581
  return
1469
1582
  }
1470
1583
  const pick = list(args.steps)
@@ -1488,7 +1601,7 @@ const commands = {
1488
1601
  const parallel = concurrency > 1 && locales.length > 1
1489
1602
  const scenarioDir = resolve(root, run.meta.scenario_file, '..')
1490
1603
  // 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 })
1604
+ const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root, environment: env?.name || null, budget: args.budget ?? null })
1492
1605
  const walked = await readCaptures(run.dir)
1493
1606
  // recordings and traces (batch D, #12): opt-in recordings, traces unless turned off; encoded in an own headless Chrome
1494
1607
  const recSettings = recordSettings({ args, config, scenario })
@@ -1664,7 +1777,14 @@ const commands = {
1664
1777
  if (failed.length) { say(`\nSteps that failed (fix and rerun with --steps):\n - ${failed.join('\n - ')}`); process.exitCode = 1 }
1665
1778
  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
1779
  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 })
1780
+ // site checks (#152, health.site): robots.txt, sitemaps, llms.txt and redirects, once per site and run
1781
+ // health.site.environments limits them (e.g. [production]); off production, HC1102 is a warning and HC1103 skipped
1782
+ const siteOpts = scenario.health?.site === true ? {} : scenario.health?.site
1783
+ if (siteOpts && env.base_url && siteChecksOn(siteOpts, env.name)) {
1784
+ say('')
1785
+ 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]}`))
1786
+ }
1787
+ if (prefetch) await walkVerdicts({ prefetch, run, scenario, config, root, budget: args.budget ?? null })
1668
1788
  },
1669
1789
 
1670
1790
  async affected (args) {
@@ -1979,9 +2099,11 @@ const commands = {
1979
2099
  say(`Rechecked deterministic criteria of ${r.rechecked} capture(s) from the stored HTML (console, overflow and performance keep their live result).`)
1980
2100
  if (r.skipped.length) say(` skipped: ${r.skipped.join('; ')}`)
1981
2101
  }
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' : ''
2102
+ const report = await evaluateRun({ run, scenario, config, root, budget: args.budget ?? null })
2103
+ 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
2104
  say(`Evaluated ${report.evaluated} capture(s); semantic evaluator ${report.model}${via}: ${report.semantic_calls} call(s), ${report.semantic_cached} cached.`)
2105
+ 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` : ''}.`)
2106
+ if (report.budget) say(budgetLine(report))
1985
2107
  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
2108
  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
2109
  if (report.skipped.length) {
@@ -1995,7 +2117,7 @@ const commands = {
1995
2117
  // --json: the counts and every verdict a person or agent has to act on (fail, needs_review)
1996
2118
  if (args.json) {
1997
2119
  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))
2120
+ 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
2121
  }
2000
2122
  },
2001
2123
 
@@ -2086,17 +2208,18 @@ const commands = {
2086
2208
  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
2209
  }
2088
2210
  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
2211
+ // sizes as the service stores them (HTML and JSON gzip-compressed): what the per-run limit and the quota count
2212
+ const total = entries.reduce((n, e) => n + (e.stored ?? e.bytes), 0)
2213
+ 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
2214
  const resumed = (await readJson(join(run.dir, PROGRESS_FILE), null).catch(() => null))?.run === manifest.id
2092
2215
  try {
2093
2216
  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.`)
2217
+ 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
2218
  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
2219
  const recBytes = entries.filter((e) => /\.rec\./.test(e.path)).reduce((n, e) => n + e.bytes, 0)
2097
2220
  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
2221
  } 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)
2222
+ 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
2223
  await writeFile(join(run.dir, PROGRESS_FILE), JSON.stringify({ run: manifest.id, started_at: new Date().toISOString() }) + '\n').catch(() => {})
2101
2224
  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
2225
  if (up.mode === 'blobs') say(` ${up.uploaded} new file(s) sent (${fmtBytes(up.bytes)}), ${up.reused} unchanged file(s) reused`)
@@ -2578,7 +2701,7 @@ const commands = {
2578
2701
  },
2579
2702
 
2580
2703
  async export (args) {
2581
- exportSubcommand(args)
2704
+ if (exportSubcommand(args) === 'findings') return exportFindings(args)
2582
2705
  const { config, root } = await context(args)
2583
2706
  const client = service(config)
2584
2707
  if (args.design) {
@@ -2849,6 +2972,7 @@ export async function main (argv, { bin = 'kaloko' } = {}) {
2849
2972
  try {
2850
2973
  await runCommand(args, json)
2851
2974
  } finally {
2975
+ await disposeShared() // the crawler checks' request context, when a walk or capture made one
2852
2976
  if (json) console.log = stdout
2853
2977
  const hint = legacyHint()
2854
2978
  if (hint) console.error(hint)
package/src/lib/build.mjs CHANGED
@@ -12,7 +12,11 @@ import { variantOf } from './dimensions.mjs'
12
12
 
13
13
  export { targetOf, statusOf } from './manifest-core.mjs'
14
14
 
15
- export async function buildManifest ({ run, scenario, config, root, catalog = [] }) {
15
+ /**
16
+ * The run manifest from the captures. `indexes` (a Map) makes it read-only: stack indexes go into the map by step id
17
+ * instead of steps/<step>/items.json and step specs are not written (kaloko export findings).
18
+ */
19
+ export async function buildManifest ({ run, scenario, config, root, catalog = [], indexes = null }) {
16
20
  const meta = run.meta
17
21
  const entries = await readCaptures(run.dir)
18
22
  const captures = latestCaptures(entries)
@@ -25,7 +29,7 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
25
29
  const mailKeys = viewportNames.filter((k) => variantOf(meta.scope, k).browser === 'chromium')
26
30
  const nodes = []
27
31
  for (const node of scenario.nodes) {
28
- if (node.kind === 'stack') { const p = pos.get(node.id); nodes.push(await buildStack({ run, node, base: { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: p.col, lane: p.lane, ...(node.refs?.length ? { refs: node.refs } : {}) }, captures, attachments })); continue }
32
+ if (node.kind === 'stack') { const p = pos.get(node.id); nodes.push(await buildStack({ run, node, base: { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: p.col, lane: p.lane, ...(node.refs?.length ? { refs: node.refs } : {}) }, captures, attachments, indexes })); continue }
29
33
  const caps = []
30
34
  // pages of a stack live in its items index; normal steps take their own captures only
31
35
  for (const c of captures.filter((x) => x.node === node.id && !x.item)) {
@@ -34,7 +38,7 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
34
38
  const ev = stored?.source === evaluationSource(node, cap) ? stored : null
35
39
  // the step spec travels as its own file (Dev mode on screenshots, `kaloko spec`, drift on the service)
36
40
  const spec = cap.spec ? `steps/${c.node}/${c.locale}/${c.viewport}.spec.json` : null
37
- if (spec) await writeJson(join(run.dir, spec), cap.spec)
41
+ if (spec && !indexes) await writeJson(join(run.dir, spec), cap.spec)
38
42
  caps.push({ locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html || null, tree: cap.files.tree || null, eval: `steps/${c.node}/${c.locale}/${c.viewport}.eval.json`, spec }, ...(cap.live ? { live: cap.live } : {}) })
39
43
  }
40
44
  // focus shots and details count only when a current capture names them (manifest-core.mjs stepAttachments)
@@ -42,7 +46,10 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
42
46
  await attachMedia(out, { run, entries: media, captures: caps })
43
47
  nodes.push(out)
44
48
  }
45
- const summary = summarize(nodes, scenario.sessions)
49
+ // site checks (#152): written by the walk once per site (site.json), advisory like the packs
50
+ const siteFile = await readJson(join(run.dir, 'site.json'), null)
51
+ const site = siteFile?.origins?.length ? { origins: siteFile.origins } : null
52
+ const summary = summarize(nodes, scenario.sessions, site)
46
53
 
47
54
  const allRuns = await listRuns(root, config.output)
48
55
  // flaky: a criterion whose verdict differs from another run of this scenario on the same commit and environment
@@ -87,6 +94,7 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
87
94
  summary,
88
95
  // "personal data hidden" (#64): the canvas says so, so a blank is not taken for a bug
89
96
  ...(runPii(meta, nodes) ? { pii: runPii(meta, nodes) } : {}),
97
+ ...(site ? { site } : {}),
90
98
  coverage: cov,
91
99
  history,
92
100
  catalog,
@@ -157,7 +165,7 @@ async function runMedia ({ run, entries }) {
157
165
  * A stack step: its pages go to steps/<node>/items.json (qawalk.stack.v1: fields, criteria, and per page the path,
158
166
  * status, index fields, capture files and compact results); the node keeps counts, a visual sample and the path.
159
167
  */
160
- async function buildStack ({ run, node, base, captures, attachments }) {
168
+ async function buildStack ({ run, node, base, captures, attachments, indexes = null }) {
161
169
  const paths = runPaths(run.dir)
162
170
  const list = []
163
171
  for (const c of captures.filter((x) => x.node === node.id && x.item)) {
@@ -167,8 +175,11 @@ async function buildStack ({ run, node, base, captures, attachments }) {
167
175
  // a variant of a design stack (#61) opens live in the lightbox: its page in the step's revision
168
176
  list.push({ item: c.item, locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html, eval: `steps/${stepIdOf(c)}/${c.locale}/${c.viewport}.eval.json` }, ...(cap.live ? { live: cap.live } : {}) })
169
177
  }
170
- const { node: out, index, file } = assembleStack({ node, base, environment: run.meta?.environment?.name || null, captures: list, attachments })
171
- await writeJson(join(run.dir, file), index)
178
+ // rotating samples: steps/<node>/rotation.<locale>.json, written by the walk
179
+ const rotation = {}
180
+ for (const l of run.meta?.scope?.locales || []) { const r = await readJson(join(run.dir, 'steps', node.id, `rotation.${l}.json`), null); if (r) rotation[l] = r }
181
+ const { node: out, index, file } = assembleStack({ node, base, environment: run.meta?.environment?.name || null, captures: list, attachments, rotation })
182
+ if (indexes) indexes.set(node.id, index); else await writeJson(join(run.dir, file), index)
172
183
  return out
173
184
  }
174
185