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.
- package/README.md +69 -4
- package/package.json +1 -1
- package/schemas/config.v1.json +7 -0
- package/schemas/scenario.v1.json +7 -0
- package/skills/kaloko/SKILL.md +13 -2
- package/skills/kaloko/reference/acceptance.md +16 -0
- package/skills/kaloko/reference/ci.md +70 -0
- package/skills/kaloko/reference/collaboration.md +9 -1
- package/skills/kaloko/reference/environments.md +5 -0
- package/skills/kaloko/reference/execution.md +56 -0
- package/skills/kaloko/reference/import.md +66 -0
- package/skills/kaloko/reference/mcp.md +1 -0
- package/src/cli.mjs +385 -96
- package/src/lib/affected-core.mjs +242 -0
- package/src/lib/affected.mjs +233 -0
- package/src/lib/ci.mjs +207 -0
- package/src/lib/config.mjs +2 -0
- package/src/lib/evaluate.mjs +4 -2
- package/src/lib/git.mjs +6 -2
- package/src/lib/heal.mjs +207 -0
- package/src/lib/import.mjs +374 -0
- package/src/lib/init-detect.mjs +219 -0
- package/src/lib/onboarding.mjs +15 -8
- package/src/lib/pr.mjs +24 -2
- package/src/lib/scenario.mjs +2 -1
- package/src/lib/service.mjs +7 -1
- package/src/lib/unzip.mjs +31 -0
- package/src/lib/upload.mjs +114 -0
package/src/lib/pr.mjs
CHANGED
|
@@ -52,8 +52,11 @@ export function prCommentBody ({ manifest, url, at = new Date() }) {
|
|
|
52
52
|
* Creates or updates the scenario's comment on the current branch's PR.
|
|
53
53
|
* @returns {{ ok: boolean, action?: 'created'|'updated', url?: string, reason?: string }}
|
|
54
54
|
*/
|
|
55
|
-
export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun }) {
|
|
56
|
-
|
|
55
|
+
export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun, vars = process.env, fetchImpl = fetch }) {
|
|
56
|
+
// GitLab merge request pipelines: a note on the merge request through the API
|
|
57
|
+
if (vars.GITLAB_CI && vars.CI_MERGE_REQUEST_IID) return upsertMrNote({ manifest, url, vars, fetchImpl })
|
|
58
|
+
// in CI the checkout is often a detached commit: KALOKO_PR (kaloko ci preview-url sets it) names the pull request
|
|
59
|
+
const view = await run('gh', ['pr', 'view', ...(vars.KALOKO_PR ? [String(vars.KALOKO_PR)] : []), '--json', 'number,url,headRepository,headRepositoryOwner'], cwd)
|
|
57
60
|
if (view == null) return { ok: false, reason: 'gh is not installed, not signed in, or this branch has no pull request' }
|
|
58
61
|
let pr
|
|
59
62
|
try { pr = JSON.parse(view) } catch { return { ok: false, reason: 'gh pr view returned no JSON' } }
|
|
@@ -73,6 +76,25 @@ export async function upsertPrComment ({ manifest, url, cwd, run = defaultRun })
|
|
|
73
76
|
return r == null ? { ok: false, reason: 'could not create the comment' } : { ok: true, action: 'created', url: r.trim() || pr.url }
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
/** One note per scenario on a GitLab merge request (KALOKO_GITLAB_TOKEN: a project access token with the api scope). */
|
|
80
|
+
export async function upsertMrNote ({ manifest, url, vars = process.env, fetchImpl = fetch }) {
|
|
81
|
+
const token = vars.KALOKO_GITLAB_TOKEN || vars.GITLAB_TOKEN
|
|
82
|
+
if (!token) return { ok: false, reason: 'set KALOKO_GITLAB_TOKEN (a project access token with the api scope) to comment on the merge request' }
|
|
83
|
+
const notes = `${vars.CI_API_V4_URL}/projects/${encodeURIComponent(vars.CI_PROJECT_ID)}/merge_requests/${vars.CI_MERGE_REQUEST_IID}/notes`
|
|
84
|
+
const headers = { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' }
|
|
85
|
+
const body = prCommentBody({ manifest, url })
|
|
86
|
+
const link = (id) => `${vars.CI_MERGE_REQUEST_PROJECT_URL || vars.CI_PROJECT_URL}/-/merge_requests/${vars.CI_MERGE_REQUEST_IID}#note_${id}`
|
|
87
|
+
try {
|
|
88
|
+
const list = await fetchImpl(`${notes}?per_page=100&sort=desc`, { headers })
|
|
89
|
+
if (!list.ok) return { ok: false, reason: `GitLab answered ${list.status} listing notes` }
|
|
90
|
+
const mine = (await list.json()).find((n) => String(n.body || '').includes(marker(manifest.scenario.id)))
|
|
91
|
+
const res = await fetchImpl(mine ? `${notes}/${mine.id}` : notes, { method: mine ? 'PUT' : 'POST', headers, body: JSON.stringify({ body }) })
|
|
92
|
+
if (!res.ok) return { ok: false, reason: `GitLab answered ${res.status}` }
|
|
93
|
+
const note = await res.json()
|
|
94
|
+
return { ok: true, action: mine ? 'updated' : 'created', url: link(note.id || mine?.id) }
|
|
95
|
+
} catch (e) { return { ok: false, reason: e.message } }
|
|
96
|
+
}
|
|
97
|
+
|
|
76
98
|
async function defaultRun (cmd, args, cwd) {
|
|
77
99
|
try { const { stdout } = await exec(cmd, args, { cwd, timeout: 30000, maxBuffer: 4 * 1024 * 1024 }); return stdout } catch { return null }
|
|
78
100
|
}
|
package/src/lib/scenario.mjs
CHANGED
|
@@ -31,7 +31,8 @@ export function validateScenario (data) {
|
|
|
31
31
|
if (node.kind === 'stack' && node.items && ['urls', 'file', 'sitemap', 'crawl', 'script'].filter((k) => node.items[k]).length !== 1) problems.push(`node "${node.id}": items needs exactly one source of urls, file, sitemap, crawl or script`)
|
|
32
32
|
if (node.kind !== 'stack' && (node.items || node.fields)) problems.push(`node "${node.id}": items and fields belong to stack steps`)
|
|
33
33
|
// app screens are reached by a script or a person as often as by a deep link
|
|
34
|
-
|
|
34
|
+
// a manual screen may have no path: a person (or an imported walkthrough) shows how to get there
|
|
35
|
+
if (node.kind === 'screen' && !node.path && (!data.platform || data.platform === 'web') && node.execution?.strategy !== 'manual') problems.push(`node "${node.id}": screen steps need a path`)
|
|
35
36
|
if (node.kind === 'stack' && data.platform && data.platform !== 'web') problems.push(`node "${node.id}": stack steps list web pages; they are not available on platform ${data.platform}`)
|
|
36
37
|
if (node.kind === 'email' && !node.mail) problems.push(`node "${node.id}": email steps need mail.subject`)
|
|
37
38
|
}
|
package/src/lib/service.mjs
CHANGED
|
@@ -43,7 +43,13 @@ export function service (config) {
|
|
|
43
43
|
},
|
|
44
44
|
async upload (path, data, name, extra = {}) {
|
|
45
45
|
for (let attempt = 0; ; attempt++) {
|
|
46
|
-
|
|
46
|
+
// a dropped connection (flaky Wi-Fi, CI runner hiccup) is tried again like a 5xx; the next share resumes otherwise
|
|
47
|
+
let res
|
|
48
|
+
try { res = await limited(() => fetch(base + path, { method: 'PUT', headers: { ...headers, ...extra }, body: data })) } catch (e) {
|
|
49
|
+
if (attempt >= 3) throw new UserError(`upload ${name}: ${e.cause?.code || e.message}. Run kaloko share again: files already stored are not sent twice.`)
|
|
50
|
+
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
47
53
|
if (res.ok || attempt >= 2 || res.status < 500) return check(res, `upload ${name}`)
|
|
48
54
|
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
|
|
49
55
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Minimal ZIP reader for `kaloko import` (Playwright traces and the report embedded in an HTML report): stored and
|
|
2
|
+
// deflated entries from the central directory. No ZIP64; entries are read when asked for.
|
|
3
|
+
import { inflateRawSync } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
/** Entries of a zip archive: Map name → () => Buffer. */
|
|
6
|
+
export function readZip (buf) {
|
|
7
|
+
let eocd = -1
|
|
8
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break }
|
|
9
|
+
if (eocd < 0) throw new Error('not a zip archive (no end of central directory)')
|
|
10
|
+
const count = buf.readUInt16LE(eocd + 10)
|
|
11
|
+
let at = buf.readUInt32LE(eocd + 16)
|
|
12
|
+
const out = new Map()
|
|
13
|
+
for (let n = 0; n < count; n++) {
|
|
14
|
+
if (buf.readUInt32LE(at) !== 0x02014b50) throw new Error('broken zip central directory')
|
|
15
|
+
const method = buf.readUInt16LE(at + 10)
|
|
16
|
+
const size = buf.readUInt32LE(at + 20)
|
|
17
|
+
const nameLen = buf.readUInt16LE(at + 28); const extraLen = buf.readUInt16LE(at + 30); const commentLen = buf.readUInt16LE(at + 32)
|
|
18
|
+
const local = buf.readUInt32LE(at + 42)
|
|
19
|
+
const name = buf.subarray(at + 46, at + 46 + nameLen).toString('utf8')
|
|
20
|
+
at += 46 + nameLen + extraLen + commentLen
|
|
21
|
+
if (name.endsWith('/')) continue
|
|
22
|
+
out.set(name, () => {
|
|
23
|
+
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
|
|
24
|
+
const data = buf.subarray(start, start + size)
|
|
25
|
+
if (method === 0) return Buffer.from(data)
|
|
26
|
+
if (method === 8) return inflateRawSync(data)
|
|
27
|
+
throw new Error(`${name}: unsupported compression method ${method}`)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Uploads of a run's files (#28): by content hash, so captures that did not change since an earlier run (or an
|
|
2
|
+
// interrupted share) are not sent again, with progress on the terminal. The service stores each file once as an
|
|
3
|
+
// organization blob and the run's file list points at it. An older service without blob references gets the files
|
|
4
|
+
// by path as before; the paths it already took are remembered in the run folder, so a share that broke off resumes.
|
|
5
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { gzipSync } from 'node:zlib'
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { readJson } from './util.mjs'
|
|
10
|
+
|
|
11
|
+
const TEXT = new Set(['html', 'json'])
|
|
12
|
+
const IMAGE = /\.(png|webp|jpe?g)$/i
|
|
13
|
+
export const PROGRESS_FILE = 'share.progress.json'
|
|
14
|
+
|
|
15
|
+
export const fmtBytes = (n) => { n = Number(n) || 0; const u = ['B', 'kB', 'MB', 'GB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++ } return `${n.toFixed(i ? 1 : 0)} ${u[i]}` }
|
|
16
|
+
const extOf = (p) => { const e = p.split('.').pop().toLowerCase(); return e === 'jpeg' ? 'jpg' : e }
|
|
17
|
+
/** Text files travel and rest gzip-compressed above 512 bytes; the hash is always of the raw content. */
|
|
18
|
+
const encode = (p, raw) => { const gz = TEXT.has(extOf(p)) && raw.length > 512; return { data: gz ? gzipSync(raw, { level: 6 }) : raw, gz } }
|
|
19
|
+
|
|
20
|
+
/** Content hash, size and type of every file to share (paths relative to the run folder). */
|
|
21
|
+
export async function hashFiles (dir, paths) {
|
|
22
|
+
const out = []
|
|
23
|
+
for (const path of paths) {
|
|
24
|
+
const raw = await readFile(join(dir, path))
|
|
25
|
+
out.push({ path, hash: createHash('sha256').update(raw).digest('hex'), bytes: raw.length, ext: extOf(path) })
|
|
26
|
+
}
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Hashes the service does not store yet; null when it has no blob endpoint (an older service, or a token without it). */
|
|
31
|
+
export async function missingHashes (client, org, hashes) {
|
|
32
|
+
const missing = new Set()
|
|
33
|
+
const list = [...new Set(hashes)]
|
|
34
|
+
try {
|
|
35
|
+
for (let i = 0; i < list.length; i += 1000) for (const h of (await client.request('POST', `/orgs/${org}/blobs/missing`, { hashes: list.slice(i, i + 1000) })).missing) missing.add(h)
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (/HTTP (403|404|405)\b/.test(e.message)) return null
|
|
38
|
+
throw e
|
|
39
|
+
}
|
|
40
|
+
return missing
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Runs `job` over `items` with `n` workers; `tick` after each item. */
|
|
44
|
+
async function pool (items, n, job, tick) {
|
|
45
|
+
const queue = [...items]
|
|
46
|
+
const worker = async () => { for (let it = queue.shift(); it; it = queue.shift()) { await job(it); tick?.(it) } }
|
|
47
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(n, queue.length)) }, worker))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function progressLine (write, label) {
|
|
51
|
+
let last = 0
|
|
52
|
+
return (done, total, bytes) => {
|
|
53
|
+
const now = Date.now()
|
|
54
|
+
if (done !== total && now - last < 250) return
|
|
55
|
+
last = now
|
|
56
|
+
write(`\r ${label} ${done}/${total}${bytes != null ? ` · ${fmtBytes(bytes)}` : ''}${done === total ? '\n' : ''}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Uploads the files of a created run. `entries` from hashFiles; `missing` from missingHashes (null: by path).
|
|
62
|
+
* Returns { mode: 'blobs'|'paths', uploaded, reused, bytes }.
|
|
63
|
+
*/
|
|
64
|
+
export async function uploadRunFiles ({ client, org, runId, dir, entries, missing, say = console.log, write = (s) => process.stdout.write(s), concurrency }) {
|
|
65
|
+
const workers = concurrency || (entries.length > 200 ? 12 : 6)
|
|
66
|
+
if (missing) {
|
|
67
|
+
// 1. the blobs the service lacks, each once (identical files in a run share one blob)
|
|
68
|
+
const byHash = new Map()
|
|
69
|
+
for (const e of entries) if (missing.has(e.hash) && !byHash.has(e.hash)) byHash.set(e.hash, e)
|
|
70
|
+
let sent = 0; let done = 0
|
|
71
|
+
const tick = progressLine(write, 'uploaded')
|
|
72
|
+
await pool([...byHash.values()], workers, async (e) => {
|
|
73
|
+
const { data, gz } = encode(e.path, await readFile(join(dir, e.path)))
|
|
74
|
+
await client.upload(`/orgs/${org}/blobs/${e.hash}`, data, e.path, { 'X-QAwalk-Type': e.ext, ...(gz ? { 'Content-Encoding': 'gzip' } : {}) })
|
|
75
|
+
sent += data.length
|
|
76
|
+
}, () => tick(++done, byHash.size, sent))
|
|
77
|
+
// 2. the run's file list points at the blobs; a service that does not know references says so by leaving out `blobs`
|
|
78
|
+
const plan = await client.request('POST', `/orgs/${org}/runs/${runId}/files`, { files: entries.map((e) => ({ path: e.path, bytes: e.bytes, blob: e.hash, ...(IMAGE.test(e.path) ? { sha256: e.hash } : {}) })) })
|
|
79
|
+
if (plan && typeof plan.blobs === 'number') {
|
|
80
|
+
return { mode: 'blobs', uploaded: byHash.size, reused: new Set(entries.map((e) => e.hash)).size - byHash.size, bytes: sent }
|
|
81
|
+
}
|
|
82
|
+
say(' The service does not take blob references yet; uploading the files by path.')
|
|
83
|
+
}
|
|
84
|
+
return uploadByPath({ client, org, runId, dir, entries, say, write, workers })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Per-path uploads (older services): a plan first, then each file; finished paths are remembered for a resume. */
|
|
88
|
+
async function uploadByPath ({ client, org, runId, dir, entries, say, write, workers }) {
|
|
89
|
+
const progressFile = join(dir, PROGRESS_FILE)
|
|
90
|
+
const saved = await readJson(progressFile, null).catch(() => null)
|
|
91
|
+
const done = new Set(saved?.run === runId ? saved.done : [])
|
|
92
|
+
const todo = entries.filter((e) => !done.has(`${e.path}|${e.hash}`))
|
|
93
|
+
if (done.size && todo.length < entries.length) say(` Resuming: ${entries.length - todo.length} of ${entries.length} file(s) were uploaded before the interruption.`)
|
|
94
|
+
// screenshots name their SHA-256 (sent uncompressed, so the hash of the raw file): the service checks it on
|
|
95
|
+
// arrival and compares unchanged steps with it (review mode)
|
|
96
|
+
if (todo.length) {
|
|
97
|
+
const plan = []
|
|
98
|
+
for (const e of todo) plan.push({ path: e.path, bytes: encode(e.path, await readFile(join(dir, e.path))).data.length, ...(IMAGE.test(e.path) ? { sha256: e.hash } : {}) })
|
|
99
|
+
await client.request('POST', `/orgs/${org}/runs/${runId}/files`, { files: plan }).catch((err) => { if (!/HTTP (404|405)/.test(err.message)) throw err })
|
|
100
|
+
}
|
|
101
|
+
let sent = 0; let n = 0; let since = 0
|
|
102
|
+
const remember = () => writeFile(progressFile, JSON.stringify({ run: runId, done: [...done] }) + '\n').catch(() => {})
|
|
103
|
+
const tick = progressLine(write, 'uploaded')
|
|
104
|
+
try {
|
|
105
|
+
await pool(todo, workers, async (e) => {
|
|
106
|
+
const { data, gz } = encode(e.path, await readFile(join(dir, e.path)))
|
|
107
|
+
await client.upload(`/orgs/${org}/runs/${runId}/files/${e.path.split('/').map(encodeURIComponent).join('/')}`, data, e.path, gz ? { 'Content-Encoding': 'gzip' } : {})
|
|
108
|
+
sent += data.length
|
|
109
|
+
done.add(`${e.path}|${e.hash}`)
|
|
110
|
+
if (++since >= 25) { since = 0; await remember() }
|
|
111
|
+
}, () => tick(++n, todo.length, sent))
|
|
112
|
+
} finally { await remember() }
|
|
113
|
+
return { mode: 'paths', uploaded: todo.length, reused: entries.length - todo.length, bytes: sent }
|
|
114
|
+
}
|