kaloko 0.4.8 → 0.4.10
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 +92 -5
- package/package.json +1 -1
- package/schemas/config.v1.json +33 -0
- package/schemas/scenario.v1.json +39 -0
- package/schemas/spec.v1.json +60 -0
- package/skills/kaloko/SKILL.md +24 -3
- package/skills/kaloko/reference/acceptance.md +50 -2
- package/skills/kaloko/reference/ci.md +70 -0
- package/skills/kaloko/reference/collaboration.md +9 -1
- package/skills/kaloko/reference/design.md +35 -2
- package/skills/kaloko/reference/environments.md +5 -0
- package/skills/kaloko/reference/evaluation.md +46 -3
- package/skills/kaloko/reference/execution.md +74 -0
- package/skills/kaloko/reference/import.md +66 -0
- package/skills/kaloko/reference/mcp.md +4 -1
- package/src/cli.mjs +428 -99
- package/src/lib/affected-core.mjs +242 -0
- package/src/lib/affected.mjs +233 -0
- package/src/lib/build.mjs +30 -5
- package/src/lib/capture.mjs +43 -1
- package/src/lib/ci.mjs +207 -0
- package/src/lib/config.mjs +2 -0
- 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 +98 -14
- package/src/lib/git.mjs +6 -2
- package/src/lib/heal.mjs +207 -0
- package/src/lib/import.mjs +374 -0
- package/src/lib/init-detect.mjs +219 -0
- package/src/lib/keyboard.mjs +261 -0
- package/src/lib/manifest-core.mjs +16 -5
- package/src/lib/onboarding.mjs +15 -8
- 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/pr.mjs +24 -2
- package/src/lib/probe.mjs +57 -0
- package/src/lib/recheck.mjs +3 -1
- package/src/lib/scenario.mjs +2 -1
- package/src/lib/service.mjs +7 -1
- 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/unzip.mjs +31 -0
- package/src/lib/upload.mjs +114 -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 +500 -44
package/src/lib/ci.mjs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Packaged CI (#15): `kaloko ci github|gitlab` writes a workflow into the customer's repository that walks the
|
|
2
|
+
// affected scenarios on the pull request's preview deployment and comments on the PR; `kaloko ci preview-url` finds
|
|
3
|
+
// that deployment at run time (Vercel, Netlify, Cloudflare Pages and anything that reports GitHub deployments).
|
|
4
|
+
import { readFile, appendFile } from 'node:fs/promises'
|
|
5
|
+
|
|
6
|
+
const clean = (u) => (u ? (/^https?:\/\//.test(u) ? u : `https://${u}`).replace(/\/$/, '') : null)
|
|
7
|
+
|
|
8
|
+
/** Preview URL from variables CI and hosting providers set; { url, source } or null. */
|
|
9
|
+
export function previewFromEnv (vars = process.env, event = null) {
|
|
10
|
+
if (vars.KALOKO_PREVIEW_URL) return { url: clean(vars.KALOKO_PREVIEW_URL), source: 'KALOKO_PREVIEW_URL' }
|
|
11
|
+
const ds = event?.deployment_status
|
|
12
|
+
if (ds && ds.state === 'success' && (ds.environment_url || ds.target_url)) return { url: clean(ds.environment_url || ds.target_url), source: `deployment_status (${ds.environment || event.deployment?.environment || 'deployment'})` }
|
|
13
|
+
for (const [name, label] of [['VERCEL_BRANCH_URL', 'Vercel'], ['VERCEL_URL', 'Vercel'], ['DEPLOY_PRIME_URL', 'Netlify'], ['DEPLOY_URL', 'Netlify'], ['CF_PAGES_URL', 'Cloudflare Pages'], ['CI_ENVIRONMENT_URL', 'GitLab environment'], ['RENDER_EXTERNAL_URL', 'Render'], ['RAILWAY_PUBLIC_DOMAIN', 'Railway']]) {
|
|
14
|
+
if (vars[name]) return { url: clean(vars[name]), source: `${name} (${label})` }
|
|
15
|
+
}
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readEvent (vars) {
|
|
20
|
+
if (!vars.GITHUB_EVENT_PATH) return null
|
|
21
|
+
try { return JSON.parse(await readFile(vars.GITHUB_EVENT_PATH, 'utf8')) } catch { return null }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function github (vars, fetchImpl) {
|
|
25
|
+
const api = (vars.GITHUB_API_URL || 'https://api.github.com').replace(/\/$/, '')
|
|
26
|
+
const token = vars.GITHUB_TOKEN || vars.GH_TOKEN
|
|
27
|
+
return async (path) => {
|
|
28
|
+
const res = await fetchImpl(`${api}${path}`, { headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', ...(token ? { Authorization: `Bearer ${token}` } : {}) } })
|
|
29
|
+
if (!res.ok) throw new Error(`GitHub ${path} → HTTP ${res.status}`)
|
|
30
|
+
return res.json()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The pull request a CI run belongs to: { number, base, head, sha } from the event or, for a deployment, its commit. */
|
|
35
|
+
export async function pullRequestOf ({ vars = process.env, event = undefined, fetchImpl = fetch } = {}) {
|
|
36
|
+
event = event === undefined ? await readEvent(vars) : event
|
|
37
|
+
if (vars.KALOKO_PR) return { number: Number(vars.KALOKO_PR), base: vars.KALOKO_BASE?.replace(/^origin\//, '') || null, sha: vars.GITHUB_SHA || null }
|
|
38
|
+
if (event?.pull_request) return { number: event.pull_request.number, base: event.pull_request.base?.ref || null, head: event.pull_request.head?.ref || null, sha: event.pull_request.head?.sha || null }
|
|
39
|
+
if (vars.CI_MERGE_REQUEST_IID) return { number: Number(vars.CI_MERGE_REQUEST_IID), base: vars.CI_MERGE_REQUEST_TARGET_BRANCH_NAME || null, head: vars.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || null, sha: vars.CI_COMMIT_SHA || null }
|
|
40
|
+
const sha = event?.deployment?.sha || vars.GITHUB_SHA
|
|
41
|
+
if (sha && vars.GITHUB_REPOSITORY) {
|
|
42
|
+
try {
|
|
43
|
+
const pulls = await github(vars, fetchImpl)(`/repos/${vars.GITHUB_REPOSITORY}/commits/${sha}/pulls`)
|
|
44
|
+
const pr = pulls.find((p) => p.state === 'open') || pulls[0]
|
|
45
|
+
if (pr) return { number: pr.number, base: pr.base?.ref || null, head: pr.head?.ref || null, sha }
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The preview deployment of this commit: variables first, then GitHub deployments of the commit (Vercel, Netlify and
|
|
53
|
+
* Cloudflare Pages report theirs there), polled until one succeeds or `wait` seconds pass. Production is skipped.
|
|
54
|
+
*/
|
|
55
|
+
export async function findPreview ({ vars = process.env, fetchImpl = fetch, wait = 0, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), say = () => {} } = {}) {
|
|
56
|
+
const event = await readEvent(vars)
|
|
57
|
+
const direct = previewFromEnv(vars, event)
|
|
58
|
+
if (direct) return direct
|
|
59
|
+
const sha = event?.pull_request?.head?.sha || event?.deployment?.sha || vars.GITHUB_SHA
|
|
60
|
+
if (!sha || !vars.GITHUB_REPOSITORY) return null
|
|
61
|
+
const gh = github(vars, fetchImpl)
|
|
62
|
+
const until = Date.now() + wait * 1000
|
|
63
|
+
for (let round = 0; ; round++) {
|
|
64
|
+
try {
|
|
65
|
+
const deployments = await gh(`/repos/${vars.GITHUB_REPOSITORY}/deployments?sha=${sha}&per_page=30`)
|
|
66
|
+
for (const d of deployments.filter((x) => !/^prod/i.test(x.environment || '') && !x.production_environment)) {
|
|
67
|
+
const statuses = await gh(`/repos/${vars.GITHUB_REPOSITORY}/deployments/${d.id}/statuses?per_page=10`)
|
|
68
|
+
const ok = statuses.find((s) => s.state === 'success' && (s.environment_url || s.target_url))
|
|
69
|
+
if (ok) return { url: clean(ok.environment_url || ok.target_url), source: `GitHub deployment ${d.environment}` }
|
|
70
|
+
}
|
|
71
|
+
} catch (e) { if (round === 0) say(` (GitHub deployments not readable: ${e.message})`) }
|
|
72
|
+
if (Date.now() >= until) return null
|
|
73
|
+
if (round === 0) say(`Waiting up to ${wait} s for a preview deployment of ${sha.slice(0, 7)}…`)
|
|
74
|
+
await sleep(10000)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Lines for $GITHUB_ENV (or a dotenv file): the preview URL, the PR and its base for --affected and share --pr. */
|
|
79
|
+
export function envLines ({ preview, pr }) {
|
|
80
|
+
return [
|
|
81
|
+
preview && `KALOKO_PREVIEW_URL=${preview.url}`,
|
|
82
|
+
pr?.number && `KALOKO_PR=${pr.number}`,
|
|
83
|
+
pr?.base && `KALOKO_BASE=origin/${pr.base}`,
|
|
84
|
+
pr?.head && `KALOKO_BRANCH=${pr.head}`
|
|
85
|
+
].filter(Boolean)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const writeEnvFile = (file, lines) => appendFile(file, lines.map((l) => l + '\n').join(''))
|
|
89
|
+
|
|
90
|
+
const q = (s) => /^[A-Za-z0-9_./-]+$/.test(s) ? s : JSON.stringify(s)
|
|
91
|
+
|
|
92
|
+
/** How the workflow runs kaloko: through the project's package manager when kaloko is a dependency, else npx. */
|
|
93
|
+
export function runner ({ pm = 'npm', dependency = true, version = 'latest' }) {
|
|
94
|
+
if (!dependency) return { install: null, kaloko: `npx --yes kaloko@${version}` }
|
|
95
|
+
return {
|
|
96
|
+
install: { npm: 'npm ci', pnpm: 'pnpm install --frozen-lockfile', yarn: 'yarn install --frozen-lockfile', bun: 'bun install --frozen-lockfile' }[pm] || 'npm ci',
|
|
97
|
+
kaloko: { npm: 'npx kaloko', pnpm: 'pnpm exec kaloko', yarn: 'yarn kaloko', bun: 'bunx kaloko' }[pm] || 'npx kaloko'
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The loop over scenarios both workflows run: walk what the change reaches, share, comment on the PR. */
|
|
102
|
+
function walkScript ({ scenarios, env, affected, k }) {
|
|
103
|
+
return [
|
|
104
|
+
'status=0',
|
|
105
|
+
`for s in ${scenarios.map(q).join(' ')}; do`,
|
|
106
|
+
...(affected ? [` if ! ${k} affected --scenario "$s" --check; then echo "Nothing in $s is affected; skipped."; continue; fi`] : []),
|
|
107
|
+
` ${k} start --scenario "$s" --env ${env}`,
|
|
108
|
+
` ${k} walk --ephemeral${affected ? ' --affected' : ''} || status=1`,
|
|
109
|
+
` ${k} share --pr || status=1`,
|
|
110
|
+
'done',
|
|
111
|
+
'exit $status'
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** .github/workflows/kaloko.yml */
|
|
116
|
+
export function githubWorkflow ({ scenarios, env = 'preview', trigger = 'deployment', affected = true, pm = 'npm', dependency = true, version = 'latest', node = 22 }) {
|
|
117
|
+
const { install, kaloko: k } = runner({ pm, dependency, version })
|
|
118
|
+
const indent = (lines, n) => lines.map((l) => ' '.repeat(n) + l).join('\n')
|
|
119
|
+
const on = trigger === 'deployment'
|
|
120
|
+
? ' # Vercel, Netlify and Cloudflare Pages report each preview as a GitHub deployment\n deployment_status:\n workflow_dispatch:'
|
|
121
|
+
: ' pull_request:\n workflow_dispatch:'
|
|
122
|
+
const guard = trigger === 'deployment'
|
|
123
|
+
? " if: github.event_name != 'deployment_status' || (github.event.deployment_status.state == 'success' && !startsWith(github.event.deployment_status.environment, 'Production') && !startsWith(github.event.deployment_status.environment, 'production'))\n"
|
|
124
|
+
: ''
|
|
125
|
+
return `# Kaloko: walk the scenarios this pull request affects on its preview deployment and comment on the PR.
|
|
126
|
+
# Written by kaloko ci github. Secrets: KALOKO_TOKEN (tester role, upload scope; on Enterprise a service account's);
|
|
127
|
+
# TYPESAFE_API_KEY only if semantic criteria should use your own evaluator key.
|
|
128
|
+
name: Kaloko
|
|
129
|
+
on:
|
|
130
|
+
${on}
|
|
131
|
+
concurrency:
|
|
132
|
+
group: kaloko-\${{ github.event.deployment.sha || github.event.pull_request.head.sha || github.sha }}
|
|
133
|
+
cancel-in-progress: true
|
|
134
|
+
permissions:
|
|
135
|
+
contents: read
|
|
136
|
+
deployments: read
|
|
137
|
+
pull-requests: write
|
|
138
|
+
jobs:
|
|
139
|
+
walk:
|
|
140
|
+
${guard} runs-on: ubuntu-latest
|
|
141
|
+
timeout-minutes: 30
|
|
142
|
+
env:
|
|
143
|
+
KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
|
|
144
|
+
TYPESAFE_API_KEY: \${{ secrets.TYPESAFE_API_KEY }}
|
|
145
|
+
GH_TOKEN: \${{ github.token }}
|
|
146
|
+
KALOKO_AGENT: github-actions
|
|
147
|
+
KALOKO_BROWSER_CHANNEL: chromium
|
|
148
|
+
steps:
|
|
149
|
+
- uses: actions/checkout@v4
|
|
150
|
+
with:
|
|
151
|
+
ref: \${{ github.event.deployment.sha || github.event.pull_request.head.sha || github.sha }}
|
|
152
|
+
fetch-depth: 0 # walk --affected compares with the pull request's base
|
|
153
|
+
- uses: actions/setup-node@v4
|
|
154
|
+
with:
|
|
155
|
+
node-version: ${node}
|
|
156
|
+
${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - run: corepack enable\n' : ''} - run: ${install}\n` : ''} - run: ${k} browser install --with-deps # the Chromium of kaloko's own Playwright
|
|
157
|
+
- name: Preview deployment
|
|
158
|
+
# the deployment's URL, the pull request and its base; waits for the preview when it is still building
|
|
159
|
+
run: ${k} ci preview-url --wait 600 --github-env
|
|
160
|
+
- name: Walk, share, comment
|
|
161
|
+
run: |
|
|
162
|
+
${indent(walkScript({ scenarios, env, affected, k }), 10)}
|
|
163
|
+
`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** kaloko.gitlab-ci.yml, included from .gitlab-ci.yml */
|
|
167
|
+
export function gitlabJob ({ scenarios, env = 'preview', affected = true, pm = 'npm', dependency = true, version = 'latest', playwright = '1.55.0' }) {
|
|
168
|
+
const { install, kaloko: k } = runner({ pm, dependency, version })
|
|
169
|
+
const indent = (lines, n) => lines.map((l) => ' '.repeat(n) + l).join('\n')
|
|
170
|
+
return `# Kaloko: walk the scenarios a merge request affects on its review app and comment on the MR.
|
|
171
|
+
# Written by kaloko ci gitlab. Include it from .gitlab-ci.yml: include: { local: kaloko.gitlab-ci.yml }
|
|
172
|
+
# CI/CD variables (masked): KALOKO_TOKEN (tester role, upload scope); KALOKO_GITLAB_TOKEN (project access
|
|
173
|
+
# token with the api scope) for the MR comment; TYPESAFE_API_KEY optional.
|
|
174
|
+
kaloko:
|
|
175
|
+
stage: test # run it after the job that deploys the review app (needs: [deploy_review])
|
|
176
|
+
image: mcr.microsoft.com/playwright:v${playwright}-noble
|
|
177
|
+
rules:
|
|
178
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
179
|
+
variables:
|
|
180
|
+
GIT_DEPTH: "0" # walk --affected compares with the target branch
|
|
181
|
+
KALOKO_BROWSER_CHANNEL: chromium
|
|
182
|
+
KALOKO_AGENT: gitlab-ci
|
|
183
|
+
# KALOKO_PREVIEW_URL: https://$CI_COMMIT_REF_SLUG.review.example.com # when the review app URL is known
|
|
184
|
+
script:
|
|
185
|
+
${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - corepack enable\n' : ''} - ${install}\n` : ''} - ${k} browser install
|
|
186
|
+
- export KALOKO_PREVIEW_URL="$(${k} ci preview-url --wait 600)"
|
|
187
|
+
- git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
188
|
+
- |
|
|
189
|
+
${indent(walkScript({ scenarios, env, affected, k }), 6)}
|
|
190
|
+
`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Adds the preview environment to kaloko.config.yml text (block style); null when it cannot be placed safely. */
|
|
194
|
+
export function addPreviewEnvironment (text, name = 'preview') {
|
|
195
|
+
if (new RegExp(`^ ${name}:`, 'm').test(text)) return text
|
|
196
|
+
const m = text.match(/^environments:\s*$/m)
|
|
197
|
+
if (!m) return null
|
|
198
|
+
const block = [
|
|
199
|
+
` ${name}: # pull request previews (kaloko ci): the URL comes from CI`,
|
|
200
|
+
' base_url: ${KALOKO_PREVIEW_URL}',
|
|
201
|
+
' # access: # Vercel deployment protection: a bypass for automation',
|
|
202
|
+
' # headers:',
|
|
203
|
+
' # x-vercel-protection-bypass: { env: VERCEL_AUTOMATION_BYPASS_SECRET }'
|
|
204
|
+
].join('\n')
|
|
205
|
+
const at = m.index + m[0].length
|
|
206
|
+
return text.slice(0, at) + '\n' + block + text.slice(at)
|
|
207
|
+
}
|
package/src/lib/config.mjs
CHANGED
|
@@ -48,6 +48,8 @@ export async function loadConfig (start = process.cwd(), explicit) {
|
|
|
48
48
|
config.output ||= !(await exists(join(dirname(file), 'tmp', 'kaloko'))) && (await exists(join(dirname(file), 'tmp', 'qawalk'))) ? 'tmp/qawalk' : 'tmp/kaloko'
|
|
49
49
|
config.browser ||= {}
|
|
50
50
|
config.browser.port ||= 9333
|
|
51
|
+
// CI runs the bundled Chromium without a separate config: KALOKO_BROWSER_CHANNEL=chromium (or chrome, msedge)
|
|
52
|
+
if (process.env.KALOKO_BROWSER_CHANNEL != null) config.browser.channel = ['', 'chromium', 'bundled'].includes(process.env.KALOKO_BROWSER_CHANNEL) ? undefined : process.env.KALOKO_BROWSER_CHANNEL
|
|
51
53
|
return { config, root: dirname(file), file }
|
|
52
54
|
}
|
|
53
55
|
|
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
|