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.
- package/package.json +1 -1
- package/schemas/config.v1.json +47 -1
- package/schemas/run.v1.json +57 -1
- package/schemas/scenario.v1.json +100 -14
- package/skills/kaloko/reference/acceptance.md +18 -4
- package/skills/kaloko/reference/evaluation.md +44 -1
- package/skills/kaloko/reference/integrations.md +30 -1
- package/skills/kaloko/reference/mcp.md +3 -0
- package/skills/kaloko/reference/stacks.md +181 -16
- package/src/cli.mjs +191 -37
- package/src/lib/build.mjs +37 -7
- package/src/lib/capture.mjs +31 -9
- package/src/lib/config.mjs +15 -1
- package/src/lib/crawler.mjs +242 -0
- package/src/lib/draft.mjs +2 -0
- package/src/lib/evaluate.mjs +124 -38
- package/src/lib/findings.mjs +98 -0
- package/src/lib/health-core.mjs +28 -5
- package/src/lib/health-findings.mjs +116 -0
- package/src/lib/health-index.mjs +202 -0
- package/src/lib/health.mjs +4 -0
- package/src/lib/integrations-cli.mjs +6 -4
- package/src/lib/jev.mjs +34 -14
- package/src/lib/linkcheck.mjs +59 -22
- package/src/lib/local-evaluator.mjs +270 -0
- package/src/lib/manifest-core.mjs +115 -14
- package/src/lib/pack-help.mjs +26 -0
- package/src/lib/packs.mjs +41 -3
- package/src/lib/pii.mjs +2 -0
- package/src/lib/pr.mjs +5 -1
- package/src/lib/probe.mjs +62 -1
- package/src/lib/recheck.mjs +5 -1
- package/src/lib/scenario.mjs +16 -3
- package/src/lib/seo-core.mjs +400 -0
- package/src/lib/seo-init.mjs +272 -0
- package/src/lib/service.mjs +31 -2
- package/src/lib/site.mjs +362 -0
- package/src/lib/stack-core.mjs +244 -0
- package/src/lib/stack.mjs +291 -51
- package/src/lib/traffic-core.mjs +289 -0
- package/src/lib/traffic.mjs +149 -0
- package/src/lib/upload.mjs +3 -2
- package/src/lib/variants-core.mjs +198 -0
- 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 {
|
|
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
|
|
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
|
-
|
|
788
|
-
const
|
|
789
|
-
const
|
|
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}: ${
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2090
|
-
const
|
|
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)}
|
|
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)
|