kaloko 0.4.9 → 0.4.10

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,374 @@
1
+ // `kaloko import` (#11): screenshots, recordings and traces that agents and tools already produced, laid out as a run.
2
+ //
3
+ // Sources (detected, or --format):
4
+ // playwright-json the JSON reporter's file (npx playwright test --reporter=json): screenshots attached to each test
5
+ // playwright-html an HTML report folder (playwright-report/): the report embedded in index.html and its data/ files
6
+ // trace a trace.zip: one screenshot after each page action (goto, click, fill …) from the screencast
7
+ // folder any folder of images: with steps.json (titles, step ids, URLs, notes), or a Markdown walkthrough
8
+ // whose images appear in order under headings (Cursor, Antigravity, Claude Code), or just images
9
+ // in file name order. Sub-folders of a test-results folder are read the same way.
10
+ // Every source becomes items { title, image, ext, url?, notes?, step?, locale?, viewport?, group? }; items are mapped
11
+ // to scenario steps by step id, URL path or title, or become the steps of an ad-hoc flow.
12
+ import { readFile, readdir, stat, writeFile, mkdir, copyFile, rm } from 'node:fs/promises'
13
+ import { join, dirname, basename, extname, relative, resolve } from 'node:path'
14
+ import { readZip } from './unzip.mjs'
15
+ import { pathOf } from './affected-core.mjs'
16
+ import { runPaths, addCapture } from './run.mjs'
17
+ import { writeJson } from './util.mjs'
18
+ import { criteriaHash } from './evaluate.mjs'
19
+ import { encodeImage } from './capture.mjs'
20
+
21
+ const IMAGE = /\.(png|jpe?g|webp)$/i
22
+ const VIDEO = /\.(webm|mp4|mov)$/i
23
+ const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp' }
24
+ const extOf = (f) => { const e = extname(f).slice(1).toLowerCase(); return e === 'jpeg' ? 'jpg' : e }
25
+ const natural = (a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
26
+ export const slug = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50)
27
+ /** "03_checkout-page@2x.png" → "Checkout page" */
28
+ export const titleFromFile = (f) => { const t = basename(f, extname(f)).replace(/@\dx$/, '').replace(/^[\d\s._-]+(?=\D)/, '').replace(/[-_.]+/g, ' ').trim(); return t ? t[0].toUpperCase() + t.slice(1) : basename(f) }
29
+
30
+ /** Width and height of a PNG, JPEG or WebP image, or null. */
31
+ export function imageSize (b) {
32
+ if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return [b.readUInt32BE(16), b.readUInt32BE(20)]
33
+ if (b[0] === 0xff && b[1] === 0xd8) {
34
+ for (let i = 2; i < b.length - 9;) {
35
+ if (b[i] !== 0xff) { i++; continue }
36
+ const m = b[i + 1]; const len = b.readUInt16BE(i + 2)
37
+ if (m >= 0xc0 && m <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(m)) return [b.readUInt16BE(i + 7), b.readUInt16BE(i + 5)]
38
+ i += 2 + len
39
+ }
40
+ return null
41
+ }
42
+ if (b.length > 30 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') {
43
+ const kind = b.toString('ascii', 12, 16)
44
+ if (kind === 'VP8X') return [1 + b.readUIntLE(24, 3), 1 + b.readUIntLE(27, 3)]
45
+ if (kind === 'VP8 ') return [b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff]
46
+ if (kind === 'VP8L') { const bits = b.readUInt32LE(21); return [1 + (bits & 0x3fff), 1 + ((bits >> 14) & 0x3fff)] }
47
+ }
48
+ return null
49
+ }
50
+
51
+ // ---------- readers ----------
52
+
53
+ /** Markdown walkthrough: images in order, titled by the heading above them, with the text between as notes. */
54
+ export function parseMarkdown (text) {
55
+ const items = []; const videos = []
56
+ let heading = null; let notes = []
57
+ for (const line of text.split('\n')) {
58
+ const h = line.match(/^#{1,4}\s+(.+?)\s*#*\s*$/)
59
+ if (h) { heading = h[1].replace(/[*_`]/g, ''); notes = []; continue }
60
+ let found = false
61
+ for (const m of line.matchAll(/!\[([^\]]*)\]\(<?([^)>\s]+)>?(?:\s+"[^"]*")?\)|<img[^>]+src=["']([^"']+)["'][^>]*>/g)) {
62
+ const src = decodeURI(m[2] || m[3])
63
+ if (/^https?:/.test(src)) continue
64
+ if (VIDEO.test(src)) { videos.push(src); found = true; continue }
65
+ items.push({ title: heading || m[1] || titleFromFile(src), alt: m[1] || null, file: src, notes: notes.join(' ').trim() || null })
66
+ found = true
67
+ }
68
+ for (const m of line.matchAll(/\[[^\]]*\]\(<?([^)>\s]+\.(?:webm|mp4|mov))>?\)/gi)) { videos.push(decodeURI(m[1])); found = true }
69
+ if (!found && line.trim() && !/^(```|---|\|)/.test(line.trim())) notes.push(line.trim().replace(/^[-*>]\s+/, ''))
70
+ }
71
+ // a heading with several images: the extra ones keep the alt text as their title
72
+ const seen = new Map()
73
+ for (const it of items) { const n = (seen.get(it.title) || 0) + 1; seen.set(it.title, n); if (n > 1) it.title = it.alt ? `${it.title}: ${it.alt}` : `${it.title} (${n})` }
74
+ return { items, videos }
75
+ }
76
+
77
+ async function readFolder (dir, { depth = 0 } = {}) {
78
+ const names = (await readdir(dir, { withFileTypes: true })).sort((a, b) => natural(a.name, b.name))
79
+ const files = names.filter((e) => e.isFile()).map((e) => e.name)
80
+ const manifest = files.find((f) => /^(steps|walkthrough|kaloko-import|manifest)\.json$/i.test(f))
81
+ if (manifest) {
82
+ const m = JSON.parse(await readFile(join(dir, manifest), 'utf8'))
83
+ const steps = Array.isArray(m) ? m : m.steps || []
84
+ const items = []
85
+ for (const s of steps) {
86
+ const file = s.image || s.screenshot || s.file
87
+ if (!file) continue
88
+ items.push({ title: s.title || titleFromFile(file), image: await readFile(resolve(dir, file)), ext: extOf(file), file, url: s.url || null, notes: s.notes || s.description || null, step: s.step || null, locale: s.locale || null, viewport: s.viewport || null })
89
+ }
90
+ const videos = steps.map((s) => s.video).filter(Boolean).map((v) => resolve(dir, v))
91
+ return { tool: m.tool || 'walkthrough', title: m.title || null, url: m.url || null, items, videos }
92
+ }
93
+ const md = files.filter((f) => /\.md$/i.test(f)).sort((a, b) => (/^(walkthrough|steps|readme)/i.test(a) ? -1 : 1) - (/^(walkthrough|steps|readme)/i.test(b) ? -1 : 1))
94
+ for (const f of md) {
95
+ const text = await readFile(join(dir, f), 'utf8')
96
+ const parsed = parseMarkdown(text)
97
+ if (!parsed.items.length) continue
98
+ const items = []
99
+ for (const it of parsed.items) {
100
+ const abs = resolve(dir, it.file)
101
+ const image = await readFile(abs).catch(() => null)
102
+ if (image && IMAGE.test(abs)) items.push({ ...it, image, ext: extOf(abs) })
103
+ }
104
+ const title = (text.match(/^#\s+(.+)$/m) || [])[1] || null
105
+ return { tool: 'walkthrough', title, items, videos: parsed.videos.map((v) => resolve(dir, v)) }
106
+ }
107
+ const images = files.filter((f) => IMAGE.test(f))
108
+ const videos = files.filter((f) => VIDEO.test(f)).map((f) => join(dir, f))
109
+ if (images.length || depth > 0) {
110
+ const items = []
111
+ for (const f of images) items.push({ title: titleFromFile(f), image: await readFile(join(dir, f)), ext: extOf(f), file: f })
112
+ return { tool: 'folder', title: null, items, videos }
113
+ }
114
+ // a folder of folders (Playwright test-results, one folder per test): each folder's images, grouped by folder
115
+ const out = { tool: 'folder', title: null, items: [], videos }
116
+ for (const sub of names.filter((e) => e.isDirectory() && !e.name.startsWith('.'))) {
117
+ const subDir = join(dir, sub.name)
118
+ const traces = (await readdir(subDir).catch(() => [])).filter((f) => /\.zip$/i.test(f))
119
+ const r = await readFolder(subDir, { depth: depth + 1 })
120
+ if (!r.items.length && traces.length) { const t = readTrace(await readFile(join(subDir, traces[0]))); r.items = t.items }
121
+ const group = titleFromFile(sub.name)
122
+ for (const it of r.items) out.items.push({ ...it, group, title: r.items.length > 1 ? `${group}: ${it.title}` : group })
123
+ out.videos.push(...r.videos)
124
+ }
125
+ return out
126
+ }
127
+
128
+ const ACTIONS = new Set(['goto', 'click', 'dblclick', 'fill', 'press', 'type', 'check', 'uncheck', 'selectOption', 'setInputFiles', 'tap', 'hover', 'reload', 'goBack', 'goForward'])
129
+ const short = (v) => String(v || '').replace(/^https?:\/\/[^/]+/, '').slice(0, 60)
130
+
131
+ /** A Playwright trace: one screenshot after each page action, from the screencast frames. */
132
+ export function readTrace (zipBuf) {
133
+ const zip = readZip(zipBuf)
134
+ const actions = new Map(); const frames = []
135
+ for (const [name, get] of zip) {
136
+ if (!/(^|\/)[^/]*\.trace$/.test(name)) continue
137
+ for (const line of get().toString('utf8').split('\n')) {
138
+ if (!line.trim()) continue
139
+ let e; try { e = JSON.parse(line) } catch { continue }
140
+ if (e.type === 'before') actions.set(e.callId, { method: e.method || (e.apiName || '').split('.').pop(), title: e.title || e.apiName || null, params: e.params || {}, start: e.startTime, end: null, error: null })
141
+ else if (e.type === 'after' && actions.has(e.callId)) Object.assign(actions.get(e.callId), { end: e.endTime, error: e.error?.message || null })
142
+ else if (e.type === 'action' && e.metadata) actions.set(e.metadata.id || actions.size, { method: e.metadata.method || (e.metadata.apiName || '').split('.').pop(), title: e.metadata.apiName || null, params: e.metadata.params || {}, start: e.metadata.startTime, end: e.metadata.endTime, error: e.metadata.error?.error?.message || null })
143
+ else if (e.type === 'screencast-frame') frames.push({ sha1: e.sha1, ts: e.timestamp, w: e.width, h: e.height })
144
+ }
145
+ }
146
+ frames.sort((a, b) => a.ts - b.ts)
147
+ const list = [...actions.values()].filter((a) => ACTIONS.has(a.method) && a.end != null).sort((a, b) => a.start - b.start)
148
+ const items = []; let url = null; let lastSha = null
149
+ for (const [i, a] of list.entries()) {
150
+ if (a.method === 'goto' && a.params.url) url = a.params.url
151
+ const until = list[i + 1]?.start ?? Infinity
152
+ // the screen as the action left it: the first frame after it ended (before the next began), else the last one before
153
+ const frame = frames.find((f) => f.ts >= a.end && f.ts < until) || [...frames].reverse().find((f) => f.ts <= a.end)
154
+ if (!frame || frame.sha1 === lastSha) continue
155
+ const file = [...zip.keys()].find((k) => k.endsWith(`resources/${frame.sha1}`))
156
+ if (!file) continue
157
+ lastSha = frame.sha1
158
+ const target = a.params.url || a.params.selector || a.params.key || ''
159
+ items.push({ title: `${a.method} ${short(target)}`.trim(), image: zip.get(file)(), ext: frame.sha1.endsWith('.png') ? 'png' : 'jpg', url, notes: a.error ? `Failed: ${a.error.split('\n')[0]}` : null, file: `trace:${i}` })
160
+ }
161
+ return { tool: 'playwright-trace', title: null, items, videos: [] }
162
+ }
163
+
164
+ /** Tests of a Playwright JSON report (nested suites): [{ title, group, result }]. */
165
+ function testsOf (suite, path = []) {
166
+ const here = suite.title ? [...path, suite.title] : path
167
+ const out = []
168
+ for (const spec of suite.specs || []) for (const t of spec.tests || []) out.push({ title: spec.title, path: here, project: t.projectName || null, result: (t.results || []).at(-1) })
169
+ for (const s of suite.suites || []) out.push(...testsOf(s, here))
170
+ return out
171
+ }
172
+
173
+ /** Items of one test result: its image attachments, else the frames of its trace. */
174
+ async function resultItems (test, readAttachment) {
175
+ const items = []; const videos = []
176
+ for (const a of test.result?.attachments || []) {
177
+ if (/^image\/(png|jpe?g|webp)$/.test(a.contentType || '')) {
178
+ const image = await readAttachment(a)
179
+ if (!image) continue
180
+ const named = a.name && !/^screenshot(-\d+)?$/.test(a.name) ? `: ${a.name}` : ''
181
+ items.push({ title: `${test.title}${named}`, group: test.title, image, ext: a.contentType.includes('png') ? 'png' : a.contentType.includes('webp') ? 'webp' : 'jpg', file: a.path || a.name, notes: test.result.status && test.result.status !== 'passed' ? `Test ${test.result.status}${test.result.error?.message ? `: ${String(test.result.error.message).split('\n')[0].slice(0, 200)}` : ''}` : null })
182
+ } else if (/^video\//.test(a.contentType || '') && a.path) videos.push(a.path)
183
+ }
184
+ if (!items.length) {
185
+ const trace = (test.result?.attachments || []).find((a) => a.name === 'trace' || /\.zip$/.test(a.path || ''))
186
+ const buf = trace && await readAttachment(trace)
187
+ if (buf) for (const it of readTrace(buf).items) items.push({ ...it, group: test.title, title: `${test.title}: ${it.title}` })
188
+ }
189
+ return { items, videos }
190
+ }
191
+
192
+ async function readPlaywrightJson (file) {
193
+ const report = JSON.parse(await readFile(file, 'utf8'))
194
+ const base = dirname(file)
195
+ const read = async (a) => a.body ? Buffer.from(a.body, 'base64') : a.path ? readFile(resolve(base, a.path)).catch(() => null) : null
196
+ const out = { tool: 'playwright', title: null, items: [], videos: [] }
197
+ for (const s of report.suites || []) for (const t of testsOf(s)) { const r = await resultItems(t, read); out.items.push(...r.items); out.videos.push(...r.videos.map((v) => resolve(base, v))) }
198
+ return out
199
+ }
200
+
201
+ async function readPlaywrightHtml (dir) {
202
+ const html = await readFile(join(dir, 'index.html'), 'utf8')
203
+ const m = html.match(/playwrightReportBase64\s*=\s*["']data:application\/zip;base64,([A-Za-z0-9+/=]+)["']/)
204
+ if (!m) throw new Error(`${dir}/index.html has no embedded Playwright report`)
205
+ const zip = readZip(Buffer.from(m[1], 'base64'))
206
+ const out = { tool: 'playwright', title: null, items: [], videos: [] }
207
+ const read = async (a) => a.body ? Buffer.from(a.body, 'base64') : a.path ? readFile(resolve(dir, a.path)).catch(() => null) : null
208
+ const files = [...zip.keys()].filter((k) => k.endsWith('.json') && k !== 'report.json').sort(natural)
209
+ for (const k of files) {
210
+ const f = JSON.parse(zip.get(k)().toString('utf8'))
211
+ for (const t of f.tests || []) {
212
+ const r = await resultItems({ title: t.title, path: t.path || [], result: (t.results || []).at(-1) }, read)
213
+ out.items.push(...r.items); out.videos.push(...r.videos.map((v) => resolve(dir, v)))
214
+ }
215
+ }
216
+ return out
217
+ }
218
+
219
+ /** Reads any supported source; { format, tool, title, items, videos }. */
220
+ export async function readSource (path, format = 'auto') {
221
+ const st = await stat(path).catch(() => null)
222
+ if (!st) throw new Error(`${path} does not exist`)
223
+ if (format === 'auto') {
224
+ if (st.isDirectory()) format = (await stat(join(path, 'index.html')).catch(() => null)) && /playwrightReportBase64/.test(await readFile(join(path, 'index.html'), 'utf8')) ? 'playwright-html' : 'folder'
225
+ else if (/\.zip$/i.test(path)) format = 'trace'
226
+ else if (/\.json$/i.test(path)) format = 'playwright-json'
227
+ else if (/\.md$/i.test(path)) format = 'markdown'
228
+ else throw new Error(`${path}: give a folder, a Playwright report (folder or .json) or a trace .zip`)
229
+ }
230
+ let r
231
+ if (format === 'folder') r = await readFolder(path)
232
+ else if (format === 'markdown') {
233
+ const parsed = parseMarkdown(await readFile(path, 'utf8'))
234
+ const items = []
235
+ for (const it of parsed.items) { const abs = resolve(dirname(path), it.file); const image = await readFile(abs).catch(() => null); if (image && IMAGE.test(abs)) items.push({ ...it, image, ext: extOf(abs) }) }
236
+ r = { tool: 'walkthrough', title: null, items, videos: parsed.videos.map((v) => resolve(dirname(path), v)) }
237
+ } else if (format === 'trace') r = readTrace(await readFile(path))
238
+ else if (format === 'playwright-json') r = await readPlaywrightJson(path)
239
+ else if (format === 'playwright-html' || format === 'playwright') r = await readPlaywrightHtml(path)
240
+ else throw new Error(`Unknown --format ${format} (auto, folder, markdown, playwright-json, playwright-html, trace)`)
241
+ for (const it of r.items) it.size = imageSize(it.image)
242
+ return { format, ...r }
243
+ }
244
+
245
+ // ---------- mapping ----------
246
+
247
+ const words = (s) => new Set(slug(s).split('-').filter((w) => w.length > 1))
248
+ const overlap = (a, b) => { const A = words(a); const B = words(b); if (!A.size || !B.size) return 0; let n = 0; for (const w of A) if (B.has(w)) n++; return n / Math.min(A.size, B.size) }
249
+
250
+ /** The step an item belongs to: its `step`, --map, the URL path, then the title against step ids and titles. */
251
+ export function stepFor (item, nodes, map = {}) {
252
+ const steps = nodes.filter((n) => n.kind !== 'decision')
253
+ const byId = (id) => steps.find((n) => n.id === id)
254
+ if (item.step && byId(item.step)) return { node: byId(item.step), how: 'step id' }
255
+ for (const [label, id] of Object.entries(map)) if (byId(id) && [item.title, item.group, item.file].some((t) => t && slug(t).includes(slug(label)))) return { node: byId(id), how: `--map ${label}` }
256
+ const p = pathOf(item.url)
257
+ if (p) {
258
+ const hit = steps.find((n) => [n.path].flat().flatMap((x) => (x && typeof x === 'object' ? Object.values(x) : [x])).some((x) => x && pathOf(x) === p))
259
+ if (hit) return { node: hit, how: `URL ${p}` }
260
+ }
261
+ const labels = [item.title, item.group, item.file && titleFromFile(item.file)].filter(Boolean)
262
+ for (const l of labels) { const s = slug(l); const hit = steps.find((n) => n.id === s || slug(typeof n.title === 'string' ? n.title : Object.values(n.title || {})[0]) === s); if (hit) return { node: hit, how: 'title' } }
263
+ let best = null
264
+ for (const n of steps) {
265
+ const t = typeof n.title === 'string' ? n.title : Object.values(n.title || {})[0] || ''
266
+ const sc = Math.max(...labels.map((l) => Math.max(overlap(l, n.id), overlap(l, t))))
267
+ if (sc >= 0.67 && (!best || sc > best.sc)) best = { node: n, sc }
268
+ }
269
+ return best ? { node: best.node, how: 'similar title' } : null
270
+ }
271
+
272
+ /** The run viewport closest to the image's width (screens taken at 2× or 3× count by their CSS width). */
273
+ export function viewportFor (size, viewports, wanted = null) {
274
+ if (wanted && viewports[wanted]) return wanted
275
+ const names = Object.keys(viewports)
276
+ if (!size || names.length === 1) return names[0]
277
+ let best = names[0]; let bestD = Infinity
278
+ for (const n of names) for (const k of [1, 2, 3]) { const d = Math.abs(size[0] / k - viewports[n][0]); if (d < bestD) { bestD = d; best = n } }
279
+ return best
280
+ }
281
+
282
+ /** An ad-hoc flow from the items: one screen per item, in order, for a source with no scenario. */
283
+ export function adhocScenario (src, { id, title, locale = 'en' } = {}) {
284
+ const ids = new Set()
285
+ const nodes = src.items.map((it, i) => {
286
+ let nid = slug(it.title) || `step-${i + 1}`
287
+ while (ids.has(nid)) nid = `${nid}-${i + 1}`
288
+ ids.add(nid)
289
+ it.step = nid
290
+ const p = pathOf(it.url)
291
+ return {
292
+ id: nid, kind: 'screen', title: String(it.title).slice(0, 100), ...(p ? { path: p } : {}),
293
+ purpose: it.notes ? String(it.notes).slice(0, 300) : `Imported from ${src.tool}: ${it.title}.`,
294
+ execution: { strategy: 'manual', instructions: `Imported from ${src.tool}${it.file ? ` (${it.file})` : ''}; capture it again with kaloko import or kaloko capture --step ${nid}.` },
295
+ criteria: []
296
+ }
297
+ })
298
+ const widest = src.items.map((it) => it.size).filter(Boolean).sort((a, b) => b[0] - a[0])[0]
299
+ const w = widest ? (widest[0] > 2000 ? Math.round(widest[0] / 2) : widest[0]) : 1280
300
+ const vp = w < 700 ? 'mobile' : w < 1100 ? 'tablet' : 'desktop'
301
+ return {
302
+ schema: 'qawalk.scenario.v1', id: id || `IMPORT-${slug(src.title || src.tool).toUpperCase().slice(0, 40) || 'RUN'}`, title: title || src.title || `Imported from ${src.tool}`,
303
+ kind: 'task', version: 1, locales: [locale], viewports: { [vp]: [w, vp === 'mobile' ? 844 : 800] },
304
+ lanes: ['Imported'], nodes, edges: nodes.slice(1).map((n, i) => ({ from: nodes[i].id, to: n.id }))
305
+ }
306
+ }
307
+
308
+ // ---------- writing captures ----------
309
+
310
+ /** PNG bytes of any browser-readable image, through the encoder page. */
311
+ async function toPng (encoder, buf, ext) {
312
+ const bytes = await encoder.evaluate(async ({ url }) => {
313
+ const img = new Image(); await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = url })
314
+ const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; c.getContext('2d').drawImage(img, 0, 0)
315
+ const b = await new Promise((res) => c.toBlob(res, 'image/png'))
316
+ return Array.from(new Uint8Array(await b.arrayBuffer()))
317
+ }, { url: `data:${MIME[ext]};base64,${buf.toString('base64')}` })
318
+ return Buffer.from(bytes)
319
+ }
320
+
321
+ /** One imported screen as a capture of a step (no page: deterministic checks are not run, semantic ones wait). */
322
+ export async function writeImported ({ run, node, locale, viewport, item, tool, encoder = null }) {
323
+ const paths = runPaths(run.dir)
324
+ const base = paths.step(node.id, locale, viewport, 'x')
325
+ await mkdir(dirname(base), { recursive: true })
326
+ for (const ext of ['eval.json', 'html', 'png', 'jpg', 'webp', 'thumb.webp']) await rm(paths.step(node.id, locale, viewport, ext), { force: true })
327
+ let image = paths.step(node.id, locale, viewport, item.ext); let thumb = null
328
+ if (encoder) {
329
+ try {
330
+ const png = item.ext === 'png' ? item.image : await toPng(encoder, item.image, item.ext)
331
+ const enc = await encodeImage(encoder, png, { format: 'webp' })
332
+ image = paths.step(node.id, locale, viewport, 'webp'); thumb = paths.step(node.id, locale, viewport, 'thumb.webp')
333
+ await writeFile(image, enc.image); await writeFile(thumb, enc.thumb)
334
+ } catch { image = paths.step(node.id, locale, viewport, item.ext); thumb = null }
335
+ }
336
+ if (!thumb) await writeFile(image, item.image)
337
+ const scale = item.size && run.meta.scope.viewports[viewport] ? Math.max(1, Math.round(item.size[0] / run.meta.scope.viewports[viewport][0])) : 1
338
+ const size = item.size ? [Math.round(item.size[0] / scale), Math.round(item.size[1] / scale)] : run.meta.scope.viewports[viewport] || [0, 0]
339
+ const record = {
340
+ schema: 'qawalk.capture.v1', node: node.id, locale, viewport, url: item.url || null,
341
+ captured_at: new Date().toISOString(), criteria_hash: criteriaHash(node),
342
+ console_errors: [], blocked_requests: [], state: '', scoped: {}, boxes: {}, overflow: [], meta: null, metadata: null,
343
+ checks: Object.fromEntries((node.criteria || []).filter((c) => c.check.type === 'deterministic').map((c) => [c.id, { ok: null, evidence: `imported screenshot (${tool}): there is no page to check; walk the step to check it` }])),
344
+ size: { width: size[0], height: size[1], viewport: size },
345
+ capture: { format: extOf(image), scale, masked: 0, annotations: [], source: 'import' },
346
+ imported: { tool, title: item.title, file: item.file || null, notes: item.notes || null },
347
+ files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumb ? relative(run.dir, thumb) : null, html: null }
348
+ }
349
+ const file = paths.step(node.id, locale, viewport, 'capture.json')
350
+ await writeJson(file, record)
351
+ await addCapture(run.dir, { type: 'capture', node: node.id, locale, viewport, file: relative(run.dir, file), at: record.captured_at })
352
+ return record
353
+ }
354
+
355
+ /** A second screen for a step already imported: an attachment (shown under the step's attachments). */
356
+ export async function attachImported ({ run, node, locale, item }) {
357
+ const name = `${node.id}-${locale}-${slug(item.title).slice(0, 30) || 'import'}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}.${item.ext}`
358
+ const file = runPaths(run.dir).attachment(name)
359
+ await mkdir(dirname(file), { recursive: true })
360
+ await writeFile(file, item.image)
361
+ const entry = { type: 'attachment', kind: 'crop', node: node.id, locale, title: item.title, file: relative(run.dir, file), at: new Date().toISOString(), source: 'import' }
362
+ await addCapture(run.dir, entry)
363
+ return entry
364
+ }
365
+
366
+ /** Recordings are kept in the run folder (imported/), not uploaded: the canvas does not play them yet. */
367
+ export async function keepVideos (run, videos) {
368
+ const kept = []
369
+ for (const v of [...new Set(videos)]) {
370
+ const to = join(run.dir, 'imported', basename(v))
371
+ try { await mkdir(dirname(to), { recursive: true }); await copyFile(v, to); kept.push(relative(run.dir, to)) } catch {}
372
+ }
373
+ return kept
374
+ }
@@ -0,0 +1,219 @@
1
+ // Smarter `kaloko init` (#26): what the project is (framework, dev server command and URL, locales from the i18n
2
+ // config, main routes) and a first scenario that walks on the first try. Reads files only; never runs the project.
3
+ import { readFile, readdir, stat } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { routeOf } from './affected-core.mjs'
6
+
7
+ const read = (root, f) => readFile(join(root, f), 'utf8').catch(() => null)
8
+ const exists = (root, f) => stat(join(root, f)).then(() => true, () => false)
9
+ const firstOf = async (root, files) => { for (const f of files) { const t = await read(root, f); if (t != null) return { file: f, text: t } } return null }
10
+
11
+ const FRAMEWORKS = [
12
+ ['next', 'Next.js', ['next'], 3000],
13
+ ['nuxt', 'Nuxt', ['nuxt', 'nuxt3'], 3000],
14
+ ['sveltekit', 'SvelteKit', ['@sveltejs/kit'], 5173],
15
+ ['remix', 'Remix / React Router', ['@remix-run/dev', '@remix-run/react', '@react-router/dev'], 5173],
16
+ ['astro', 'Astro', ['astro'], 4321],
17
+ ['gatsby', 'Gatsby', ['gatsby'], 8000],
18
+ ['angular', 'Angular', ['@angular/core'], 4200],
19
+ ['cra', 'Create React App', ['react-scripts'], 3000],
20
+ ['vite', 'Vite', ['vite'], 5173]
21
+ ]
22
+
23
+ /** Framework, package manager and dev server (command, port, URL). */
24
+ async function detectStack (root) {
25
+ const pkgText = await read(root, 'package.json')
26
+ let pkg = null
27
+ try { pkg = pkgText ? JSON.parse(pkgText) : null } catch {}
28
+ const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) }
29
+ const pm = (await exists(root, 'pnpm-lock.yaml')) ? 'pnpm' : (await exists(root, 'yarn.lock')) ? 'yarn' : (await exists(root, 'bun.lockb')) || (await exists(root, 'bun.lock')) ? 'bun' : 'npm'
30
+ const runCmd = (script) => (pm === 'npm' ? `npm run ${script}` : pm === 'bun' ? `bun run ${script}` : `${pm} ${script}`)
31
+ let framework = null
32
+ for (const [id, name, names, port] of FRAMEWORKS) if (names.some((n) => deps[n])) { framework = { id, name, port }; break }
33
+ const gem = await read(root, 'Gemfile')
34
+ if (!framework && gem && /^\s*gem\s+['"]rails['"]/m.test(gem)) framework = { id: 'rails', name: 'Rails', port: 3000 }
35
+ if (!framework && await exists(root, 'manage.py')) framework = { id: 'django', name: 'Django', port: 8000 }
36
+ if (!framework && await exists(root, 'artisan')) framework = { id: 'laravel', name: 'Laravel', port: 8000 }
37
+ let command = null; let port = framework?.port || 3000
38
+ if (pkg?.scripts) {
39
+ const script = ['dev', 'start', 'serve', 'develop'].find((s) => pkg.scripts[s])
40
+ if (script) {
41
+ command = runCmd(script)
42
+ const m = pkg.scripts[script].match(/(?:--port[ =]|-p\s+|PORT=)(\d{2,5})/)
43
+ if (m) port = Number(m[1])
44
+ }
45
+ }
46
+ if (framework?.id === 'rails') command = (await exists(root, 'bin/dev')) ? 'bin/dev' : 'bin/rails server'
47
+ if (framework?.id === 'django') command = 'python manage.py runserver'
48
+ if (framework?.id === 'laravel') command = 'php artisan serve'
49
+ // a port in the bundler config wins over the default
50
+ const vite = await firstOf(root, ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'])
51
+ const vp = vite?.text.match(/server\s*:\s*{[^}]*port\s*:\s*(\d{2,5})/)
52
+ if (vp && !/(?:--port|-p\s|PORT=)/.test(pkg?.scripts?.dev || '')) port = Number(vp[1])
53
+ const nuxtCfg = framework?.id === 'nuxt' ? await firstOf(root, ['nuxt.config.ts', 'nuxt.config.js']) : null
54
+ const np = nuxtCfg?.text.match(/devServer\s*:\s*{[^}]*port\s*:\s*(\d{2,5})/)
55
+ if (np) port = Number(np[1])
56
+ return { framework, pm, command, port, url: `http://localhost:${port}`, name: pkg?.name || null }
57
+ }
58
+
59
+ const LOCALE = /^[a-z]{2}(?:-[A-Za-z]{2,4})?$/
60
+ const listOf = (src) => [...String(src || '').matchAll(/['"`]([a-z]{2}(?:-[A-Za-z]{2,4})?)['"`]/g)].map((m) => m[1])
61
+ const codesOf = (src) => { const codes = [...String(src || '').matchAll(/code\s*:\s*['"`]([a-z]{2}(?:-[A-Za-z]{2,4})?)['"`]/g)].map((m) => m[1]); return codes.length ? codes : listOf(src) }
62
+ const ordered = (list, def) => { const u = [...new Set(list)]; return def && u.includes(def) ? [def, ...u.filter((l) => l !== def)] : u }
63
+
64
+ /** Locales from the i18n config: { list, default, source, prefix: 'always'|'except-default'|'none' } or null. */
65
+ async function detectLocales (root, framework) {
66
+ // Next.js pages router i18n
67
+ const next = await firstOf(root, ['next.config.js', 'next.config.mjs', 'next.config.ts'])
68
+ const ni = next?.text.match(/i18n\s*:\s*{([\s\S]*?)}/)
69
+ if (ni) {
70
+ const list = listOf((ni[1].match(/locales\s*:\s*\[([^\]]*)\]/) || [])[1])
71
+ const def = (ni[1].match(/defaultLocale\s*:\s*['"`]([^'"`]+)/) || [])[1]
72
+ if (list.length) return { list: ordered(list, def), default: def || list[0], source: next.file, prefix: 'except-default' }
73
+ }
74
+ // next-intl, i18next routing files
75
+ const routing = await firstOf(root, ['src/i18n/routing.ts', 'src/i18n/routing.js', 'i18n/routing.ts', 'i18n/routing.js', 'src/i18n.ts', 'i18n.ts', 'src/i18n/config.ts', 'i18n/config.ts', 'src/middleware.ts', 'middleware.ts'])
76
+ const rl = routing?.text.match(/locales\s*:\s*\[([^\]]*)\]/) || routing?.text.match(/(?:export\s+)?const\s+locales\s*=\s*\[([^\]]*)\]/)
77
+ if (rl && listOf(rl[1]).length) {
78
+ const def = (routing.text.match(/defaultLocale\s*[:=]\s*['"`]([^'"`]+)/) || [])[1]
79
+ const prefix = /localePrefix\s*:\s*['"`](as-needed)['"`]/.test(routing.text) ? 'except-default' : /localePrefix\s*:\s*['"`]never['"`]/.test(routing.text) ? 'none' : 'always'
80
+ const list = listOf(rl[1])
81
+ return { list: ordered(list, def), default: def || list[0], source: routing.file, prefix }
82
+ }
83
+ // Nuxt i18n
84
+ const nuxt = framework?.id === 'nuxt' ? await firstOf(root, ['nuxt.config.ts', 'nuxt.config.js']) : null
85
+ const nx = nuxt?.text.match(/i18n\s*:\s*{([\s\S]*?)\n\s{0,4}}/)
86
+ if (nx) {
87
+ const block = nx[1]
88
+ const list = codesOf((block.match(/locales\s*:\s*\[([\s\S]*?)\]/) || [])[1])
89
+ const def = (block.match(/defaultLocale\s*:\s*['"`]([^'"`]+)/) || [])[1]
90
+ const strategy = (block.match(/strategy\s*:\s*['"`]([^'"`]+)/) || [])[1] || 'prefix_except_default'
91
+ if (list.length) return { list: ordered(list, def), default: def || list[0], source: nuxt.file, prefix: strategy === 'prefix' ? 'always' : strategy === 'no_prefix' ? 'none' : 'except-default' }
92
+ }
93
+ // Paraglide / inlang (SvelteKit and others)
94
+ const inlang = await read(root, 'project.inlang/settings.json')
95
+ if (inlang) {
96
+ try {
97
+ const s = JSON.parse(inlang); const list = s.locales || s.languageTags || []; const def = s.baseLocale || s.sourceLanguageTag
98
+ if (list.length) return { list: ordered(list, def), default: def || list[0], source: 'project.inlang/settings.json', prefix: 'except-default' }
99
+ } catch {}
100
+ }
101
+ // Rails
102
+ if (framework?.id === 'rails') {
103
+ const app = await read(root, 'config/application.rb') || ''
104
+ const avail = (app.match(/available_locales\s*=\s*\[([^\]]*)\]/) || [])[1]
105
+ const def = (app.match(/default_locale\s*=\s*:?['"]?([a-z]{2}(?:-[A-Z]{2})?)/) || [])[1]
106
+ let list = avail ? [...avail.matchAll(/:?['"]?([a-z]{2}(?:-[A-Z]{2})?)['"]?/g)].map((m) => m[1]) : []
107
+ if (!list.length) list = (await readdir(join(root, 'config/locales')).catch(() => [])).map((f) => f.replace(/\.(ya?ml|rb)$/, '')).filter((f) => LOCALE.test(f))
108
+ const routes = await read(root, 'config/routes.rb') || ''
109
+ if (list.length) return { list: ordered(list, def), default: def || list[0], source: avail ? 'config/application.rb' : 'config/locales', prefix: /scope\s+['"]\(?:locale\)?['"]|scope\s+['"]\/?:locale['"]/.test(routes) ? (/\(:locale\)/.test(routes) ? 'except-default' : 'always') : 'none' }
110
+ }
111
+ // message folders: messages/en.json, locales/cs.json, public/locales/en/…, src/locales/de.ts
112
+ for (const dir of ['messages', 'locales', 'lang', 'src/locales', 'src/messages', 'src/lang', 'public/locales', 'i18n', 'src/i18n']) {
113
+ const names = (await readdir(join(root, dir)).catch(() => [])).map((f) => f.replace(/\.(json|ya?ml|ts|js)$/, '')).filter((f) => LOCALE.test(f))
114
+ if (names.length) {
115
+ const appDir = await firstOf(root, ['app/[locale]/page.tsx', 'src/app/[locale]/page.tsx', 'app/[locale]/layout.tsx', 'src/app/[locale]/layout.tsx', 'app/[lang]/page.tsx', 'src/app/[lang]/page.tsx'])
116
+ return { list: ordered(names, names.includes('en') ? 'en' : null), default: names.includes('en') ? 'en' : names[0], source: dir, prefix: appDir ? 'always' : 'none' }
117
+ }
118
+ }
119
+ return null
120
+ }
121
+
122
+ const SOURCE_DIRS = ['app', 'src/app', 'pages', 'src/pages', 'src/routes', 'app/routes', 'app/pages']
123
+
124
+ async function filesUnder (root, dir, out = [], depth = 0) {
125
+ if (depth > 8 || out.length > 5000) return out
126
+ let entries = []
127
+ try { entries = await readdir(join(root, dir), { withFileTypes: true }) } catch { return out }
128
+ for (const e of entries) {
129
+ const p = `${dir}/${e.name}`
130
+ if (e.isDirectory()) { if (!['node_modules', 'api', 'components', 'lib'].includes(e.name) && !e.name.startsWith('.')) await filesUnder(root, p, out, depth + 1) } else out.push(p)
131
+ }
132
+ return out
133
+ }
134
+
135
+ /** Static routes from route files (any locale segment removed) or Rails routes; '/' first, shallow before deep. */
136
+ async function detectRoutes (root, framework) {
137
+ const found = new Map()
138
+ if (framework?.id === 'rails') {
139
+ const text = await read(root, 'config/routes.rb') || ''
140
+ if (/^\s*root\b/m.test(text)) found.set('/', 'config/routes.rb')
141
+ for (const m of text.matchAll(/^\s*(?:get|resources|resource)\s+[:'"]\/?([a-z][\w/-]*)['"]?/gm)) found.set('/' + m[1].replace(/_/g, '-'), 'config/routes.rb')
142
+ } else {
143
+ for (const dir of SOURCE_DIRS) {
144
+ for (const f of await filesUnder(root, dir)) {
145
+ if (!/\.(m?[jt]sx?|vue|svelte|astro|mdx?)$/.test(f) || /\/(_|\[\.\.\.)/.test(f) && !/\/\[(locale|lang)\]\//.test(f)) continue
146
+ const r = routeOf(f)
147
+ if (!r || r.scope !== 'page' || /\/api(\/|$)/.test(r.route)) continue
148
+ const route = r.route.replace(/^\/:(locale|lang|lng)(?=\/|$)/, '') || '/'
149
+ if (/[:*]/.test(route)) continue
150
+ if (!found.has(route)) found.set(route, f)
151
+ }
152
+ }
153
+ }
154
+ const skip = /^\/(api|admin|_|auth|oauth|callback|404|500|error)(\/|$)/
155
+ return [...found].filter(([p]) => !skip.test(p)).map(([path, file]) => ({ path, file })).sort((a, b) => (a.path === '/' ? -1 : b.path === '/' ? 1 : a.path.split('/').length - b.path.split('/').length || a.path.localeCompare(b.path)))
156
+ }
157
+
158
+ /** Everything init can tell about the project. */
159
+ export async function detectProject (root) {
160
+ const stack = await detectStack(root)
161
+ const locales = await detectLocales(root, stack.framework)
162
+ const routes = await detectRoutes(root, stack.framework)
163
+ return { ...stack, locales, routes }
164
+ }
165
+
166
+ const words = (p) => p === '/' ? 'Home' : p.split('/').filter(Boolean).pop().replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase())
167
+ const slug = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
168
+
169
+ /** A path per locale for the locale strategy: prefix always, except the default locale, or none. */
170
+ export function localizedPath (path, locales) {
171
+ if (!locales || locales.prefix === 'none' || locales.list.length < 2) return path
172
+ return Object.fromEntries(locales.list.map((l) => [l, locales.prefix === 'except-default' && l === locales.default ? path : `/${l}${path === '/' ? '' : path}`]))
173
+ }
174
+
175
+ /** The first scenario: the main routes, one screen each, that a walk can open as they are. */
176
+ export function initScenario (d, { max = 6, project = null } = {}) {
177
+ const routes = (d.routes.length ? d.routes : [{ path: '/', file: null }]).slice(0, max)
178
+ const name = project || d.name || 'app'
179
+ const ids = new Set()
180
+ const nodes = routes.map((r, i) => {
181
+ let id = r.path === '/' ? 'home' : slug(r.path) || `page-${i + 1}`
182
+ while (ids.has(id)) id += `-${i + 1}`
183
+ ids.add(id)
184
+ const title = words(r.path)
185
+ return {
186
+ id, kind: 'screen', title, path: localizedPath(r.path, d.locales),
187
+ purpose: `A visitor sees the ${title.toLowerCase()} page and can tell what to do next.`,
188
+ execution: i === 0
189
+ ? { strategy: 'playwright', script: 'steps/consent.mjs', instructions: `Open ${r.path} as a new visitor and accept the consent dialog if there is one.` }
190
+ : { strategy: 'playwright', script: 'steps/open.mjs', instructions: `Open ${r.path}.` },
191
+ criteria: [
192
+ { id: 'AC1', text: 'No errors in the browser console', check: { type: 'deterministic', assert: 'no_console_errors' } },
193
+ { id: 'AC2', text: 'The page makes its purpose clear', check: { type: 'semantic', question: `Does the first screen make clear what the ${title.toLowerCase()} page offers and what the visitor can do next?`, scope: 'main, body' } }
194
+ ]
195
+ }
196
+ })
197
+ return {
198
+ schema: 'qawalk.scenario.v1',
199
+ id: `${slug(name).toUpperCase() || 'APP'}-MAIN`,
200
+ title: `Main pages of ${name}`,
201
+ kind: 'flow', version: 1,
202
+ locales: d.locales?.list?.slice(0, 6) || ['en'],
203
+ viewports: { desktop: [1280, 800], mobile: [390, 844] },
204
+ environments: ['local'],
205
+ lanes: ['Visitor'],
206
+ nodes,
207
+ edges: nodes.slice(1).map((n) => ({ from: nodes[0].id, to: n.id, label: 'visit', kind: 'alt' }))
208
+ }
209
+ }
210
+
211
+ /** One paragraph for the terminal. */
212
+ export function formatDetection (d, root) {
213
+ const lines = [`Detected ${d.framework ? d.framework.name : 'no known framework'}${d.command ? `; dev server: ${d.command}` : ''} → ${d.url}`]
214
+ if (d.locales) lines.push(` locales: ${d.locales.list.join(', ')} (from ${d.locales.source}; ${d.locales.prefix === 'none' ? 'no locale in the URL' : d.locales.prefix === 'always' ? 'every locale prefixes the URL' : `/${d.locales.list.find((l) => l !== d.locales.default) || 'xx'}/… except ${d.locales.default}`})`)
215
+ else lines.push(' locales: none found in an i18n config (the scenario uses en)')
216
+ lines.push(d.routes.length ? ` routes: ${d.routes.slice(0, 8).map((r) => r.path).join(', ')}${d.routes.length > 8 ? `, +${d.routes.length - 8} more` : ''}` : ' routes: none found in route files (the scenario starts at /)')
217
+ return lines.join('\n')
218
+ }
219
+
@@ -10,16 +10,23 @@ export const realOrg = (org) => (typeof org === 'string' && /^[a-z0-9][a-z0-9-]{
10
10
  /** Address of the organization's sample run on the service. */
11
11
  export const sampleUrl = (base, org) => `${String(base).replace(/\/$/, '')}/${org}/sample`
12
12
 
13
- /** Lines printed at the end of `kaloko init`. */
14
- export function initNextSteps ({ base, org, loggedIn = false }) {
13
+ /**
14
+ * Lines printed at the end of `kaloko init`. `devServer` ({ command, url }) when init found the app's dev server but it
15
+ * is not answering yet; `scenario` the path of the first scenario init drafted, which then is the first real flow.
16
+ */
17
+ export function initNextSteps ({ base, org, loggedIn = false, devServer = null, scenario = null }) {
15
18
  const o = realOrg(org)
16
- return [
17
- 'Next:',
18
- loggedIn ? ' 1. Signed in already (KALOKO_TOKEN found).' : ' 1. npx kaloko login (approve a token in the browser; or put KALOKO_TOKEN from Settings → API tokens into .env)',
19
- o ? ` 2. Try a review on the sample run: ${sampleUrl(base, o)}` : ' 2. Set org: in kaloko.config.yml, then open <service>/<org>/sample to try a review on the sample run.',
20
- ' 3. Your first real flow: npx kaloko draft <your app URL> (public pages), or ask your agent:',
21
- ` "${FIRST_FLOW_PROMPT}"`
19
+ const steps = [
20
+ loggedIn ? 'Signed in already (KALOKO_TOKEN found).' : 'npx kaloko login (approve a token in the browser; or put KALOKO_TOKEN from Settings → API tokens into .env)'
22
21
  ]
22
+ if (devServer) steps.push(`${devServer.command || 'start your app'} (the dev server is not answering on ${devServer.url} yet)`)
23
+ steps.push(o ? `Try a review on the sample run: ${sampleUrl(base, o)}` : 'Set org: in kaloko.config.yml, then open <service>/<org>/sample to try a review on the sample run.')
24
+ if (scenario) steps.push(`Your first real flow: npx kaloko start --scenario ${scenario} --env local && npx kaloko walk`)
25
+ else steps.push('Your first real flow: npx kaloko draft <your app URL> (public pages), or ask your agent:', ` "${FIRST_FLOW_PROMPT}"`)
26
+ const lines = ['Next:']
27
+ let n = 0
28
+ for (const s of steps) lines.push(s.startsWith(' "') ? ` ${s}` : ` ${++n}. ${s}`)
29
+ return lines
23
30
  }
24
31
 
25
32
  /** Lines printed after a successful `kaloko login`. */