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,207 @@
1
+ // Self-healing selectors (#13): when a step script waits in vain for a selector, propose a replacement from the page
2
+ // as it is now (role and accessible name, test ids, text), preferring the element the old selector matched in the
3
+ // step's previous capture. The proposal is printed as a patch; `walk --heal` applies it to the script only after the
4
+ // step passed with it. Nothing is ever changed silently.
5
+ import { readFile, writeFile, rm, readdir } from 'node:fs/promises'
6
+ import { join, dirname, basename, relative, resolve } from 'node:path'
7
+ import { pathToFileURL } from 'node:url'
8
+ import { readJson } from './util.mjs'
9
+
10
+ /** The selector a Playwright timeout waited for, or null (only plain locator strings can be patched). */
11
+ export function failedSelector (message) {
12
+ const text = String(message || '')
13
+ if (!/Timeout \d+ms exceeded|waiting for/i.test(text) || /strict mode violation/i.test(text)) return null
14
+ const unq = (s, q) => s.replace(new RegExp(`\\\\${q}`, 'g'), q)
15
+ let m = text.match(/waiting for locator\('((?:\\'|[^'])+)'\)/)
16
+ if (m) return unq(m[1], "'")
17
+ m = text.match(/waiting for locator\("((?:\\"|[^"])+)"\)/)
18
+ if (m) return unq(m[1], '"')
19
+ m = text.match(/waiting for selector "((?:\\"|[^"])+)"/)
20
+ if (m) return unq(m[1], '"')
21
+ return null
22
+ }
23
+
24
+ /** Words a selector is made of: ids, classes, attribute values and text, split on punctuation and camelCase. */
25
+ export function tokensOf (selector) {
26
+ return [...new Set(String(selector)
27
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
28
+ .toLowerCase()
29
+ .split(/[^a-z0-9áčďéěíňóřšťúůýž]+/i)
30
+ .filter((w) => w.length > 1 && !['text', 'has', 'nth', 'first', 'last', 'visible', 'true', 'role', 'name', 'internal', 'data', 'testid', 'test', 'id', 'qa', 'cy', 'div', 'span'].includes(w)))]
31
+ }
32
+
33
+ /**
34
+ * In the page (self-contained, passed to evaluate): interactive and labelled elements with role, accessible name,
35
+ * test id, id and classes; given an element, that element only.
36
+ */
37
+ function describeAll (limit) {
38
+ const TEST = ['data-testid', 'data-test', 'data-qa', 'data-cy', 'data-test-id']
39
+ const roleOf = (el) => {
40
+ const r = el.getAttribute('role'); if (r) return r
41
+ const t = el.tagName.toLowerCase()
42
+ if (t === 'a' && el.hasAttribute('href')) return 'link'
43
+ if (t === 'button' || (t === 'input' && ['button', 'submit', 'reset'].includes(el.type))) return 'button'
44
+ if (t === 'input') return { checkbox: 'checkbox', radio: 'radio', range: 'slider', search: 'searchbox' }[el.type] || 'textbox'
45
+ if (t === 'textarea') return 'textbox'
46
+ if (t === 'select') return 'combobox'
47
+ if (/^h[1-6]$/.test(t)) return 'heading'
48
+ if (t === 'img') return 'img'
49
+ return null
50
+ }
51
+ const text = (s) => String(s || '').replace(/\s+/g, ' ').trim()
52
+ const nameOf = (el) => {
53
+ const l = el.getAttribute('aria-label'); if (l) return text(l)
54
+ const by = el.getAttribute('aria-labelledby'); if (by) { const t = by.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' '); if (text(t)) return text(t) }
55
+ if (el.id) { const lab = document.querySelector(`label[for="${CSS.escape(el.id)}"]`); if (lab) return text(lab.textContent) }
56
+ const wrap = el.closest('label'); if (wrap && wrap !== el && /^(input|select|textarea)$/i.test(el.tagName)) return text(wrap.textContent)
57
+ if (/^(input|textarea)$/i.test(el.tagName)) return text(el.getAttribute('placeholder') || el.getAttribute('title') || (['button', 'submit'].includes(el.type) ? el.value : ''))
58
+ if (el.tagName === 'IMG') return text(el.getAttribute('alt'))
59
+ return text(el.innerText || el.textContent).slice(0, 80)
60
+ }
61
+ const describe = (el) => {
62
+ const testid = TEST.map((a) => el.getAttribute(a) ? [a, el.getAttribute(a)] : null).find(Boolean) || null
63
+ const r = el.getBoundingClientRect(); const cs = getComputedStyle(el)
64
+ return { tag: el.tagName.toLowerCase(), role: roleOf(el), name: nameOf(el), testid, id: el.id || null, classes: [...el.classList].slice(0, 8), visible: r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' }
65
+ }
66
+ if (typeof limit === 'object' && limit?.nodeType === 1) return describe(limit)
67
+ const sel = 'a[href], button, input, select, textarea, summary, [role], [aria-label], [data-testid], [data-test], [data-qa], [data-cy], [data-test-id], h1, h2, h3, label'
68
+ return [...document.querySelectorAll(sel)].slice(0, limit).map(describe).filter((d) => d.visible && (d.name || d.testid || d.id))
69
+ }
70
+
71
+ /** The element the old selector matched in the previous capture of this step (its HTML), described; null if none. */
72
+ async function previousFingerprint ({ page, selector, root, output, run, node, locale }) {
73
+ const base = resolve(root, output)
74
+ let names = []
75
+ try { names = (await readdir(base, { withFileTypes: true })).filter((e) => e.isDirectory() && e.name !== run.meta.id && !e.name.startsWith('.')).map((e) => e.name).sort().reverse() } catch { return null }
76
+ for (const name of names.slice(0, 20)) {
77
+ const meta = await readJson(join(base, name, 'meta.json'), null)
78
+ if (meta?.scenario?.id !== run.meta.scenario.id) continue
79
+ let html = null
80
+ for (const vp of Object.keys(run.meta.scope.viewports || {})) { html = await readFile(join(base, name, 'steps', node.id, locale, `${vp}.html`), 'utf8').catch(() => null); if (html) break }
81
+ if (!html) continue
82
+ let scratch = null
83
+ try {
84
+ const browser = page.context().browser()
85
+ scratch = browser ? await browser.newContext({ javaScriptEnabled: false }) : null
86
+ if (!scratch) return null
87
+ const p = await scratch.newPage()
88
+ await p.route('**/*', (r) => r.abort())
89
+ await p.setContent(html.replace(/<script[\s\S]*?<\/script>/gi, ''), { waitUntil: 'domcontentloaded', timeout: 5000 })
90
+ const el = p.locator(selector).first()
91
+ if (!(await el.count().catch(() => 0))) continue
92
+ const fp = await el.evaluate(describeAll)
93
+ return { ...fp, run: name }
94
+ } catch { continue } finally { await scratch?.close().catch(() => {}) }
95
+ }
96
+ return null
97
+ }
98
+
99
+ const q = (s) => JSON.stringify(String(s))
100
+
101
+ /** Scores a candidate against the fingerprint of the old element and the words of the old selector. */
102
+ export function score (c, { fp, tokens }) {
103
+ let s = 0; const why = []
104
+ if (fp) {
105
+ if (fp.testid && c.testid && fp.testid[1] === c.testid[1]) { s += 6; why.push(`same ${c.testid[0]} "${c.testid[1]}"`) }
106
+ if (fp.name && c.name && fp.name.toLowerCase() === c.name.toLowerCase()) { s += 4; why.push(`same name "${c.name}"`) }
107
+ if (fp.role && fp.role === c.role) { s += 2; why.push(`role ${c.role}`) }
108
+ if (fp.tag === c.tag) s += 1
109
+ if (fp.id && fp.id === c.id) { s += 3; why.push(`same id ${c.id}`) }
110
+ const shared = (fp.classes || []).filter((k) => c.classes.includes(k)).length
111
+ s += Math.min(2, shared)
112
+ }
113
+ const hay = [c.id, ...(c.classes || []), c.testid?.[1], c.name, c.role, c.tag].filter(Boolean).join(' ').toLowerCase()
114
+ const words = tokens.filter((t) => hay.includes(t))
115
+ if (words.length) { s += words.length * 1.5; why.push(`words ${words.map((w) => `"${w}"`).join(', ')} of the old selector`) }
116
+ return { score: s, why }
117
+ }
118
+
119
+ /** Selectors for an element, most stable first: test id, role + name, id, text. */
120
+ export function selectorsFor (c) {
121
+ const out = []
122
+ if (c.testid) out.push(`[${c.testid[0]}=${q(c.testid[1])}]`)
123
+ if (c.role && c.name) out.push(`role=${c.role}[name=${q(c.name)}]`)
124
+ if (c.id && !/\d{3,}|^[a-f0-9-]{16,}$|^(radix|headlessui|mui|react-aria|:r)/i.test(c.id)) out.push(`#${c.id}`)
125
+ if (c.name && c.name.length <= 60) out.push(`text=${q(c.name)}`)
126
+ return out
127
+ }
128
+
129
+ /**
130
+ * A replacement for `selector` on the page as it is now: { replacement, why, from } or null.
131
+ * The replacement matches exactly one element.
132
+ */
133
+ export async function proposeSelector ({ page, selector, root, output, run, node, locale }) {
134
+ const fp = await previousFingerprint({ page, selector, root, output, run, node, locale }).catch(() => null)
135
+ const tokens = tokensOf(selector)
136
+ let candidates
137
+ try { candidates = await page.evaluate(describeAll, 1500) } catch { return null }
138
+ const ranked = candidates.map((c) => ({ c, ...score(c, { fp, tokens }) })).filter((x) => x.score >= (fp ? 4 : 3)).sort((a, b) => b.score - a.score)
139
+ for (const { c, why } of ranked.slice(0, 8)) {
140
+ for (const sel of selectorsFor(c)) {
141
+ if (sel === selector) continue
142
+ const n = await page.locator(sel).count().catch(() => 0)
143
+ if (n === 1) return { replacement: sel, why: why.join(', ') || 'closest element', from: fp?.run || null, element: { role: c.role, name: c.name, tag: c.tag } }
144
+ }
145
+ }
146
+ return null
147
+ }
148
+
149
+ /** Replaces the selector's string literal in a script; { text, diff, count } or null when it is not written literally. */
150
+ export function patchScript (source, selector, replacement, file = 'script.mjs') {
151
+ const lines = source.split('\n')
152
+ const out = []; const diff = []; let count = 0
153
+ for (const [i, line] of lines.entries()) {
154
+ let next = line
155
+ for (const quote of ["'", '"', '`']) {
156
+ const lit = quote + selector.replaceAll('\\', '\\\\').replaceAll(quote, '\\' + quote) + quote
157
+ if (next.includes(lit)) {
158
+ const rep = quote + replacement.replaceAll('\\', '\\\\').replaceAll(quote, '\\' + quote) + quote
159
+ count += next.split(lit).length - 1
160
+ next = next.replaceAll(lit, rep)
161
+ }
162
+ }
163
+ if (next !== line) diff.push(`@@ -${i + 1} +${i + 1} @@`, `-${line}`, `+${next}`)
164
+ out.push(next)
165
+ }
166
+ if (!count) return null
167
+ return { text: out.join('\n'), count, diff: [`--- a/${file}`, `+++ b/${file}`, ...diff].join('\n') }
168
+ }
169
+
170
+ /**
171
+ * After a failed step: the proposal and, with `apply`, a re-run of the step with the patched script. `rerun(module)`
172
+ * runs the step with a script module and throws when it fails. Returns { selector, proposal, patch, applied, note }.
173
+ */
174
+ export async function healStep ({ error, page, scriptFile, root, output, run, node, locale, apply = false, rerun }) {
175
+ const selector = failedSelector(error?.message)
176
+ if (!selector) return null
177
+ const proposal = await proposeSelector({ page, selector, root, output, run, node, locale })
178
+ if (!proposal) return { selector, proposal: null, note: `no element on the page now resembles ${selector}` }
179
+ const source = await readFile(scriptFile, 'utf8').catch(() => null)
180
+ const rel = relative(root, scriptFile)
181
+ const patch = source ? patchScript(source, selector, proposal.replacement, rel) : null
182
+ if (!patch) return { selector, proposal, patch: null, note: `${selector} is not written literally in ${rel}; change it by hand` }
183
+ if (!apply) return { selector, proposal, patch, applied: false }
184
+ // the patched script runs from a sibling file (its relative imports resolve the same) and replaces the original
185
+ // only when the step passed with it
186
+ const trial = join(dirname(scriptFile), `.${basename(scriptFile, '.mjs')}.kaloko-heal-${process.pid}.mjs`)
187
+ try {
188
+ await writeFile(trial, patch.text)
189
+ await rerun(await import(`${pathToFileURL(trial).href}?t=${Date.now()}`))
190
+ await writeFile(scriptFile, patch.text)
191
+ return { selector, proposal, patch, applied: true }
192
+ } catch (e) {
193
+ return { selector, proposal, patch, applied: false, note: `the step still fails with the proposal (${String(e.message).split('\n')[0]}); ${rel} is unchanged` }
194
+ } finally { await rm(trial, { force: true }) }
195
+ }
196
+
197
+ /** Lines for the terminal: what failed, what is proposed and why, and the patch. */
198
+ export function formatHeal (h, { stepArg }) {
199
+ if (!h) return ''
200
+ if (!h.proposal) return ` ↳ selector ${h.selector}: ${h.note}`
201
+ const lines = [` ↳ selector ${h.selector} not found; proposed ${h.proposal.replacement} (${h.proposal.why}${h.proposal.from ? `; the old selector matched it in run ${h.proposal.from}` : ''})`]
202
+ if (h.applied) lines.push(` ↳ healed: the step passed with it, ${h.patch.count} occurrence(s) changed:`)
203
+ else if (h.note) lines.push(` ↳ ${h.note}`)
204
+ if (h.patch) lines.push(h.patch.diff.split('\n').map((l) => ` ${l}`).join('\n'))
205
+ if (h.patch && !h.applied && !h.note) lines.push(` ↳ apply it after the step passes: kaloko walk --steps ${stepArg} --heal`)
206
+ return lines.join('\n')
207
+ }
@@ -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
+ /** Imported recordings are kept in the run folder (imported/), not attached to a step and not uploaded. */
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
+ }