kaloko 0.4.10 → 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.
@@ -0,0 +1,125 @@
1
+ // Animated previews in pure Node: an animated WebP assembled from single-frame WebP images (the browser encodes each
2
+ // frame, this only writes the container), and a GIF encoder for browsers that cannot encode WebP. Both stay small:
3
+ // a preview is a few seconds of a step at thumbnail size.
4
+
5
+ const chunk = (fourcc, payload) => {
6
+ const pad = payload.length % 2
7
+ const head = Buffer.alloc(8); head.write(fourcc, 0, 'ascii'); head.writeUInt32LE(payload.length, 4)
8
+ return Buffer.concat([head, payload, pad ? Buffer.alloc(1) : Buffer.alloc(0)])
9
+ }
10
+ const u24 = (n) => { const b = Buffer.alloc(3); b.writeUIntLE(n, 0, 3); return b }
11
+
12
+ /** Chunks of a still WebP: { width, height, alpha, frame: Buffer of the ALPH + VP8/VP8L chunks }. */
13
+ export function readWebp (buf) {
14
+ if (buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') throw new Error('not a WebP image')
15
+ let p = 12; let width = 0; let height = 0; let alpha = false
16
+ const keep = []
17
+ while (p + 8 <= buf.length) {
18
+ const fourcc = buf.toString('ascii', p, p + 4); const size = buf.readUInt32LE(p + 4)
19
+ const body = buf.subarray(p + 8, p + 8 + size)
20
+ if (fourcc === 'VP8X') { width = body.readUIntLE(4, 3) + 1; height = body.readUIntLE(7, 3) + 1 } else if (fourcc === 'ALPH') { alpha = true; keep.push(chunk('ALPH', body)) } else if (fourcc === 'VP8 ') {
21
+ // key frame header: 3-byte tag, start code 9d 01 2a, then 14-bit width and height
22
+ if (!width) { width = body.readUInt16LE(6) & 0x3fff; height = body.readUInt16LE(8) & 0x3fff }
23
+ keep.push(chunk('VP8 ', body))
24
+ } else if (fourcc === 'VP8L') {
25
+ if (!width) { const bits = body.readUInt32LE(1); width = (bits & 0x3fff) + 1; height = ((bits >>> 14) & 0x3fff) + 1; alpha = !!((bits >>> 28) & 1) }
26
+ keep.push(chunk('VP8L', body))
27
+ }
28
+ p += 8 + size + (size % 2)
29
+ }
30
+ if (!keep.length) throw new Error('WebP without image data')
31
+ return { width, height, alpha, frame: Buffer.concat(keep) }
32
+ }
33
+
34
+ /** frames: [{ webp: Buffer, ms }] → animated WebP looping forever. */
35
+ export function animatedWebp (frames, { loop = 0, background = [255, 255, 255, 255] } = {}) {
36
+ if (!frames.length) throw new Error('no frames')
37
+ const parsed = frames.map((f) => ({ ...readWebp(f.webp), ms: Math.max(20, Math.round(f.ms)) }))
38
+ const W = Math.max(...parsed.map((f) => f.width)); const H = Math.max(...parsed.map((f) => f.height))
39
+ const alpha = parsed.some((f) => f.alpha)
40
+ const vp8x = Buffer.alloc(10); vp8x[0] = 0x02 | (alpha ? 0x10 : 0); vp8x.writeUIntLE(W - 1, 4, 3); vp8x.writeUIntLE(H - 1, 7, 3)
41
+ const anim = Buffer.alloc(6); anim[0] = background[2]; anim[1] = background[1]; anim[2] = background[0]; anim[3] = background[3]; anim.writeUInt16LE(loop, 4)
42
+ // each frame covers the canvas without blending (bit 1) and is kept until the next (no dispose)
43
+ const anmf = parsed.map((f) => chunk('ANMF', Buffer.concat([u24(0), u24(0), u24(f.width - 1), u24(f.height - 1), u24(Math.min(0xffffff, f.ms)), Buffer.from([0x02]), f.frame])))
44
+ const body = Buffer.concat([Buffer.from('WEBP'), chunk('VP8X', vp8x), chunk('ANIM', anim), ...anmf])
45
+ const head = Buffer.alloc(8); head.write('RIFF', 0, 'ascii'); head.writeUInt32LE(body.length, 4)
46
+ return Buffer.concat([head, body])
47
+ }
48
+
49
+ /** Frames and loop count of an animated WebP (tests). */
50
+ export function webpFrames (buf) {
51
+ let p = 12; const out = []; let loop = null
52
+ while (p + 8 <= buf.length) {
53
+ const fourcc = buf.toString('ascii', p, p + 4); const size = buf.readUInt32LE(p + 4)
54
+ if (fourcc === 'ANIM') loop = buf.readUInt16LE(p + 12)
55
+ if (fourcc === 'ANMF') out.push({ width: buf.readUIntLE(p + 14, 3) + 1, height: buf.readUIntLE(p + 17, 3) + 1, ms: buf.readUIntLE(p + 20, 3) })
56
+ p += 8 + size + (size % 2)
57
+ }
58
+ return { loop, frames: out }
59
+ }
60
+
61
+ // ---------- GIF ----------
62
+ // A fixed 6×7×6 colour cube (252 colours) plus four greys, with 4×4 ordered dithering: no per-frame palette search,
63
+ // stable colours from frame to frame, and small files for flat UI screens.
64
+ const LEVELS = [6, 7, 6]
65
+ const PALETTE = (() => {
66
+ const p = []
67
+ for (let r = 0; r < 6; r++) for (let g = 0; g < 7; g++) for (let b = 0; b < 6; b++) p.push([Math.round(r * 255 / 5), Math.round(g * 255 / 6), Math.round(b * 255 / 5)])
68
+ for (const v of [32, 96, 160, 224]) p.push([v, v, v])
69
+ return p
70
+ })()
71
+ const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16 - 0.5)
72
+
73
+ /** RGBA pixels → palette indices of the colour cube, dithered. */
74
+ export function quantize (rgba, width, height) {
75
+ const out = new Uint8Array(width * height)
76
+ for (let y = 0; y < height; y++) {
77
+ for (let x = 0; x < width; x++) {
78
+ const i = y * width + x; const d = BAYER[(y & 3) * 4 + (x & 3)]
79
+ const q = (v, n) => Math.max(0, Math.min(n - 1, Math.round(v / 255 * (n - 1) + d)))
80
+ out[i] = q(rgba[i * 4], LEVELS[0]) * 42 + q(rgba[i * 4 + 1], LEVELS[1]) * 6 + q(rgba[i * 4 + 2], LEVELS[2])
81
+ }
82
+ }
83
+ return out
84
+ }
85
+
86
+ /** GIF LZW with 8-bit codes, packed into 255-byte sub-blocks. */
87
+ export function lzw (indices, minCode = 8) {
88
+ const clear = 1 << minCode; const eoi = clear + 1
89
+ const bytes = []; let cur = 0; let nbits = 0
90
+ let size = minCode + 1; let next = eoi + 1
91
+ let dict = new Map()
92
+ const emit = (code) => { cur |= code << nbits; nbits += size; while (nbits >= 8) { bytes.push(cur & 0xff); cur >>>= 8; nbits -= 8 } }
93
+ emit(clear)
94
+ let prefix = indices[0]
95
+ for (let i = 1; i < indices.length; i++) {
96
+ const k = indices[i]; const key = prefix * 256 + k
97
+ const hit = dict.get(key)
98
+ if (hit !== undefined) { prefix = hit; continue }
99
+ emit(prefix)
100
+ if (next < 4096) { dict.set(key, next++); if (next > (1 << size) && size < 12) size++ } else { emit(clear); dict = new Map(); size = minCode + 1; next = eoi + 1 }
101
+ prefix = k
102
+ }
103
+ emit(prefix); emit(eoi)
104
+ if (nbits > 0) bytes.push(cur & 0xff)
105
+ const out = [minCode]
106
+ for (let i = 0; i < bytes.length; i += 255) { const n = Math.min(255, bytes.length - i); out.push(n, ...bytes.slice(i, i + n)) }
107
+ out.push(0)
108
+ return Buffer.from(out)
109
+ }
110
+
111
+ /** frames: [{ rgba, ms }] of one size → looping GIF. */
112
+ export function encodeGif (frames, { width, height, loop = 0 }) {
113
+ const parts = [Buffer.from('GIF89a')]
114
+ const lsd = Buffer.alloc(7); lsd.writeUInt16LE(width, 0); lsd.writeUInt16LE(height, 2); lsd[4] = 0xf7; parts.push(lsd) // global table of 256
115
+ const table = Buffer.alloc(256 * 3); PALETTE.forEach(([r, g, b], i) => { table[i * 3] = r; table[i * 3 + 1] = g; table[i * 3 + 2] = b }); parts.push(table)
116
+ parts.push(Buffer.from([0x21, 0xff, 0x0b, ...Buffer.from('NETSCAPE2.0'), 0x03, 0x01, loop & 0xff, loop >> 8, 0x00]))
117
+ for (const f of frames) {
118
+ const delay = Math.max(2, Math.round(f.ms / 10))
119
+ parts.push(Buffer.from([0x21, 0xf9, 0x04, 0x04, delay & 0xff, delay >> 8, 0x00, 0x00]))
120
+ const desc = Buffer.alloc(10); desc[0] = 0x2c; desc.writeUInt16LE(width, 5); desc.writeUInt16LE(height, 7); parts.push(desc)
121
+ parts.push(lzw(quantize(f.rgba, width, height)))
122
+ }
123
+ parts.push(Buffer.from([0x3b]))
124
+ return Buffer.concat(parts)
125
+ }
@@ -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)
@@ -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
 
@@ -363,7 +363,7 @@ export async function attachImported ({ run, node, locale, item }) {
363
363
  return entry
364
364
  }
365
365
 
366
- /** Recordings are kept in the run folder (imported/), not uploaded: the canvas does not play them yet. */
366
+ /** Imported recordings are kept in the run folder (imported/), not attached to a step and not uploaded. */
367
367
  export async function keepVideos (run, videos) {
368
368
  const kept = []
369
369
  for (const v of [...new Set(videos)]) {