kaloko 0.3.0
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 +119 -0
- package/bin/kaloko.mjs +4 -0
- package/bin/qawalk.mjs +4 -0
- package/package.json +42 -0
- package/schemas/bundle.v1.json +19 -0
- package/schemas/config.v1.json +268 -0
- package/schemas/revision.v1.json +36 -0
- package/schemas/run.v1.json +549 -0
- package/schemas/scenario.v1.json +668 -0
- package/schemas/tokens.v1.json +102 -0
- package/skills/kaloko/SKILL.md +94 -0
- package/skills/kaloko/reference/acceptance.md +208 -0
- package/skills/kaloko/reference/apps.md +161 -0
- package/skills/kaloko/reference/collaboration.md +218 -0
- package/skills/kaloko/reference/design.md +143 -0
- package/skills/kaloko/reference/documentation.md +72 -0
- package/skills/kaloko/reference/environments.md +140 -0
- package/skills/kaloko/reference/evaluation.md +62 -0
- package/skills/kaloko/reference/execution.md +75 -0
- package/skills/kaloko/reference/mcp.md +69 -0
- package/skills/kaloko/reference/stacks.md +122 -0
- package/src/cli.mjs +1344 -0
- package/src/lib/access.mjs +169 -0
- package/src/lib/apps/capture.mjs +59 -0
- package/src/lib/apps/drivers.mjs +284 -0
- package/src/lib/apps/helper.swift +102 -0
- package/src/lib/apps/tree.mjs +222 -0
- package/src/lib/browser-daemon.mjs +41 -0
- package/src/lib/browser.mjs +161 -0
- package/src/lib/build.mjs +149 -0
- package/src/lib/calibrate.mjs +97 -0
- package/src/lib/capture.mjs +233 -0
- package/src/lib/compare.mjs +88 -0
- package/src/lib/config.mjs +117 -0
- package/src/lib/console.mjs +13 -0
- package/src/lib/coverage.mjs +75 -0
- package/src/lib/design-cli.mjs +440 -0
- package/src/lib/design-core.mjs +163 -0
- package/src/lib/design.mjs +164 -0
- package/src/lib/doctor.mjs +149 -0
- package/src/lib/draft.mjs +76 -0
- package/src/lib/drift.mjs +145 -0
- package/src/lib/evaluate.mjs +222 -0
- package/src/lib/git.mjs +70 -0
- package/src/lib/htmlview.mjs +130 -0
- package/src/lib/jev.mjs +95 -0
- package/src/lib/mail.mjs +182 -0
- package/src/lib/manifest-core.mjs +127 -0
- package/src/lib/pack-help.mjs +55 -0
- package/src/lib/packs.mjs +121 -0
- package/src/lib/pr.mjs +78 -0
- package/src/lib/probe.mjs +379 -0
- package/src/lib/recheck.mjs +70 -0
- package/src/lib/run.mjs +85 -0
- package/src/lib/scenario.mjs +98 -0
- package/src/lib/schema.mjs +25 -0
- package/src/lib/secrets.mjs +59 -0
- package/src/lib/service.mjs +52 -0
- package/src/lib/stack.mjs +130 -0
- package/src/lib/tokens.mjs +352 -0
- package/src/lib/util.mjs +97 -0
- package/templates/ACCEPTANCE.md +55 -0
- package/templates/cs/ACCEPTANCE.md +55 -0
- package/templates/kaloko.config.yml +76 -0
- package/templates/mail/letter-opener.mjs +47 -0
- package/templates/scenario.yml +119 -0
- package/templates/steps/consent.mjs +33 -0
- package/viewer/index.html +2772 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path'
|
|
2
|
+
import { readJson, writeJson, sha256 } from './util.mjs'
|
|
3
|
+
import { readCaptures, runPaths } from './run.mjs'
|
|
4
|
+
import { buildRequest, evaluateWithJev, verdictFor, DEFAULTS, PROMPT_VERSION, SIGNAL_NAMES } from './jev.mjs'
|
|
5
|
+
import { needsDrift, driftForCapture, loadTokens, driftCacheDir } from './drift.mjs'
|
|
6
|
+
import { packHelp } from './pack-help.mjs'
|
|
7
|
+
|
|
8
|
+
/** Latest capture per node/locale/viewport. */
|
|
9
|
+
export function latestCaptures (entries) {
|
|
10
|
+
const map = new Map()
|
|
11
|
+
for (const e of entries) if (e.type === 'capture') map.set(`${e.node}|${e.item || ''}|${e.locale}|${e.viewport}`, e)
|
|
12
|
+
return [...map.values()]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const criteriaHash = (node) => sha256(node.criteria || [])
|
|
16
|
+
/** Folder of a capture under steps/: the node, or steps/<node>/items/<item> for a page of a stack step. */
|
|
17
|
+
export const stepIdOf = (e) => (e.item ? `${e.node}/items/${e.item}` : e.node)
|
|
18
|
+
export const evaluationSource = (node, capture) => sha256({ capture, criteria: node.criteria || [], purpose: node.purpose || '' })
|
|
19
|
+
|
|
20
|
+
/** Criterion verdicts for one capture, combining deterministic results and JEV answers. */
|
|
21
|
+
export function combine ({ node, capture, jev, thresholds }) {
|
|
22
|
+
return (node.criteria || []).map((c) => {
|
|
23
|
+
if (c.check.type === 'deterministic') {
|
|
24
|
+
const r = capture.checks?.[c.id]
|
|
25
|
+
const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
|
|
26
|
+
const live = c.check.assert === 'no_console_errors' || c.check.assert === 'no_overflow' || c.check.assert === 'http_status' || /^perf_/.test(c.check.assert || '')
|
|
27
|
+
return { id: c.id, type: 'deterministic', verdict, evidence: r?.evidence ?? null, ...(capture.rechecked_at && !live ? { rechecked: true } : {}) }
|
|
28
|
+
}
|
|
29
|
+
if (c.check.type === 'semantic' && c.check.about === 'console' && !(capture.console_errors || []).length && capture.checks) {
|
|
30
|
+
return { id: c.id, type: 'semantic', verdict: 'pass', evidence: 'no console errors', score: null, provider: null, note: null }
|
|
31
|
+
}
|
|
32
|
+
if (c.check.type === 'semantic') {
|
|
33
|
+
const answer = jev?.response?.answers?.[c.id]
|
|
34
|
+
const score = answer?.noul ?? null
|
|
35
|
+
return { id: c.id, type: 'semantic', verdict: verdictFor(score, thresholds), evidence: null, score, provider: answer ? 'typesafe-jev' : null, note: jev?.skipped || jev?.error || null }
|
|
36
|
+
}
|
|
37
|
+
return { id: c.id, type: 'manual', verdict: 'not_evaluated', evidence: c.check.note || null }
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const BATCH = 50
|
|
42
|
+
|
|
43
|
+
export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
44
|
+
const settings = { ...DEFAULTS, ...(config.evaluators?.jev || {}), thresholds: { ...DEFAULTS.thresholds, ...(config.evaluators?.jev?.thresholds || {}) } }
|
|
45
|
+
const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
|
|
46
|
+
const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
|
|
47
|
+
const paths = runPaths(run.dir)
|
|
48
|
+
const report = { evaluated: 0, semantic_calls: 0, semantic_cached: 0, skipped: [], model: settings.model, drift: 0, outdated: new Set(), via: {}, hosted: null, hostedStop: null, findings: [] }
|
|
49
|
+
// checks against the design reference and the token set (pack design, #42) need the service and a browser
|
|
50
|
+
const drift = scenario.nodes.some(needsDrift) ? await driftContext({ run, scenario, config, root }) : null
|
|
51
|
+
try {
|
|
52
|
+
const latest = latestCaptures(await readCaptures(run.dir))
|
|
53
|
+
// stack steps: semantic criteria only on the first N pages (items.semantic_sample, default 20); cost stays bounded
|
|
54
|
+
const sampled = new Map()
|
|
55
|
+
for (const e of latest) {
|
|
56
|
+
if (!e.item) continue
|
|
57
|
+
const node = nodes.get(e.node); if (!node) continue
|
|
58
|
+
const n = node.items?.semantic_sample ?? 20
|
|
59
|
+
const set = sampled.get(e.node) || new Set(); sampled.set(e.node, set)
|
|
60
|
+
if (set.size < n) set.add(e.item)
|
|
61
|
+
}
|
|
62
|
+
// in batches of 50 captures: memory holds one batch of page states, not a whole stack of thousands
|
|
63
|
+
const hosted = hostedSender(config, report)
|
|
64
|
+
for (let at = 0; at < latest.length; at += BATCH) {
|
|
65
|
+
const batch = latest.slice(at, at + BATCH)
|
|
66
|
+
// 1. what each capture needs; 2. the evaluator calls, several at a time; 3. drift and verdicts in order
|
|
67
|
+
const work = []
|
|
68
|
+
for (const entry of batch) {
|
|
69
|
+
const node = nodes.get(entry.node)
|
|
70
|
+
if (!node) continue
|
|
71
|
+
const capture = await readJson(join(run.dir, entry.file))
|
|
72
|
+
// Old captures remain readable, but checks cannot be reinterpreted after
|
|
73
|
+
// their definitions change. Recapture to bind them to the current criteria.
|
|
74
|
+
const current = capture.criteria_hash === criteriaHash(node)
|
|
75
|
+
// console triage needs the evaluator only when the capture has console errors
|
|
76
|
+
const outOfSample = entry.item && !sampled.get(entry.node)?.has(entry.item)
|
|
77
|
+
const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
78
|
+
// quick signals ride along on every captured page state (also steps without semantic criteria)
|
|
79
|
+
const signals = settings.signals !== false && !outOfSample && !!capture.state
|
|
80
|
+
const request = (semantic.length || signals) && current ? buildRequest({ node, capture, criteria: semantic, model: settings.model, signals }) : null
|
|
81
|
+
work.push({ entry, node, capture, current, outOfSample, request, asks: semantic.length > 0, jev: current ? null : { skipped: 'Capture has no matching criteria fingerprint; capture this step again.' } })
|
|
82
|
+
if (!current) report.outdated.add(entry.node)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const queue = work.filter((w) => w.request)
|
|
86
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(settings.concurrency || 6, queue.length)) }, async () => {
|
|
87
|
+
for (let w = queue.shift(); w; w = queue.shift()) {
|
|
88
|
+
w.jev = report.hostedStop && !process.env[settings.api_key_env] ? { skipped: report.hostedStop } : await evaluateWithJev({ request: w.request, settings, cacheDir, fetchImpl, hosted })
|
|
89
|
+
if (w.jev.response) { w.jev.cached ? report.semantic_cached++ : report.semantic_calls++; report.via[w.jev.via] = (report.via[w.jev.via] || 0) + 1; if (w.jev.response.qawalk) report.hosted = w.jev.response.qawalk }
|
|
90
|
+
// signals alone are best effort: only captures with semantic criteria report why they were not evaluated
|
|
91
|
+
if ((w.jev.skipped || w.jev.error) && w.asks) report.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${w.jev.skipped || w.jev.error}`)
|
|
92
|
+
}
|
|
93
|
+
}))
|
|
94
|
+
for (const { entry, node, capture, current, outOfSample, request, jev } of work) {
|
|
95
|
+
const source = evaluationSource(node, capture)
|
|
96
|
+
if (drift && current && needsDrift(node)) { capture.checks = { ...capture.checks, ...await driftForCapture({ node, entry, capture, runDir: run.dir, ctx: drift }) }; report.drift++ }
|
|
97
|
+
let criteria = combine({ node, capture: current ? capture : {}, jev, thresholds: settings.thresholds })
|
|
98
|
+
// pages outside the semantic sample carry only the deterministic results
|
|
99
|
+
if (outOfSample) criteria = criteria.filter((c) => c.type !== 'semantic' || c.evidence === 'no console errors')
|
|
100
|
+
const evaluation = {
|
|
101
|
+
schema: 'qawalk.eval.v1',
|
|
102
|
+
source,
|
|
103
|
+
node: entry.node, ...(entry.item ? { item: entry.item, semantic_sample: !outOfSample } : {}), locale: entry.locale, viewport: entry.viewport,
|
|
104
|
+
evaluated_at: new Date().toISOString(),
|
|
105
|
+
semantic: jev?.response ? {
|
|
106
|
+
provider: 'typesafe-jev',
|
|
107
|
+
via: jev.via || 'own-key',
|
|
108
|
+
model: jev.response.model || settings.model,
|
|
109
|
+
prompt_version: PROMPT_VERSION,
|
|
110
|
+
state_hash: jev.key,
|
|
111
|
+
cached: jev.cached,
|
|
112
|
+
usage: jev.response.usage || null,
|
|
113
|
+
answers: jev.response.answers
|
|
114
|
+
} : (jev ? { provider: 'typesafe-jev', skipped: jev.skipped || null, error: jev.error || null } : null),
|
|
115
|
+
thresholds: settings.thresholds,
|
|
116
|
+
state_chars: request ? JSON.stringify(request.state).length : null,
|
|
117
|
+
...(signalsOf(jev) ? { signals: signalsOf(jev) } : {}),
|
|
118
|
+
criteria
|
|
119
|
+
}
|
|
120
|
+
await writeJson(paths.step(stepIdOf(entry), entry.locale, entry.viewport, 'eval.json'), evaluation)
|
|
121
|
+
report.evaluated++
|
|
122
|
+
// what did not pass, for the walk summary and agents: step, locale, viewport, criterion and why
|
|
123
|
+
for (const c of criteria) if (c.verdict === 'fail' || c.verdict === 'needs_review') report.findings.push({ node: entry.node, ...(entry.item ? { item: entry.item } : {}), locale: entry.locale, viewport: entry.viewport, id: c.id, verdict: c.verdict, type: c.type, why: c.evidence || (c.score != null ? `AI ${Math.round(c.score * 100)} % yes` : c.note) || null, ...((node.criteria || []).find((x) => x.id === c.id)?.pack && packHelp(c.id) ? { fix: packHelp(c.id).fix } : {}) })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} finally { if (drift?.browser) await drift.browser.close() }
|
|
127
|
+
const { hostedStop, ...out } = report
|
|
128
|
+
return { ...out, outdated: [...report.outdated] }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* While a walk runs: the semantic questions of each new capture go to the evaluator in the background, so the
|
|
133
|
+
* answers are cached by the time the walk ends and `evaluateRun` only assembles verdicts. Pages of stack steps
|
|
134
|
+
* are left to evaluateRun (it samples them).
|
|
135
|
+
*/
|
|
136
|
+
/** The quick signals of an answer: { purpose, language, broken } as probabilities of "yes". */
|
|
137
|
+
function signalsOf (jev) {
|
|
138
|
+
const a = jev?.response?.answers
|
|
139
|
+
if (!a) return null
|
|
140
|
+
const out = {}
|
|
141
|
+
for (const [id, name] of Object.entries(SIGNAL_NAMES)) if (a[id]?.noul != null) out[name] = a[id].noul
|
|
142
|
+
return Object.keys(out).length ? out : null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function semanticPrefetch ({ scenario, config, root }) {
|
|
146
|
+
const settings = { ...DEFAULTS, ...(config.evaluators?.jev || {}), thresholds: { ...DEFAULTS.thresholds, ...(config.evaluators?.jev?.thresholds || {}) } }
|
|
147
|
+
const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
|
|
148
|
+
const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
|
|
149
|
+
const report = { hostedStop: null }
|
|
150
|
+
const hosted = hostedSender(config, report)
|
|
151
|
+
const queue = []; const running = new Set(); let asked = 0
|
|
152
|
+
let idle = null // resolves when the queue drains, for done()
|
|
153
|
+
const pump = () => {
|
|
154
|
+
while (running.size < (settings.concurrency || 6) && queue.length) {
|
|
155
|
+
const job = queue.shift()
|
|
156
|
+
const p = job().catch(() => {}).finally(() => { running.delete(p); pump() })
|
|
157
|
+
running.add(p)
|
|
158
|
+
}
|
|
159
|
+
if (!running.size && !queue.length && idle) { idle.resolve(); idle = null }
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
add (entries, runDir) {
|
|
163
|
+
if (config.evaluators?.jev === false || config.evaluators?.jev === null) return
|
|
164
|
+
for (const e of [].concat(entries || [])) {
|
|
165
|
+
if (e?.type !== 'capture' || e.item) continue
|
|
166
|
+
const node = nodes.get(e.node)
|
|
167
|
+
const semantic = (node?.criteria || []).filter((c) => c.check.type === 'semantic')
|
|
168
|
+
if (!semantic.length && settings.signals === false) continue
|
|
169
|
+
queue.push(async () => {
|
|
170
|
+
const capture = await readJson(join(runDir, e.file))
|
|
171
|
+
const crit = semantic.filter((c) => !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
172
|
+
const signals = settings.signals !== false && !!capture.state
|
|
173
|
+
if ((!crit.length && !signals) || capture.criteria_hash !== criteriaHash(node)) return
|
|
174
|
+
asked++
|
|
175
|
+
await evaluateWithJev({ request: buildRequest({ node, capture, criteria: crit, model: settings.model, signals }), settings, cacheDir, hosted })
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
pump()
|
|
179
|
+
},
|
|
180
|
+
async done () {
|
|
181
|
+
if (running.size || queue.length) { let resolve; const p = new Promise((r) => { resolve = r }); idle = { resolve }; await p }
|
|
182
|
+
return { asked, stopped: report.hostedStop }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Sends a JEV request through the organization's service (hosted evaluation) when a service token is set.
|
|
189
|
+
* An allowance used up or a service without hosting stops further hosted calls for this run with one message.
|
|
190
|
+
*/
|
|
191
|
+
function hostedSender (config, report) {
|
|
192
|
+
let client = null
|
|
193
|
+
return async (body) => {
|
|
194
|
+
if (report.hostedStop) return { skipped: report.hostedStop }
|
|
195
|
+
try { if (!client) { const { service } = await import('./service.mjs'); client = service(config) } } catch { return { skipped: 'no evaluator key (TYPESAFE_API_KEY) and no service token (KALOKO_TOKEN) for hosted evaluation' } }
|
|
196
|
+
try { return { response: await client.request('POST', `/orgs/${config.org}/semantic`, JSON.parse(body)) } } catch (e) {
|
|
197
|
+
const m = String(e.message || e)
|
|
198
|
+
if (/HTTP (402|403|503)/.test(m)) { report.hostedStop = m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.hostedStop } }
|
|
199
|
+
return { error: m }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Context of the design checks: service client, token set, reference cache and a browser for pixel diffs. */
|
|
205
|
+
async function driftContext ({ run, scenario, config, root }) {
|
|
206
|
+
const isDesign = run.meta.environment.kind === 'design'
|
|
207
|
+
const ctx = { org: config.org, scenarioId: scenario.id, isDesign, git: run.meta.git?.branch || null, references: new Map(), cacheDir: driftCacheDir(root, config.output), mode: null, tokens: null, client: null, page: null, browser: null }
|
|
208
|
+
try { const { service } = await import('./service.mjs'); ctx.client = service(config) } catch (e) { ctx.referenceError = `Design checks need the service: ${e.message}` }
|
|
209
|
+
let designDir = null
|
|
210
|
+
try {
|
|
211
|
+
const { environment } = await import('./config.mjs')
|
|
212
|
+
const d = Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design')
|
|
213
|
+
if (d) { designDir = resolve(root, d.serve); ctx.mode = d.tokens_mode || null }
|
|
214
|
+
} catch {}
|
|
215
|
+
ctx.tokens = await loadTokens({ client: ctx.client, config, root, designDir })
|
|
216
|
+
if (!isDesign && scenario.nodes.some((n) => (n.criteria || []).some((c) => c.check.assert === 'design_pixels'))) {
|
|
217
|
+
const { launchEphemeral } = await import('./browser.mjs')
|
|
218
|
+
ctx.browser = await launchEphemeral(config)
|
|
219
|
+
ctx.page = await (await ctx.browser.newContext()).newPage()
|
|
220
|
+
}
|
|
221
|
+
return ctx
|
|
222
|
+
}
|
package/src/lib/git.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
|
|
4
|
+
const run = promisify(execFile)
|
|
5
|
+
|
|
6
|
+
async function out (cmd, args, cwd) {
|
|
7
|
+
try {
|
|
8
|
+
const { stdout } = await run(cmd, args, { cwd, timeout: 15000 })
|
|
9
|
+
return stdout.trim()
|
|
10
|
+
} catch {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** owner/name of the origin remote, for GitHub and GitLab style URLs; null when absent. */
|
|
16
|
+
export async function repoSlug (cwd) {
|
|
17
|
+
const url = await out('git', ['remote', 'get-url', 'origin'], cwd)
|
|
18
|
+
if (!url) return null
|
|
19
|
+
const m = url.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/)
|
|
20
|
+
return m ? { slug: m[1], host: url.includes('github.com') ? 'github' : url.includes('gitlab') ? 'gitlab' : 'other' } : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Project = config.project, else the repository name. Issue = scenario.issue,
|
|
25
|
+
* else a number found in the scenario id (TASK-1273) or the branch (feat/1273-…).
|
|
26
|
+
*/
|
|
27
|
+
export async function projectAndIssue ({ cwd, config, scenario, branch }) {
|
|
28
|
+
const repo = await repoSlug(cwd)
|
|
29
|
+
// the scenario may sort itself into a project; otherwise the config, then the repository name
|
|
30
|
+
const project = scenario.project
|
|
31
|
+
? { slug: scenario.project, repo: repo?.slug || null, source: 'scenario' }
|
|
32
|
+
: config.project
|
|
33
|
+
? { slug: config.project, repo: repo?.slug || null, source: 'config' }
|
|
34
|
+
: repo ? { slug: repo.slug.split('/').pop(), repo: repo.slug, source: 'git' } : { slug: cwd.split('/').pop(), repo: null, source: 'directory' }
|
|
35
|
+
let issue = null
|
|
36
|
+
const given = scenario.issue
|
|
37
|
+
const num = given?.number || Number((scenario.id.match(/(?:^|[-_#])(\d{2,6})(?:$|[-_])/) || [])[1]) || Number((String(branch || '').match(/(?:^|[/-])(\d{2,6})(?:-|$)/) || [])[1]) || null
|
|
38
|
+
if (num) {
|
|
39
|
+
const slug = given?.repo || repo?.slug || null
|
|
40
|
+
const url = given?.url || (slug && repo?.host === 'github' ? `https://github.com/${slug}/issues/${num}` : slug && repo?.host === 'gitlab' ? `https://gitlab.com/${slug}/-/issues/${num}` : null)
|
|
41
|
+
issue = { repo: slug, number: num, url, title: given?.title || null }
|
|
42
|
+
}
|
|
43
|
+
return { project, issue }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Branch, commit, author and open/merged PR (via gh, optional). */
|
|
47
|
+
/** Application version for documentation captures: the tag on HEAD (git describe --exact-match), else null. */
|
|
48
|
+
export async function appVersion (cwd) {
|
|
49
|
+
return (await out('git', ['describe', '--tags', '--exact-match', 'HEAD'], cwd)) || null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function gitMeta (cwd) {
|
|
53
|
+
const commit = await out('git', ['rev-parse', '--short', 'HEAD'], cwd)
|
|
54
|
+
if (!commit) return null
|
|
55
|
+
const [branch, message, author, sha] = await Promise.all([
|
|
56
|
+
out('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd),
|
|
57
|
+
out('git', ['log', '-1', '--format=%s'], cwd),
|
|
58
|
+
out('git', ['log', '-1', '--format=%an'], cwd),
|
|
59
|
+
out('git', ['rev-parse', 'HEAD'], cwd)
|
|
60
|
+
])
|
|
61
|
+
let pr = null
|
|
62
|
+
const json = await out('gh', ['pr', 'view', '--json', 'number,url,state'], cwd)
|
|
63
|
+
if (json) {
|
|
64
|
+
try {
|
|
65
|
+
const p = JSON.parse(json)
|
|
66
|
+
pr = { number: p.number, url: p.url, state: String(p.state).toLowerCase() }
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
return { branch, commit, sha, message, author, pr }
|
|
70
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// HTML view of captured app pages (#42, experimental): a static copy of the page as it rendered, with its
|
|
2
|
+
// stylesheets, images and fonts rehosted, every script and navigation removed, and secrets and personal data
|
|
3
|
+
// scrubbed. It opens in the same frame as live design steps, so the inspector and pins work on the real product.
|
|
4
|
+
// The service sanitises again when serving (defence in depth): nothing here is trusted by it.
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { join, dirname } from 'node:path'
|
|
7
|
+
import { sha256Hex, revisionId, bundleProblems } from './design-core.mjs'
|
|
8
|
+
|
|
9
|
+
const ASSET_TYPES = { 'text/css': 'css', 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/svg+xml': 'svg', 'image/avif': 'avif', 'font/woff2': 'woff2', 'font/woff': 'woff', 'application/font-woff2': 'woff2', 'application/font-woff': 'woff' }
|
|
10
|
+
export const VIEW_LIMITS = { max_assets: 150, max_asset_bytes: 3 * 1024 * 1024, max_total_bytes: 25 * 1024 * 1024 }
|
|
11
|
+
|
|
12
|
+
/** Runs in the page: a scrubbed clone of the document with asset URLs replaced by placeholders. Self-contained. */
|
|
13
|
+
export function viewInPage ({ mask = [] }) {
|
|
14
|
+
const doc = document.documentElement.cloneNode(true)
|
|
15
|
+
const liveEls = [...document.querySelectorAll('input, textarea, select')]
|
|
16
|
+
const cloneEls = [...doc.querySelectorAll('input, textarea, select')]
|
|
17
|
+
// form state as rendered (not passwords): values into attributes so the static copy shows them
|
|
18
|
+
liveEls.forEach((el, i) => {
|
|
19
|
+
const c = cloneEls[i]; if (!c) return
|
|
20
|
+
if (el.type === 'password' || el.type === 'hidden') { c.removeAttribute('value'); return }
|
|
21
|
+
if (el.tagName === 'TEXTAREA') c.textContent = el.value
|
|
22
|
+
else if (el.tagName === 'SELECT') [...c.options].forEach((o, k) => { if (el.options[k]?.selected) o.setAttribute('selected', ''); else o.removeAttribute('selected') })
|
|
23
|
+
else if (el.type === 'checkbox' || el.type === 'radio') { if (el.checked) c.setAttribute('checked', ''); else c.removeAttribute('checked') } else c.setAttribute('value', el.value)
|
|
24
|
+
})
|
|
25
|
+
const drop = 'script, noscript, iframe, frame, frameset, object, embed, applet, base, portal, template, link[rel~=manifest], link[rel~=serviceworker], link[rel~=modulepreload], link[rel~=preload][as=script], link[rel~=prefetch], link[rel~=dns-prefetch], link[rel~=preconnect], meta[http-equiv]'
|
|
26
|
+
doc.querySelectorAll(drop).forEach((e) => e.remove())
|
|
27
|
+
// inline JSON state (framework hydration data) and csrf metas carry session data
|
|
28
|
+
doc.querySelectorAll('meta[name*=csrf i], meta[name*=token i], meta[name*=nonce i]').forEach((e) => e.remove())
|
|
29
|
+
const SECRET_SRC = '\\b(eyJ[\\w-]{10,}\\.[\\w-]{10,}\\.[\\w-]{10,}|(?:sk|pk|rk|ghp|gho|xox[abp])_[A-Za-z0-9_-]{16,}|[A-Za-z0-9+/_-]{40,}={0,2})\\b'
|
|
30
|
+
// a fresh expression per use: a shared global one would skip matches through lastIndex
|
|
31
|
+
const SECRET = { test: (v) => new RegExp(SECRET_SRC).test(v), all: () => new RegExp(SECRET_SRC, 'g') }
|
|
32
|
+
const URL_ATTR = /^(href|src|action|formaction|xlink:href|poster|data|background|srcset|ping)$/i
|
|
33
|
+
for (const el of doc.querySelectorAll('*')) {
|
|
34
|
+
for (const a of [...el.attributes]) {
|
|
35
|
+
if (/^on/i.test(a.name)) { el.removeAttribute(a.name); continue }
|
|
36
|
+
if (URL_ATTR.test(a.name) && /^\s*(javascript|vbscript|data:(text\/html|application|image\/svg))/i.test(a.value)) { el.removeAttribute(a.name); continue }
|
|
37
|
+
if (/^(data-|aria-label|title|alt|value|content)/i.test(a.name) && SECRET.test(a.value)) el.setAttribute(a.name, a.value.replace(SECRET.all(), '<token>'))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
doc.querySelectorAll('form').forEach((f) => { f.removeAttribute('action'); f.removeAttribute('method') })
|
|
41
|
+
// a view is for looking: links keep their target for reference but navigate nowhere
|
|
42
|
+
doc.querySelectorAll('a[href], area[href]').forEach((a) => { a.setAttribute('data-href', a.getAttribute('href')); a.setAttribute('href', '#') })
|
|
43
|
+
// personal data: masked text (not only blurred pixels)
|
|
44
|
+
for (const sel of mask) { try { doc.querySelectorAll(sel).forEach((el) => { el.textContent = '••••'; el.removeAttribute('value') }) } catch {} }
|
|
45
|
+
const walker = document.createTreeWalker(doc, NodeFilter.SHOW_TEXT)
|
|
46
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) if (SECRET.test(n.textContent)) n.textContent = n.textContent.replace(SECRET.all(), '<token>')
|
|
47
|
+
// assets → placeholders
|
|
48
|
+
const assets = []
|
|
49
|
+
const slot = (url) => { try { const abs = new URL(url, document.baseURI).href; if (!/^https?:/.test(abs)) return null; let i = assets.indexOf(abs); if (i < 0) { i = assets.push(abs) - 1 } return `__QW_ASSET_${i}__` } catch { return null } }
|
|
50
|
+
doc.querySelectorAll('link[rel~=stylesheet][href], link[rel~=icon][href]').forEach((l) => { const s = slot(l.getAttribute('href')); if (s) l.setAttribute('href', s); else l.remove() })
|
|
51
|
+
doc.querySelectorAll('link:not([rel~=stylesheet]):not([rel~=icon])').forEach((l) => l.remove())
|
|
52
|
+
doc.querySelectorAll('img[src], source[src], video[poster], image, use').forEach((el) => {
|
|
53
|
+
for (const at of ['src', 'poster', 'href', 'xlink:href']) { const v = el.getAttribute(at); if (!v || v.startsWith('data:image/') || v.startsWith('#')) continue; const s = slot(v); if (s) el.setAttribute(at, s); else el.removeAttribute(at) }
|
|
54
|
+
})
|
|
55
|
+
doc.querySelectorAll('img[srcset], source[srcset]').forEach((el) => {
|
|
56
|
+
// the candidate the browser picked is enough for a static copy
|
|
57
|
+
const cur = el.currentSrc || el.getAttribute('srcset').split(',')[0].trim().split(/\s+/)[0]
|
|
58
|
+
el.removeAttribute('srcset'); const s = slot(cur); if (s && el.tagName === 'IMG') el.setAttribute('src', s)
|
|
59
|
+
})
|
|
60
|
+
const cssUrls = (text) => text.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (m, q, u) => u.startsWith('data:') ? m : (slot(u) ? `url(${slot(u)})` : 'none'))
|
|
61
|
+
doc.querySelectorAll('style').forEach((s) => { s.textContent = cssUrls(s.textContent) })
|
|
62
|
+
doc.querySelectorAll('[style]').forEach((el) => el.setAttribute('style', cssUrls(el.getAttribute('style'))))
|
|
63
|
+
return { html: '<!doctype html>\n' + doc.outerHTML, assets, base: document.baseURI }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Builds the HTML view of the page for one capture: downloads the assets through the browser context (same
|
|
68
|
+
* cookies and gate headers as the capture), rewrites CSS url()s, writes the bundle into the run directory
|
|
69
|
+
* (live/<revision>/…) and returns the revision ({ id, entry, files, sizes }).
|
|
70
|
+
*/
|
|
71
|
+
export async function buildHtmlView ({ page, run, scenarioId, node, locale, viewport, mask = [] }) {
|
|
72
|
+
const { html, assets } = await page.evaluate(viewInPage, { mask })
|
|
73
|
+
const request = page.context().request
|
|
74
|
+
const files = {}, sizes = {}, bodies = new Map()
|
|
75
|
+
let total = 0
|
|
76
|
+
const names = new Map()
|
|
77
|
+
const fetchAsset = async (url, depth = 0) => {
|
|
78
|
+
if (names.has(url)) return names.get(url)
|
|
79
|
+
if (names.size >= VIEW_LIMITS.max_assets) return null
|
|
80
|
+
names.set(url, null)
|
|
81
|
+
let res
|
|
82
|
+
try { res = await request.get(url, { timeout: 15000, failOnStatusCode: false, maxRedirects: 3 }) } catch { return null }
|
|
83
|
+
if (!res.ok()) return null
|
|
84
|
+
const type = (res.headers()['content-type'] || '').split(';')[0].trim().toLowerCase()
|
|
85
|
+
const ext = ASSET_TYPES[type]
|
|
86
|
+
if (!ext) return null
|
|
87
|
+
let body = await res.body()
|
|
88
|
+
if (body.length > VIEW_LIMITS.max_asset_bytes || total + body.length > VIEW_LIMITS.max_total_bytes) return null
|
|
89
|
+
if (ext === 'css' && depth < 2) {
|
|
90
|
+
// stylesheets point at fonts, images and other sheets relative to themselves: rehost those too
|
|
91
|
+
let css = body.toString('utf8')
|
|
92
|
+
const refs = [...css.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)|@import\s+(['"])([^'"]+)\3/gi)].map((m) => m[2] || m[4]).filter((u) => u && !u.startsWith('data:'))
|
|
93
|
+
for (const ref of [...new Set(refs)]) {
|
|
94
|
+
let abs; try { abs = new URL(ref, url).href } catch { continue }
|
|
95
|
+
const name = await fetchAsset(abs, depth + 1)
|
|
96
|
+
css = css.split(ref).join(name ? name.replace('assets/', '') : 'about:blank')
|
|
97
|
+
}
|
|
98
|
+
body = Buffer.from(css.replace(/expression\s*\(|javascript:|behavior\s*:/gi, ''), 'utf8')
|
|
99
|
+
}
|
|
100
|
+
const hash = await sha256Hex(body)
|
|
101
|
+
const name = `assets/${hash.slice(0, 16)}.${ext}`
|
|
102
|
+
total += body.length
|
|
103
|
+
names.set(url, name)
|
|
104
|
+
files[name] = hash; sizes[name] = body.length; bodies.set(name, body)
|
|
105
|
+
return name
|
|
106
|
+
}
|
|
107
|
+
let out = html
|
|
108
|
+
for (const [i, url] of assets.entries()) {
|
|
109
|
+
const name = await fetchAsset(url)
|
|
110
|
+
out = out.split(`__QW_ASSET_${i}__`).join(name || 'about:blank')
|
|
111
|
+
}
|
|
112
|
+
const htmlBody = Buffer.from(out, 'utf8')
|
|
113
|
+
files['index.html'] = await sha256Hex(htmlBody); sizes['index.html'] = htmlBody.length; bodies.set('index.html', htmlBody)
|
|
114
|
+
const problems = bundleProblems(Object.keys(files).map((p) => ({ path: p, bytes: sizes[p] })), 'index.html')
|
|
115
|
+
if (problems.length) throw new Error(`HTML view of ${node} (${locale}, ${viewport}): ${problems.join('; ')}`)
|
|
116
|
+
const state = `view:${viewport}`
|
|
117
|
+
const id = await revisionId({ scenario: scenarioId, node, locale, state, kind: 'html_view', entry: 'index.html', files })
|
|
118
|
+
for (const [p, body] of bodies) { const to = join(run.dir, 'live', id, p); await mkdir(dirname(to), { recursive: true }); await writeFile(to, body) }
|
|
119
|
+
return { id, node, locale, state, kind: 'html_view', entry: 'index.html', files, sizes }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether an environment may keep HTML views: opted in, and never production pages behind a sign-in. */
|
|
123
|
+
export function htmlViewAllowed (env, scenario) {
|
|
124
|
+
if (!env?.html_view) return false
|
|
125
|
+
if (env.name === 'production' || env.readonly) {
|
|
126
|
+
const signedIn = Object.keys(env.accounts || {}).length || Object.values(scenario.sessions || {}).some((s) => s.account)
|
|
127
|
+
if (signedIn) return false
|
|
128
|
+
}
|
|
129
|
+
return true
|
|
130
|
+
}
|
package/src/lib/jev.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { readJson, writeJson, sha256 } from './util.mjs'
|
|
3
|
+
|
|
4
|
+
export const PROMPT_VERSION = 'qawalk-jev-v1'
|
|
5
|
+
export const DEFAULTS = { hosted: true, signals: true, concurrency: 6, model: 'jev-1.13.0', base_url: 'https://api.typesafe.ai', api_key_env: 'TYPESAFE_API_KEY', thresholds: { pass: 0.85, fail: 0.15 }, max_state_chars: 90000 }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Quick signals asked beside the criteria of every capture: cheap, independent questions about the page that do
|
|
9
|
+
* not change verdicts; they point a person at a step that looks wrong. Question ids start with "_" (the service
|
|
10
|
+
* does not count them against the allowance).
|
|
11
|
+
*/
|
|
12
|
+
export const SIGNALS = {
|
|
13
|
+
_purpose: (node) => node.purpose ? 'Does `page` do what `screen_purpose` says this screen must achieve?' : null,
|
|
14
|
+
_language: (node, capture) => capture.locale ? `Is the visible text of \`page\` mainly in the language with code "${capture.locale}"?` : null,
|
|
15
|
+
_broken: () => 'Does `page` look broken: an error message, placeholder text (lorem ipsum, TODO, {{variables}}), missing content or a blank page?'
|
|
16
|
+
}
|
|
17
|
+
export const SIGNAL_NAMES = { _purpose: 'purpose', _language: 'language', _broken: 'broken' }
|
|
18
|
+
|
|
19
|
+
/** Builds the JEV request for one capture: one `noul` question per semantic criterion, plus the quick signals. */
|
|
20
|
+
export function buildRequest ({ node, capture, criteria, model, signals = false }) {
|
|
21
|
+
const scopes = {}
|
|
22
|
+
const questions = {}
|
|
23
|
+
for (const c of criteria) {
|
|
24
|
+
const scoped = c.check.scope && capture.scoped?.[c.id]
|
|
25
|
+
if (scoped) scopes[c.id] = scoped
|
|
26
|
+
const target = c.check.about === 'console' ? '`console_errors` (browser console errors captured on this page) together with `screen_purpose`' : scoped ? `\`scopes.${c.id}\` (the part of the page matching ${c.check.scope})` : '`page`'
|
|
27
|
+
questions[c.id] = {
|
|
28
|
+
type: 'noul',
|
|
29
|
+
instructions: `The state describes one web page captured during an acceptance test. \`screen_purpose\` states what the page must achieve. Answer only from ${target}. Question: ${c.check.question}`,
|
|
30
|
+
...(c.check.true || c.check.false ? { criteria: { true: c.check.true || 'Yes, the criterion is met.', false: c.check.false || 'No, the criterion is not met.' } } : {})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (signals) {
|
|
34
|
+
for (const [id, q] of Object.entries(SIGNALS)) {
|
|
35
|
+
const text = q(node, capture)
|
|
36
|
+
if (text) questions[id] = { type: 'noul', instructions: `The state describes one web page captured during an acceptance test. Answer only from \`page\`. Question: ${text}` }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const state = {
|
|
40
|
+
screen_purpose: node.purpose || '',
|
|
41
|
+
url: capture.url,
|
|
42
|
+
locale: capture.locale,
|
|
43
|
+
viewport: capture.viewport,
|
|
44
|
+
page: capture.state,
|
|
45
|
+
...(Object.keys(scopes).length ? { scopes } : {}),
|
|
46
|
+
...(criteria.some((c) => c.check.about === 'console') ? { console_errors: (capture.console_errors || []).slice(0, 30) } : {})
|
|
47
|
+
}
|
|
48
|
+
return { model, state, questions }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function verdictFor (noul, thresholds) {
|
|
52
|
+
if (noul == null) return 'not_evaluated'
|
|
53
|
+
if (noul >= thresholds.pass) return 'pass'
|
|
54
|
+
if (noul <= thresholds.fail) return 'fail'
|
|
55
|
+
return 'needs_review'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Calls JEV with a content-addressed cache. Returns {response, cached, via} or {skipped} / {error}.
|
|
60
|
+
* With the evaluator key set locally the call goes to TypeSafe directly (via: 'own-key'); without it, and with a
|
|
61
|
+
* `hosted` sender (the organization's service token), the service evaluates with its key (via: 'qawalk').
|
|
62
|
+
*/
|
|
63
|
+
export async function evaluateWithJev ({ request, settings, cacheDir, fetchImpl = fetch, hosted = null }) {
|
|
64
|
+
const apiKey = process.env[settings.api_key_env]
|
|
65
|
+
let body = JSON.stringify(request)
|
|
66
|
+
if (body.length > settings.max_state_chars + 20000) return { skipped: `state too large (${body.length} chars)` }
|
|
67
|
+
const key = sha256(body + PROMPT_VERSION)
|
|
68
|
+
const cacheFile = join(cacheDir, `${key}.json`)
|
|
69
|
+
const cached = await readJson(cacheFile, null)
|
|
70
|
+
if (cached) return { response: cached, cached: true, key, via: cached.qawalk ? 'qawalk' : 'own-key' }
|
|
71
|
+
if (!apiKey && hosted && settings.hosted !== false) {
|
|
72
|
+
const r = await hosted(body)
|
|
73
|
+
if (r.skipped || r.error) return r
|
|
74
|
+
await writeJson(cacheFile, r.response)
|
|
75
|
+
return { response: r.response, cached: !!r.response.qawalk?.cached, key, via: 'qawalk' }
|
|
76
|
+
}
|
|
77
|
+
if (!apiKey) return { skipped: `${settings.api_key_env} is not set${hosted ? '' : ' and the service is not configured (KALOKO_TOKEN)'}` }
|
|
78
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
79
|
+
const res = await fetchImpl(`${settings.base_url.replace(/\/$/, '')}/v1/systemone`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
82
|
+
body
|
|
83
|
+
})
|
|
84
|
+
if (res.status === 429 || res.status === 529) {
|
|
85
|
+
const wait = Number(res.headers.get('retry-after')) * 1000 || 1000 * 2 ** attempt
|
|
86
|
+
await new Promise((r) => setTimeout(r, wait))
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (!res.ok) return { error: `JEV HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` }
|
|
90
|
+
const response = await res.json()
|
|
91
|
+
await writeJson(cacheFile, response)
|
|
92
|
+
return { response, cached: false, key, via: 'own-key' }
|
|
93
|
+
}
|
|
94
|
+
return { error: 'JEV rate limited after 4 attempts' }
|
|
95
|
+
}
|