kaloko 0.5.0 → 0.5.2
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 +28 -0
- package/package.json +1 -1
- package/schemas/config.v1.json +46 -0
- package/schemas/run.v1.json +8 -0
- package/schemas/scenario.v1.json +8 -0
- package/skills/kaloko/SKILL.md +20 -4
- package/skills/kaloko/reference/acceptance.md +4 -2
- package/skills/kaloko/reference/collaboration.md +35 -6
- package/skills/kaloko/reference/docs.md +127 -0
- package/skills/kaloko/reference/execution.md +9 -0
- package/skills/kaloko/reference/mcp.md +8 -2
- package/src/cli.mjs +136 -7
- package/src/lib/changelog-core.mjs +323 -0
- package/src/lib/changelog.mjs +333 -0
- package/src/lib/doctor.mjs +6 -1
- package/src/lib/run.mjs +2 -2
- package/src/lib/signoff-core.mjs +138 -0
- package/src/lib/signoff.mjs +83 -0
- package/src/lib/stability.mjs +82 -0
- package/viewer/index.html +458 -30
package/src/cli.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import { configureGate } from './lib/linkcheck.mjs'
|
|
|
18
18
|
import { rememberEnvironment } from './lib/secrets.mjs'
|
|
19
19
|
import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
|
|
20
20
|
import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
|
|
21
|
-
import { stabilityCmd } from './lib/stability.mjs'
|
|
21
|
+
import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
|
|
22
|
+
import { signoffCmd } from './lib/signoff.mjs'
|
|
22
23
|
import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote } from './lib/util.mjs'
|
|
23
24
|
import { packageRoot } from './lib/schema.mjs'
|
|
24
25
|
import { compareRuns, formatComparison } from './lib/compare.mjs'
|
|
@@ -68,10 +69,11 @@ Project
|
|
|
68
69
|
draft <url> [url…] [--id SITE-X] [--title t] [--out qa/flows/site.yml] [--force]
|
|
69
70
|
First plan + read-only production scenario for public pages, drafted from the
|
|
70
71
|
pages (title, description, H1) with the seo, a11y, perf and console packs
|
|
71
|
-
init [--agent claude|codex|all] [--org <slug>] [--no-detect] [--no-scenario]
|
|
72
|
+
init [--agent claude|codex|all] [--org <slug>] [--no-detect] [--no-scenario] [--docs=off|changelog|full]
|
|
72
73
|
Create ${CONFIG_FILE} and install the kaloko skill for your agent; detects the
|
|
73
74
|
framework, dev server URL, locales and main routes, and drafts a first scenario
|
|
74
|
-
(qa/flows/main-pages.yml) when the project has none
|
|
75
|
+
(qa/flows/main-pages.yml) when the project has none; asks "Keep a visual changelog?"
|
|
76
|
+
(--docs: changelog adds CHANGELOG.md and the config block, full also docs/ folders)
|
|
75
77
|
ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected]
|
|
76
78
|
[--out file] [--print] [--force]
|
|
77
79
|
Write a workflow that walks what each pull request affects on its preview
|
|
@@ -217,10 +219,31 @@ Results
|
|
|
217
219
|
Regions that change on every run or move while the page is idle (clocks, ads,
|
|
218
220
|
carousels): ignore suggestions per step in <run>/stability.yml; --apply adds
|
|
219
221
|
them to the scenario (ignore:), and pixel diffs then skip them
|
|
222
|
+
signoff <run-id> [--out dir] [--lang cs|en] [--json]
|
|
223
|
+
The signed acceptance record of an accepted run (Business): JSON and PDF with who
|
|
224
|
+
approved what and when, criteria results and capture hashes, checked against the
|
|
225
|
+
service's public key
|
|
226
|
+
signoff --verify <file.json|file.pdf> [--key file|url] [--json]
|
|
227
|
+
Check a record someone handed over (no token needed); exit 1 when it does not verify
|
|
228
|
+
stability --pull [--scenario id] [--json]
|
|
229
|
+
Regions people drew on screenshots on kaloko.app (step detail → Page → Draw a
|
|
230
|
+
region) go into the scenario's ignore: of their steps; walk them again after
|
|
220
231
|
compare [--run id] [--baseline id | --design] [--json]
|
|
221
232
|
Text diff of two runs: step statuses, criterion verdicts, scores and evidence
|
|
222
233
|
(default: current run against the scenario baseline, else the previous run)
|
|
223
234
|
|
|
235
|
+
Changelog (Keep a Changelog in CHANGELOG.md; pictures are step references kaloko:<scenario>/<step>)
|
|
236
|
+
changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]
|
|
237
|
+
The Unreleased entry since the last release: merged pull requests and commits
|
|
238
|
+
(Conventional Commits when used, gh for titles) grouped Added / Changed / Fixed /
|
|
239
|
+
Removed, plus before/after pictures of the screens accepted runs show differently;
|
|
240
|
+
printed, or --apply writes it into CHANGELOG.md (not when a release tool owns it)
|
|
241
|
+
release [<version>] [--product p] [--date YYYY-MM-DD] [--runs id,id] [--no-write] [--dry-run] [--json]
|
|
242
|
+
Record a release on Kaloko: the version (default: from git tags, package.json,
|
|
243
|
+
release-please or changesets), its CHANGELOG.md entry (Unreleased becomes the
|
|
244
|
+
version unless --no-write) and the accepted runs that are its pictures; prints the
|
|
245
|
+
illustrated changelog page
|
|
246
|
+
|
|
224
247
|
Design (environments with kind: design; live HTML steps in a folder)
|
|
225
248
|
start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
|
|
226
249
|
walk [--steps a,b] [--no-cache] Render the steps; unchanged revisions reuse their captures
|
|
@@ -257,6 +280,15 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
257
280
|
Common options: --run <id|dir> (or KALOKO_RUN) --config <file> KALOKO_AGENT=<name> labels writes by an agent
|
|
258
281
|
`
|
|
259
282
|
|
|
283
|
+
/** A comment's drawing for the terminal: "· box 120,340 300×80 px on desktop" (CSS px when the size is known). */
|
|
284
|
+
export function regionText (g) {
|
|
285
|
+
if (!g) return ''
|
|
286
|
+
const pct = (v) => `${Math.round(v * 100)}%`
|
|
287
|
+
const where = g.px ? `${g.px[0]},${g.px[1]} ${g.px[2]}×${g.px[3]} px` : `${pct(g.box[0])},${pct(g.box[1])} ${pct(g.box[2])}×${pct(g.box[3])}`
|
|
288
|
+
const kind = g.kind === 'arrow' ? 'arrow over' : g.kind === 'pen' ? 'drawing over' : 'box'
|
|
289
|
+
return ` · ${kind} ${where}${g.viewport ? ` on ${g.viewport}` : ''}`
|
|
290
|
+
}
|
|
291
|
+
|
|
260
292
|
/** validate: scenarios the service still lists as active whose scenario file no longer exists here (#58). */
|
|
261
293
|
async function warnRetired (args) {
|
|
262
294
|
if (args.offline) return
|
|
@@ -754,6 +786,38 @@ const commands = {
|
|
|
754
786
|
await writeFile(file, tpl)
|
|
755
787
|
say(`Created ${CONFIG_FILE}${detected ? ` (local: ${detected.url})` : ''}. Edit org and the staging and production environments.`)
|
|
756
788
|
} else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
|
|
789
|
+
// the visual changelog (docs/DOCS-CHANGELOG.md): asked once; --docs=off|changelog|full for scripts and agents
|
|
790
|
+
let docsMode = typeof args.docs === 'string' ? args.docs : null
|
|
791
|
+
if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new UserError('--docs must be off, changelog or full.')
|
|
792
|
+
if (!docsMode && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !process.env.NODE_TEST_CONTEXT) {
|
|
793
|
+
const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
|
|
794
|
+
const a = (await rl.question('Keep a visual changelog? (recommended) [Y/n] ')).trim().toLowerCase(); rl.close()
|
|
795
|
+
docsMode = a === 'n' || a === 'no' ? 'off' : 'changelog'
|
|
796
|
+
}
|
|
797
|
+
if (docsMode && docsMode !== 'off') {
|
|
798
|
+
const { docsConfigBlock, detectProducts, DOCS_SKELETON } = await import('./lib/changelog.mjs')
|
|
799
|
+
const { CHANGELOG_SKELETON } = await import('./lib/changelog-core.mjs')
|
|
800
|
+
const { mkdir: mkdirp } = await import('node:fs/promises')
|
|
801
|
+
const cfgFile = existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE)
|
|
802
|
+
const cfg = await readFile(cfgFile, 'utf8')
|
|
803
|
+
const products = /^products:/m.test(cfg) ? [] : await detectProducts(cwd)
|
|
804
|
+
if (!/^docs:/m.test(cfg) && !/^products:/m.test(cfg)) { await writeFile(cfgFile, `${cfg.replace(/\n*$/, '\n')}${docsConfigBlock({ products })}`); say(`Added the changelog settings to ${relative(cwd, cfgFile)}${products.length ? ` with ${products.length} products (${products.map((p) => p.slug).join(', ')})` : ''}`) }
|
|
805
|
+
for (const f of products.length ? products.map((p) => join(cwd, p.dir, 'CHANGELOG.md')) : [join(cwd, 'CHANGELOG.md')]) {
|
|
806
|
+
if (existsSync(f)) continue
|
|
807
|
+
await mkdirp(dirname(f), { recursive: true })
|
|
808
|
+
await writeFile(f, CHANGELOG_SKELETON)
|
|
809
|
+
say(`Created ${relative(cwd, f)} (Keep a Changelog; kaloko changelog draft fills Unreleased, kaloko release records a version)`)
|
|
810
|
+
}
|
|
811
|
+
if (docsMode === 'full') {
|
|
812
|
+
for (const [rel, text] of Object.entries(DOCS_SKELETON)) {
|
|
813
|
+
const f = join(cwd, rel)
|
|
814
|
+
if (existsSync(f)) continue
|
|
815
|
+
await mkdirp(dirname(f), { recursive: true })
|
|
816
|
+
await writeFile(f, text)
|
|
817
|
+
}
|
|
818
|
+
say('Docs folders in docs/ (tutorials, how-to, reference, explanation) with an index')
|
|
819
|
+
}
|
|
820
|
+
} else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
|
|
757
821
|
const agent = typeof args.agent === 'string' ? args.agent : 'none'
|
|
758
822
|
if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UserError('--agent must be claude, codex, all or none.')
|
|
759
823
|
const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
|
|
@@ -858,7 +922,9 @@ const commands = {
|
|
|
858
922
|
const executor = { kind: args.executor ? 'agent' : 'cli', name: args.executor || 'kaloko-cli' }
|
|
859
923
|
const { project, issue } = await projectAndIssue({ cwd: root, config, scenario, branch: git?.branch })
|
|
860
924
|
const appVer = typeof args['app-version'] === 'string' ? args['app-version'].slice(0, 80) : await appVersion(root)
|
|
861
|
-
|
|
925
|
+
// the product the flow documents (visual changelog): its tag, or the only product of the config
|
|
926
|
+
const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
|
|
927
|
+
const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product })
|
|
862
928
|
say(`Run ${meta.id}\n dir: ${relative(process.cwd(), dir)}\n env: ${env.name}${env.readonly ? ' (readonly)' : ''}\n locales: ${locales.join(', ')} viewports: ${vpNames.length > 12 ? `${vpNames.slice(0, 12).join(', ')} … (${vpNames.length})` : vpNames.join(', ')}${dimensionLine(scope)}\n project: ${project.slug}${issue ? ` issue: ${issue.repo ? issue.repo + '#' : '#'}${issue.number}` : ''}`)
|
|
863
929
|
const addr = recipient(env.mail, meta.short)
|
|
864
930
|
if (addr) say(` test e-mail: ${addr}`)
|
|
@@ -1485,6 +1551,8 @@ const commands = {
|
|
|
1485
1551
|
|
|
1486
1552
|
async stability (args) {
|
|
1487
1553
|
const { config, root, run, scenario } = await runContext(args)
|
|
1554
|
+
// regions drawn by hand on the canvas (kaloko.app) into the scenario's ignore:
|
|
1555
|
+
if (args.pull) { await pullDrawn({ root, config, run, args, say, client: service(config) }); return }
|
|
1488
1556
|
await stabilityCmd({ root, config, run, scenario, args, say })
|
|
1489
1557
|
},
|
|
1490
1558
|
|
|
@@ -1685,7 +1753,8 @@ const commands = {
|
|
|
1685
1753
|
say(`Feedback for ${data.run.id} (${data.scenario}) — run verdict: ${data.verdict?.verdict || 'pending'}${data.verdict?.by ? ` by ${data.verdict.by}` : ''}${data.verdict?.note ? `: ${data.verdict.note}` : ''}${data.verdict?.awaiting?.length ? `\n awaiting approval from: ${data.verdict.awaiting.join(', ')}` : ''}${data.approved.length ? `\n approved: ${data.approved.join(', ')}` : ''}`)
|
|
1686
1754
|
for (const c of data.criterion_verdicts || []) say(` ✕ ${c.node_id} ${c.criterion_id}${c.locale ? ` [${c.locale}]` : ''} — ${c.email}: ${c.note}`)
|
|
1687
1755
|
const thread = (r, where) => {
|
|
1688
|
-
say(` ${r.status === 'rejected' ? '✕' : '•'} [${r.id}] ${where}${r.locale ? ` [${r.locale}]` : ''}${r.pin ? ` · pinned at ${Math.round(r.pin.x * 100)}%,${Math.round(r.pin.y * 100)}%${r.pin.viewport ? ` (${r.pin.viewport})` : ''}` : ''}${r.anchor?.selector ? ` · on ${r.anchor.selector}` : ''} — ${r.email}${r.agent ? ` via ${r.agent}` : ''}: ${r.note}`)
|
|
1756
|
+
say(` ${r.status === 'rejected' ? '✕' : '•'} [${r.id}] ${where}${r.locale ? ` [${r.locale}]` : ''}${r.pin ? ` · pinned at ${Math.round(r.pin.x * 100)}%,${Math.round(r.pin.y * 100)}%${r.pin.viewport ? ` (${r.pin.viewport})` : ''}` : ''}${r.anchor?.selector ? ` · on ${r.anchor.selector}` : ''}${regionText(r.region)} — ${r.email}${r.agent ? ` via ${r.agent}` : ''}: ${r.note}`)
|
|
1757
|
+
for (const f of r.attachments || []) say(` attached ${f.name} (${f.type}): ${f.url || f.id}`)
|
|
1689
1758
|
for (const x of r.replies || []) say(` ↳ ${x.email}${x.agent ? ` via ${x.agent}` : ''}: ${x.deleted ? '(deleted)' : x.note}`)
|
|
1690
1759
|
}
|
|
1691
1760
|
const flow = data.flow_comments || []
|
|
@@ -1708,6 +1777,22 @@ const commands = {
|
|
|
1708
1777
|
* accepted run, returned, commented), the approvals carried over unchanged steps, and the review-mode link. For
|
|
1709
1778
|
* agents: report it to the person; never approve on their behalf.
|
|
1710
1779
|
*/
|
|
1780
|
+
/**
|
|
1781
|
+
* The signed acceptance record of an accepted run: JSON and PDF downloaded and checked against the service's public
|
|
1782
|
+
* key; --verify checks a record someone handed over (no token, no project needed).
|
|
1783
|
+
*/
|
|
1784
|
+
async signoff (args) {
|
|
1785
|
+
if (typeof args.verify === 'string') {
|
|
1786
|
+
const config = await context(args).then((c) => c.config).catch(() => ({}))
|
|
1787
|
+
return signoffCmd({ config, args, say })
|
|
1788
|
+
}
|
|
1789
|
+
const { config, root } = await context(args)
|
|
1790
|
+
let runId = args._[0] || (typeof args.run === 'string' ? args.run : null)
|
|
1791
|
+
if (!runId) { try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id } catch {} }
|
|
1792
|
+
if (!runId) throw new UserError('Usage: kaloko signoff <run-id> [--out dir] | kaloko signoff --verify <file.json|file.pdf>')
|
|
1793
|
+
return signoffCmd({ config, args, say, client: service(config), runId })
|
|
1794
|
+
},
|
|
1795
|
+
|
|
1711
1796
|
async review (args) {
|
|
1712
1797
|
const { config, root } = await context(args)
|
|
1713
1798
|
const client = service(config)
|
|
@@ -1981,12 +2066,56 @@ const commands = {
|
|
|
1981
2066
|
const { config, root, file } = await context(args)
|
|
1982
2067
|
// engines the scenarios ask for (browsers: [webkit, firefox]), so doctor checks they are installed
|
|
1983
2068
|
const engines = []
|
|
1984
|
-
|
|
1985
|
-
const
|
|
2069
|
+
const scenarios = []
|
|
2070
|
+
for (const f of await scenarioFiles(config, root).catch(() => [])) { try { const s = yaml.load(await readFile(f, 'utf8')) || {}; engines.push(...(s.browsers || [])); scenarios.push({ file: relative(root, f), product: s.product || null, module: s.module || null }) } catch {} }
|
|
2071
|
+
const r = await doctor({ config, root, file, fix: !!args.fix, engines, scenarios })
|
|
1986
2072
|
say(args.json ? JSON.stringify(r, null, 2) : formatDoctor(r))
|
|
1987
2073
|
if (!r.ok) process.exitCode = 1
|
|
1988
2074
|
},
|
|
1989
2075
|
|
|
2076
|
+
/** `kaloko changelog draft`: the Unreleased entry from merged work and the screens accepted runs show differently. */
|
|
2077
|
+
async changelog (args) {
|
|
2078
|
+
const sub = args._[0]
|
|
2079
|
+
if (sub !== 'draft') throw new UserError('Usage: kaloko changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]')
|
|
2080
|
+
const { config, root } = await context(args)
|
|
2081
|
+
const { pickProduct, draftChangelog, draftText, applyToChangelog } = await import('./lib/changelog.mjs')
|
|
2082
|
+
const product = pickProduct(config, root, args.product)
|
|
2083
|
+
let client = null
|
|
2084
|
+
if (!args.offline) { try { client = service(config) } catch {} }
|
|
2085
|
+
const d = await draftChangelog({ root, config, product, since: typeof args.since === 'string' ? args.since : null, client })
|
|
2086
|
+
if (args.json) { say(JSON.stringify(d, null, 2)); return }
|
|
2087
|
+
const pictures = d.changes.reduce((n, s) => n + (s.steps?.length || 0), 0)
|
|
2088
|
+
const head = `${product.slug}: ${d.commits} change(s) since ${d.since.ref || 'the first commit'}${d.service === 'ok' ? `, ${pictures} changed screen(s) in accepted runs since ${d.previous ? `release ${d.previous.version}` : 'the first accepted runs'}` : d.service === 'offline' ? ' (no service: pictures of changed screens need a token)' : ` (service: ${d.service})`}; ${d.internal.length} internal left out.`
|
|
2089
|
+
if (args.apply) {
|
|
2090
|
+
const r = await applyToChangelog(d)
|
|
2091
|
+
say(`${head}\n${r.created ? 'Created' : 'Updated'} ${relative(process.cwd(), d.file) || d.file}: ${r.added} new item(s) in Unreleased. Edit them for people, then kaloko release <version>.`)
|
|
2092
|
+
return
|
|
2093
|
+
}
|
|
2094
|
+
console.error(head)
|
|
2095
|
+
say(draftText(d))
|
|
2096
|
+
},
|
|
2097
|
+
|
|
2098
|
+
/** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
|
|
2099
|
+
async release (args) {
|
|
2100
|
+
const { config, root } = await context(args)
|
|
2101
|
+
if (!config.org) throw new UserError('Set org: in the config first.')
|
|
2102
|
+
const { pickProduct, releaseProduct } = await import('./lib/changelog.mjs')
|
|
2103
|
+
const product = pickProduct(config, root, args.product)
|
|
2104
|
+
const client = args['dry-run'] ? null : service(config)
|
|
2105
|
+
const r = await releaseProduct({ root, config, product, version: args._[0] || null, date: typeof args.date === 'string' ? args.date : null, client, write: !args['no-write'], runs: list(args.runs), dryRun: !!args['dry-run'] })
|
|
2106
|
+
if (args.json) { say(JSON.stringify(r, null, 2)); return }
|
|
2107
|
+
const file = relative(process.cwd(), r.file) || r.file
|
|
2108
|
+
const lines = [`${product.slug} ${r.version} (${r.date}) · version source: ${r.source.tool || r.source.source}${r.source.path ? ` (${r.source.path})` : ''}`]
|
|
2109
|
+
lines.push(r.section ? ` ${file}: ${r.finalized ? 'Unreleased became' : 'entry'} ${r.version}, ${r.items} item(s), ${r.refs} picture reference(s)` : ` ${file}: no entry for ${r.version}`)
|
|
2110
|
+
if (r.release) {
|
|
2111
|
+
lines.push(` ${r.release.created ? 'Recorded' : 'Updated'} on the service with ${r.release.runs.length} reference run(s)${r.release.versioned_runs ? `; ${r.release.versioned_runs} accepted run(s) now carry ${r.version}` : ''}`)
|
|
2112
|
+
lines.push(` ${r.release.url}`)
|
|
2113
|
+
} else lines.push(' Dry run: nothing sent.')
|
|
2114
|
+
for (const n of r.notes) lines.push(` ! ${n}`)
|
|
2115
|
+
if (r.finalized) lines.push(` Commit ${file} with the release.`)
|
|
2116
|
+
say(lines.join('\n'))
|
|
2117
|
+
},
|
|
2118
|
+
|
|
1990
2119
|
async help () { say(HELP) }
|
|
1991
2120
|
}
|
|
1992
2121
|
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// The visual changelog (docs/DOCS-CHANGELOG.md, phase 1), the parts the CLI and the service share: step references
|
|
2
|
+
// in Markdown, Keep a Changelog parsing and editing, commit classification and the draft skeleton. No I/O here: the
|
|
3
|
+
// worker imports this file too (worker/src/changelog.js), so it stays plain JavaScript without Node built-ins.
|
|
4
|
+
|
|
5
|
+
// ---------- step references ----------
|
|
6
|
+
|
|
7
|
+
/** Scenario and step ids as the service accepts them (design.js SCENARIO_ID). */
|
|
8
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/
|
|
9
|
+
const PARAMS = {
|
|
10
|
+
locale: /^[A-Za-z0-9_-]{1,20}$/,
|
|
11
|
+
viewport: /^[A-Za-z0-9._-]{1,80}$/,
|
|
12
|
+
version: /^[A-Za-z0-9._+-]{1,80}$/,
|
|
13
|
+
state: /^[A-Za-z0-9._-]{1,60}$/
|
|
14
|
+
}
|
|
15
|
+
export const REF_SCHEME = 'kaloko:'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A step reference `kaloko:<scenario>/<step>[?locale=&viewport=&version=&state=]`, or the short `kaloko:<step>` that
|
|
19
|
+
* means a step of the product's default scenario. `version` is a released version, `latest` (default) or `prev` (the
|
|
20
|
+
* release before the one the text belongs to); `state` picks a variant of the capture (dark, webkit, reduce: the
|
|
21
|
+
* dimension values in the capture key). Returns null for anything else.
|
|
22
|
+
*/
|
|
23
|
+
export function parseStepRef (ref) {
|
|
24
|
+
const s = String(ref || '').trim()
|
|
25
|
+
if (!s.startsWith(REF_SCHEME)) return null
|
|
26
|
+
const rest = s.slice(REF_SCHEME.length)
|
|
27
|
+
const qi = rest.indexOf('?')
|
|
28
|
+
const path = qi < 0 ? rest : rest.slice(0, qi)
|
|
29
|
+
let parts
|
|
30
|
+
try { parts = path.split('/').map((p) => decodeURIComponent(p)) } catch { return null }
|
|
31
|
+
if (!parts.length || parts.length > 2 || parts.some((p) => !ID.test(p))) return null
|
|
32
|
+
const out = { scenario: parts.length === 2 ? parts[0] : null, step: parts[parts.length - 1], locale: null, viewport: null, version: null, state: null }
|
|
33
|
+
const q = new URLSearchParams(qi < 0 ? '' : rest.slice(qi + 1))
|
|
34
|
+
for (const [k, v] of q) {
|
|
35
|
+
if (!(k in PARAMS)) return null
|
|
36
|
+
if (!PARAMS[k].test(v)) return null
|
|
37
|
+
out[k] = v
|
|
38
|
+
}
|
|
39
|
+
if (out.version === 'latest') out.version = null
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The reference text of a parsed (or hand-built) reference; parameters in a fixed order. */
|
|
44
|
+
export function formatStepRef ({ scenario = null, step, locale = null, viewport = null, version = null, state = null }) {
|
|
45
|
+
const path = `${scenario ? `${encodeURIComponent(scenario)}/` : ''}${encodeURIComponent(step)}`
|
|
46
|
+
const q = Object.entries({ locale, viewport, version, state }).filter(([, v]) => v != null && v !== '')
|
|
47
|
+
return `${REF_SCHEME}${path}${q.length ? `?${q.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}` : ''}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every image with a step reference in a Markdown text: { alt, ref, parsed, index }; broken ones have parsed null. */
|
|
51
|
+
export function findStepRefs (markdown) {
|
|
52
|
+
const out = []
|
|
53
|
+
const re = /!\[([^\]]*)\]\((kaloko:[^)\s]+)\)/g
|
|
54
|
+
let m
|
|
55
|
+
while ((m = re.exec(String(markdown || '')))) out.push({ alt: m[1], ref: m[2], parsed: parseStepRef(m[2]), index: m.index })
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------- versions ----------
|
|
60
|
+
|
|
61
|
+
/** "v2.4.0" → "2.4.0"; anything else stays as it is (trimmed). */
|
|
62
|
+
export const cleanVersion = (v) => String(v ?? '').trim().replace(/^v(?=\d)/, '')
|
|
63
|
+
|
|
64
|
+
/** Semantic Versioning order (prereleases before their release); versions that are not semver sort by text. */
|
|
65
|
+
export function compareVersions (a, b) {
|
|
66
|
+
const p = (v) => { const m = cleanVersion(v).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/); return m ? { n: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null } : null }
|
|
67
|
+
const x = p(a); const y = p(b)
|
|
68
|
+
if (!x || !y) return String(a).localeCompare(String(b))
|
|
69
|
+
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] - y.n[i]
|
|
70
|
+
if (x.pre === y.pre) return 0
|
|
71
|
+
if (!x.pre) return 1
|
|
72
|
+
if (!y.pre) return -1
|
|
73
|
+
return x.pre.localeCompare(y.pre, 'en', { numeric: true })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The git tag of a version for a tag pattern ("v*" → "v2.4.0", "shop-v*" → "shop-v2.4.0"). */
|
|
77
|
+
export const tagFor = (pattern, version) => String(pattern || 'v*').replace('*', cleanVersion(version))
|
|
78
|
+
/** The version a tag names under a pattern, or null when the tag does not match it. */
|
|
79
|
+
export function versionOfTag (pattern, tag) {
|
|
80
|
+
const [pre, post = ''] = String(pattern || 'v*').split('*')
|
|
81
|
+
const t = String(tag || '')
|
|
82
|
+
if (!t.startsWith(pre) || !t.endsWith(post) || t.length <= pre.length + post.length) return null
|
|
83
|
+
return cleanVersion(t.slice(pre.length, t.length - post.length))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------- Keep a Changelog ----------
|
|
87
|
+
|
|
88
|
+
export const GROUPS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
|
|
89
|
+
const GROUP_ALIASES = { added: 'Added', new: 'Added', features: 'Added', 'minor changes': 'Added', changed: 'Changed', 'patch changes': 'Fixed', 'major changes': 'Changed', improvements: 'Changed', deprecated: 'Deprecated', removed: 'Removed', fixed: 'Fixed', 'bug fixes': 'Fixed', fixes: 'Fixed', security: 'Security', 'performance improvements': 'Changed' }
|
|
90
|
+
/** A group heading as written by people or release tools ("Bug Fixes", "Minor Changes") in Keep a Changelog terms. */
|
|
91
|
+
export const groupName = (h) => GROUP_ALIASES[String(h || '').trim().toLowerCase()] || String(h || '').trim()
|
|
92
|
+
|
|
93
|
+
export const CHANGELOG_HEAD = `# Changelog
|
|
94
|
+
|
|
95
|
+
All notable changes for the people who use this product. The format follows
|
|
96
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow
|
|
97
|
+
[Semantic Versioning](https://semver.org/). Pictures are step references
|
|
98
|
+
(\`\`) that Kaloko shows from the accepted runs.
|
|
99
|
+
`
|
|
100
|
+
export const CHANGELOG_SKELETON = `${CHANGELOG_HEAD}
|
|
101
|
+
## [Unreleased]
|
|
102
|
+
`
|
|
103
|
+
|
|
104
|
+
const RELEASE_HEADING = /^##\s+(.+?)\s*$/
|
|
105
|
+
const DATE = /(\d{4}-\d{2}-\d{2})/
|
|
106
|
+
|
|
107
|
+
/** "## [2.4.0] - 2026-10-12", "## [1.2.0](https://…compare…) (2026-10-12)", "## 1.2.0", "## [Unreleased]". */
|
|
108
|
+
export function parseHeading (line) {
|
|
109
|
+
const m = String(line).match(RELEASE_HEADING)
|
|
110
|
+
if (!m || /^#/.test(m[1])) return null
|
|
111
|
+
const text = m[1]
|
|
112
|
+
const bracket = text.match(/^\[([^\]]+)\]/)
|
|
113
|
+
const label = (bracket ? bracket[1] : text.split(/\s+/)[0]).trim()
|
|
114
|
+
if (/^unreleased$/i.test(label)) return { unreleased: true, version: null, date: null }
|
|
115
|
+
const version = cleanVersion(label)
|
|
116
|
+
if (!/^\d/.test(version)) return null
|
|
117
|
+
const rest = text.slice(bracket ? bracket[0].length : label.length)
|
|
118
|
+
return { unreleased: false, version, date: (rest.match(DATE) || [])[1] || null }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** List items of a group body: each item keeps its continuation lines (pictures under it), without the "- ". */
|
|
122
|
+
export function listItems (body) {
|
|
123
|
+
const items = []
|
|
124
|
+
let cur = null
|
|
125
|
+
for (const line of String(body || '').split('\n')) {
|
|
126
|
+
const m = line.match(/^[-*+]\s+(.*)$/)
|
|
127
|
+
if (m) { if (cur) items.push(cur.trimEnd()); cur = m[1]; continue }
|
|
128
|
+
if (cur != null && (/^\s+\S/.test(line) || line.trim() === '')) { cur += `\n${line.replace(/^ {1,4}/, '')}`; continue }
|
|
129
|
+
if (cur != null) { items.push(cur.trimEnd()); cur = null }
|
|
130
|
+
}
|
|
131
|
+
if (cur) items.push(cur.trimEnd())
|
|
132
|
+
return items.map((x) => x.replace(/\n+$/, '')).filter(Boolean)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Groups of a release body: [{ name, heading, items }] plus text before the first group (notes). */
|
|
136
|
+
export function parseEntry (body) {
|
|
137
|
+
const groups = []; const notes = []
|
|
138
|
+
let cur = null
|
|
139
|
+
for (const line of String(body || '').replace(/\r/g, '').split('\n')) {
|
|
140
|
+
const h = line.match(/^###\s+(.+?)\s*$/)
|
|
141
|
+
if (h) { cur = { name: groupName(h[1]), heading: h[1].trim(), lines: [] }; groups.push(cur); continue }
|
|
142
|
+
if (cur) cur.lines.push(line); else notes.push(line)
|
|
143
|
+
}
|
|
144
|
+
return { notes: notes.join('\n').trim(), groups: groups.map((g) => ({ name: g.name, heading: g.heading, items: listItems(g.lines.join('\n')) })).filter((g) => g.items.length || g.name) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A changelog file: { head (text before the first release heading), sections: [{ unreleased, version, date, heading,
|
|
149
|
+
* body, start, end }] (line numbers, end exclusive), links (the reference definitions at the bottom, by lower-case
|
|
150
|
+
* label), linksStart }.
|
|
151
|
+
*/
|
|
152
|
+
export function parseChangelog (text) {
|
|
153
|
+
const lines = String(text || '').replace(/\r/g, '').split('\n')
|
|
154
|
+
let linksStart = lines.length
|
|
155
|
+
while (linksStart > 0 && (/^\[[^\]]+\]:\s*\S+/.test(lines[linksStart - 1]) || lines[linksStart - 1].trim() === '')) linksStart--
|
|
156
|
+
if (!lines.slice(linksStart).some((l) => /^\[[^\]]+\]:/.test(l))) linksStart = lines.length
|
|
157
|
+
const links = {}
|
|
158
|
+
for (const l of lines.slice(linksStart)) { const m = l.match(/^\[([^\]]+)\]:\s*(\S+)/); if (m) links[m[1].toLowerCase()] = m[2] }
|
|
159
|
+
const sections = []
|
|
160
|
+
for (let i = 0; i < linksStart; i++) {
|
|
161
|
+
const h = /^##\s/.test(lines[i]) ? parseHeading(lines[i]) : null
|
|
162
|
+
if (!h) continue
|
|
163
|
+
if (sections.length) sections[sections.length - 1].end = i
|
|
164
|
+
sections.push({ ...h, heading: lines[i], start: i, end: linksStart })
|
|
165
|
+
}
|
|
166
|
+
for (const s of sections) s.body = lines.slice(s.start + 1, s.end).join('\n').trim()
|
|
167
|
+
const head = lines.slice(0, sections.length ? sections[0].start : linksStart).join('\n').trimEnd()
|
|
168
|
+
return { head, sections, links, linksStart, lines }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The section of a version (or the Unreleased one for null), or null. */
|
|
172
|
+
export function sectionOf (text, version) {
|
|
173
|
+
const { sections } = parseChangelog(text)
|
|
174
|
+
const v = version == null ? null : cleanVersion(version)
|
|
175
|
+
return sections.find((s) => v == null ? s.unreleased : !s.unreleased && s.version === v) || null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The newest released version written in the file (null when none). */
|
|
179
|
+
export function latestVersionIn (text) {
|
|
180
|
+
const v = parseChangelog(text).sections.filter((s) => !s.unreleased).map((s) => s.version)
|
|
181
|
+
return v.sort(compareVersions).pop() || null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const norm = (item) => String(item).split('\n')[0].replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
185
|
+
|
|
186
|
+
/** Markdown of groups ({ Added: [items] } in Keep a Changelog order; other names after them). */
|
|
187
|
+
export function groupsMarkdown (groups) {
|
|
188
|
+
const names = [...GROUPS.filter((g) => groups[g]?.length), ...Object.keys(groups).filter((g) => !GROUPS.includes(g) && groups[g]?.length)]
|
|
189
|
+
return names.map((g) => `### ${g}\n\n${groups[g].map((it) => `- ${String(it).split('\n').map((l, i) => i && l ? ` ${l}` : l).join('\n')}`).join('\n')}`).join('\n\n')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Puts draft items into the Unreleased section of a changelog: items whose first line is already there are left
|
|
194
|
+
* out, groups that exist get the new items at their end, missing groups are added in Keep a Changelog order. A file
|
|
195
|
+
* without an Unreleased section gets one above the newest release; an empty file gets the skeleton.
|
|
196
|
+
* Returns { text, added } (the number of new items).
|
|
197
|
+
*/
|
|
198
|
+
export function applyDraft (text, groups) {
|
|
199
|
+
let src = String(text || '')
|
|
200
|
+
if (!src.trim()) src = CHANGELOG_SKELETON
|
|
201
|
+
let parsed = parseChangelog(src)
|
|
202
|
+
let un = parsed.sections.find((s) => s.unreleased)
|
|
203
|
+
if (!un) {
|
|
204
|
+
const lines = parsed.lines
|
|
205
|
+
const at = parsed.sections.length ? parsed.sections[0].start : parsed.linksStart
|
|
206
|
+
const before = lines.slice(0, at); while (before.length && before[before.length - 1].trim() === '') before.pop()
|
|
207
|
+
src = [...before, '', '## [Unreleased]', '', ...lines.slice(at)].join('\n')
|
|
208
|
+
parsed = parseChangelog(src)
|
|
209
|
+
un = parsed.sections.find((s) => s.unreleased)
|
|
210
|
+
}
|
|
211
|
+
const existing = parseEntry(un.body)
|
|
212
|
+
const seen = new Set(parsed.sections.flatMap((s) => parseEntry(s.body).groups.flatMap((g) => g.items.map(norm))))
|
|
213
|
+
const merged = new Map(existing.groups.map((g) => [g.name, { heading: g.heading, items: [...g.items] }]))
|
|
214
|
+
let added = 0
|
|
215
|
+
for (const [name, items] of Object.entries(groups)) {
|
|
216
|
+
for (const it of items || []) {
|
|
217
|
+
if (seen.has(norm(it))) continue
|
|
218
|
+
seen.add(norm(it))
|
|
219
|
+
if (!merged.has(name)) merged.set(name, { heading: name, items: [] })
|
|
220
|
+
merged.get(name).items.push(it); added++
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const order = [...GROUPS.filter((g) => merged.has(g)), ...[...merged.keys()].filter((g) => !GROUPS.includes(g))]
|
|
224
|
+
const body = [existing.notes, ...order.map((g) => { const x = merged.get(g); return groupsMarkdown({ [g]: x.items }).replace(/^### [^\n]+/, `### ${x.heading}`) })].filter(Boolean).join('\n\n')
|
|
225
|
+
const lines = parsed.lines
|
|
226
|
+
const out = [...lines.slice(0, un.start + 1), '', ...(body ? [body, ''] : []), ...lines.slice(un.end)]
|
|
227
|
+
return { text: out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n*$/, '\n'), added }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Turns the Unreleased section into the release `version` of `date` and opens a new, empty Unreleased section above
|
|
232
|
+
* it. Compare links at the bottom ([unreleased]: …/compare/v1...HEAD) move along. Returns { text, changed, empty }.
|
|
233
|
+
*/
|
|
234
|
+
export function finalizeRelease (text, version, date, { tagPattern = 'v*' } = {}) {
|
|
235
|
+
const v = cleanVersion(version)
|
|
236
|
+
const parsed = parseChangelog(text)
|
|
237
|
+
if (parsed.sections.some((s) => !s.unreleased && s.version === v)) return { text, changed: false, empty: false }
|
|
238
|
+
const un = parsed.sections.find((s) => s.unreleased)
|
|
239
|
+
if (!un) return { text, changed: false, empty: true }
|
|
240
|
+
const lines = [...parsed.lines]
|
|
241
|
+
lines.splice(un.start, 1, '## [Unreleased]', '', `## [${v}] - ${date}`)
|
|
242
|
+
let out = lines.join('\n')
|
|
243
|
+
const link = parsed.links.unreleased
|
|
244
|
+
const m = link && link.match(/^(.*\/compare\/)(.+)\.\.\.HEAD$/)
|
|
245
|
+
if (m) {
|
|
246
|
+
const tag = tagFor(tagPattern, v)
|
|
247
|
+
out = out.replace(/^\[unreleased\]:\s*\S+$/im, `[unreleased]: ${m[1]}${tag}...HEAD\n[${v}]: ${m[1]}${m[2]}...${tag}`)
|
|
248
|
+
}
|
|
249
|
+
return { text: out, changed: true, empty: !un.body.trim() }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------- commits and the draft ----------
|
|
253
|
+
|
|
254
|
+
const CONVENTIONAL = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/
|
|
255
|
+
const INTERNAL = new Set(['docs', 'test', 'tests', 'chore', 'ci', 'build', 'style', 'refactor', 'release', 'wip', 'deps'])
|
|
256
|
+
const TYPE_GROUP = { feat: 'Added', feature: 'Added', add: 'Added', fix: 'Fixed', bugfix: 'Fixed', hotfix: 'Fixed', perf: 'Changed', revert: 'Changed', remove: 'Removed', deprecate: 'Deprecated', security: 'Security', sec: 'Security' }
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Where a commit or pull request title goes: { group, text, internal, breaking, pr }. Conventional Commits when the
|
|
260
|
+
* title uses them; otherwise the first verb decides (add → Added, fix → Fixed, remove → Removed, else Changed).
|
|
261
|
+
* Documentation, tests, chores, CI, refactors, merges and releases are internal.
|
|
262
|
+
*/
|
|
263
|
+
export function classifyCommit (subject, body = '') {
|
|
264
|
+
let s = String(subject || '').trim()
|
|
265
|
+
let pr = null
|
|
266
|
+
const sq = s.match(/\s*\(#(\d+)\)\s*$/)
|
|
267
|
+
if (sq) { pr = Number(sq[1]); s = s.slice(0, sq.index).trim() }
|
|
268
|
+
const breaking = /BREAKING[ -]CHANGE/.test(body)
|
|
269
|
+
if (/^Merge\b|^Revert "|^v?\d+\.\d+\.\d+$|\[skip changelog\]|\[no changelog\]/i.test(s)) return { group: null, text: s, internal: true, breaking, pr }
|
|
270
|
+
const m = s.match(CONVENTIONAL)
|
|
271
|
+
if (m) {
|
|
272
|
+
const type = m[1].toLowerCase()
|
|
273
|
+
const text = sentence(m[4])
|
|
274
|
+
if (INTERNAL.has(type) && !m[3] && !breaking) return { group: null, text, internal: true, breaking: false, pr, type, scope: m[2] || null }
|
|
275
|
+
const group = TYPE_GROUP[type] || (/^(remove|drop|delete)\b/i.test(m[4]) ? 'Removed' : 'Changed')
|
|
276
|
+
return { group: m[3] || breaking ? (group === 'Added' ? 'Added' : 'Changed') : group, text, internal: false, breaking: !!m[3] || breaking, pr, type, scope: m[2] || null }
|
|
277
|
+
}
|
|
278
|
+
const verb = s.toLowerCase()
|
|
279
|
+
const group = /^(add|adds|added|new|introduce|support|allow|enable)\b/.test(verb) ? 'Added'
|
|
280
|
+
: /^(fix|fixes|fixed|correct|repair|resolve|prevent)\b/.test(verb) ? 'Fixed'
|
|
281
|
+
: /^(remove|removes|removed|drop|delete)\b/.test(verb) ? 'Removed'
|
|
282
|
+
: /^(deprecate)\b/.test(verb) ? 'Deprecated' : 'Changed'
|
|
283
|
+
return { group, text: sentence(s), internal: false, breaking, pr }
|
|
284
|
+
}
|
|
285
|
+
const sentence = (t) => { const x = String(t || '').trim().replace(/\.$/, ''); return x ? x[0].toUpperCase() + x.slice(1) : x }
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The draft entry: items grouped Added / Changed / Fixed / Removed (Deprecated, Security when present).
|
|
289
|
+
*
|
|
290
|
+
* commits [{ subject, body, pr: { number, url } | null }] — merged pull requests or commits since the last release
|
|
291
|
+
* changes [{ scenario, title, steps: [{ step, title, reason: new|changed|removed, pr: number|null }] }] — what the
|
|
292
|
+
* accepted runs since the last release show differently (from the service)
|
|
293
|
+
* Pictures are step references: a changed step gets before and after (`?version=prev` is the previous release), a
|
|
294
|
+
* new step one picture. A step whose run came from a pull request goes under that pull request's item.
|
|
295
|
+
* Returns { groups, internal (titles left out), markdown }.
|
|
296
|
+
*/
|
|
297
|
+
export function draftEntry ({ commits = [], changes = [], scenarioLabel = (s) => s.title || s.scenario } = {}) {
|
|
298
|
+
const groups = {}
|
|
299
|
+
const internal = []
|
|
300
|
+
const byPr = new Map()
|
|
301
|
+
const add = (g, item) => { (groups[g] ||= []).push(item); return groups[g].length - 1 }
|
|
302
|
+
for (const c of commits) {
|
|
303
|
+
const k = classifyCommit(c.subject, c.body)
|
|
304
|
+
if (k.internal) { internal.push(c.subject); continue }
|
|
305
|
+
const number = c.pr?.number || k.pr
|
|
306
|
+
const url = c.pr?.url || null
|
|
307
|
+
const text = `${k.text}${k.breaking ? ' (breaking: say what people have to do)' : ''}${number ? ` (${url ? `[#${number}](${url})` : `#${number}`})` : ''}`
|
|
308
|
+
const i = add(k.group, text)
|
|
309
|
+
if (number && !byPr.has(number)) byPr.set(number, { group: k.group, i })
|
|
310
|
+
}
|
|
311
|
+
for (const sc of changes) {
|
|
312
|
+
for (const st of sc.steps || []) {
|
|
313
|
+
const ref = (version) => formatStepRef({ scenario: sc.scenario, step: st.step, version })
|
|
314
|
+
const title = st.title || st.step
|
|
315
|
+
const pics = st.reason === 'new' ? `})` : st.reason === 'removed' ? `})` : `}) })`
|
|
316
|
+
const home = st.pr && byPr.get(st.pr)
|
|
317
|
+
if (home) { groups[home.group][home.i] += `\n${pics}`; continue }
|
|
318
|
+
const g = st.reason === 'new' ? 'Added' : st.reason === 'removed' ? 'Removed' : 'Changed'
|
|
319
|
+
add(g, `**${title}** in ${scenarioLabel(sc)} <!-- say what a person notices here -->\n${pics}`)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { groups, internal, markdown: groupsMarkdown(groups) }
|
|
323
|
+
}
|