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/package.json +1 -1
- package/schemas/config.v1.json +36 -1
- package/schemas/run.v1.json +47 -1
- package/schemas/scenario.v1.json +81 -14
- package/skills/kaloko/reference/acceptance.md +18 -4
- package/skills/kaloko/reference/evaluation.md +44 -1
- package/skills/kaloko/reference/integrations.md +6 -0
- package/skills/kaloko/reference/mcp.md +2 -0
- package/skills/kaloko/reference/stacks.md +104 -14
- package/src/cli.mjs +157 -33
- package/src/lib/build.mjs +18 -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 +73 -0
- package/src/lib/health-core.mjs +28 -5
- package/src/lib/health-index.mjs +187 -0
- package/src/lib/health.mjs +4 -0
- 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 +38 -12
- package/src/lib/pack-help.mjs +24 -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 +11 -3
- package/src/lib/seo-core.mjs +372 -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.mjs +230 -23
- package/src/lib/upload.mjs +3 -2
- package/viewer/index.html +31 -17
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 {
|
|
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
|
|
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
|
-
|
|
788
|
-
const
|
|
789
|
-
const
|
|
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}: ${
|
|
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
|
-
|
|
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
|
-
|
|
2090
|
-
const
|
|
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)}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
|