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,212 @@
1
+ // Recordings of native app steps (batch D): the device or the desktop records itself while the step runs.
2
+ // android adb shell screenrecord, in segments (the tool stops after 3 minutes), touches shown while recording
3
+ // ios xcrun simctl io <device> recordVideo (H.264)
4
+ // desktop macOS screencapture -v with clicks shown (needs Screen Recording permission)
5
+ // Electron windows are web pages and use the screencast recorder (recording.mjs).
6
+ // Each finished clip is MP4 H.264, which every browser plays; ffmpeg joins segments, remuxes the macOS .mov and makes
7
+ // the poster and preview. Taps the step script made through the driver are kept as markers for the canvas.
8
+ import { mkdir, rm, rename, writeFile, stat, copyFile } from 'node:fs/promises'
9
+ import { join, dirname } from 'node:path'
10
+ import { execFile } from 'node:child_process'
11
+ import { promisify } from 'node:util'
12
+ import { startProcess, ffmpeg, mediaDuration, stillsFromVideo, videoTools, installHint } from '../video.mjs'
13
+ import { recBase, saveRecording, stepRecorded, parseRecord } from '../recording.mjs'
14
+ import { addCapture, runPaths } from '../run.mjs'
15
+ import { writeJson } from '../util.mjs'
16
+ import { summarizeTrace } from '../trace.mjs'
17
+
18
+ const run = promisify(execFile)
19
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
20
+ /** screenrecord stops at 180 s; segments a little shorter leave time to start the next one. */
21
+ export const ANDROID_SEGMENT = 170
22
+ const ANDROID_BITRATE = { low: 2_000_000, medium: 4_000_000, high: 8_000_000 }
23
+
24
+ /** Shell arguments of one Android segment (tests read them). */
25
+ export function screenrecordArgs ({ serial, file, quality = 'medium', seconds = ANDROID_SEGMENT }) {
26
+ return [...(serial ? ['-s', serial] : []), 'shell', 'screenrecord', '--time-limit', String(Math.min(180, seconds)), '--bit-rate', String(ANDROID_BITRATE[quality] || ANDROID_BITRATE.medium), file]
27
+ }
28
+
29
+ /** The booted simulator a config means (udid, device name, or the first booted one), as the iOS driver picks it. */
30
+ export async function bootedSimulator (cfg = {}, exec = run) {
31
+ const { stdout } = await exec('xcrun', ['simctl', 'list', 'devices', 'booted', '-j'])
32
+ const all = Object.values(JSON.parse(stdout).devices || {}).flat()
33
+ const dev = cfg.udid ? all.find((d) => d.udid === cfg.udid) : cfg.device ? all.find((d) => d.name === cfg.device) : all[0]
34
+ return dev?.udid || null
35
+ }
36
+
37
+ /**
38
+ * One recorder for one step: start() before the step, stop() at its capture → { file (.mp4 or .mov), source } or null.
39
+ * `tools` from videoTools(). Throws nothing: a recording that cannot start leaves `reason`.
40
+ */
41
+ export function nativeRecorder ({ platform, cfg = {}, tools, quality = 'medium', maxSeconds = 60, tmp }) {
42
+ const r = { platform, reason: null, segments: [], child: null, started: 0 }
43
+ if (platform === 'android') {
44
+ const adb = cfg.adb || process.env.KALOKO_ADB || 'adb'
45
+ const serial = cfg.serial || process.env.ANDROID_SERIAL || null
46
+ let n = 0; let stopping = false; let showTouches = null
47
+ const shell = (...a) => run(adb, [...(serial ? ['-s', serial] : []), 'shell', ...a], { timeout: 20000 })
48
+ const next = () => {
49
+ const remote = `/sdcard/kaloko-rec-${process.pid}-${Date.now().toString(36)}-${n++}.mp4`
50
+ r.segments.push(remote)
51
+ const left = Math.max(1, Math.ceil(maxSeconds - (Date.now() - r.started) / 1000))
52
+ r.child = startProcess(adb, screenrecordArgs({ serial, file: remote, quality, seconds: Math.min(ANDROID_SEGMENT, left) }))
53
+ r.child.done.then(() => { if (!stopping && (Date.now() - r.started) / 1000 < maxSeconds - 1) next() })
54
+ }
55
+ r.start = async () => {
56
+ if (!tools.adb) { r.reason = `adb is not installed (${installHint('adb')})`; return false }
57
+ showTouches = (await shell('settings', 'get', 'system', 'show_touches').catch(() => ({ stdout: '' }))).stdout.trim()
58
+ await shell('settings', 'put', 'system', 'show_touches', '1').catch(() => {})
59
+ r.started = Date.now(); next(); await sleep(700)
60
+ return true
61
+ }
62
+ r.stop = async () => {
63
+ stopping = true
64
+ await shell('pkill', '-INT', 'screenrecord').catch(() => {})
65
+ await Promise.race([r.child?.done, sleep(6000)])
66
+ if (showTouches !== '1') await shell('settings', 'put', 'system', 'show_touches', showTouches || '0').catch(() => {})
67
+ await sleep(400) // the device finishes writing the file
68
+ const local = []
69
+ for (const [i, remote] of r.segments.entries()) {
70
+ const f = join(tmp, `seg${i}.mp4`)
71
+ try { await run(adb, [...(serial ? ['-s', serial] : []), 'pull', remote, f], { timeout: 120000 }); local.push(f) } catch {}
72
+ await shell('rm', '-f', remote).catch(() => {})
73
+ }
74
+ if (!local.length) { r.reason = 'the device kept no recording'; return null }
75
+ return { file: await joinSegments(tools, local, join(tmp, 'android.mp4')), source: 'adb', segments: local.length }
76
+ }
77
+ } else if (platform === 'ios') {
78
+ let file = null
79
+ r.start = async () => {
80
+ if (!tools.simctl) { r.reason = `simctl is missing (${installHint('simctl', 'darwin')})`; return false }
81
+ const udid = await bootedSimulator(cfg).catch(() => null)
82
+ if (!udid) { r.reason = 'no booted iOS Simulator'; return false }
83
+ file = join(tmp, 'ios.mp4')
84
+ r.child = startProcess('xcrun', ['simctl', 'io', udid, 'recordVideo', '--codec=h264', '--force', file])
85
+ r.started = Date.now()
86
+ // simctl says when the first frame is in; a step that starts before would miss its beginning
87
+ for (let i = 0; i < 50 && !/Recording started/i.test(r.child.output()); i++) await sleep(100)
88
+ return true
89
+ }
90
+ r.stop = async () => {
91
+ r.child?.kill('SIGINT')
92
+ await Promise.race([r.child?.done, sleep(8000)])
93
+ return (await stat(file).catch(() => null))?.size ? { file, source: 'simctl' } : null
94
+ }
95
+ } else if (platform === 'desktop') {
96
+ let file = null
97
+ r.start = async () => {
98
+ if (process.platform !== 'darwin') { r.reason = 'desktop recording needs macOS'; return false }
99
+ file = join(tmp, 'desktop.mov')
100
+ // -v video, -C cursor, -k clicks, -x no sound, -V the longest it may take
101
+ r.child = startProcess('/usr/sbin/screencapture', ['-v', '-C', '-k', '-x', '-V', String(Math.max(2, Math.round(maxSeconds))), file])
102
+ r.started = Date.now(); await sleep(800)
103
+ return true
104
+ }
105
+ r.stop = async () => {
106
+ r.child?.kill('SIGINT')
107
+ await Promise.race([r.child?.done, sleep(8000)])
108
+ if (!(await stat(file).catch(() => null))?.size) { r.reason = 'screencapture kept no recording (allow Screen Recording for this terminal in System Settings → Privacy & Security)'; return null }
109
+ if (!tools.ffmpeg) { r.reason = `the macOS recording is QuickTime (.mov); ffmpeg turns it into MP4 for the canvas (${installHint('ffmpeg', 'darwin')})`; return { file, source: 'screencapture', mov: true } }
110
+ const mp4 = join(tmp, 'desktop.mp4')
111
+ try { await ffmpeg(tools, ['-i', file, '-c:v', 'copy', '-movflags', '+faststart', '-an', mp4]) } catch { await ffmpeg(tools, ['-i', file, '-c:v', tools.ffmpeg.encoders.h264 || 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', mp4]) }
112
+ return { file: mp4, source: 'screencapture' }
113
+ }
114
+ } else {
115
+ r.start = async () => { r.reason = `no native recorder for ${platform}`; return false }
116
+ r.stop = async () => null
117
+ }
118
+ return r
119
+ }
120
+
121
+ /** Segments of one recording as one MP4: ffmpeg concatenates them; without ffmpeg the last segment is kept. */
122
+ export async function joinSegments (tools, files, out) {
123
+ if (files.length === 1) { await rename(files[0], out); return out }
124
+ if (!tools.ffmpeg) { await rename(files.at(-1), out); return out }
125
+ const list = join(dirname(out), 'segments.txt')
126
+ await writeFile(list, files.map((f) => `file '${f.replaceAll("'", "'\\''")}'`).join('\n') + '\n')
127
+ await ffmpeg(tools, ['-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', '-movflags', '+faststart', out])
128
+ return out
129
+ }
130
+
131
+ /**
132
+ * The driver with its interactions logged ({ t, type, target }) for the trace and the tap markers; typed text is
133
+ * never kept, only its length.
134
+ */
135
+ export function instrumentDriver (driver, log) {
136
+ const wrap = (name, describe) => {
137
+ const fn = driver[name]
138
+ if (typeof fn !== 'function') return
139
+ driver[name] = async (...a) => { log.push({ t: Date.now(), type: name, ...describe(...a) }); return fn.apply(driver, a) }
140
+ }
141
+ wrap('tap', (target) => (target && typeof target === 'object' && target.x != null ? { x: target.x, y: target.y } : { target: String(target ?? '').slice(0, 120) }))
142
+ wrap('type', (text) => ({ target: `${String(text ?? '').length} characters` }))
143
+ wrap('back', () => ({}))
144
+ wrap('link', (url) => ({ url: String(url ?? '').slice(0, 300) }))
145
+ wrap('swipe', (...a) => ({ target: a.map((x) => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' ').slice(0, 120) }))
146
+ return driver
147
+ }
148
+
149
+ /**
150
+ * Per locale of an app walk (android, ios, desktop): beforeStep(node) starts the device recording when the step is
151
+ * recorded; afterStep(node, { record, error }) stops it, stores the MP4, poster, preview, taps and a trace of the
152
+ * driver's actions. `encoder` returns a Chrome page for the WebP preview (optional).
153
+ */
154
+ export async function nativeSession ({ platform, env, run, scenario, locale, settings, driver, encoder, say = () => {} }) {
155
+ const anyRecord = !!settings.record || scenario.nodes.some((n) => parseRecord(n.record ?? n.capture?.record))
156
+ if (!anyRecord && !settings.trace) return null
157
+ const tools = anyRecord ? await videoTools() : {}
158
+ const actions = []
159
+ instrumentDriver(driver, actions)
160
+ const cfg = env.app?.[platform] || {}
161
+ let cur = null
162
+ const notes = new Set()
163
+ return {
164
+ async beforeStep (node) {
165
+ actions.length = 0
166
+ cur = { node, t0: Date.now(), t1: null, rec: null }
167
+ const want = settings.record?.mode === 'flow' ? { ...settings.record, mode: 'step' } : stepRecorded(settings, node)
168
+ if (!want) return
169
+ const tmp = join(run.dir, '.rec-tmp', `${node.id}-${locale}`)
170
+ await mkdir(tmp, { recursive: true })
171
+ const r = nativeRecorder({ platform, cfg, tools, quality: want.quality, maxSeconds: want.max_seconds || 60, tmp })
172
+ if (await r.start()) cur.rec = { r, want, tmp }
173
+ else if (r.reason) notes.add(`recording ${platform}: ${r.reason}`)
174
+ },
175
+ async beforeCapture () { if (cur && !cur.t1) { cur.t1 = Date.now(); if (cur.rec) cur.clip = await cur.rec.r.stop().catch(() => null) } },
176
+ async afterStep (node, { record = null, error = null } = {}) {
177
+ if (!cur || cur.node !== node) return
178
+ const { t0, rec } = cur
179
+ const t1 = cur.t1 || Date.now()
180
+ const clip = cur.clip !== undefined ? cur.clip : rec ? await rec.r.stop().catch(() => null) : null
181
+ cur = null
182
+ const vp = record?.viewport || Object.keys(run.meta.scope.viewports || {})[0] || 'phone'
183
+ const failed = !!error || Object.values(record?.checks || {}).some((c) => c?.ok === false)
184
+ const size = record?.size?.viewport || null
185
+ if (settings.trace) {
186
+ const file = runPaths(run.dir).step(node.id, locale, vp, 'trace.json')
187
+ const trace = { schema: 'qawalk.trace.v1', node: node.id, locale, viewport: vp, platform, started_at: new Date(t0).toISOString(), duration_ms: t1 - t0, actions: actions.filter((a) => a.t <= t1).map((a) => ({ ...a, t: Math.max(0, a.t - t0) })), network: [], console: [] }
188
+ trace.summary = summarizeTrace(trace)
189
+ await mkdir(dirname(file), { recursive: true })
190
+ await writeJson(file, trace)
191
+ await addCapture(run.dir, { type: 'trace', node: node.id, locale, viewport: vp, file: file.slice(run.dir.length + 1), captured_at: record?.captured_at || null, at: new Date().toISOString() })
192
+ }
193
+ if (!rec) return
194
+ try {
195
+ if (!clip || (rec.want.mode === 'failed' && !failed)) { if (!clip && rec.r.reason) notes.add(`recording ${platform}: ${rec.r.reason}`); return }
196
+ const base = recBase(run.dir, node.id, locale, vp)
197
+ await mkdir(dirname(base), { recursive: true })
198
+ if (clip.mov) { await copyFile(clip.file, `${base}.mov`); notes.add(rec.r.reason); return }
199
+ await copyFile(clip.file, `${base}.mp4`)
200
+ const duration = (tools.ffmpeg && await mediaDuration(tools, `${base}.mp4`)) || (t1 - t0)
201
+ const stills = await stillsFromVideo({ tools, page: await encoder?.(), file: `${base}.mp4`, out: base, quality: rec.want.quality, duration })
202
+ const files = { mp4: `${base}.mp4`, ...(stills.poster ? { poster: stills.poster } : {}), ...(stills.preview ? { preview: stills.preview } : {}) }
203
+ const bytes = {}
204
+ for (const [k, f] of Object.entries(files)) bytes[k] = (await stat(f)).size
205
+ // taps with coordinates, as fractions of the screen, relative to the clip
206
+ const taps = size ? actions.filter((a) => a.type === 'tap' && a.x != null && a.t <= t1).map((a) => ({ t: Math.max(0, a.t - t0), x: +(a.x / size[0]).toFixed(4), y: +(a.y / size[1]).toFixed(4) })) : []
207
+ await saveRecording({ run, node, locale, viewport: vp, result: { files, bytes, duration_ms: duration, width: null, height: null, codec: 'h264' }, mode: rec.want.mode, failed, error, captured_at: record?.captured_at || null, taps, source: clip.source })
208
+ } catch (e) { notes.add(`recording ${platform}: ${String(e.message).split('\n')[0]}`) } finally { await rm(rec.tmp, { recursive: true, force: true }) }
209
+ },
210
+ async finish () { await rm(join(run.dir, '.rec-tmp'), { recursive: true, force: true }); return [...notes].filter(Boolean).map((n) => `${n} (${locale})`) }
211
+ }
212
+ }
package/src/lib/build.mjs CHANGED
@@ -17,6 +17,7 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
17
17
  const entries = await readCaptures(run.dir)
18
18
  const captures = latestCaptures(entries)
19
19
  const attachments = entries.filter((e) => e.type === 'attachment')
20
+ const media = entries.filter((e) => e.type === 'trace' || e.type === 'recording' || e.type === 'walkthrough')
20
21
  const pos = new Map(layout(scenario).map((p) => [p.id, p]))
21
22
  const locales = meta.scope.locales
22
23
  const viewportNames = Object.keys(meta.scope.viewports)
@@ -36,7 +37,9 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
36
37
  }
37
38
  // focus screenshots of the keyboard walk (#19): only those of the current captures, not of earlier walks
38
39
  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))) }))
40
+ const out = 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))) })
41
+ await attachMedia(out, { run, entries: media, captures: caps })
42
+ nodes.push(out)
40
43
  }
41
44
  const summary = summarize(nodes, scenario.sessions)
42
45
 
@@ -79,10 +82,62 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
79
82
  summary,
80
83
  coverage: cov,
81
84
  history,
82
- catalog
85
+ catalog,
86
+ ...(await runMedia({ run, entries: media }))
83
87
  }
84
88
  }
85
89
 
90
+ /** The recording entry of a sidecar as the manifest carries it: file paths, duration, size, markers. */
91
+ async function recEntry (run, e) {
92
+ const r = await readJson(join(run.dir, e.file), null)
93
+ if (!r?.files || !Object.values(r.files).some(Boolean)) return null
94
+ const files = Object.fromEntries(Object.entries(r.files).filter(([k, f]) => f && ['webm', 'mp4', 'preview', 'poster'].includes(k) && existsSync(join(run.dir, f))))
95
+ if (!files.webm && !files.mp4) return null
96
+ return { ...files, duration_ms: r.duration_ms, ...(r.width ? { size: [r.width, r.height] } : {}), codec: r.codec || null, mode: r.mode, source: r.source || null, bytes: Object.values(r.bytes || {}).reduce((n, b) => n + (b || 0), 0), ...(r.failed ? { failed: true } : {}), ...(r.error ? { error: r.error } : {}), ...(r.trimmed ? { trimmed: true } : {}), ...(r.offset_ms ? { offset_ms: r.offset_ms } : {}), ...(r.taps?.length ? { taps: r.taps.slice(0, 200) } : {}), ...(r.chapters ? { chapters: r.chapters } : {}) }
97
+ }
98
+
99
+ /**
100
+ * Traces and recordings of a step's captures (batch D, #12): each capture gets the trace and recording made in the
101
+ * same walk (matched by captured_at), `trace_summary` for the canvas, MCP and the CLI; a step whose script failed
102
+ * before its capture keeps its recording under `recordings`.
103
+ */
104
+ async function attachMedia (node, { run, entries, captures }) {
105
+ const mine = entries.filter((e) => e.node === node.id && !e.scope)
106
+ if (!mine.length) return
107
+ const latest = (type, c) => mine.filter((e) => e.type === type && e.locale === c.locale && e.viewport === c.viewport && e.captured_at && e.captured_at === c.cap.captured_at).pop()
108
+ for (const c of captures) {
109
+ const out = node.captures?.[c.locale]?.[c.viewport]
110
+ if (!out) continue
111
+ const t = latest('trace', c)
112
+ if (t) {
113
+ const trace = await readJson(join(run.dir, t.file), null)
114
+ if (trace) { out.trace = t.file; out.trace_summary = trace.summary || null }
115
+ }
116
+ const r = latest('recording', c)
117
+ if (r) { const rec = await recEntry(run, r); if (rec) out.rec = rec }
118
+ }
119
+ // recordings of failed attempts newer than the step's capture in that locale and viewport
120
+ const failed = []
121
+ for (const e of mine.filter((x) => x.type === 'recording' && !x.captured_at)) {
122
+ const cap = captures.find((c) => c.locale === e.locale && c.viewport === e.viewport)
123
+ if (cap && cap.cap.captured_at > e.at) continue
124
+ const rec = await recEntry(run, e)
125
+ if (rec) failed.push({ locale: e.locale, viewport: e.viewport, ...rec })
126
+ }
127
+ if (failed.length) node.recordings = failed.slice(-4)
128
+ }
129
+
130
+ /** Run-level media: flow recordings per locale with their chapters, and the exported walkthrough video. */
131
+ async function runMedia ({ run, entries }) {
132
+ const out = {}
133
+ const flows = {}
134
+ for (const e of entries.filter((x) => x.type === 'recording' && x.scope === 'flow')) { const rec = await recEntry(run, e); if (rec) flows[e.locale] = { viewport: e.viewport, ...rec } }
135
+ if (Object.keys(flows).length) out.recordings = { flow: flows }
136
+ const w = entries.filter((x) => x.type === 'walkthrough' && existsSync(join(run.dir, x.file))).pop()
137
+ if (w) out.walkthrough = { file: w.file, format: w.format, duration_ms: w.duration_ms ?? null, bytes: w.bytes ?? null, locale: w.locale || null, viewport: w.viewport || null, steps: w.steps ?? null }
138
+ return out
139
+ }
140
+
86
141
 
87
142
  /**
88
143
  * A stack step: its pages go to steps/<node>/items.json (qawalk.stack.v1: fields, criteria, and per page the path,
@@ -160,10 +215,11 @@ export async function writePreview ({ run, manifest }) {
160
215
  const template = await readFile(join(packageRoot, 'viewer', 'index.html'), 'utf8')
161
216
  // the local preview opens from file://, where fetch cannot read files: stack indexes go inline
162
217
  // file:// cannot fetch: stack indexes and app UI trees travel inside the preview
218
+ // traces of steps (#12) too: the Technical tab reads them without a request
163
219
  const withTrees = async (n) => {
164
- if (!Object.values(n.captures || {}).some((vps) => Object.values(vps).some((c) => c?.tree || c?.spec))) return n
220
+ if (!Object.values(n.captures || {}).some((vps) => Object.values(vps).some((c) => c?.tree || c?.spec || c?.trace))) return n
165
221
  const captures = {}
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 }
222
+ 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?.trace ? { ...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.trace ? { trace_inline: await readJson(join(run.dir, c.trace), null) } : {}) } : c }
167
223
  return { ...n, captures }
168
224
  }
169
225
  // design runs: live steps open from the merged tree, where links between steps resolve (design.mjs copyLive)
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
+ }
@@ -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
 
@@ -11,6 +11,7 @@ import { portFor } from './browser.mjs'
11
11
  import { DEFAULTS as JEV } from './jev.mjs'
12
12
  import { visionSettings, VISION_WEIGHT } from './vision-core.mjs'
13
13
  import { serviceUrl } from './service.mjs'
14
+ import { videoTools, recordingChecks } from './video.mjs'
14
15
 
15
16
  const portFree = (port) => new Promise((done) => {
16
17
  const s = createServer().once('error', () => done(false)).once('listening', () => s.close(() => done(true)))
@@ -33,7 +34,7 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
33
34
  * @param {Function} [o.launchChrome] async () => { version } — throws when Chrome is missing
34
35
  * @param {object} [o.env] process.env
35
36
  */
36
- export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false }) {
37
+ export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, videoToolsImpl = null }) {
37
38
  const checks = []
38
39
  const add = (name, ok, detail = '', hint = '') => checks.push({ name, ok, detail, hint })
39
40
 
@@ -144,6 +145,9 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
144
145
  else if (token && vision.hosted !== false) add('vision evaluator', true, `via Kaloko · model ${vision.model} · one vision question counts as ${VISION_WEIGHT}`)
145
146
  else add('vision evaluator', true, `${vision.api_key_env} not set (optional)`, `Criteria with vision: true and unsure answers stay with a person until ${vision.api_key_env} is set or ${tokenEnv} lets the service ask.`)
146
147
 
148
+ // recordings (batch D): ffmpeg and the device recorders, with install hints per OS
149
+ checks.push(...recordingChecks({ config, tools: await (videoToolsImpl || videoTools)() }))
150
+
147
151
  return { ok: checks.every((c) => c.ok), checks }
148
152
  }
149
153
 
@@ -101,8 +101,10 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
101
101
  const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && !(c.check.about === 'console' && !(capture.console_errors || []).length))
102
102
  // quick signals ride along on every captured page state (also steps without semantic criteria)
103
103
  const signals = settings.signals !== false && !outOfSample && !!capture.state
104
- const request = (semantic.length || signals) && current ? buildRequest({ node, capture, criteria: semantic, model: settings.model, signals }) : null
105
- work.push({ entry, node, capture, current, outOfSample, request, asks: semantic.length > 0, jev: current ? null : { skipped: 'Capture has no matching criteria fingerprint; capture this step again.' } })
104
+ // an imported screen without page text (#11) has nothing to ask about: a person decides on the canvas
105
+ const textless = capture.capture?.source === 'import' && !capture.state
106
+ const request = (semantic.length || signals) && current && !textless ? buildRequest({ node, capture, criteria: semantic, model: settings.model, signals }) : null
107
+ work.push({ entry, node, capture, current, outOfSample, request, asks: semantic.length > 0, jev: !current ? { skipped: 'Capture has no matching criteria fingerprint; capture this step again.' } : textless && semantic.length ? { skipped: 'imported screenshot without page text; a person decides on the canvas' } : null })
106
108
  if (!current) report.outdated.add(entry.node)
107
109
  }
108
110
 
package/src/lib/git.mjs CHANGED
@@ -59,12 +59,16 @@ export async function gitMeta (cwd) {
59
59
  out('git', ['rev-parse', 'HEAD'], cwd)
60
60
  ])
61
61
  let pr = null
62
- const json = await out('gh', ['pr', 'view', '--json', 'number,url,state'], cwd)
62
+ const vars = process.env
63
+ // CI checks out a detached commit: the branch and pull request come from the environment
64
+ const ciBranch = vars.KALOKO_BRANCH || vars.GITHUB_HEAD_REF || vars.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || vars.CI_COMMIT_REF_NAME || null
65
+ if (vars.GITLAB_CI && vars.CI_MERGE_REQUEST_IID) pr = { number: Number(vars.CI_MERGE_REQUEST_IID), url: `${vars.CI_MERGE_REQUEST_PROJECT_URL || vars.CI_PROJECT_URL}/-/merge_requests/${vars.CI_MERGE_REQUEST_IID}`, state: 'open' }
66
+ const json = pr ? null : await out('gh', ['pr', 'view', ...(vars.KALOKO_PR ? [String(vars.KALOKO_PR)] : []), '--json', 'number,url,state'], cwd)
63
67
  if (json) {
64
68
  try {
65
69
  const p = JSON.parse(json)
66
70
  pr = { number: p.number, url: p.url, state: String(p.state).toLowerCase() }
67
71
  } catch {}
68
72
  }
69
- return { branch, commit, sha, message, author, pr }
73
+ return { branch: branch === 'HEAD' && ciBranch ? ciBranch : branch, commit, sha, message, author, pr }
70
74
  }