kaloko 0.4.7 → 0.4.9
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/README.md +41 -2
- package/package.json +1 -1
- package/schemas/config.v1.json +26 -0
- package/schemas/scenario.v1.json +32 -0
- package/schemas/spec.v1.json +60 -0
- package/skills/kaloko/SKILL.md +24 -7
- package/skills/kaloko/reference/acceptance.md +34 -2
- package/skills/kaloko/reference/collaboration.md +45 -2
- package/skills/kaloko/reference/design.md +35 -2
- package/skills/kaloko/reference/evaluation.md +51 -3
- package/skills/kaloko/reference/execution.md +18 -0
- package/skills/kaloko/reference/first-run.md +51 -0
- package/skills/kaloko/reference/mcp.md +8 -1
- package/src/cli.mjs +93 -9
- package/src/lib/build.mjs +30 -5
- package/src/lib/capture.mjs +43 -1
- package/src/lib/design-cli.mjs +7 -3
- package/src/lib/devspec.mjs +821 -0
- package/src/lib/doctor.mjs +7 -0
- package/src/lib/drift.mjs +16 -8
- package/src/lib/evaluate.mjs +94 -12
- package/src/lib/keyboard.mjs +261 -0
- package/src/lib/manifest-core.mjs +16 -5
- package/src/lib/onboarding.mjs +33 -0
- package/src/lib/pack-help.mjs +7 -0
- package/src/lib/packs.mjs +9 -2
- package/src/lib/png.mjs +61 -0
- package/src/lib/probe.mjs +57 -0
- package/src/lib/recheck.mjs +3 -1
- package/src/lib/review.mjs +69 -0
- package/src/lib/spec-cli.mjs +90 -0
- package/src/lib/stability-core.mjs +114 -0
- package/src/lib/stability.mjs +249 -0
- package/src/lib/vision-core.mjs +132 -0
- package/src/lib/vision.mjs +127 -0
- package/templates/kaloko.config.yml +6 -0
- package/viewer/index.html +889 -46
|
@@ -23,13 +23,18 @@ file.
|
|
|
23
23
|
| Tool | Use it for |
|
|
24
24
|
|---|---|
|
|
25
25
|
| `list_runs` | published runs, newest first; filters `scenario`, `project`, `verdict`, `author`, `environment`, `branch`, `limit` |
|
|
26
|
-
| `get_run_summary` | one run: statuses per step and locale, criteria that need attention with evidence, screenshot URLs, verdict, baseline, canvas URL, open feedback |
|
|
26
|
+
| `get_run_summary` | one run: statuses per step and locale, criteria that need attention with evidence (semantic ones with score and the evaluator's one-sentence `reason` per locale and viewport), regions ignored in pixel diffs and regions seen moving (`ignored_regions`, `unstable_regions`), screenshot URLs, verdict, baseline, canvas URL, open feedback |
|
|
27
27
|
| `get_feedback` | run verdict with note, approved steps, unresolved rejections and comments, failed manual criteria (`run_id`, or `scenario` for its latest run) |
|
|
28
28
|
| `list_notifications` | the token owner's inbox (`unread_only`, `limit`) |
|
|
29
29
|
| `list_projects` | projects of the organization with counts; the slug is the `project` filter of `list_runs` and the value for `project:` in a scenario |
|
|
30
30
|
| `get_criteria_verdicts` | reviewer verdicts on manual or overridden criteria of a run |
|
|
31
|
+
| `get_review_queue` | what a person still has to decide in a run (`run_id`, or `scenario` / nothing for the newest run): steps in review order with reasons, decisions, approvals carried over unchanged steps with their origin, counts and `review_url` to give the person |
|
|
32
|
+
| `decide_step` | records your (the agent's) decision on a step: `approve`, or `return` with a `note`; marked as an agent's and not counted as a person's. Never use it to approve for a person |
|
|
33
|
+
| `revoke_carried_approval` | revokes an approval carried over to a step that looks unchanged but should be looked at again (`run_id`, `step`) |
|
|
31
34
|
| `get_step_assets` | documentation layer (Business): newest capture of every step of a scenario with stable image, HTML and metadata URLs (`scenario`, `step`, `locale`, `viewport`, `accepted`) |
|
|
32
35
|
| `list_step_changes` | documentation layer: steps whose capture or status changed since the previous accepted run or `since` (`scenario`, `since`, `accepted`); see [documentation.md](documentation.md) |
|
|
36
|
+
| `get_started` | where the organization stands (`empty`, `sample`, `active`), its sample run, the next steps and a prompt; call it in a new project, see [first-run.md](first-run.md) |
|
|
37
|
+
| `add_sample_run` | adds the sample run (or returns the one there is); needs an upload or full token and the Developer / Tester role |
|
|
33
38
|
|
|
34
39
|
## Comment tools
|
|
35
40
|
|
|
@@ -60,6 +65,8 @@ The same data and permissions as the REST API; each tool is its REST route.
|
|
|
60
65
|
|---|---|
|
|
61
66
|
| `get_draft` | which revision each step shows, step status, who changed it (person or agent) |
|
|
62
67
|
| `get_step` | the handoff: live files, signed entry URL, token set, page structure, open comments (`ref`: draft, design, vN, run id) |
|
|
68
|
+
| `get_step_spec` | what to build for a step as approved: elements with role, name and selector, styles mapped to tokens, states, contrast (`ref`: design by default, draft, vN, run id; `format` text or json) |
|
|
69
|
+
| `get_step_drift` | how an implementation run (`run`: id or `latest`) differs from the design reference, per element: expected token against the actual value |
|
|
63
70
|
| `get_step_history` | versions, and revisions of a step with authors |
|
|
64
71
|
| `list_step_changes` | steps that differ between two references (default design reference → draft) |
|
|
65
72
|
| `get_references`, `get_tokens` | design reference and baseline; the project's token set |
|
package/src/cli.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { writeFile, access, readFile, mkdir } from 'node:fs/promises'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
3
|
import { gzipSync } from 'node:zlib'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
4
5
|
import { join, resolve, relative, dirname } from 'node:path'
|
|
5
6
|
import { pathToFileURL } from 'node:url'
|
|
6
7
|
import yaml from 'js-yaml'
|
|
@@ -16,7 +17,8 @@ import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher, gateFo
|
|
|
16
17
|
import { configureGate } from './lib/linkcheck.mjs'
|
|
17
18
|
import { rememberEnvironment } from './lib/secrets.mjs'
|
|
18
19
|
import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
|
|
19
|
-
import { buildManifest, writePreview } from './lib/build.mjs'
|
|
20
|
+
import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
|
|
21
|
+
import { stabilityCmd } from './lib/stability.mjs'
|
|
20
22
|
import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote } from './lib/util.mjs'
|
|
21
23
|
import { packageRoot } from './lib/schema.mjs'
|
|
22
24
|
import { compareRuns, formatComparison } from './lib/compare.mjs'
|
|
@@ -29,9 +31,12 @@ import { resolveItems, fieldsOf, DEFAULT_LIMIT } from './lib/stack.mjs'
|
|
|
29
31
|
import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
|
|
30
32
|
import { expandPacks } from './lib/packs.mjs'
|
|
31
33
|
import { upsertPrComment } from './lib/pr.mjs'
|
|
34
|
+
import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
|
|
32
35
|
import { htmlViewAllowed } from './lib/htmlview.mjs'
|
|
33
36
|
import { service, serviceUrl } from './lib/service.mjs'
|
|
34
37
|
import { login } from './lib/login.mjs'
|
|
38
|
+
import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
|
|
39
|
+
import { specCmd, driftCmd } from './lib/spec-cli.mjs'
|
|
35
40
|
import { walkDesign, syncDesign, publishVersion, historyCmd, referenceCmd, tokensCmd, exportLive, localRevisions, branchCmd, uploadBlobs } from './lib/design-cli.mjs'
|
|
36
41
|
import { designDir, readBase, headKey } from './lib/design.mjs'
|
|
37
42
|
import { lintDesign, formatLint } from './lib/design-lint.mjs'
|
|
@@ -98,6 +103,8 @@ Run
|
|
|
98
103
|
verdicts and what failed (--no-evaluate skips that; kaloko evaluate redoes it)
|
|
99
104
|
[--items a,b] [--limit N] Stack steps (kind: stack, many pages of one template): only pages whose id or
|
|
100
105
|
path contains a, b; at most N pages (default items.limit, 500)
|
|
106
|
+
[--stability N] Take N screenshots of each capture a moment apart and note regions that move
|
|
107
|
+
(clocks, carousels); kaloko stability turns them into ignore suggestions
|
|
101
108
|
status Show what the current run has captured
|
|
102
109
|
Scenarios may declare "sessions" (people in the flow); walk keeps one browser context per session and locale
|
|
103
110
|
|
|
@@ -111,7 +118,11 @@ Results
|
|
|
111
118
|
share [--expires 30] [--pr] Upload the run to kaloko.app (KALOKO_TOKEN, tester role);
|
|
112
119
|
--pr adds or updates one comment on the branch's pull request via gh
|
|
113
120
|
feedback [--run id] [--scenario id] [--json]
|
|
114
|
-
Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies
|
|
121
|
+
Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies;
|
|
122
|
+
returned steps first, with their notes
|
|
123
|
+
review [--run id] [--scenario id] [--json]
|
|
124
|
+
What a person still has to decide on a shared run (failed, unsure, changed, returned,
|
|
125
|
+
commented steps), approvals carried over unchanged steps, and the review-mode link
|
|
115
126
|
comment [--step id | --flow] --note "…" [--status rejected|approved] [--run id] [--scenario id]
|
|
116
127
|
comment --reply|--resolve|--reopen|--edit|--delete <id> [--note "…"] [--flow]
|
|
117
128
|
Comment, answer and close feedback from the CLI (resolve with "Fixed in <commit>")
|
|
@@ -136,6 +147,10 @@ Results
|
|
|
136
147
|
Newest capture of every step as steps/<step>/<locale>/<viewport>.<ext> plus
|
|
137
148
|
index.json (documentation layer, Business plan); unchanged files are skipped
|
|
138
149
|
public [--run id] [--off] Make a shared run readable without sign-in (admin; testers may refresh an already public scenario)
|
|
150
|
+
stability [--last 4 | --runs a,b] [--same-commit] [--apply] [--json]
|
|
151
|
+
Regions that change on every run or move while the page is idle (clocks, ads,
|
|
152
|
+
carousels): ignore suggestions per step in <run>/stability.yml; --apply adds
|
|
153
|
+
them to the scenario (ignore:), and pixel diffs then skip them
|
|
139
154
|
compare [--run id] [--baseline id | --design] [--json]
|
|
140
155
|
Text diff of two runs: step statuses, criterion verdicts, scores and evidence
|
|
141
156
|
(default: current run against the scenario baseline, else the previous run)
|
|
@@ -153,6 +168,12 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
153
168
|
share [--name <text>] [--branch b] Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged"
|
|
154
169
|
history [--scenario id] [--step id] [--locale l] [--json]
|
|
155
170
|
Versions of the flow; revisions of a step with who (person or agent) and when
|
|
171
|
+
spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]
|
|
172
|
+
What to build for a step as approved: elements with role, name and selector, styles
|
|
173
|
+
mapped to DTCG tokens (or the nearest one), :hover/:focus/:active/:disabled, contrast
|
|
174
|
+
drift [step] [--ref design|vN] [--remote <run id|latest>] [--json]
|
|
175
|
+
How this implementation run differs from the approved design (expected token vs actual
|
|
176
|
+
value, raw values, missing elements and states); exit code 1 when something differs
|
|
156
177
|
reference [--scenario id] (--design|--baseline) (--run id|--version n|--off) [--branch b]
|
|
157
178
|
Show or set the design reference (accepted version) and the baseline
|
|
158
179
|
tokens pull|push|check|validate [--project p] [--file tokens.json|tokens.resolver.json] [--mode m] [--json]
|
|
@@ -563,7 +584,13 @@ const commands = {
|
|
|
563
584
|
await cp(join(packageRoot, 'templates', 'steps', 'consent.mjs'), consentStep)
|
|
564
585
|
say('Added qa/flows/steps/consent.mjs (accepts the consent dialog, if the site has one)')
|
|
565
586
|
}
|
|
566
|
-
|
|
587
|
+
// the organization's sample run and the way to a first real flow (src/lib/onboarding.mjs)
|
|
588
|
+
let written = ''
|
|
589
|
+
try { written = await readFile(existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE), 'utf8') } catch {}
|
|
590
|
+
const org = typeof args.org === 'string' ? args.org : (written.match(/^org:\s*["']?([a-z0-9-]+)["']?\s*$/m) || [])[1]
|
|
591
|
+
const base = serviceUrl({ share: { url: (written.match(/^share:[ \t]*\n(?:[ \t]+.*\n)*?[ \t]+url:[ \t]*["']?(https?:\/\/[^\s"']+)/m) || [])[1] } })
|
|
592
|
+
say(initNextSteps({ base, org }).join('\n'))
|
|
593
|
+
say(`Scenario template: ${join(packageRoot, 'templates', 'scenario.yml')}`)
|
|
567
594
|
},
|
|
568
595
|
|
|
569
596
|
async validate (args) {
|
|
@@ -691,7 +718,7 @@ const commands = {
|
|
|
691
718
|
return
|
|
692
719
|
}
|
|
693
720
|
const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
|
|
694
|
-
const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, list(args.viewports)), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null })
|
|
721
|
+
const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, list(args.viewports)), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, stability: Number(args.stability) || 0 })
|
|
695
722
|
for (const r of results) {
|
|
696
723
|
const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
|
|
697
724
|
say(`✓ ${node.id} ${locale} ${r.viewport} ${r.size.width}×${r.size.height}px ${failed.length ? `failing checks: ${failed.join(', ')}` : 'checks ok'}${r.blocked_requests.length ? ` blocked: ${r.blocked_requests.length}` : ''}`)
|
|
@@ -815,7 +842,7 @@ const commands = {
|
|
|
815
842
|
// the automatic capture runs unless this script invocation captured the step itself: a crop is an
|
|
816
843
|
// attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
|
|
817
844
|
let captured = false
|
|
818
|
-
const capture = (opts = {}) => { captured = true; return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, opts.viewports), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile: conn && !sess ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {} }).then((r) => { prefetch?.add(r, run.dir); return r }) }
|
|
845
|
+
const capture = (opts = {}) => { captured = true; return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, opts.viewports), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile: conn && !sess ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); return r }) }
|
|
819
846
|
await mod.default({ page: sPage, context: sCtx, locale, node: { id: node.id, title: localized(node.title, locale), kind: node.kind }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0 }), acceptDialogs: () => acceptDialogs(sPage) })
|
|
820
847
|
if (!captured) await capture()
|
|
821
848
|
say(`✓ ${node.id} ${locale}`)
|
|
@@ -936,6 +963,7 @@ const commands = {
|
|
|
936
963
|
const report = await evaluateRun({ run, scenario, config, root })
|
|
937
964
|
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' : ''
|
|
938
965
|
say(`Evaluated ${report.evaluated} capture(s); semantic evaluator ${report.model}${via}: ${report.semantic_calls} call(s), ${report.semantic_cached} cached.`)
|
|
966
|
+
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('; ')}` : ''}.`)
|
|
939
967
|
if (report.skipped.length) {
|
|
940
968
|
// one line per reason: "12 captures: TYPESAFE_API_KEY is not set …"
|
|
941
969
|
const byReason = new Map()
|
|
@@ -951,9 +979,15 @@ const commands = {
|
|
|
951
979
|
const file = await writePreview({ run, manifest })
|
|
952
980
|
const s = manifest.summary
|
|
953
981
|
say(`${manifest.id}: ${s.pass} pass · ${s.partial} partial · ${s.fail} fail · ${s.skip} not checked\nPreview: ${file}`)
|
|
982
|
+
const why = attentionLines(manifest); if (why.length) say(`AI verdicts to act on (score and why):\n${why.join('\n')}`)
|
|
954
983
|
return file
|
|
955
984
|
},
|
|
956
985
|
|
|
986
|
+
async stability (args) {
|
|
987
|
+
const { config, root, run, scenario } = await runContext(args)
|
|
988
|
+
await stabilityCmd({ root, config, run, scenario, args, say })
|
|
989
|
+
},
|
|
990
|
+
|
|
957
991
|
async preview (args) {
|
|
958
992
|
const file = await commands.build(args)
|
|
959
993
|
say(pathToFileURL(file).href)
|
|
@@ -976,7 +1010,7 @@ const commands = {
|
|
|
976
1010
|
await writePreview({ run, manifest })
|
|
977
1011
|
const files = new Set()
|
|
978
1012
|
for (const n of manifest.nodes) {
|
|
979
|
-
for (const byVp of Object.values(n.captures || {})) for (const c of Object.values(byVp)) [c.image || c.png, c.thumb, c.html, c.eval, c.tree].filter(Boolean).forEach((p) => files.add(p))
|
|
1013
|
+
for (const byVp of Object.values(n.captures || {})) for (const c of Object.values(byVp)) [c.image || c.png, c.thumb, c.html, c.eval, c.tree, c.spec].filter(Boolean).forEach((p) => files.add(p))
|
|
980
1014
|
for (const a of n.attachments || []) files.add(a.file)
|
|
981
1015
|
// stack steps: the items index and every page's files
|
|
982
1016
|
if (n.stack?.items) {
|
|
@@ -1000,9 +1034,10 @@ const commands = {
|
|
|
1000
1034
|
const body = async (p) => { const raw = await readFile(join(run.dir, p)); const gz = /\.(html|json)$/.test(p) && raw.length > 512; return { data: gz ? gzipSync(raw, { level: 6 }) : raw, gz } }
|
|
1001
1035
|
// the file list goes first: the service checks limits once and each upload is then a plain write
|
|
1002
1036
|
// (an older service without the plan answers 404/405 and every upload checks itself, as before)
|
|
1003
|
-
|
|
1037
|
+
// screenshots name their SHA-256: the service checks it on arrival and compares unchanged steps with it
|
|
1038
|
+
if (files.size) {
|
|
1004
1039
|
const plan = []
|
|
1005
|
-
for (const p of files) plan.push({ path: p, bytes:
|
|
1040
|
+
for (const p of files) { const { data } = await body(p); plan.push({ path: p, bytes: data.length, ...(/\.(png|webp|jpe?g)$/i.test(p) ? { sha256: createHash('sha256').update(data).digest('hex') } : {}) }) }
|
|
1006
1041
|
await client.request('POST', `/orgs/${config.org}/runs/${manifest.id}/files`, { files: plan }).catch((e) => { if (!/HTTP (404|405)/.test(e.message)) throw e })
|
|
1007
1042
|
}
|
|
1008
1043
|
const queue = [...files]
|
|
@@ -1036,6 +1071,9 @@ const commands = {
|
|
|
1036
1071
|
meta.shared = { url: fin.url, at: new Date().toISOString(), expires_days: created.expires_days }
|
|
1037
1072
|
await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
|
|
1038
1073
|
say(`Shared ${manifest.id} (${files.size} files, expires in ${created.expires_days} days)\n${fin.url}`)
|
|
1074
|
+
// what waits for a person, with the review-mode link to give them (approvals of unchanged steps carry over)
|
|
1075
|
+
const review = shareReviewLine(fin.review)
|
|
1076
|
+
if (review) say(review)
|
|
1039
1077
|
const capped = expiryNote(days, created.expires_days)
|
|
1040
1078
|
if (capped) say(`Note: ${capped}`)
|
|
1041
1079
|
if (args.pr) {
|
|
@@ -1080,6 +1118,25 @@ const commands = {
|
|
|
1080
1118
|
await historyCmd({ config, scenarioId, client: service(config), args, say })
|
|
1081
1119
|
},
|
|
1082
1120
|
|
|
1121
|
+
async spec (args) {
|
|
1122
|
+
const { config, root } = await context(args)
|
|
1123
|
+
let run = null
|
|
1124
|
+
try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
|
|
1125
|
+
const scenario = run ? await loadScenario(resolve(root, run.meta.scenario_file)).catch(() => null) : typeof args.scenario === 'string' && /\.ya?ml$/.test(args.scenario) ? await loadScenario(resolve(args.scenario)) : null
|
|
1126
|
+
const scenarioId = await scenarioIdOf(args, config, root)
|
|
1127
|
+
let client = null
|
|
1128
|
+
if (!args.local) { try { client = service(config) } catch (e) { if (!run) throw e } }
|
|
1129
|
+
await specCmd({ config, client, run, scenario, scenarioId, args, say })
|
|
1130
|
+
},
|
|
1131
|
+
|
|
1132
|
+
async drift (args) {
|
|
1133
|
+
const { config, root } = await context(args)
|
|
1134
|
+
let run = null
|
|
1135
|
+
try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
|
|
1136
|
+
const scenarioId = await scenarioIdOf(args, config, root)
|
|
1137
|
+
await driftCmd({ config, client: service(config), run, scenarioId, args, say })
|
|
1138
|
+
},
|
|
1139
|
+
|
|
1083
1140
|
async reference (args) {
|
|
1084
1141
|
const { config, root } = await context(args)
|
|
1085
1142
|
const scenarioId = await scenarioIdOf(args, config, root)
|
|
@@ -1132,12 +1189,38 @@ const commands = {
|
|
|
1132
1189
|
for (const x of r.replies || []) say(` ↳ ${x.email}${x.agent ? ` via ${x.agent}` : ''}: ${x.deleted ? '(deleted)' : x.note}`)
|
|
1133
1190
|
}
|
|
1134
1191
|
const flow = data.flow_comments || []
|
|
1192
|
+
// how many steps still wait for a person (review mode); an older service answers 404 and the line is left out
|
|
1193
|
+
const queue = await client.request('GET', `/orgs/${config.org}/review-queue?run=${encodeURIComponent(data.run.id)}`).catch(() => null)
|
|
1194
|
+
if (queue?.counts) say(` review: ${queue.counts.pending} of ${queue.counts.queue} step(s) wait for a person${queue.counts.carried ? `, ${queue.counts.carried} unchanged kept their approval` : ''} (kaloko review; review mode: ${queue.review_url})`)
|
|
1135
1195
|
if (!data.items.length && !flow.length) { say(' No open rejections or comments.'); return }
|
|
1136
|
-
|
|
1196
|
+
// returned steps first: a reviewer said what should change
|
|
1197
|
+
const returned = data.items.filter((r) => r.status === 'rejected')
|
|
1198
|
+
if (returned.length) say(`Returned steps (${returned.length}), fix these first:`)
|
|
1199
|
+
for (const r of returned) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
|
|
1200
|
+
if (returned.length && data.items.length > returned.length) say('Comments:')
|
|
1201
|
+
for (const r of data.items.filter((x) => x.status !== 'rejected')) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
|
|
1137
1202
|
for (const r of flow) thread(r, 'whole flow')
|
|
1138
1203
|
say(`\nAnswer with: kaloko comment --reply <id> --note "…"; close with: kaloko comment --resolve <id> --note "Fixed in <commit>"${flow.length ? ' (add --flow for flow comments)' : ''}`)
|
|
1139
1204
|
},
|
|
1140
1205
|
|
|
1206
|
+
/**
|
|
1207
|
+
* What a person still has to decide on a shared run: the service's review queue (failed, unsure, changed since the
|
|
1208
|
+
* accepted run, returned, commented), the approvals carried over unchanged steps, and the review-mode link. For
|
|
1209
|
+
* agents: report it to the person; never approve on their behalf.
|
|
1210
|
+
*/
|
|
1211
|
+
async review (args) {
|
|
1212
|
+
const { config, root } = await context(args)
|
|
1213
|
+
const client = service(config)
|
|
1214
|
+
let runId = typeof args.run === 'string' ? args.run : null
|
|
1215
|
+
let scenarioId = typeof args.scenario === 'string' ? args.scenario : null
|
|
1216
|
+
if (!runId && !scenarioId) {
|
|
1217
|
+
try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id; else scenarioId = r.meta.scenario.id } catch {}
|
|
1218
|
+
}
|
|
1219
|
+
const q = new URLSearchParams({ run: runId || 'latest', ...(scenarioId ? { scenario: scenarioId } : {}) })
|
|
1220
|
+
const data = await client.request('GET', `/orgs/${config.org}/review-queue?${q}`)
|
|
1221
|
+
say(args.json ? JSON.stringify(data, null, 2) : formatReviewQueue(data))
|
|
1222
|
+
},
|
|
1223
|
+
|
|
1141
1224
|
/**
|
|
1142
1225
|
* Comments from the CLI: on a step (--step), the whole run (default) or the whole flow (--flow); reply, resolve
|
|
1143
1226
|
* (with a closing note), reopen, edit and delete by id. Targets the shared run (--run, default the current one).
|
|
@@ -1236,6 +1319,7 @@ const commands = {
|
|
|
1236
1319
|
if (args.print) return
|
|
1237
1320
|
say(`✓ Signed in to ${r.org_name || r.org} as ${r.email} (${r.scope}; expires ${String(r.expires_at || '').slice(0, 10)}). ${tokenEnv} saved to ${r.file}.`)
|
|
1238
1321
|
if (config.org && r.org && config.org !== r.org) say(`! ${CONFIG_FILE} names the organization ${config.org}; the token is for ${r.org}.`)
|
|
1322
|
+
for (const line of loginNextSteps({ base, org: r.org })) say(line)
|
|
1239
1323
|
let projectEnv = ''
|
|
1240
1324
|
try { projectEnv = await readFile(join(process.cwd(), '.env'), 'utf8') } catch {}
|
|
1241
1325
|
if (inShell || new RegExp(`^\\s*(export\\s+)?${tokenEnv}\\s*=`, 'm').test(projectEnv)) say(`! ${tokenEnv} is also set ${inShell ? 'in your shell' : 'in this project\'s .env'}, which wins over ~/.config/kaloko/.env. Remove it there to use the new token.`)
|
package/src/lib/build.mjs
CHANGED
|
@@ -29,9 +29,14 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
|
|
|
29
29
|
const cap = await readJson(join(run.dir, c.file))
|
|
30
30
|
const stored = await readJson(runPaths(run.dir).step(c.node, c.locale, c.viewport, 'eval.json'), null)
|
|
31
31
|
const ev = stored?.source === evaluationSource(node, cap) ? stored : null
|
|
32
|
-
|
|
32
|
+
// the step spec travels as its own file (Dev mode on screenshots, `kaloko spec`, drift on the service)
|
|
33
|
+
const spec = cap.spec ? `steps/${c.node}/${c.locale}/${c.viewport}.spec.json` : null
|
|
34
|
+
if (spec) await writeJson(join(run.dir, spec), cap.spec)
|
|
35
|
+
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 } : {}) })
|
|
33
36
|
}
|
|
34
|
-
|
|
37
|
+
// focus screenshots of the keyboard walk (#19): only those of the current captures, not of earlier walks
|
|
38
|
+
const shots = new Set(caps.flatMap((c) => (c.cap.keyboard?.shots || []).map((s) => s.file)))
|
|
39
|
+
nodes.push(assembleNode({ node, pos: pos.get(node.id), locales, viewportNames, captures: caps, attachments: attachments.filter((a) => a.node === node.id && (a.kind !== 'focus' || shots.has(a.file))) }))
|
|
35
40
|
}
|
|
36
41
|
const summary = summarize(nodes, scenario.sessions)
|
|
37
42
|
|
|
@@ -126,19 +131,39 @@ async function buildStack ({ run, node, base, captures, attachments }) {
|
|
|
126
131
|
return {
|
|
127
132
|
...base, status, by_locale, captures: {}, criteria, semantic: {}, metadata: {}, session: node.session || null,
|
|
128
133
|
execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : { strategy: 'playwright', instructions: 'Open every page of the stack and capture it.' },
|
|
129
|
-
attachments: attachments.filter((a) => a.node === node.id).map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
|
|
134
|
+
attachments: attachments.filter((a) => a.node === node.id && a.kind !== 'focus').map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
|
|
130
135
|
stack: { count: items.length, items: file, summary, fields, sample, semantic_sample: semanticSample, source: ['urls', 'file', 'sitemap', 'crawl', 'script'].find((k) => src[k]) || null, stale: items.filter((it) => it.stale).length }
|
|
131
136
|
}
|
|
132
137
|
}
|
|
133
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Lines for `kaloko build`: semantic criteria that failed or need a person, per step, locale and viewport, with the
|
|
141
|
+
* score and the one-sentence reason (#17). At most `max` lines.
|
|
142
|
+
*/
|
|
143
|
+
export function attentionLines (manifest, max = 20) {
|
|
144
|
+
const out = []
|
|
145
|
+
for (const n of manifest.nodes || []) {
|
|
146
|
+
for (const c of n.criteria || []) {
|
|
147
|
+
if (c.type !== 'semantic' || !['fail', 'needs_review'].includes(c.verdict)) continue
|
|
148
|
+
for (const [l, vps] of Object.entries(c.by_locale || {})) {
|
|
149
|
+
for (const [vp, r] of Object.entries(vps)) {
|
|
150
|
+
if (!['fail', 'needs_review'].includes(r.verdict)) continue
|
|
151
|
+
out.push(` ${r.verdict === 'fail' ? '✕' : '?'} ${n.id} ${l}/${vp} ${c.id}${r.score != null ? ` ${Math.round(r.score * 100)} %${r.vision ? ' (with screenshot)' : ''}` : ''}${r.reason ? `: ${r.reason}` : r.note ? `: ${r.note}` : ''}`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out.length > max ? [...out.slice(0, max), ` … ${out.length - max} more on the canvas`] : out
|
|
157
|
+
}
|
|
158
|
+
|
|
134
159
|
export async function writePreview ({ run, manifest }) {
|
|
135
160
|
const template = await readFile(join(packageRoot, 'viewer', 'index.html'), 'utf8')
|
|
136
161
|
// the local preview opens from file://, where fetch cannot read files: stack indexes go inline
|
|
137
162
|
// file:// cannot fetch: stack indexes and app UI trees travel inside the preview
|
|
138
163
|
const withTrees = async (n) => {
|
|
139
|
-
if (!Object.values(n.captures || {}).some((vps) => Object.values(vps).some((c) => c?.tree))) return n
|
|
164
|
+
if (!Object.values(n.captures || {}).some((vps) => Object.values(vps).some((c) => c?.tree || c?.spec))) return n
|
|
140
165
|
const captures = {}
|
|
141
|
-
for (const [l, vps] of Object.entries(n.captures)) { captures[l] = {}; for (const [vp, c] of Object.entries(vps)) captures[l][vp] = c?.tree ? { ...c, tree_inline: await readJson(join(run.dir, c.tree), null) } : c }
|
|
166
|
+
for (const [l, vps] of Object.entries(n.captures)) { captures[l] = {}; for (const [vp, c] of Object.entries(vps)) captures[l][vp] = c?.tree || c?.spec ? { ...c, ...(c.tree ? { tree_inline: await readJson(join(run.dir, c.tree), null) } : {}), ...(c.spec ? { spec_inline: await readJson(join(run.dir, c.spec), null) } : {}) } : c }
|
|
142
167
|
return { ...n, captures }
|
|
143
168
|
}
|
|
144
169
|
// design runs: live steps open from the merged tree, where links between steps resolve (design.mjs copyLive)
|
package/src/lib/capture.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFile, mkdir, rm } from 'node:fs/promises'
|
|
|
2
2
|
import { dirname, relative, join } from 'node:path'
|
|
3
3
|
import { buildHtmlView } from './htmlview.mjs'
|
|
4
4
|
import { probe } from './probe.mjs'
|
|
5
|
+
import { pageSpecScript } from './devspec.mjs'
|
|
5
6
|
import { writeJson, readJsonl } from './util.mjs'
|
|
6
7
|
import { addCapture, runPaths } from './run.mjs'
|
|
7
8
|
|
|
@@ -37,6 +38,15 @@ export const toWebp = (page, png, opts = {}) => encodeImage(page, png, { ...opts
|
|
|
37
38
|
* Capture options: scenario-wide `capture:` merged with the node's. Documentation runs use
|
|
38
39
|
* { format: png, scale: 2, mask: [selectors], annotate: true }; daily acceptance keeps the WebP default.
|
|
39
40
|
*/
|
|
41
|
+
/** The page spec (src/lib/devspec.mjs), kept under ~250 kB so it fits a design revision's capture record. */
|
|
42
|
+
export async function stepSpec (page, tokens = null) {
|
|
43
|
+
try {
|
|
44
|
+
const spec = await page.evaluate(pageSpecScript(tokens))
|
|
45
|
+
while (spec.elements.length > 20 && JSON.stringify(spec).length > 250000) spec.elements = spec.elements.slice(0, Math.floor(spec.elements.length * 0.8))
|
|
46
|
+
return spec
|
|
47
|
+
} catch { return null }
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
export function captureOptions (scenario, node) {
|
|
41
51
|
const o = { ...(scenario?.capture || {}), ...(node?.capture || {}) }
|
|
42
52
|
const q = Number(o.quality)
|
|
@@ -56,6 +66,8 @@ import { consoleEntriesFor } from './console.mjs'
|
|
|
56
66
|
import { redact, redactDeep } from './secrets.mjs'
|
|
57
67
|
import { readFields } from './stack.mjs'
|
|
58
68
|
import { checkLinks, checkHreflang, requester, walkCache, NETWORK_ASSERTS, isolatedRequest, cacheScope } from './linkcheck.mjs'
|
|
69
|
+
import { keyboardChecks, needsKeyboard, KEYBOARD_ASSERTS } from './keyboard.mjs'
|
|
70
|
+
import { repeatRegions, resolveIgnore } from './stability.mjs'
|
|
59
71
|
|
|
60
72
|
/** Probe arguments derived from the node's criteria. */
|
|
61
73
|
export function probeArgs (node, maxChars, design = false) {
|
|
@@ -74,6 +86,9 @@ export function probeArgs (node, maxChars, design = false) {
|
|
|
74
86
|
// md-menu, listboxes, tooltips). A step that leaves one open wants it on the capture.
|
|
75
87
|
const OVERLAY = '[role=menu], [role=listbox], [role=tooltip], .MuiPopover-root, .MuiPopper-root, .MuiMenu-root, md-menu-content, .md-open-menu-container.md-active, .md-panel-outer-wrapper.md-panel-is-showing'
|
|
76
88
|
|
|
89
|
+
const STACK_KEYBOARD = new Map()
|
|
90
|
+
const STACK_KEYBOARD_PAGES = 5
|
|
91
|
+
|
|
77
92
|
export async function settle (page) {
|
|
78
93
|
try { await page.waitForLoadState('networkidle', { timeout: 5000 }) } catch {}
|
|
79
94
|
try { await page.evaluate(() => document.fonts?.ready) } catch {}
|
|
@@ -95,7 +110,7 @@ export async function settle (page) {
|
|
|
95
110
|
* Captures the current state of `page` for one step and locale in every
|
|
96
111
|
* requested viewport: full-page PNG, rendered HTML, probe results.
|
|
97
112
|
*/
|
|
98
|
-
export async function captureStep ({ page, node, locale, viewports, run, consoleFile, maxChars = 90000, since, extra = {}, format = null, options = null, designSummary = false, htmlView = null, item = null, fields = null }) {
|
|
113
|
+
export async function captureStep ({ page, node, locale, viewports, run, consoleFile, maxChars = 90000, since, extra = {}, format = null, options = null, designSummary = false, htmlView = null, item = null, fields = null, tokens = null, stability = 0 }) {
|
|
99
114
|
const opts = { ...(options || captureOptions(null, node)), ...(format ? { format } : {}) }
|
|
100
115
|
// a page of a stack step lives under steps/<node>/items/<item>/…; its capture record names node and item
|
|
101
116
|
const stepId = item ? `${node.id}/items/${item.id}` : node.id
|
|
@@ -132,6 +147,12 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
132
147
|
throw error
|
|
133
148
|
}
|
|
134
149
|
}
|
|
150
|
+
// the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
|
|
151
|
+
if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens)
|
|
152
|
+
// regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
|
|
153
|
+
const ignore = await resolveIgnore(page, node, locale, viewport)
|
|
154
|
+
// what moves while the page sits still (walk --stability N): repeated screenshots, compared here
|
|
155
|
+
const unstable = stability > 1 ? await repeatRegions(page, { n: Math.min(6, stability), fullPage: opts.fullPage }).catch((error) => ({ error: String(error.message).slice(0, 200) })) : null
|
|
135
156
|
// numbered marks on the highlighted elements, in criteria order (first box of each)
|
|
136
157
|
const annotations = opts.annotate ? Object.entries(result.boxes || {}).filter(([, list]) => list?.length).map(([criterion, list], i) => ({ n: i + 1, criterion, x: list[0].x, y: list[0].y })) : []
|
|
137
158
|
let image = paths.step(stepId, locale, viewport, opts.format)
|
|
@@ -168,6 +189,24 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
168
189
|
: { ok: errors.length === 0, evidence: errors.length ? errors.slice(0, 3).join(' | ') : 'no console errors' }
|
|
169
190
|
}
|
|
170
191
|
}
|
|
192
|
+
// keyboard and screen-reader checks (#19): a Tab walk and the accessibility tree, after the screenshot and after
|
|
193
|
+
// the console was read (focus handlers of the page must not count as its errors)
|
|
194
|
+
let keyboard = null
|
|
195
|
+
// stack steps walk the keyboard on their first few pages only (one template, hundreds of pages)
|
|
196
|
+
const kbKey = `${run.dir}|${node.id}|${viewport}`
|
|
197
|
+
const kbSampled = !item || (STACK_KEYBOARD.set(kbKey, (STACK_KEYBOARD.get(kbKey) || 0) + 1).get(kbKey) <= STACK_KEYBOARD_PAGES)
|
|
198
|
+
if (needsKeyboard(node) && !kbSampled) {
|
|
199
|
+
for (const c of node.criteria) if (KEYBOARD_ASSERTS.has(c.check.assert)) result.checks[c.id] = { ok: null, evidence: `keyboard walk runs on the first ${STACK_KEYBOARD_PAGES} pages of a stack step` }
|
|
200
|
+
}
|
|
201
|
+
if (needsKeyboard(node) && kbSampled) {
|
|
202
|
+
const kb = await keyboardChecks(page, node.criteria, {
|
|
203
|
+
saveShot: async (n, png) => { const f = paths.step(stepId, locale, viewport, `focus-${n}.png`); await writeFile(f, png); return relative(run.dir, f) }
|
|
204
|
+
})
|
|
205
|
+
Object.assign(result.checks, kb.checks)
|
|
206
|
+
result.boxes = { ...result.boxes, ...kb.boxes }
|
|
207
|
+
keyboard = kb.keyboard
|
|
208
|
+
for (const s of keyboard?.shots || []) await addCapture(run.dir, { type: 'attachment', kind: 'focus', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, title: `Focus ${s.n}: ${s.name}`, file: s.file, at: new Date().toISOString() })
|
|
209
|
+
}
|
|
171
210
|
// experimental HTML view (#42): a scrubbed, script-free static copy with rehosted assets
|
|
172
211
|
let view = null
|
|
173
212
|
if (htmlView) {
|
|
@@ -189,6 +228,9 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
189
228
|
...extra,
|
|
190
229
|
http_status: extra.http_status ?? result.http_status ?? null,
|
|
191
230
|
capture: { format: String(image).split('.').pop(), scale: opts.scale, masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
|
|
231
|
+
...(ignore.length ? { ignore } : {}),
|
|
232
|
+
...(unstable ? { unstable } : {}),
|
|
233
|
+
...(keyboard ? { keyboard } : {}),
|
|
192
234
|
...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) } } : {}),
|
|
193
235
|
...(fields ? { fields: { ...(await page.evaluate(readFields, fields).catch(() => ({}))), ...(fields.some((f) => (f.from || f.id) === 'http') ? Object.fromEntries(fields.filter((f) => (f.from || f.id) === 'http').map((f) => [f.id, extra.http_status ?? result.http_status ?? null])) : {}) } } : {}),
|
|
194
236
|
files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
|
package/src/lib/design-cli.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { readJson, writeJson, UserError } from './util.mjs'
|
|
|
7
7
|
import { readCaptures, addCapture, runPaths } from './run.mjs'
|
|
8
8
|
import { latestCaptures, criteriaHash, evaluationSource } from './evaluate.mjs'
|
|
9
9
|
import { captureStep } from './capture.mjs'
|
|
10
|
+
import { loadTokens } from './drift.mjs'
|
|
10
11
|
import { layout } from './scenario.mjs'
|
|
11
12
|
import { launchEphemeral } from './browser.mjs'
|
|
12
13
|
import { observeConsole } from './console.mjs'
|
|
@@ -45,6 +46,8 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
|
|
|
45
46
|
const browser = await launchEphemeral(config)
|
|
46
47
|
const paths = runPaths(run.dir)
|
|
47
48
|
const mode = env.tokens_mode || ''
|
|
49
|
+
// the design folder's own set names the values in the step spec (the one its live pages reference)
|
|
50
|
+
const tokens = await loadTokens({ client: null, config, root, designDir: dir })
|
|
48
51
|
let rendered = 0, reused = 0
|
|
49
52
|
try {
|
|
50
53
|
const page = await (await browser.newContext()).newPage()
|
|
@@ -58,7 +61,8 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
|
|
|
58
61
|
for (const [vp, size] of Object.entries(viewports)) {
|
|
59
62
|
const ck = captureKey(rev.id, vp, mode)
|
|
60
63
|
const cached = noCache ? null : await fromCache({ root, output: config.output, key: ck })
|
|
61
|
-
|
|
64
|
+
// a capture from before the step spec existed renders again, so Dev mode and `kaloko spec` have it
|
|
65
|
+
if (cached && cached.record.criteria_hash === criteriaHash(node) && cached.record.spec) {
|
|
62
66
|
const record = await restoreCached({ cached, runDir: run.dir, paths, node: node.id, locale: rev.locale, viewport: vp })
|
|
63
67
|
const file = paths.step(node.id, rev.locale, vp, 'capture.json')
|
|
64
68
|
await writeJson(file, record)
|
|
@@ -69,7 +73,7 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
|
|
|
69
73
|
if (Object.keys(todo).length) {
|
|
70
74
|
const since = new Date().toISOString()
|
|
71
75
|
await page.goto(`${server.url}/${rev.entry}`, { waitUntil: 'load' })
|
|
72
|
-
const results = await captureStep({ page, node, locale: rev.locale, viewports: todo, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live }, designSummary: true })
|
|
76
|
+
const results = await captureStep({ page, node, locale: rev.locale, viewports: todo, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live }, designSummary: true, tokens })
|
|
73
77
|
for (const r of results) { await toCache({ root, output: config.output, key: captureKey(rev.id, r.viewport, mode), runDir: run.dir, record: r }); rendered++ }
|
|
74
78
|
}
|
|
75
79
|
say(`✓ ${node.id} ${rev.locale} revision ${rev.id.slice(0, 8)}${Object.keys(todo).length ? '' : ' (unchanged, reused)'}`)
|
|
@@ -231,7 +235,7 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
|
|
|
231
235
|
out[k] = h
|
|
232
236
|
}
|
|
233
237
|
const stored = await readJson(runPaths(run.dir).step(rev.node, rev.locale, vp, 'eval.json'), null)
|
|
234
|
-
out.record = slimCapture(rec)
|
|
238
|
+
out.record = { ...slimCapture(rec), ...(rec.spec ? { spec: rec.spec } : {}) }
|
|
235
239
|
out.evaluation = stored?.source === evaluationSource(node, rec) ? stored : null
|
|
236
240
|
out.mode = env.tokens_mode || ''
|
|
237
241
|
out.renderer = RENDERER
|