kaloko 0.4.9 → 0.4.11

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.
Files changed (39) hide show
  1. package/README.md +88 -4
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +35 -0
  4. package/schemas/scenario.v1.json +30 -1
  5. package/skills/kaloko/SKILL.md +22 -4
  6. package/skills/kaloko/reference/acceptance.md +16 -0
  7. package/skills/kaloko/reference/ci.md +70 -0
  8. package/skills/kaloko/reference/collaboration.md +9 -1
  9. package/skills/kaloko/reference/environments.md +5 -0
  10. package/skills/kaloko/reference/execution.md +56 -0
  11. package/skills/kaloko/reference/import.md +66 -0
  12. package/skills/kaloko/reference/mcp.md +2 -0
  13. package/skills/kaloko/reference/recording.md +95 -0
  14. package/src/cli.mjs +467 -100
  15. package/src/lib/affected-core.mjs +242 -0
  16. package/src/lib/affected.mjs +233 -0
  17. package/src/lib/anim.mjs +125 -0
  18. package/src/lib/apps/recording-native.mjs +212 -0
  19. package/src/lib/build.mjs +60 -4
  20. package/src/lib/ci.mjs +207 -0
  21. package/src/lib/config.mjs +2 -0
  22. package/src/lib/doctor.mjs +5 -1
  23. package/src/lib/evaluate.mjs +4 -2
  24. package/src/lib/git.mjs +6 -2
  25. package/src/lib/heal.mjs +207 -0
  26. package/src/lib/import.mjs +374 -0
  27. package/src/lib/init-detect.mjs +219 -0
  28. package/src/lib/onboarding.mjs +15 -8
  29. package/src/lib/pr.mjs +24 -2
  30. package/src/lib/recording.mjs +373 -0
  31. package/src/lib/scenario.mjs +2 -1
  32. package/src/lib/service.mjs +7 -1
  33. package/src/lib/trace.mjs +109 -0
  34. package/src/lib/unzip.mjs +31 -0
  35. package/src/lib/upload.mjs +114 -0
  36. package/src/lib/video-cli.mjs +209 -0
  37. package/src/lib/video.mjs +427 -0
  38. package/src/lib/webm.mjs +144 -0
  39. package/viewer/index.html +186 -7
@@ -0,0 +1,114 @@
1
+ // Uploads of a run's files (#28): by content hash, so captures that did not change since an earlier run (or an
2
+ // interrupted share) are not sent again, with progress on the terminal. The service stores each file once as an
3
+ // organization blob and the run's file list points at it. An older service without blob references gets the files
4
+ // by path as before; the paths it already took are remembered in the run folder, so a share that broke off resumes.
5
+ import { readFile, writeFile } from 'node:fs/promises'
6
+ import { gzipSync } from 'node:zlib'
7
+ import { createHash } from 'node:crypto'
8
+ import { join } from 'node:path'
9
+ import { readJson } from './util.mjs'
10
+
11
+ const TEXT = new Set(['html', 'json'])
12
+ const IMAGE = /\.(png|webp|jpe?g)$/i
13
+ export const PROGRESS_FILE = 'share.progress.json'
14
+
15
+ export const fmtBytes = (n) => { n = Number(n) || 0; const u = ['B', 'kB', 'MB', 'GB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++ } return `${n.toFixed(i ? 1 : 0)} ${u[i]}` }
16
+ const extOf = (p) => { const e = p.split('.').pop().toLowerCase(); return e === 'jpeg' ? 'jpg' : e }
17
+ /** Text files travel and rest gzip-compressed above 512 bytes; the hash is always of the raw content. */
18
+ const encode = (p, raw) => { const gz = TEXT.has(extOf(p)) && raw.length > 512; return { data: gz ? gzipSync(raw, { level: 6 }) : raw, gz } }
19
+
20
+ /** Content hash, size and type of every file to share (paths relative to the run folder). */
21
+ export async function hashFiles (dir, paths) {
22
+ const out = []
23
+ for (const path of paths) {
24
+ const raw = await readFile(join(dir, path))
25
+ out.push({ path, hash: createHash('sha256').update(raw).digest('hex'), bytes: raw.length, ext: extOf(path) })
26
+ }
27
+ return out
28
+ }
29
+
30
+ /** Hashes the service does not store yet; null when it has no blob endpoint (an older service, or a token without it). */
31
+ export async function missingHashes (client, org, hashes) {
32
+ const missing = new Set()
33
+ const list = [...new Set(hashes)]
34
+ try {
35
+ for (let i = 0; i < list.length; i += 1000) for (const h of (await client.request('POST', `/orgs/${org}/blobs/missing`, { hashes: list.slice(i, i + 1000) })).missing) missing.add(h)
36
+ } catch (e) {
37
+ if (/HTTP (403|404|405)\b/.test(e.message)) return null
38
+ throw e
39
+ }
40
+ return missing
41
+ }
42
+
43
+ /** Runs `job` over `items` with `n` workers; `tick` after each item. */
44
+ async function pool (items, n, job, tick) {
45
+ const queue = [...items]
46
+ const worker = async () => { for (let it = queue.shift(); it; it = queue.shift()) { await job(it); tick?.(it) } }
47
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(n, queue.length)) }, worker))
48
+ }
49
+
50
+ function progressLine (write, label) {
51
+ let last = 0
52
+ return (done, total, bytes) => {
53
+ const now = Date.now()
54
+ if (done !== total && now - last < 250) return
55
+ last = now
56
+ write(`\r ${label} ${done}/${total}${bytes != null ? ` · ${fmtBytes(bytes)}` : ''}${done === total ? '\n' : ''}`)
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Uploads the files of a created run. `entries` from hashFiles; `missing` from missingHashes (null: by path).
62
+ * Returns { mode: 'blobs'|'paths', uploaded, reused, bytes }.
63
+ */
64
+ export async function uploadRunFiles ({ client, org, runId, dir, entries, missing, say = console.log, write = (s) => process.stdout.write(s), concurrency }) {
65
+ const workers = concurrency || (entries.length > 200 ? 12 : 6)
66
+ if (missing) {
67
+ // 1. the blobs the service lacks, each once (identical files in a run share one blob)
68
+ const byHash = new Map()
69
+ for (const e of entries) if (missing.has(e.hash) && !byHash.has(e.hash)) byHash.set(e.hash, e)
70
+ let sent = 0; let done = 0
71
+ const tick = progressLine(write, 'uploaded')
72
+ await pool([...byHash.values()], workers, async (e) => {
73
+ const { data, gz } = encode(e.path, await readFile(join(dir, e.path)))
74
+ await client.upload(`/orgs/${org}/blobs/${e.hash}`, data, e.path, { 'X-QAwalk-Type': e.ext, ...(gz ? { 'Content-Encoding': 'gzip' } : {}) })
75
+ sent += data.length
76
+ }, () => tick(++done, byHash.size, sent))
77
+ // 2. the run's file list points at the blobs; a service that does not know references says so by leaving out `blobs`
78
+ const plan = await client.request('POST', `/orgs/${org}/runs/${runId}/files`, { files: entries.map((e) => ({ path: e.path, bytes: e.bytes, blob: e.hash, ...(IMAGE.test(e.path) ? { sha256: e.hash } : {}) })) })
79
+ if (plan && typeof plan.blobs === 'number') {
80
+ return { mode: 'blobs', uploaded: byHash.size, reused: new Set(entries.map((e) => e.hash)).size - byHash.size, bytes: sent }
81
+ }
82
+ say(' The service does not take blob references yet; uploading the files by path.')
83
+ }
84
+ return uploadByPath({ client, org, runId, dir, entries, say, write, workers })
85
+ }
86
+
87
+ /** Per-path uploads (older services): a plan first, then each file; finished paths are remembered for a resume. */
88
+ async function uploadByPath ({ client, org, runId, dir, entries, say, write, workers }) {
89
+ const progressFile = join(dir, PROGRESS_FILE)
90
+ const saved = await readJson(progressFile, null).catch(() => null)
91
+ const done = new Set(saved?.run === runId ? saved.done : [])
92
+ const todo = entries.filter((e) => !done.has(`${e.path}|${e.hash}`))
93
+ if (done.size && todo.length < entries.length) say(` Resuming: ${entries.length - todo.length} of ${entries.length} file(s) were uploaded before the interruption.`)
94
+ // screenshots name their SHA-256 (sent uncompressed, so the hash of the raw file): the service checks it on
95
+ // arrival and compares unchanged steps with it (review mode)
96
+ if (todo.length) {
97
+ const plan = []
98
+ for (const e of todo) plan.push({ path: e.path, bytes: encode(e.path, await readFile(join(dir, e.path))).data.length, ...(IMAGE.test(e.path) ? { sha256: e.hash } : {}) })
99
+ await client.request('POST', `/orgs/${org}/runs/${runId}/files`, { files: plan }).catch((err) => { if (!/HTTP (404|405)/.test(err.message)) throw err })
100
+ }
101
+ let sent = 0; let n = 0; let since = 0
102
+ const remember = () => writeFile(progressFile, JSON.stringify({ run: runId, done: [...done] }) + '\n').catch(() => {})
103
+ const tick = progressLine(write, 'uploaded')
104
+ try {
105
+ await pool(todo, workers, async (e) => {
106
+ const { data, gz } = encode(e.path, await readFile(join(dir, e.path)))
107
+ await client.upload(`/orgs/${org}/runs/${runId}/files/${e.path.split('/').map(encodeURIComponent).join('/')}`, data, e.path, gz ? { 'Content-Encoding': 'gzip' } : {})
108
+ sent += data.length
109
+ done.add(`${e.path}|${e.hash}`)
110
+ if (++since >= 25) { since = 0; await remember() }
111
+ }, () => tick(++n, todo.length, sent))
112
+ } finally { await remember() }
113
+ return { mode: 'paths', uploaded: todo.length, reused: entries.length - todo.length, bytes: sent }
114
+ }
@@ -0,0 +1,209 @@
1
+ /* global MediaRecorder, createImageBitmap, requestAnimationFrame */
2
+ // `kaloko video …` (batch D, #23): the walkthrough video of a run (step recordings stitched with a title card per
3
+ // step: its title and verdict), recordings and traces of a run, and what this machine can record with.
4
+ import { mkdtemp, rm, writeFile, stat, mkdir } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { join, resolve, relative, dirname } from 'node:path'
7
+ import { resolveRun, addCapture } from './run.mjs'
8
+ import { loadScenario } from './scenario.mjs'
9
+ import { buildManifest } from './build.mjs'
10
+ import { launchEphemeral } from './browser.mjs'
11
+ import { readJson, localized, UserError } from './util.mjs'
12
+ import { videoTools, encoderCaps, ffmpeg, preset, installHint, outputSize } from './video.mjs'
13
+ import { formatTrace } from './trace.mjs'
14
+
15
+ const FORMATS = ['mp4', 'webm', 'gif']
16
+ const VERDICT = {
17
+ en: { pass: 'Passed', fail: 'Failed', partial: 'Partly passed', skip: 'Not walked', step: (i, n) => `Step ${i} of ${n}` },
18
+ cs: { pass: 'Splněno', fail: 'Nesplněno', partial: 'Splněno zčásti', skip: 'Nekontrolováno', step: (i, n) => `Krok ${i} z ${n}` }
19
+ }
20
+ const fmtSize = (n) => n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.round(n / 1024)} kB`
21
+ const flowOrder = (nodes) => nodes.filter((n) => n.kind !== 'decision' && n.kind !== 'stack').sort((a, b) => a.col - b.col || a.lane - b.lane)
22
+
23
+ /**
24
+ * The segments of a walkthrough in flow order for one locale and viewport: a step's own recording, or its chapter of
25
+ * the flow recording. [{ node, title, status, file, from, to }] (from/to in seconds, null for the whole file).
26
+ */
27
+ export function walkthroughPlan (manifest, { locale, viewport = null, steps = null } = {}) {
28
+ const loc = locale || manifest.scope?.locales?.[0]
29
+ const flow = manifest.recordings?.flow?.[loc]
30
+ const out = []
31
+ for (const n of flowOrder(manifest.nodes || [])) {
32
+ if (steps && !steps.includes(n.id)) continue
33
+ const vps = n.captures?.[loc] || {}
34
+ const vp = viewport && vps[viewport]?.rec ? viewport : Object.keys(vps).find((v) => vps[v]?.rec && (!viewport || v === viewport))
35
+ const rec = vp ? vps[vp].rec : (n.recordings || []).find((r) => r.locale === loc && (!viewport || r.viewport === viewport))
36
+ const status = n.by_locale?.[loc] || n.status || 'skip'
37
+ const title = localized(n.title, loc) || n.id
38
+ if (rec) { out.push({ node: n.id, title, status, file: rec.mp4 || rec.webm, from: null, to: null }); continue }
39
+ const ch = flow?.chapters?.find((c) => c.node === n.id)
40
+ if (ch) out.push({ node: n.id, title, status, file: flow.mp4 || flow.webm, from: ch.start / 1000, to: ch.capture / 1000 })
41
+ }
42
+ return out
43
+ }
44
+
45
+ /** ffmpeg arguments that stitch title cards and clips into one file. */
46
+ export function stitchArgs ({ parts, out, format, width, height, fps, card = 1.6, encoders = {} }) {
47
+ const inputs = []; const filters = []; let k = 0
48
+ for (const p of parts) {
49
+ if (p.card) inputs.push('-loop', '1', '-t', String(card), '-framerate', String(fps), '-i', p.card)
50
+ else inputs.push(...(p.from != null ? ['-ss', p.from.toFixed(3)] : []), ...(p.to != null ? ['-to', p.to.toFixed(3)] : []), '-i', p.file)
51
+ filters.push(`[${k}:v]scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=0x151514,fps=${fps},format=yuv420p,setsar=1[v${k}]`)
52
+ k++
53
+ }
54
+ const joined = `${parts.map((_, i) => `[v${i}]`).join('')}concat=n=${parts.length}:v=1:a=0[out]`
55
+ if (format === 'gif') return [...inputs, '-filter_complex', `${filters.join(';')};${joined};[out]split[a][b];[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer[g]`, '-map', '[g]', '-loop', '0', out]
56
+ const codec = format === 'webm'
57
+ ? ['-c:v', encoders.vp9 || 'libvpx-vp9', '-deadline', 'good', '-cpu-used', '5', '-row-mt', '1', '-b:v', '0', '-crf', '36']
58
+ : encoders.h264 === 'libx264' || !encoders.h264 ? ['-c:v', 'libx264', '-preset', 'medium', '-crf', '26', '-movflags', '+faststart'] : ['-c:v', encoders.h264, '-b:v', '2M', '-movflags', '+faststart']
59
+ return [...inputs, '-filter_complex', `${filters.join(';')};${joined}`, '-map', '[out]', '-an', ...codec, '-g', String(fps * 2), out]
60
+ }
61
+
62
+ /** Without ffmpeg: the browser plays the clips and title cards onto a canvas and MediaRecorder keeps it (real time). */
63
+ async function stitchInBrowser (page, { parts, width, height, fps, card }) {
64
+ const clips = []
65
+ for (const p of parts) {
66
+ if (p.card) clips.push({ card: p.cardData, ms: card * 1000 })
67
+ else clips.push({ video: (await import('node:fs/promises').then((fs) => fs.readFile(p.file))).toString('base64'), type: p.file.endsWith('.mp4') ? 'video/mp4' : 'video/webm', from: p.from, to: p.to })
68
+ }
69
+ return page.evaluate(async ({ clips, width, height, fps }) => {
70
+ const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; document.body.append(canvas)
71
+ const g = canvas.getContext('2d')
72
+ const fromB64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0))
73
+ const stream = canvas.captureStream(fps)
74
+ const type = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'].find((t) => MediaRecorder.isTypeSupported(t))
75
+ const rec = new MediaRecorder(stream, { mimeType: type, videoBitsPerSecond: 1_500_000 })
76
+ const chunks = []; rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data) }
77
+ const fit = (w, h) => { const k = Math.min(width / w, height / h); return [(width - w * k) / 2, (height - h * k) / 2, w * k, h * k] }
78
+ rec.start(500)
79
+ for (const c of clips) {
80
+ g.fillStyle = '#151514'; g.fillRect(0, 0, width, height)
81
+ if (c.card) {
82
+ const bm = await createImageBitmap(new Blob([fromB64(c.card)], { type: 'image/png' }))
83
+ const end = performance.now() + c.ms
84
+ while (performance.now() < end) { g.drawImage(bm, 0, 0, width, height); await new Promise((resolve) => requestAnimationFrame(resolve)) }
85
+ continue
86
+ }
87
+ const v = document.createElement('video'); v.muted = true; v.playsInline = true
88
+ v.src = URL.createObjectURL(new Blob([fromB64(c.video)], { type: c.type }))
89
+ await new Promise((resolve, reject) => { v.onloadeddata = resolve; v.onerror = reject })
90
+ if (c.from != null) { v.currentTime = c.from; await new Promise((resolve) => { v.onseeked = resolve }) }
91
+ await v.play()
92
+ while (!v.ended && (c.to == null || v.currentTime < c.to)) { const [x, y, w, h] = fit(v.videoWidth, v.videoHeight); g.fillStyle = '#151514'; g.fillRect(0, 0, width, height); g.drawImage(v, x, y, w, h); await new Promise((resolve) => requestAnimationFrame(resolve)) }
93
+ v.pause(); URL.revokeObjectURL(v.src)
94
+ }
95
+ await new Promise((resolve) => { rec.onstop = resolve; rec.stop() })
96
+ const buf = new Uint8Array(await new Blob(chunks).arrayBuffer())
97
+ let s = ''; for (let i = 0; i < buf.length; i += 0x8000) s += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000))
98
+ return btoa(s)
99
+ }, { clips, width, height, fps })
100
+ }
101
+
102
+ /** `kaloko video export`: the walkthrough of the current run (or --run) as MP4, WebM or GIF. */
103
+ export async function exportWalkthrough ({ config, root, args, say }) {
104
+ const run = await resolveRun({ root, output: config.output, run: args.run })
105
+ const scenario = await loadScenario(resolve(root, run.meta.scenario_file))
106
+ const manifest = await buildManifest({ run, scenario, config, root, catalog: [] })
107
+ const format = FORMATS.includes(args.format) ? args.format : 'mp4'
108
+ const lang = args.lang === 'cs' ? 'cs' : 'en'
109
+ const locale = typeof args.locale === 'string' ? args.locale : manifest.scope.locales[0]
110
+ const steps = typeof args.steps === 'string' ? args.steps.split(',').map((s) => s.trim()).filter(Boolean) : null
111
+ const plan = walkthroughPlan(manifest, { locale, viewport: typeof args.viewport === 'string' ? args.viewport : null, steps })
112
+ if (!plan.length) throw new UserError(`No recordings in run ${run.meta.id} for ${locale}. Walk with recordings first: kaloko walk --record (or --record flow), then export.`)
113
+ const tools = await videoTools()
114
+ if (!tools.ffmpeg && format !== 'webm') throw new UserError(`A walkthrough as ${format.toUpperCase()} needs ffmpeg (${installHint('ffmpeg')}). Without it, export WebM: kaloko video export --format webm (it plays the clips once in real time).`)
115
+ const P = preset(args.quality || 'medium')
116
+ const fps = format === 'gif' ? 10 : P.fps
117
+ const [width, height] = format === 'gif' ? outputSize(1280, 800, 800) : outputSize(1280, 800, Math.min(P.width, 1280))
118
+ const card = Math.max(0.5, Math.min(6, Number(args['card-seconds']) || 1.6))
119
+ const browser = await launchEphemeral(config)
120
+ const tmp = await mkdtemp(join(tmpdir(), 'kaloko-walkthrough-'))
121
+ const defaultOut = join(run.dir, 'attachments', `walkthrough.rec.${format}`)
122
+ const out = typeof args.out === 'string' ? resolve(args.out) : defaultOut
123
+ try {
124
+ const page = await browser.newPage()
125
+ await encoderCaps(page)
126
+ const V = VERDICT[lang]
127
+ const parts = []
128
+ const cardPng = async (lines, verdict) => {
129
+ const r = await page.evaluate((a) => window.__kenc.card(a), { width, height, lines, verdict })
130
+ const f = join(tmp, `card${parts.length}.png`)
131
+ await writeFile(f, Buffer.from(r.data, 'base64'))
132
+ return { card: f, cardData: r.data }
133
+ }
134
+ const s = manifest.summary || {}
135
+ parts.push(await cardPng([`${manifest.environment?.name || ''} · ${locale} · ${new Date(manifest.started_at).toISOString().slice(0, 10)}`, localized(manifest.scenario.title, locale) || manifest.scenario.id, `${plan.length} ${lang === 'cs' ? 'kroků se záznamem' : 'recorded steps'}${s.pass != null ? ` · ${s.pass} ${V.pass.toLowerCase()} · ${s.fail || 0} ${V.fail.toLowerCase()}` : ''}`], null))
136
+ plan.forEach((p, i) => { p.index = i + 1 })
137
+ for (const p of plan) {
138
+ parts.push(await cardPng([V.step(p.index, plan.length), p.title, p.node], { status: p.status, text: V[p.status] || p.status }))
139
+ parts.push({ file: join(run.dir, p.file), from: p.from, to: p.to })
140
+ }
141
+ await mkdir(dirname(out), { recursive: true })
142
+ if (tools.ffmpeg) {
143
+ say(`Stitching ${plan.length} recording(s) with title cards (${format.toUpperCase()}, ${width}×${height})…`)
144
+ await ffmpeg(tools, stitchArgs({ parts, out, format, width, height, fps, card, encoders: tools.ffmpeg.encoders }))
145
+ } else {
146
+ say(`No ffmpeg: playing ${plan.length} recording(s) through the browser in real time to record a WebM…`)
147
+ await writeFile(out, Buffer.from(await stitchInBrowser(page, { parts, width, height, fps, card }), 'base64'))
148
+ }
149
+ const bytes = (await stat(out)).size
150
+ const duration = Math.round((plan.reduce((n, p) => n + ((p.to ?? 0) - (p.from ?? 0)), 0) + card * (plan.length + 1)) * 1000)
151
+ const inside = !relative(run.dir, out).startsWith('..')
152
+ if (inside) await addCapture(run.dir, { type: 'walkthrough', file: relative(run.dir, out), format, bytes, locale, viewport: args.viewport || null, steps: plan.length, ...(plan.every((p) => p.to != null) ? { duration_ms: duration } : {}), at: new Date().toISOString() })
153
+ say(`Walkthrough: ${relative(process.cwd(), out)} (${fmtSize(bytes)}, ${plan.length} steps)`)
154
+ if (inside) say('kaloko share uploads it with the run (Business plan and up); people with the link play or download it on the canvas.')
155
+ return { file: out, bytes, steps: plan.length }
156
+ } finally { await browser.close().catch(() => {}); await rm(tmp, { recursive: true, force: true }) }
157
+ }
158
+
159
+ export async function videoCmd ({ config, root, args, say }) {
160
+ const sub = args._[0] || 'help'
161
+ if (sub === 'export') return exportWalkthrough({ config, root, args, say })
162
+ if (sub === 'tools') {
163
+ const t = await videoTools({ refresh: true })
164
+ const ff = t.ffmpeg
165
+ say([
166
+ `ffmpeg ${ff ? `${ff.version} · H.264 ${ff.encoders.h264 || '—'} · VP9 ${ff.encoders.vp9 ? 'yes' : '—'} · AV1 ${ff.encoders.av1 || '—'}` : `not found (${installHint('ffmpeg', t.platform)}); WebM still works, MP4 and the walkthrough export need it`}`,
167
+ `adb ${t.adb ? 'found (Android screenrecord)' : `not found (${installHint('adb', t.platform)})`}`,
168
+ ...(t.platform === 'darwin' ? [`simctl ${t.simctl ? 'found (iOS Simulator recordVideo)' : `not found (${installHint('simctl', 'darwin')})`}`, 'screencapture macOS desktop recordings (needs Screen Recording permission: kaloko doctor checks it)'] : [])
169
+ ].join('\n'))
170
+ return t
171
+ }
172
+ const run = await resolveRun({ root, output: config.output, run: args.run })
173
+ const scenario = await loadScenario(resolve(root, run.meta.scenario_file))
174
+ const manifest = await buildManifest({ run, scenario, config, root, catalog: [] })
175
+ if (sub === 'list') {
176
+ const rows = []
177
+ for (const n of manifest.nodes) {
178
+ for (const [l, vps] of Object.entries(n.captures || {})) for (const [vp, c] of Object.entries(vps || {})) if (c?.rec || c?.trace_summary) rows.push({ step: n.id, locale: l, viewport: vp, recording: c.rec || null, trace: c.trace_summary || null })
179
+ for (const r of n.recordings || []) rows.push({ step: n.id, locale: r.locale, viewport: r.viewport, recording: r, trace: null })
180
+ }
181
+ const flows = manifest.recordings?.flow || {}
182
+ if (args.json) { say(JSON.stringify({ run: run.meta.id, steps: rows, flow: flows, walkthrough: manifest.walkthrough || null }, null, 2)); return }
183
+ for (const r of rows) say(`${r.step.padEnd(24)} ${r.locale}/${r.viewport} ${r.recording ? `${Math.round(r.recording.duration_ms / 100) / 10} s ${[r.recording.webm && 'WebM', r.recording.mp4 && 'MP4'].filter(Boolean).join('+')} ${fmtSize(r.recording.bytes || 0)}${r.recording.failed ? ' (failed attempt)' : ''}` : 'no recording'}${r.trace ? ` · trace ${r.trace.actions} actions, ${r.trace.requests} requests${r.trace.failed_requests ? ` (${r.trace.failed_requests} failed)` : ''}, ${r.trace.console_errors} console errors` : ''}`)
184
+ for (const [l, f] of Object.entries(flows)) say(`flow ${l}: ${Math.round(f.duration_ms / 1000)} s, ${f.chapters?.length || 0} chapters, ${fmtSize(f.bytes || 0)}`)
185
+ if (manifest.walkthrough) say(`walkthrough: ${manifest.walkthrough.file} (${fmtSize(manifest.walkthrough.bytes || 0)})`)
186
+ if (!rows.length && !Object.keys(flows).length) say('Nothing recorded in this run. kaloko walk --record records each step; traces are on unless capture.trace is false.')
187
+ return
188
+ }
189
+ if (sub === 'trace') {
190
+ const step = typeof args.step === 'string' ? args.step : null
191
+ const n = manifest.nodes.find((x) => x.id === step)
192
+ if (!n) throw new UserError(`Usage: kaloko video trace --step <id> [--locale l] [--viewport v] [--json]. Steps: ${manifest.nodes.map((x) => x.id).join(', ')}`)
193
+ const loc = typeof args.locale === 'string' ? args.locale : Object.keys(n.captures || {})[0]
194
+ const vps = n.captures?.[loc] || {}
195
+ const vp = typeof args.viewport === 'string' ? args.viewport : Object.keys(vps).find((v) => vps[v]?.trace)
196
+ const file = vps[vp]?.trace
197
+ if (!file) throw new UserError(`No trace for ${step} ${loc}${vp ? `/${vp}` : ''}. Walk the step again (traces are on unless capture.trace is false).`)
198
+ const trace = await readJson(join(run.dir, file), null)
199
+ say(args.json ? JSON.stringify(trace, null, 2) : `${step} ${loc}/${vp}: ${trace.duration_ms} ms, ${trace.summary?.requests ?? 0} requests, ${trace.summary?.console_errors ?? 0} console errors\n${formatTrace(trace)}`)
200
+ return
201
+ }
202
+ say(`Usage:
203
+ kaloko video export [--format mp4|webm|gif] [--locale l] [--viewport v] [--steps a,b] [--lang en|cs] [--out file]
204
+ The run's recordings stitched in flow order with a title card per step (title, verdict)
205
+ kaloko video list [--json] Recordings and traces of the run, with sizes
206
+ kaloko video trace --step <id> [--locale l] [--viewport v] [--json]
207
+ Actions, requests and console messages of the step, in time order
208
+ kaloko video tools ffmpeg, adb, simctl: what this machine can record and encode with`)
209
+ }