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,373 @@
1
+ // Recordings and traces of walked steps (batch D, #12). A web or Electron page is observed over one CDP session:
2
+ // Page.startScreencast frames (JPEG, at most the preset's frame rate, written to a temporary folder), pointer and
3
+ // input events from a page hook, and Network/console events for the trace. The walk calls four hooks per step:
4
+ // beforeStep(node) → beforeCapture(node) → afterStep(node, { records, error }) … finish()
5
+ // A step's clip runs from the start of its script to the moment it is captured (the interaction from the previous
6
+ // state to this one); a flow recording runs over the whole walk of a locale with a chapter per step.
7
+ // Opt-in: `walk --record [step|flow|failed]`, config/scenario `capture.record`, per step `record: true`.
8
+ import { mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises'
9
+ import { tmpdir } from 'node:os'
10
+ import { join, relative, dirname } from 'node:path'
11
+ import { writeJson, localized } from './util.mjs'
12
+ import { addCapture, runPaths } from './run.mjs'
13
+ import { encodeClip, preset, MAX_SECONDS } from './video.mjs'
14
+ import { PAGE_HOOK, networkEntries, actionEntries, summarizeTrace, cleanText, TRACE_LIMITS } from './trace.mjs'
15
+
16
+ export const RECORD_MODES = ['step', 'flow', 'failed']
17
+ const QUALITIES = ['low', 'medium', 'high']
18
+
19
+ /** `record` as written anywhere (true, 'flow', { mode, quality, … }) → { mode, quality, max_seconds, codec, mp4 } or null. */
20
+ export function parseRecord (v) {
21
+ if (v == null || v === false || v === 'off' || v === 'false' || v === 'none') return v == null ? undefined : null
22
+ if (v === true || v === 'true' || v === '') return { mode: 'step' }
23
+ if (typeof v === 'string') return RECORD_MODES.includes(v) ? { mode: v } : undefined
24
+ if (typeof v === 'object') {
25
+ const o = {}
26
+ if (v.mode === false || v.mode === 'off') return null
27
+ o.mode = RECORD_MODES.includes(v.mode) ? v.mode : 'step'
28
+ if (QUALITIES.includes(v.quality)) o.quality = v.quality
29
+ if (Number(v.max_seconds) > 0) o.max_seconds = Math.min(3600, Number(v.max_seconds))
30
+ if (['auto', 'vp9', 'av1', 'h264'].includes(v.codec)) o.codec = v.codec
31
+ if (v.mp4 != null) o.mp4 = v.mp4 === true || v.mp4 === 'true' ? true : v.mp4 === false || v.mp4 === 'false' ? false : 'auto'
32
+ if (v.cursor === false) o.cursor = false
33
+ return o
34
+ }
35
+ return undefined
36
+ }
37
+
38
+ /**
39
+ * Recording and trace settings of a walk: CLI over scenario over config. { record: {…} | null, trace: boolean }.
40
+ * --record [step|flow|failed|off], --record-quality, --no-trace; capture.record / capture.trace in the config and the
41
+ * scenario.
42
+ */
43
+ export function recordSettings ({ args = {}, config = {}, scenario = {} } = {}) {
44
+ const layers = [config.capture?.record, scenario.capture?.record, args.record === undefined ? undefined : args.record]
45
+ let record = null
46
+ for (const l of layers) {
47
+ const p = parseRecord(l)
48
+ if (p === undefined) continue
49
+ record = p === null ? null : { ...(record || {}), ...p }
50
+ }
51
+ if (record && QUALITIES.includes(args['record-quality'])) record.quality = args['record-quality']
52
+ if (record) { record.quality ||= 'medium'; record.max_seconds ||= MAX_SECONDS[record.mode === 'flow' ? 'flow' : 'step'] }
53
+ const trace = !args['no-trace'] && args.trace !== false && config.capture?.trace !== false && scenario.capture?.trace !== false
54
+ return { record, trace }
55
+ }
56
+
57
+ /** Whether one step is recorded: per step `record: true|false` (or capture.record) wins over the walk's mode. */
58
+ export function stepRecorded (settings, node) {
59
+ const own = parseRecord(node?.record ?? node?.capture?.record)
60
+ if (own === null) return null
61
+ if (own) return { quality: 'medium', max_seconds: MAX_SECONDS.step, ...(settings.record || {}), ...own, mode: settings.record?.mode === 'flow' ? 'flow' : own.mode || 'step' }
62
+ return settings.record
63
+ }
64
+
65
+ /** Width and height of a JPEG from its SOF marker. */
66
+ export function jpegSize (buf) {
67
+ let p = 2
68
+ while (p + 9 < buf.length) {
69
+ if (buf[p] !== 0xff) { p++; continue }
70
+ const m = buf[p + 1]; const len = buf.readUInt16BE(p + 2)
71
+ if (m >= 0xc0 && m <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(m)) return [buf.readUInt16BE(p + 7), buf.readUInt16BE(p + 5)]
72
+ p += 2 + len
73
+ }
74
+ return null
75
+ }
76
+
77
+ /**
78
+ * Observes a Chromium page over CDP. screencast: frames for recordings; trace: network, console, actions.
79
+ * Returns null where CDP is not available (Firefox, WebKit): such walks are neither recorded nor traced.
80
+ */
81
+ export async function observePage (page, { screencast = false, trace = true, quality = 'medium' } = {}) {
82
+ let cdp
83
+ try { cdp = await page.context().newCDPSession(page) } catch { return null }
84
+ const P = preset(quality)
85
+ const dir = screencast ? await mkdtemp(join(tmpdir(), 'kaloko-frames-')) : null
86
+ const o = { frames: [], events: [], requests: new Map(), console: [], navigations: [], dir, cdp, closed: false }
87
+ let seq = 0; let lastKept = 0; let pending = null; let timer = null; let writing = Promise.resolve()
88
+ const minGap = 1000 / P.fps
89
+ const keep = (f) => {
90
+ const buf = Buffer.from(f.data, 'base64')
91
+ const size = jpegSize(buf) || [f.metadata.deviceWidth, f.metadata.deviceHeight]
92
+ const file = join(dir, `${String(seq++).padStart(6, '0')}.jpg`)
93
+ const t = f.metadata.timestamp ? Math.round(f.metadata.timestamp * 1000) : Date.now()
94
+ o.frames.push({ t, file, w: size[0], h: size[1], dw: f.metadata.deviceWidth || size[0], ot: f.metadata.offsetTop || 0 })
95
+ lastKept = t
96
+ writing = writing.then(() => writeFile(file, buf)).catch(() => {})
97
+ }
98
+ const on = (event, fn) => cdp.on(event, (e) => { try { fn(e) } catch {} })
99
+ on('Runtime.bindingCalled', (e) => { if (e.name !== '__kalokoRec') return; const ev = JSON.parse(e.payload); if (o.events.length < 20000) o.events.push(ev) })
100
+ if (screencast) {
101
+ on('Page.screencastFrame', (f) => {
102
+ cdp.send('Page.screencastFrameAck', { sessionId: f.sessionId }).catch(() => {})
103
+ const t = f.metadata.timestamp ? f.metadata.timestamp * 1000 : Date.now()
104
+ // at most the preset's frame rate: a frame that comes too soon waits, and is replaced by a newer one
105
+ if (t - lastKept >= minGap) { pending = null; keep(f); return }
106
+ pending = f
107
+ if (!timer) timer = setTimeout(() => { timer = null; if (pending) { const p = pending; pending = null; keep(p) } }, minGap)
108
+ })
109
+ }
110
+ if (trace) {
111
+ let base = null // wall clock minus the monotonic clock of network timestamps
112
+ const wall = (ts) => base == null ? Date.now() : Math.round((ts + base) * 1000)
113
+ on('Network.requestWillBeSent', (e) => {
114
+ if (e.wallTime) base = e.wallTime - e.timestamp
115
+ if (o.requests.size >= TRACE_LIMITS.network * 4) return
116
+ const prev = o.requests.get(e.requestId)
117
+ if (prev && e.redirectResponse) { prev.status = e.redirectResponse.status; prev.end = wall(e.timestamp); o.requests.set(`${e.requestId}:${prev.start}`, prev) }
118
+ o.requests.set(e.requestId, { method: e.request.method, url: e.request.url, type: e.type, start: wall(e.timestamp) })
119
+ })
120
+ on('Network.responseReceived', (e) => { const r = o.requests.get(e.requestId); if (r) { r.status = e.response.status; r.mime = e.response.mimeType; r.fromCache = !!(e.response.fromDiskCache || e.response.fromServiceWorker) } })
121
+ on('Network.loadingFinished', (e) => { const r = o.requests.get(e.requestId); if (r) { r.bytes = Math.round(e.encodedDataLength); r.end = wall(e.timestamp) } })
122
+ on('Network.loadingFailed', (e) => { const r = o.requests.get(e.requestId); if (r) { r.failed = e.blockedReason ? `blocked: ${e.blockedReason}` : e.canceled ? 'canceled' : e.errorText; r.end = wall(e.timestamp) } })
123
+ on('Runtime.consoleAPICalled', (e) => { if (String(e.args?.[0]?.value || '').startsWith('--playwright')) return; if (o.console.length < TRACE_LIMITS.console * 4) o.console.push({ t: Math.round(e.timestamp), level: e.type === 'warning' ? 'warn' : ['error', 'assert'].includes(e.type) ? 'error' : e.type, text: cleanText((e.args || []).map((a) => a.value ?? a.description ?? a.type).join(' ')) }) })
124
+ on('Log.entryAdded', (e) => { if (o.console.length < TRACE_LIMITS.console * 4) o.console.push({ t: Math.round(e.entry.timestamp), level: e.entry.level === 'warning' ? 'warn' : e.entry.level, text: cleanText(e.entry.text), source: e.entry.source }) })
125
+ on('Page.frameNavigated', (e) => { if (!e.frame.parentId) o.navigations.push({ t: Date.now(), url: e.frame.url }) })
126
+ }
127
+ try {
128
+ await cdp.send('Runtime.enable')
129
+ await cdp.send('Page.enable')
130
+ await cdp.send('Runtime.addBinding', { name: '__kalokoRec' })
131
+ const { identifier } = await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: PAGE_HOOK })
132
+ o.scriptId = identifier
133
+ await cdp.send('Runtime.evaluate', { expression: PAGE_HOOK }).catch(() => {})
134
+ if (trace) { await cdp.send('Network.enable', { maxTotalBufferSize: 1_000_000, maxResourceBufferSize: 0 }); await cdp.send('Log.enable').catch(() => {}) }
135
+ if (screencast) await cdp.send('Page.startScreencast', { format: 'jpeg', quality: P.jpeg, maxWidth: P.width, maxHeight: Math.round(P.width * 2.2), everyNthFrame: 1 })
136
+ } catch {
137
+ await cdp.detach().catch(() => {})
138
+ if (dir) await rm(dir, { recursive: true, force: true })
139
+ return null
140
+ }
141
+ o.flush = async () => { if (pending) { const p = pending; pending = null; keep(p) } await writing }
142
+ /** Frames, pointer events (in frame pixels) and labels between two moments; the frame on screen at `from` too. */
143
+ o.clip = (from, to) => {
144
+ let first = 0
145
+ while (first + 1 < o.frames.length && o.frames[first + 1].t <= from) first++
146
+ const frames = o.frames.slice(first).filter((f, i) => i === 0 || f.t <= to)
147
+ const ref = frames[0]
148
+ const k = ref ? ref.w / ref.dw : 1
149
+ const events = o.events.filter((e) => e.t >= from - 4000 && e.t <= to && ['move', 'down', 'up', 'click'].includes(e.k) && e.x != null).map((e) => ({ t: e.t, type: e.k === 'click' ? 'move' : e.k, x: e.x * k, y: (e.y + (ref?.ot || 0)) * k }))
150
+ return { frames, events }
151
+ }
152
+ /** Trace between two moments (ms epoch). */
153
+ o.trace = (from, to) => ({
154
+ actions: actionEntries(o.events, o.navigations, from, to),
155
+ network: networkEntries(o.requests, from, to),
156
+ console: o.console.filter((c) => c.t >= from - 50 && c.t <= to).slice(0, TRACE_LIMITS.console).map((c) => ({ ...c, t: Math.max(0, c.t - from) }))
157
+ })
158
+ /** Forgets what happened before `t` (frames on disk too, except the one on screen at `t`). */
159
+ o.prune = async (t) => {
160
+ await writing
161
+ let keepFrom = 0
162
+ while (keepFrom + 1 < o.frames.length && o.frames[keepFrom + 1].t <= t) keepFrom++
163
+ const gone = o.frames.splice(0, keepFrom)
164
+ for (const f of gone) await rm(f.file, { force: true })
165
+ o.events = o.events.filter((e) => e.t >= t - 4000)
166
+ for (const [id, r] of o.requests) if ((r.end ?? r.start) < t) o.requests.delete(id)
167
+ o.console = o.console.filter((c) => c.t >= t)
168
+ o.navigations = o.navigations.filter((n) => n.t >= t)
169
+ }
170
+ o.stop = async () => {
171
+ if (o.closed) return
172
+ o.closed = true
173
+ if (timer) clearTimeout(timer)
174
+ await o.flush().catch(() => {})
175
+ if (screencast) await cdp.send('Page.stopScreencast').catch(() => {})
176
+ if (o.scriptId) await cdp.send('Page.removeScriptToEvaluateOnNewDocument', { identifier: o.scriptId }).catch(() => {})
177
+ await cdp.send('Runtime.removeBinding', { name: '__kalokoRec' }).catch(() => {})
178
+ await cdp.detach().catch(() => {})
179
+ }
180
+ o.cleanup = async () => { await o.stop(); if (dir) await rm(dir, { recursive: true, force: true }) }
181
+ return o
182
+ }
183
+
184
+ /** Paths of a step's recording files (without extension: .webm, .mp4, .webp/.gif, .poster.webp, .json). */
185
+ export const recBase = (runDir, nodeId, locale, viewport) => runPaths(runDir).step(nodeId, locale, viewport, 'rec')
186
+ export const flowBase = (runDir, locale) => join(runDir, 'attachments', 'flow', `${locale}.rec`)
187
+
188
+ /** Viewport name of a page size among the walk's viewports (first match by width), else the first captured one. */
189
+ function viewportOf (viewports, width, records) {
190
+ const hit = Object.entries(viewports || {}).find(([, [w]]) => w === width)
191
+ return hit?.[0] || records?.[0]?.viewport || Object.keys(viewports || {})[0] || 'desktop'
192
+ }
193
+
194
+ const failedRecord = (records) => (records || []).some((r) => Object.values(r.checks || {}).some((c) => c?.ok === false))
195
+
196
+ /**
197
+ * One locale's walk on one page: per step trace and recording, and the flow recording. `encoder` is an async
198
+ * function returning a page to encode in (a headless Chrome of its own, started on first use).
199
+ * settings: recordSettings(); viewports: the walk's scope viewports; say: progress lines.
200
+ */
201
+ export async function recordingSession ({ page, run, scenario, locale, settings, viewports, encoder, say = () => {}, label = 'walk' }) {
202
+ const anyRecord = !!settings.record || scenario.nodes.some((n) => parseRecord(n.record ?? n.capture?.record))
203
+ if (!anyRecord && !settings.trace) return null
204
+ const flow = settings.record?.mode === 'flow'
205
+ const quality = settings.record?.quality || 'medium'
206
+ const obs = await observePage(page, { screencast: anyRecord, trace: settings.trace, quality })
207
+ if (!obs) { if (anyRecord) say(`Recording skipped (${label}): it needs a Chromium-based browser (Chrome, Edge, Electron).`); return null }
208
+ const steps = new Map()
209
+ const chapters = []
210
+ let queue = Promise.resolve()
211
+ const notes = new Set()
212
+ const enqueue = (job) => { queue = queue.then(job).catch((e) => notes.add(`recording: ${String(e.message).split('\n')[0]}`)); return queue }
213
+
214
+ const writeTrace = async (node, vp, t0, t1, records) => {
215
+ const tr = obs.trace(t0, t1)
216
+ const file = runPaths(run.dir).step(node.id, locale, vp, 'trace.json')
217
+ const trace = { schema: 'qawalk.trace.v1', node: node.id, locale, viewport: vp, started_at: new Date(t0).toISOString(), duration_ms: Math.max(0, t1 - t0), ...tr }
218
+ trace.summary = summarizeTrace(trace)
219
+ await mkdir(dirname(file), { recursive: true })
220
+ await writeJson(file, trace)
221
+ await addCapture(run.dir, { type: 'trace', node: node.id, locale, viewport: vp, file: relative(run.dir, file), captured_at: records?.[0]?.captured_at || null, at: new Date().toISOString() })
222
+ }
223
+
224
+ return {
225
+ observer: obs,
226
+ beforeStep (node) { steps.set(node.id, { t0: Date.now(), t1: null }) },
227
+ beforeCapture (node) { const s = steps.get(node.id); if (s && !s.t1) s.t1 = Date.now() },
228
+ async afterStep (node, { records = null, error = null } = {}) {
229
+ const s = steps.get(node.id); if (!s) return
230
+ steps.delete(node.id)
231
+ const t2 = Date.now()
232
+ const t1 = s.t1 || t2
233
+ await obs.flush()
234
+ const failed = !!error || failedRecord(records)
235
+ const frameW = obs.frames.at(-1)?.dw || page.viewportSize?.()?.width
236
+ const vp = records?.length ? (records.find((r) => viewports?.[r.viewport]?.[0] === frameW) || records[0]).viewport : viewportOf(viewports, frameW, records)
237
+ if (settings.trace) await writeTrace(node, vp, s.t0, error ? t2 : t1, records).catch((e) => notes.add(`trace: ${e.message}`))
238
+ const want = stepRecorded(settings, node)
239
+ if (flow) { chapters.push({ node: node.id, title: localized(node.title, locale) || node.id, start: s.t0, capture: t1, end: t2, status: failed ? 'fail' : 'ok' }); return }
240
+ const keepClip = want && (want.mode !== 'failed' || failed)
241
+ if (!keepClip) { await obs.prune(t2); return }
242
+ const clip = obs.clip(s.t0, error ? t2 : t1)
243
+ // the frames stay on disk until the encoder has them; pruning waits in the same queue
244
+ enqueue(async () => {
245
+ const base = recBase(run.dir, node.id, locale, vp)
246
+ await mkdir(dirname(base), { recursive: true })
247
+ const r = await encodeClip({ page: await encoder(), frames: clip.frames, events: want.cursor === false ? [] : clip.events, start: s.t0, end: error ? t2 : t1, out: base, quality: want.quality, maxSeconds: want.max_seconds, codec: want.codec || 'auto', mp4: want.mp4 ?? 'auto' })
248
+ // offset_ms: where the clip starts after the step's start (a trimmed long step keeps its end); the trace aligns by it
249
+ if (r) await saveRecording({ run, node, locale, viewport: vp, result: { ...r, offset_ms: Math.max(0, Math.round(r.start - s.t0)) }, mode: want.mode, failed, error, captured_at: records?.[0]?.captured_at || null, source: 'screencast' })
250
+ for (const n of r?.notes || []) notes.add(n)
251
+ await obs.prune(t2)
252
+ })
253
+ },
254
+ async finish () {
255
+ await queue
256
+ if (flow && chapters.length) {
257
+ await obs.flush()
258
+ const t0 = chapters[0].start; const t1 = chapters.at(-1).end
259
+ const clip = obs.clip(t0, t1)
260
+ const labels = chapters.map((c, i) => ({ t: c.start, text: `${i + 1} · ${c.title}` }))
261
+ const base = flowBase(run.dir, locale)
262
+ await mkdir(dirname(base), { recursive: true })
263
+ try {
264
+ const r = await encodeClip({ page: await encoder(), frames: clip.frames, events: settings.record.cursor === false ? [] : clip.events, labels, start: t0, end: t1, out: base, quality, maxSeconds: settings.record.max_seconds, codec: settings.record.codec || 'auto', mp4: settings.record.mp4 ?? 'auto' })
265
+ if (r) {
266
+ const off = r.start
267
+ const info = await saveRecording({ run, node: null, locale, viewport: viewportOf(viewports, clip.frames[0]?.dw, null), result: r, mode: 'flow', chapters: chapters.map((c) => ({ node: c.node, title: c.title, start: Math.max(0, c.start - off), capture: Math.max(0, c.capture - off), end: Math.max(0, c.end - off), status: c.status })), source: 'screencast' })
268
+ say(`Flow recording ${locale}: ${Math.round(r.duration_ms / 1000)} s, ${chapters.length} chapters → ${relative(run.dir, info.file)}`)
269
+ }
270
+ for (const n of r?.notes || []) notes.add(n)
271
+ } catch (e) { notes.add(`flow recording: ${String(e.message).split('\n')[0]}`) }
272
+ }
273
+ await obs.cleanup()
274
+ return [...notes]
275
+ }
276
+ }
277
+ }
278
+
279
+ /** Writes the recording's sidecar (qawalk.recording.v1) and its captures.jsonl entry; returns { file, info }. */
280
+ export async function saveRecording ({ run, node, locale, viewport, result, mode, failed = false, error = null, captured_at = null, chapters = null, taps = null, source }) {
281
+ const rel = (f) => (f ? relative(run.dir, f) : null)
282
+ const files = Object.fromEntries(Object.entries(result.files || {}).map(([k, f]) => [k, rel(f)]))
283
+ const base = node ? recBase(run.dir, node.id, locale, viewport) : flowBase(run.dir, locale)
284
+ const info = {
285
+ schema: 'qawalk.recording.v1',
286
+ ...(node ? { node: node.id } : { scope: 'flow' }),
287
+ locale,
288
+ viewport,
289
+ mode,
290
+ source,
291
+ ...(failed ? { failed: true } : {}),
292
+ ...(error ? { error: String(error.message || error).split('\n')[0].slice(0, 300) } : {}),
293
+ captured_at,
294
+ recorded_at: new Date().toISOString(),
295
+ duration_ms: result.duration_ms,
296
+ width: result.width,
297
+ height: result.height,
298
+ fps: result.fps || null,
299
+ codec: result.codec,
300
+ ...(result.trimmed ? { trimmed: true } : {}),
301
+ ...(result.offset_ms ? { offset_ms: result.offset_ms } : {}),
302
+ files,
303
+ bytes: result.bytes || {},
304
+ ...(chapters ? { chapters } : {}),
305
+ ...(taps?.length ? { taps } : {})
306
+ }
307
+ const file = `${base}.json`
308
+ await mkdir(dirname(file), { recursive: true })
309
+ await writeJson(file, info)
310
+ await addCapture(run.dir, { type: 'recording', ...(node ? { node: node.id } : { scope: 'flow' }), locale, viewport, file: relative(run.dir, file), captured_at, ...(failed ? { failed: true } : {}), at: info.recorded_at })
311
+ return { file, info }
312
+ }
313
+
314
+ /** Recording files a manifest names (step captures, failed attempts, flow recordings, the walkthrough). */
315
+ export function recordingFiles (manifest) {
316
+ const out = new Set()
317
+ const add = (r) => { for (const k of ['webm', 'mp4', 'preview', 'poster']) if (r?.[k]) out.add(r[k]) }
318
+ for (const n of manifest.nodes || []) {
319
+ for (const vps of Object.values(n.captures || {})) for (const c of Object.values(vps || {})) add(c?.rec)
320
+ for (const r of n.recordings || []) add(r)
321
+ }
322
+ for (const r of Object.values(manifest.recordings?.flow || {})) add(r)
323
+ if (manifest.walkthrough?.file) out.add(manifest.walkthrough.file)
324
+ return [...out]
325
+ }
326
+
327
+ /** Trace files a manifest names (shared on every plan: they are small JSON). */
328
+ export function traceFiles (manifest) {
329
+ const out = new Set()
330
+ for (const n of manifest.nodes || []) for (const vps of Object.values(n.captures || {})) for (const c of Object.values(vps || {})) if (c?.trace) out.add(c.trace)
331
+ return [...out]
332
+ }
333
+
334
+ /** The manifest without recordings (plans without the recordings feature share screenshots and traces only). */
335
+ export function stripRecordings (manifest) {
336
+ const nodes = (manifest.nodes || []).map((n) => {
337
+ const { recordings, ...rest } = n
338
+ if (!rest.captures) return rest
339
+ const captures = Object.fromEntries(Object.entries(rest.captures).map(([l, vps]) => [l, Object.fromEntries(Object.entries(vps || {}).map(([vp, c]) => {
340
+ if (!c?.rec) return [vp, c]
341
+ const { rec, ...keep } = c
342
+ return [vp, keep]
343
+ }))]))
344
+ return { ...rest, captures }
345
+ })
346
+ const m = { ...manifest, nodes }
347
+ delete m.recordings
348
+ delete m.walkthrough
349
+ return m
350
+ }
351
+
352
+ /** The manifest without the recordings that use any of `paths` (each such recording goes as a whole). */
353
+ export function withoutRecordings (manifest, paths) {
354
+ const uses = (r) => r && ['webm', 'mp4', 'preview', 'poster'].some((k) => r[k] && paths.has(r[k]))
355
+ const nodes = (manifest.nodes || []).map((n) => {
356
+ const captures = Object.fromEntries(Object.entries(n.captures || {}).map(([l, vps]) => [l, Object.fromEntries(Object.entries(vps || {}).map(([vp, c]) => {
357
+ if (!uses(c?.rec)) return [vp, c]
358
+ const { rec, ...keep } = c
359
+ return [vp, keep]
360
+ }))]))
361
+ const recs = (n.recordings || []).filter((r) => !uses(r))
362
+ const out = { ...n, captures }
363
+ if (n.recordings) { if (recs.length) out.recordings = recs; else delete out.recordings }
364
+ return out
365
+ })
366
+ const m = { ...manifest, nodes }
367
+ if (manifest.recordings?.flow) {
368
+ const flow = Object.fromEntries(Object.entries(manifest.recordings.flow).filter(([, r]) => !uses(r)))
369
+ if (Object.keys(flow).length) m.recordings = { ...manifest.recordings, flow }; else delete m.recordings
370
+ }
371
+ if (manifest.walkthrough?.file && paths.has(manifest.walkthrough.file)) delete m.walkthrough
372
+ return m
373
+ }
@@ -31,7 +31,8 @@ export function validateScenario (data) {
31
31
  if (node.kind === 'stack' && node.items && ['urls', 'file', 'sitemap', 'crawl', 'script'].filter((k) => node.items[k]).length !== 1) problems.push(`node "${node.id}": items needs exactly one source of urls, file, sitemap, crawl or script`)
32
32
  if (node.kind !== 'stack' && (node.items || node.fields)) problems.push(`node "${node.id}": items and fields belong to stack steps`)
33
33
  // app screens are reached by a script or a person as often as by a deep link
34
- if (node.kind === 'screen' && !node.path && (!data.platform || data.platform === 'web')) problems.push(`node "${node.id}": screen steps need a path`)
34
+ // a manual screen may have no path: a person (or an imported walkthrough) shows how to get there
35
+ if (node.kind === 'screen' && !node.path && (!data.platform || data.platform === 'web') && node.execution?.strategy !== 'manual') problems.push(`node "${node.id}": screen steps need a path`)
35
36
  if (node.kind === 'stack' && data.platform && data.platform !== 'web') problems.push(`node "${node.id}": stack steps list web pages; they are not available on platform ${data.platform}`)
36
37
  if (node.kind === 'email' && !node.mail) problems.push(`node "${node.id}": email steps need mail.subject`)
37
38
  }
@@ -43,7 +43,13 @@ export function service (config) {
43
43
  },
44
44
  async upload (path, data, name, extra = {}) {
45
45
  for (let attempt = 0; ; attempt++) {
46
- const res = await limited(() => fetch(base + path, { method: 'PUT', headers: { ...headers, ...extra }, body: data }))
46
+ // a dropped connection (flaky Wi-Fi, CI runner hiccup) is tried again like a 5xx; the next share resumes otherwise
47
+ let res
48
+ try { res = await limited(() => fetch(base + path, { method: 'PUT', headers: { ...headers, ...extra }, body: data })) } catch (e) {
49
+ if (attempt >= 3) throw new UserError(`upload ${name}: ${e.cause?.code || e.message}. Run kaloko share again: files already stored are not sent twice.`)
50
+ await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
51
+ continue
52
+ }
47
53
  if (res.ok || attempt >= 2 || res.status < 500) return check(res, `upload ${name}`)
48
54
  await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
49
55
  }
@@ -0,0 +1,109 @@
1
+ // Trace per step (#12): a light, Playwright-trace-like record of what happened between the previous capture and this
2
+ // one: actions (clicks, typing into fields, keys, navigations), network requests with status, size and timing, and
3
+ // console messages. Collected from CDP events while the step runs (recording.mjs) and stored as
4
+ // steps/<node>/<locale>/<viewport>.trace.json (qawalk.trace.v1). Field values and typed characters are never kept.
5
+ import { redact } from './secrets.mjs'
6
+
7
+ export const TRACE_LIMITS = { network: 600, console: 200, actions: 300, url: 400, text: 500 }
8
+
9
+ /** A URL as stored: secrets the CLI knows removed, values of credential-like query parameters masked, length capped. */
10
+ export function cleanUrl (u) {
11
+ let s = redact(String(u || ''))
12
+ try {
13
+ const url = new URL(s)
14
+ for (const k of [...url.searchParams.keys()]) if (/token|secret|password|passwd|auth|key|session|code|sig/i.test(k)) url.searchParams.set(k, '***')
15
+ s = url.href
16
+ } catch {}
17
+ return s.length > TRACE_LIMITS.url ? s.slice(0, TRACE_LIMITS.url) + '…' : s
18
+ }
19
+
20
+ /** Console text as stored: known secrets removed, credential-like query values in URLs masked. */
21
+ export const cleanText = (t) => redact(String(t ?? '')).replace(/([?&;](?:[\w-]*(?:token|secret|password|passwd|auth|key|session|code|sig)[\w-]*)=)[^&\s'"#]+/gi, '$1***').slice(0, TRACE_LIMITS.text)
22
+
23
+ /** Network entries from CDP Network.* events (already joined per request id), relative to `t0` (ms epoch). */
24
+ export function networkEntries (requests, t0, t1 = Infinity) {
25
+ return [...requests.values()]
26
+ .filter((r) => r.start >= t0 - 50 && r.start <= t1)
27
+ .sort((a, b) => a.start - b.start)
28
+ .slice(0, TRACE_LIMITS.network)
29
+ .map((r) => ({
30
+ t: Math.max(0, Math.round(r.start - t0)),
31
+ method: r.method,
32
+ url: cleanUrl(r.url),
33
+ type: r.type || null,
34
+ status: r.status ?? null,
35
+ bytes: r.bytes ?? null,
36
+ ms: r.end != null ? Math.max(0, Math.round(r.end - r.start)) : null,
37
+ ...(r.failed ? { failed: String(r.failed).slice(0, 120) } : {}),
38
+ ...(r.mime ? { mime: r.mime } : {}),
39
+ ...(r.fromCache ? { cache: true } : {})
40
+ }))
41
+ }
42
+
43
+ /** Summary for the manifest and MCP: counts, failed requests, errors, bytes, the slowest request. */
44
+ export function summarizeTrace (trace) {
45
+ const net = trace?.network || []
46
+ // a request the page cancelled itself (a favicon, a navigation away) is not a failure
47
+ const failed = net.filter((r) => (r.failed && r.failed !== 'canceled') || (r.status != null && r.status >= 400))
48
+ const slowest = net.filter((r) => r.ms != null).sort((a, b) => b.ms - a.ms)[0] || null
49
+ return {
50
+ duration_ms: trace?.duration_ms ?? null,
51
+ actions: (trace?.actions || []).length,
52
+ requests: net.length,
53
+ failed_requests: failed.length,
54
+ bytes: net.reduce((n, r) => n + (r.bytes || 0), 0),
55
+ console_errors: (trace?.console || []).filter((c) => c.level === 'error').length,
56
+ console: (trace?.console || []).length,
57
+ ...(slowest ? { slowest: { url: slowest.url, ms: slowest.ms, status: slowest.status } } : {}),
58
+ ...(failed.length ? { failed: failed.slice(0, 5).map((r) => ({ method: r.method, url: r.url, status: r.status, ...(r.failed ? { error: r.failed } : {}) })) } : {})
59
+ }
60
+ }
61
+
62
+ /** One line per entry for the terminal (`kaloko video trace`), in time order. */
63
+ export function formatTrace (trace, { max = 60 } = {}) {
64
+ const rows = [
65
+ ...(trace.actions || []).map((a) => ({ t: a.t, s: `action ${a.type}${a.target ? ` ${a.target}` : ''}${a.url ? ` ${a.url}` : ''}` })),
66
+ ...(trace.network || []).map((r) => ({ t: r.t, s: `${r.failed || r.status >= 400 ? 'network ✕' : 'network '} ${r.method} ${r.status ?? '—'} ${r.url}${r.ms != null ? ` ${r.ms} ms` : ''}${r.bytes ? ` ${Math.round(r.bytes / 1024)} kB` : ''}${r.failed ? ` (${r.failed})` : ''}` })),
67
+ ...(trace.console || []).map((c) => ({ t: c.t, s: `console ${c.level}: ${c.text}` }))
68
+ ].sort((a, b) => a.t - b.t)
69
+ const lines = rows.slice(0, max).map((r) => `${String(r.t).padStart(6)} ms ${r.s}`)
70
+ if (rows.length > max) lines.push(`… ${rows.length - max} more`)
71
+ return lines.join('\n')
72
+ }
73
+
74
+ /**
75
+ * Script injected into every document of a recorded or traced page: reports pointer positions, clicks, field edits,
76
+ * named keys, submits and scrolls to the binding `__kalokoRec` (no field values, no typed characters).
77
+ */
78
+ export const PAGE_HOOK = `(() => {
79
+ if (window.__kalokoHooked || typeof window.__kalokoRec !== 'function') return; window.__kalokoHooked = true
80
+ const send = (e) => { try { window.__kalokoRec(JSON.stringify({ ...e, t: Date.now() })) } catch {} }
81
+ const name = (el) => {
82
+ if (!el || el.nodeType !== 1) return ''
83
+ const tag = el.tagName.toLowerCase(); const role = el.getAttribute('role')
84
+ const label = el.getAttribute('aria-label') || (el.labels && el.labels[0] && el.labels[0].innerText) || el.getAttribute('name') || el.getAttribute('placeholder') || el.getAttribute('title') || (/^(input|textarea|select)$/.test(tag) ? '' : (el.innerText || el.value || ''))
85
+ return ((role || tag) + (el.id && !/\\d{4,}/.test(el.id) ? '#' + el.id : '') + (label ? ' "' + String(label).trim().replace(/\\s+/g, ' ').slice(0, 60) + '"' : '')).slice(0, 120)
86
+ }
87
+ const target = (el) => el && el.closest ? (el.closest('a,button,input,select,textarea,label,summary,[role=button],[role=link],[role=tab],[role=menuitem],[role=option],[role=checkbox],[onclick]') || el) : el
88
+ let last = 0
89
+ addEventListener('pointermove', (e) => { const n = performance.now(); if (n - last < 40) return; last = n; send({ k: 'move', x: e.clientX, y: e.clientY }) }, { capture: true, passive: true })
90
+ addEventListener('pointerdown', (e) => send({ k: 'down', x: e.clientX, y: e.clientY }), { capture: true, passive: true })
91
+ addEventListener('pointerup', (e) => send({ k: 'up', x: e.clientX, y: e.clientY }), { capture: true, passive: true })
92
+ addEventListener('click', (e) => send({ k: 'click', x: e.clientX, y: e.clientY, d: name(target(e.target)) }), { capture: true, passive: true })
93
+ addEventListener('change', (e) => { const el = e.target; const type = (el.type || '').toLowerCase(); send({ k: type === 'checkbox' || type === 'radio' ? (el.checked ? 'check' : 'uncheck') : el.tagName === 'SELECT' ? 'select' : 'fill', d: name(el) }) }, { capture: true, passive: true })
94
+ addEventListener('keydown', (e) => { if (e.key && e.key.length > 1 && !/^(Shift|Control|Alt|Meta|CapsLock)$/.test(e.key)) send({ k: 'key', key: e.key, d: name(e.target) }) }, { capture: true, passive: true })
95
+ addEventListener('submit', (e) => send({ k: 'submit', d: name(e.target) }), { capture: true, passive: true })
96
+ let sc = 0
97
+ addEventListener('scroll', () => { const n = performance.now(); if (n - sc < 400) return; sc = n; send({ k: 'scroll', y: Math.round(scrollY) }) }, { capture: true, passive: true })
98
+ })()`
99
+
100
+ /** Actions of the trace from page hook events and navigations, relative to `t0`. Pointer moves are left out. */
101
+ export function actionEntries (events, navigations, t0, t1 = Infinity) {
102
+ const acts = []
103
+ for (const e of events) {
104
+ if (e.t < t0 - 50 || e.t > t1 || ['move', 'down', 'up'].includes(e.k)) continue
105
+ acts.push({ t: Math.max(0, Math.round(e.t - t0)), type: e.k === 'key' ? `key ${e.key}` : e.k, ...(e.d ? { target: e.d } : {}), ...(e.k === 'scroll' ? { y: e.y } : {}) })
106
+ }
107
+ for (const n of navigations) if (n.t >= t0 - 50 && n.t <= t1) acts.push({ t: Math.max(0, Math.round(n.t - t0)), type: 'navigate', url: cleanUrl(n.url) })
108
+ return acts.sort((a, b) => a.t - b.t).slice(0, TRACE_LIMITS.actions)
109
+ }
@@ -0,0 +1,31 @@
1
+ // Minimal ZIP reader for `kaloko import` (Playwright traces and the report embedded in an HTML report): stored and
2
+ // deflated entries from the central directory. No ZIP64; entries are read when asked for.
3
+ import { inflateRawSync } from 'node:zlib'
4
+
5
+ /** Entries of a zip archive: Map name → () => Buffer. */
6
+ export function readZip (buf) {
7
+ let eocd = -1
8
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break }
9
+ if (eocd < 0) throw new Error('not a zip archive (no end of central directory)')
10
+ const count = buf.readUInt16LE(eocd + 10)
11
+ let at = buf.readUInt32LE(eocd + 16)
12
+ const out = new Map()
13
+ for (let n = 0; n < count; n++) {
14
+ if (buf.readUInt32LE(at) !== 0x02014b50) throw new Error('broken zip central directory')
15
+ const method = buf.readUInt16LE(at + 10)
16
+ const size = buf.readUInt32LE(at + 20)
17
+ const nameLen = buf.readUInt16LE(at + 28); const extraLen = buf.readUInt16LE(at + 30); const commentLen = buf.readUInt16LE(at + 32)
18
+ const local = buf.readUInt32LE(at + 42)
19
+ const name = buf.subarray(at + 46, at + 46 + nameLen).toString('utf8')
20
+ at += 46 + nameLen + extraLen + commentLen
21
+ if (name.endsWith('/')) continue
22
+ out.set(name, () => {
23
+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
24
+ const data = buf.subarray(start, start + size)
25
+ if (method === 0) return Buffer.from(data)
26
+ if (method === 8) return inflateRawSync(data)
27
+ throw new Error(`${name}: unsupported compression method ${method}`)
28
+ })
29
+ }
30
+ return out
31
+ }