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,164 @@
|
|
|
1
|
+
// Design environments (#42): a folder of live HTML steps, served locally, rendered into captures and
|
|
2
|
+
// exchanged with the service as revisions of a shared draft. Node side of design-core.mjs.
|
|
3
|
+
import { createServer } from 'node:http'
|
|
4
|
+
import { readFile, readdir, stat, mkdir, writeFile, copyFile, rm } from 'node:fs/promises'
|
|
5
|
+
import { join, resolve, dirname, relative, sep } from 'node:path'
|
|
6
|
+
import { UserError } from './util.mjs'
|
|
7
|
+
import { bundleProblems, closure, contentTypeOf, revisionId, sha256Hex, TOKENS_FILE, SHARED_DIR, extOf } from './design-core.mjs'
|
|
8
|
+
|
|
9
|
+
export const isDesign = (env) => env?.kind === 'design'
|
|
10
|
+
|
|
11
|
+
/** Absolute design folder of a design environment. */
|
|
12
|
+
export function designDir (env, root) {
|
|
13
|
+
if (!isDesign(env)) throw new UserError(`Environment ${env?.name} is not a design environment (kind: design).`)
|
|
14
|
+
return resolve(root, env.serve)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const exists = async (p) => { try { await stat(p); return true } catch { return false } }
|
|
18
|
+
const isFile = async (p) => { try { return (await stat(p)).isFile() } catch { return false } }
|
|
19
|
+
const toPosix = (p) => p.split(sep).join('/')
|
|
20
|
+
|
|
21
|
+
async function listFiles (dir, base = dir) {
|
|
22
|
+
let entries = []
|
|
23
|
+
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return [] }
|
|
24
|
+
const out = []
|
|
25
|
+
for (const e of entries) {
|
|
26
|
+
if (e.name.startsWith('.')) continue
|
|
27
|
+
const full = join(dir, e.name)
|
|
28
|
+
if (e.isDirectory()) out.push(...await listFiles(full, base))
|
|
29
|
+
else out.push(toPosix(relative(base, full)))
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The files of one step in one locale: `<step>/<locale>/…` overrides `<step>/…` (other locales' folders
|
|
36
|
+
* are left out), plus every bundle file those reference. Returns { entry, files: [{ path, bytes, hash }] }
|
|
37
|
+
* or null when the step has no folder.
|
|
38
|
+
*/
|
|
39
|
+
export async function scanStep ({ dir, node, locale, locales }) {
|
|
40
|
+
const stepDir = join(dir, node)
|
|
41
|
+
if (!await exists(stepDir)) return null
|
|
42
|
+
const own = await listFiles(stepDir)
|
|
43
|
+
const localeDirs = new Set(locales)
|
|
44
|
+
const common = own.filter((p) => !localeDirs.has(p.split('/')[0]))
|
|
45
|
+
const mine = own.filter((p) => p.split('/')[0] === locale)
|
|
46
|
+
const entry = mine.includes(`${locale}/index.html`) ? `${node}/${locale}/index.html` : common.includes('index.html') ? `${node}/index.html` : null
|
|
47
|
+
if (!entry) throw new UserError(`Design step ${node}: add ${node}/index.html (or ${node}/${locale}/index.html).`)
|
|
48
|
+
// roots: the entry and the assets next to it (not the other pages of the folder: a locale's page must not pull in
|
|
49
|
+
// the default page or another locale's); everything else comes in through references
|
|
50
|
+
const entryDir = entry.slice(0, entry.lastIndexOf('/') + 1)
|
|
51
|
+
const beside = (entryDir === `${node}/` ? common : mine.map((p) => p.slice(locale.length + 1))).filter((p) => !p.includes('/') && extOf(p) !== 'html')
|
|
52
|
+
const roots = [entry, ...beside.map((p) => entryDir + p)]
|
|
53
|
+
const paths = await closure({ roots, read: (p) => readFile(join(dir, p), 'utf8'), exists: (p) => isFile(join(dir, p)) })
|
|
54
|
+
const files = []
|
|
55
|
+
for (const path of paths) {
|
|
56
|
+
const buf = await readFile(join(dir, path))
|
|
57
|
+
files.push({ path, bytes: buf.length, hash: await sha256Hex(buf) })
|
|
58
|
+
}
|
|
59
|
+
const problems = bundleProblems(files, entry)
|
|
60
|
+
if (problems.length) throw new UserError(`Design step ${node} (${locale}) breaks the bundle rules:\n - ${problems.join('\n - ')}`)
|
|
61
|
+
return { entry, files }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Token set reference of the design folder (tokens.json), or null. */
|
|
65
|
+
export async function tokensRef (dir, project) {
|
|
66
|
+
const file = join(dir, TOKENS_FILE)
|
|
67
|
+
if (!await exists(file)) return null
|
|
68
|
+
const text = await readFile(file, 'utf8')
|
|
69
|
+
try { JSON.parse(text) } catch (e) { throw new UserError(`${TOKENS_FILE} is not valid JSON: ${e.message}`) }
|
|
70
|
+
return { project: project || null, version: null, hash: await sha256Hex(text) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A revision of one step and locale, with its id. */
|
|
74
|
+
export async function revisionOf ({ dir, scenario, node, locale, locales, tokens }) {
|
|
75
|
+
const scan = await scanStep({ dir, node, locale, locales })
|
|
76
|
+
if (!scan) return null
|
|
77
|
+
// the token set travels with every revision: the canvas inspector names values from it on the live origin
|
|
78
|
+
if (tokens && !scan.files.some((f) => f.path === TOKENS_FILE)) {
|
|
79
|
+
const buf = await readFile(join(dir, TOKENS_FILE))
|
|
80
|
+
scan.files.push({ path: TOKENS_FILE, bytes: buf.length, hash: await sha256Hex(buf) })
|
|
81
|
+
}
|
|
82
|
+
const files = Object.fromEntries(scan.files.map((f) => [f.path, f.hash]))
|
|
83
|
+
const id = await revisionId({ scenario, node, locale, entry: scan.entry, files, tokens })
|
|
84
|
+
return { id, scenario, node, locale, state: 'default', kind: 'design', entry: scan.entry, files, sizes: Object.fromEntries(scan.files.map((f) => [f.path, f.bytes])), tokens }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Serves the design folder on 127.0.0.1 (random port). Only GET/HEAD of allowed bundle types;
|
|
89
|
+
* nothing outside the folder, no directory listings. Returns { url, close }.
|
|
90
|
+
*/
|
|
91
|
+
export async function serveDesign (dir) {
|
|
92
|
+
const server = createServer(async (req, res) => {
|
|
93
|
+
try {
|
|
94
|
+
if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405).end(); return }
|
|
95
|
+
let path = decodeURIComponent(new URL(req.url, 'http://x').pathname).replace(/^\/+/, '')
|
|
96
|
+
if (!path || path.endsWith('/')) path += 'index.html'
|
|
97
|
+
const full = resolve(dir, path)
|
|
98
|
+
const type = contentTypeOf(path)
|
|
99
|
+
if (!full.startsWith(dir + sep) || !type || path.split('/').some((s) => s.startsWith('.'))) { res.writeHead(404).end(); return }
|
|
100
|
+
const body = await readFile(full)
|
|
101
|
+
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store' })
|
|
102
|
+
res.end(req.method === 'HEAD' ? undefined : body)
|
|
103
|
+
} catch { res.writeHead(404).end() }
|
|
104
|
+
})
|
|
105
|
+
await new Promise((ok) => server.listen(0, '127.0.0.1', ok))
|
|
106
|
+
return { url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((ok) => server.close(ok)) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Copies the live files of a revision into the run directory (live/<revision>/<path>) for the local preview. */
|
|
110
|
+
export async function copyLive (runDir, dir, rev) {
|
|
111
|
+
const base = join(runDir, 'live', rev.id)
|
|
112
|
+
for (const path of Object.keys(rev.files)) {
|
|
113
|
+
const to = join(base, path)
|
|
114
|
+
if (await exists(to)) continue
|
|
115
|
+
await mkdir(dirname(to), { recursive: true })
|
|
116
|
+
await copyFile(join(dir, path), to)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- render cache: a capture is reused when the same revision was rendered in the same viewport ----
|
|
121
|
+
|
|
122
|
+
export const cacheDir = (root, output) => resolve(root, output, '.cache', 'design')
|
|
123
|
+
|
|
124
|
+
export async function fromCache ({ root, output, key }) {
|
|
125
|
+
const dir = join(cacheDir(root, output), key.replace(/[^A-Za-z0-9_-]/g, '_'))
|
|
126
|
+
try { return { dir, record: JSON.parse(await readFile(join(dir, 'capture.json'), 'utf8')) } } catch { return null }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function toCache ({ root, output, key, runDir, record }) {
|
|
130
|
+
const dir = join(cacheDir(root, output), key.replace(/[^A-Za-z0-9_-]/g, '_'))
|
|
131
|
+
await rm(dir, { recursive: true, force: true })
|
|
132
|
+
await mkdir(dir, { recursive: true })
|
|
133
|
+
for (const f of ['image', 'thumb', 'html']) if (record.files[f]) await copyFile(join(runDir, record.files[f]), join(dir, `${f}.${extOf(record.files[f])}`))
|
|
134
|
+
await writeFile(join(dir, 'capture.json'), JSON.stringify(record))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Restores a cached capture into the run directory under the step's paths; returns the new record. */
|
|
138
|
+
export async function restoreCached ({ cached, runDir, paths, node, locale, viewport }) {
|
|
139
|
+
const rec = JSON.parse(JSON.stringify(cached.record))
|
|
140
|
+
const files = {}
|
|
141
|
+
for (const f of ['image', 'thumb', 'html']) {
|
|
142
|
+
if (!rec.files[f]) continue
|
|
143
|
+
const ext = f === 'thumb' ? 'thumb.webp' : extOf(rec.files[f])
|
|
144
|
+
const to = paths.step(node, locale, viewport, ext)
|
|
145
|
+
await mkdir(dirname(to), { recursive: true })
|
|
146
|
+
await copyFile(join(cached.dir, `${f}.${extOf(rec.files[f])}`), to)
|
|
147
|
+
files[f] = toPosix(relative(runDir, to))
|
|
148
|
+
}
|
|
149
|
+
rec.files = { png: files.image, image: files.image, thumb: files.thumb || null, html: files.html }
|
|
150
|
+
return rec
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- draft base: which draft revisions the local folder was last synced with ----
|
|
154
|
+
|
|
155
|
+
const baseFile = (dir) => join(dir, '.qawalk', 'draft.json')
|
|
156
|
+
export async function readBase (dir) { try { return JSON.parse(await readFile(baseFile(dir), 'utf8')) } catch { return null } }
|
|
157
|
+
export async function writeBase (dir, base) {
|
|
158
|
+
await mkdir(dirname(baseFile(dir)), { recursive: true })
|
|
159
|
+
await writeFile(baseFile(dir), JSON.stringify(base, null, 2) + '\n')
|
|
160
|
+
await writeFile(join(dir, '.qawalk', '.gitignore'), '*\n').catch(() => {})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const headKey = (node, locale, state = 'default') => `${node}|${locale}|${state}`
|
|
164
|
+
export { SHARED_DIR }
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// `kaloko doctor`: is this project ready to run and share a walkthrough?
|
|
2
|
+
// Every check returns { name, ok, detail, hint } so the CLI and tests can read it.
|
|
3
|
+
import { appTools, macPermissions } from './apps/drivers.mjs'
|
|
4
|
+
import { createServer } from 'node:net'
|
|
5
|
+
import { access as exists, stat } from 'node:fs/promises'
|
|
6
|
+
import { resolve } from 'node:path'
|
|
7
|
+
import { environment } from './config.mjs'
|
|
8
|
+
import { gateOf, gateHeaders, accountFor } from './access.mjs'
|
|
9
|
+
import { missing } from './secrets.mjs'
|
|
10
|
+
import { portFor } from './browser.mjs'
|
|
11
|
+
import { DEFAULTS as JEV } from './jev.mjs'
|
|
12
|
+
import { serviceUrl } from './service.mjs'
|
|
13
|
+
|
|
14
|
+
const portFree = (port) => new Promise((done) => {
|
|
15
|
+
const s = createServer().once('error', () => done(false)).once('listening', () => s.close(() => done(true)))
|
|
16
|
+
s.listen(port, '127.0.0.1')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
async function reach (url, fetchImpl, ms = 6000, headers = {}) {
|
|
20
|
+
const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), ms)
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetchImpl(url, { method: 'GET', redirect: 'manual', signal: ctl.signal, headers: { 'User-Agent': 'kaloko-doctor', ...headers } })
|
|
23
|
+
return { ok: res.status < 500, status: res.status }
|
|
24
|
+
} catch (e) { return { ok: false, error: e.name === 'AbortError' ? `no answer in ${ms / 1000} s` : e.message } } finally { clearTimeout(t) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} o
|
|
29
|
+
* @param {object} o.config loaded kaloko.config.yml
|
|
30
|
+
* @param {string} o.root project root
|
|
31
|
+
* @param {Function} [o.fetchImpl]
|
|
32
|
+
* @param {Function} [o.launchChrome] async () => { version } — throws when Chrome is missing
|
|
33
|
+
* @param {object} [o.env] process.env
|
|
34
|
+
*/
|
|
35
|
+
export async function doctor ({ config, root, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false }) {
|
|
36
|
+
const checks = []
|
|
37
|
+
const add = (name, ok, detail = '', hint = '') => checks.push({ name, ok, detail, hint })
|
|
38
|
+
|
|
39
|
+
const major = Number(process.versions.node.split('.')[0])
|
|
40
|
+
add('node', major >= 20, `Node ${process.versions.node}`, major >= 20 ? '' : 'Kaloko needs Node 20 or newer.')
|
|
41
|
+
|
|
42
|
+
add('config', true, `${root}/qawalk.config.yml · org ${config.org || '—'} · ${Object.keys(config.environments || {}).length} environments`)
|
|
43
|
+
if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the organization slug from kaloko.app).')
|
|
44
|
+
|
|
45
|
+
try { const c = await launchChrome(config); add('chrome', true, c.version ? `Chrome ${c.version}` : 'Chrome found') } catch (e) { add('chrome', false, e.message.split('\n')[0].slice(0, 160), config.browser?.channel ? 'Install Google Chrome, or remove browser.channel to use bundled Chromium (npx playwright install chromium).' : 'Run: npx playwright install chromium') }
|
|
46
|
+
|
|
47
|
+
for (const name of Object.keys(config.environments || {})) {
|
|
48
|
+
let e
|
|
49
|
+
try { e = environment(config, name, env) } catch (err) { add(`env ${name}`, false, err.message, 'Set the variable, or run doctor where CI provides it.'); continue }
|
|
50
|
+
const port = portFor(config, name)
|
|
51
|
+
const free = await portFree(port)
|
|
52
|
+
add(`port ${name}`, true, `${port} ${free ? 'free' : 'in use (shared browser running, or another project)'}`)
|
|
53
|
+
const base = typeof e.base_url === 'string' ? e.base_url : Object.values(e.base_url || {})[0]
|
|
54
|
+
// apps: the tools their driver needs
|
|
55
|
+
for (const p of Object.keys(e.app || {})) {
|
|
56
|
+
const t = await appTools()
|
|
57
|
+
if (p === 'android') add(`app ${name} android`, t.adb, t.adb ? `adb found · ${e.app.android.package || 'no package'}` : 'adb not found', 'Install Android platform-tools and start an emulator or connect a device (adb devices).')
|
|
58
|
+
if (p === 'ios') add(`app ${name} ios`, t.simctl, t.simctl ? `simctl · ${t.idb ? 'idb for the UI tree and taps' : 'no idb: screen text by OCR, deep links only'}` : 'Xcode simctl not found', 'Install Xcode with an iOS Simulator runtime; optional idb (brew install facebook/fb/idb-companion, pip install fb-idb) for the UI tree and taps.')
|
|
59
|
+
if (p === 'desktop') {
|
|
60
|
+
if (process.platform !== 'darwin') { add(`app ${name} desktop`, false, 'built-in desktop capture needs macOS', 'On Windows or Linux capture with kaloko capture --image --tree.'); continue }
|
|
61
|
+
const perm = await macPermissions(false).catch(() => null)
|
|
62
|
+
add(`app ${name} desktop`, !!perm?.screen, perm ? `Screen Recording ${perm.screen ? 'allowed' : 'not allowed'} · Accessibility ${perm.accessibility ? 'allowed (UI tree)' : 'not allowed (text by OCR)'}` : 'the macOS helper could not run', perm?.screen ? '' : 'Run kaloko doctor --fix to ask macOS, then turn this terminal on in System Settings → Privacy & Security → Screen Recording (and Accessibility for the UI tree) and restart it.')
|
|
63
|
+
if (fix && perm && (!perm.screen || !perm.accessibility)) await macPermissions(true).catch(() => {})
|
|
64
|
+
}
|
|
65
|
+
if (p === 'electron') add(`app ${name} electron`, true, e.app.electron.main || e.app.electron.executable || 'electron')
|
|
66
|
+
}
|
|
67
|
+
if (!base && e.app) continue
|
|
68
|
+
if (!base) { add(`env ${name}`, false, 'no base_url', 'Every environment needs base_url (or an app block for app scenarios).'); continue }
|
|
69
|
+
// the gate in front of the environment: its variables, then a request through it
|
|
70
|
+
const gate = gateOf(e)
|
|
71
|
+
const gateVars = [gate.basic?.user_env, gate.basic?.password_env, ...Object.values(gate.headers || {}).map((h) => h?.env), gate.client_certificate?.cert_env, gate.client_certificate?.key_env, gate.client_certificate?.passphrase_env]
|
|
72
|
+
const unset = missing(gateVars, env)
|
|
73
|
+
if (unset.length) { add(`access ${name}`, false, `not set: ${unset.join(', ')}`, `Put them into .env or ~/.config/kaloko/.env (ask whoever runs ${name} for the values).`); continue }
|
|
74
|
+
let headers = {}
|
|
75
|
+
try { headers = gateHeaders(e, env) } catch (err) { add(`access ${name}`, false, err.message); continue }
|
|
76
|
+
const certOnly = !!gate.client_certificate && !Object.keys(headers).length
|
|
77
|
+
const r = await reach(base, fetchImpl, 6000, headers)
|
|
78
|
+
const denied = r.status === 401 || r.status === 403
|
|
79
|
+
let hint = ''
|
|
80
|
+
if (denied) hint = Object.keys(headers).length ? `The gate rejected the credentials in access (HTTP ${r.status}); check the values.` : `The environment is protected (HTTP ${r.status}). Describe its gate under environments.${name}.access (basic, headers or client_certificate).`
|
|
81
|
+
else if (!r.ok) hint = name === 'local' ? 'Start the application locally, or run against another environment.' : 'Check the URL and your network or VPN.'
|
|
82
|
+
// doctor does not present client certificates; with mutual TLS only the browser gets through
|
|
83
|
+
const ok = certOnly || (r.ok && !denied)
|
|
84
|
+
add(`env ${name}`, ok, `${base} → ${r.error || `HTTP ${r.status}`}${Object.keys(headers).length ? ' · through the gate' : ''}${certOnly ? ' · client certificate (checked by the browser only)' : ''}${e.readonly ? ' · readonly' : ''}`, ok ? '' : hint)
|
|
85
|
+
for (const [key, spec] of Object.entries(e.accounts || {})) {
|
|
86
|
+
const need = missing([spec.user_env, spec.password_env, spec.totp_env], env)
|
|
87
|
+
const saved = await stat(accountFor(e, key, { root, config }).storage_state).catch(() => null)
|
|
88
|
+
const detail = saved ? `saved sign-in ${Math.round((Date.now() - saved.mtimeMs) / 36e5)} h old` : 'no saved sign-in'
|
|
89
|
+
add(`account ${name}/${key}`, !need.length || !!saved, need.length ? `not set: ${need.join(', ')} · ${detail}` : detail, need.length && !saved ? `Set the variables, or have a person run: kaloko auth save --env ${name} --account ${key}` : '')
|
|
90
|
+
}
|
|
91
|
+
const mail = e.mail
|
|
92
|
+
if (mail?.provider === 'script') {
|
|
93
|
+
const file = resolve(root, mail.script || '')
|
|
94
|
+
const found = mail.script && await exists(file).then(() => true, () => false)
|
|
95
|
+
add(`mail ${name}`, !!found, found ? `script ${mail.script}` : `mail.script ${mail.script || '(missing)'} not found`, found ? '' : 'Point mail.script to the adapter module (see templates/mail/letter-opener.mjs in the package).')
|
|
96
|
+
} else if (mail?.provider === 'mailpit' && mail.url) {
|
|
97
|
+
const basic = mail.auth?.basic || (mail.user_env ? mail : null)
|
|
98
|
+
const need = missing([basic?.user_env, basic?.password_env, ...Object.values(mail.auth?.headers || {}).map((h) => h?.env)], env)
|
|
99
|
+
if (need.length) add(`mail ${name}`, false, `not set: ${need.join(', ')}`, 'Put the mailbox credentials into .env.')
|
|
100
|
+
else {
|
|
101
|
+
const m = await reach(mail.url.replace(/\/$/, '') + '/api/v1/info', fetchImpl, 6000, basic ? { Authorization: 'Basic ' + Buffer.from(`${env[basic.user_env]}:${env[basic.password_env]}`).toString('base64') } : {})
|
|
102
|
+
add(`mail ${name}`, m.ok && m.status < 400, `Mailpit ${mail.url} → ${m.error || `HTTP ${m.status}`}`, m.status === 401 || m.status === 403 ? 'Mailpit needs mail.auth (basic or headers).' : m.ok && m.status < 400 ? '' : 'Check mail.url and that Mailpit runs.')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
|
|
108
|
+
const token = env[tokenEnv]
|
|
109
|
+
const service = serviceUrl(config, env)
|
|
110
|
+
if (!token) add('token', false, `${tokenEnv} not set`, `Create a tester token in ${service}/${config.org || '<org>'}/settings and put ${tokenEnv}=… into .env (or ~/.config/kaloko/.env).`)
|
|
111
|
+
else {
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetchImpl(`${service}/api/v1/me`, { headers: { Authorization: `Bearer ${token}` } })
|
|
114
|
+
if (!res.ok) add('token', false, `${service} → HTTP ${res.status}`, res.status === 401 ? `${tokenEnv} is invalid, expired or revoked; create a new one in Settings.` : 'The service did not accept the request.')
|
|
115
|
+
else {
|
|
116
|
+
const me = await res.json()
|
|
117
|
+
const sameOrg = !config.org || me.org === config.org
|
|
118
|
+
add('token', sameOrg, `${me.email} · ${me.role} · org ${me.org}${me.role === 'viewer' ? ' (viewer tokens cannot share runs)' : ''}`, sameOrg ? '' : `The token belongs to organization "${me.org}" but the config says "${config.org}".`)
|
|
119
|
+
if (me.role === 'viewer') add('token role', false, 'viewer', 'kaloko share needs a tester token.')
|
|
120
|
+
}
|
|
121
|
+
} catch (e) { add('token', false, `${service} unreachable: ${e.message}`, 'Check your network; KALOKO_URL overrides the service URL.') }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const jev = config.evaluators?.jev
|
|
125
|
+
if (jev === null || config.evaluators?.jev === false) add('evaluator', true, 'semantic evaluator disabled in config')
|
|
126
|
+
else {
|
|
127
|
+
const keyEnv = jev?.api_key_env || JEV.api_key_env
|
|
128
|
+
if (env[keyEnv]) add('evaluator', true, `${keyEnv} set · your key · model ${jev?.model || JEV.model}`)
|
|
129
|
+
else if (token && jev?.hosted !== false) {
|
|
130
|
+
// no own key: the organization's service evaluates (plan allowance per month)
|
|
131
|
+
let s = null
|
|
132
|
+
try { const r = await fetchImpl(`${service}/api/v1/orgs/${config.org}/semantic`, { headers: { Authorization: `Bearer ${token}` } }); if (r.ok) s = await r.json() } catch {}
|
|
133
|
+
if (s?.available) add('evaluator', true, `via Kaloko · ${s.questions}${s.limit != null ? ` of ${s.limit}` : ''} questions this month`)
|
|
134
|
+
else add('evaluator', false, s ? 'hosted evaluation not included in the plan' : 'hosted evaluation unavailable', `Put ${keyEnv} into .env or ~/.config/kaloko/.env to evaluate with your own key${s ? ', or upgrade the plan' : ''}.`)
|
|
135
|
+
} else add('evaluator', false, `${keyEnv} not set`, `Semantic criteria will be "not evaluated" until ${keyEnv} is in .env or ~/.config/kaloko/.env${jev?.hosted === false ? '' : `, or ${tokenEnv} lets the service evaluate`}.`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { ok: checks.every((c) => c.ok), checks }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function defaultLaunch (config) {
|
|
142
|
+
const { chromium } = await import('playwright')
|
|
143
|
+
const browser = await chromium.launch({ headless: true, channel: config.browser?.channel || undefined })
|
|
144
|
+
const version = browser.version()
|
|
145
|
+
await browser.close()
|
|
146
|
+
return { version }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const formatDoctor = ({ ok, checks }) => [...checks.map((c) => `${c.ok ? '✓' : '✕'} ${c.name.padEnd(16)} ${c.detail}${!c.ok && c.hint ? `\n → ${c.hint}` : ''}`), ok ? 'All good.' : 'Fix the ✕ items above.'].join('\n')
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// `kaloko draft <url…>`: a first acceptance plan and scenario for public pages, drafted from the pages
|
|
2
|
+
// themselves (title, description, H1, language). Read-only production scenario with the seo, a11y, perf and
|
|
3
|
+
// console packs; the agent or person then sharpens purposes and criteria. No service or model call.
|
|
4
|
+
import yaml from 'js-yaml'
|
|
5
|
+
|
|
6
|
+
const decode = (s) => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'|'/g, "'").replace(/ /g, ' ').replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
|
7
|
+
const text = (html) => decode(String(html || '').replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim()
|
|
8
|
+
|
|
9
|
+
/** Title, description, first H1 and language of an HTML document (regex, no DOM). */
|
|
10
|
+
export function pageFacts (html) {
|
|
11
|
+
const pick = (re) => { const m = String(html || '').match(re); return m ? text(m[1]) : '' }
|
|
12
|
+
const meta = (name) => { const m = String(html || '').match(new RegExp(`<meta[^>]+(?:name|property)=["']${name}["'][^>]*>`, 'i')); return m ? decode((m[0].match(/content=["']([^"']*)["']/i) || [])[1] || '').trim() : '' }
|
|
13
|
+
return { title: pick(/<title[^>]*>([\s\S]*?)<\/title>/i), description: meta('description') || meta('og:description'), h1: pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i), lang: ((String(html || '').match(/<html[^>]*\blang=["']([a-zA-Z-]+)["']/i) || [])[1] || '').slice(0, 2).toLowerCase() }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const slug = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
|
|
17
|
+
|
|
18
|
+
/** pages: [{ url, facts }] (same origin) → { scenario, plan, origin } */
|
|
19
|
+
export function draftScenario (pages, { id, title } = {}) {
|
|
20
|
+
if (!pages.length) throw new Error('Give at least one URL.')
|
|
21
|
+
const origin = new URL(pages[0].url).origin
|
|
22
|
+
for (const p of pages) if (new URL(p.url).origin !== origin) throw new Error(`All URLs must share one origin (${origin}); ${p.url} does not.`)
|
|
23
|
+
const host = new URL(origin).hostname.replace(/^www\./, '')
|
|
24
|
+
const ids = new Set()
|
|
25
|
+
const nodes = pages.map((p, i) => {
|
|
26
|
+
const u = new URL(p.url)
|
|
27
|
+
let nid = u.pathname === '/' ? 'home' : slug(u.pathname) || `page-${i + 1}`
|
|
28
|
+
while (ids.has(nid)) nid = `${nid}-${i + 1}`
|
|
29
|
+
ids.add(nid)
|
|
30
|
+
const f = p.facts || {}
|
|
31
|
+
const name = f.h1 || f.title || u.pathname
|
|
32
|
+
return {
|
|
33
|
+
id: nid, kind: 'screen', title: name.slice(0, 80), path: u.pathname + u.search,
|
|
34
|
+
purpose: f.description ? `A visitor understands: ${f.description}` : `A visitor understands what ${name} offers and what to do next.`,
|
|
35
|
+
packs: ['seo', 'a11y', 'perf', 'console'],
|
|
36
|
+
execution: i === 0
|
|
37
|
+
? { strategy: 'playwright', script: 'steps/consent.mjs', instructions: 'Open the page as a new visitor and accept the consent dialog if there is one.' }
|
|
38
|
+
: { strategy: 'playwright', script: 'steps/open.mjs', instructions: `Open ${u.pathname}.` },
|
|
39
|
+
criteria: [
|
|
40
|
+
{ id: 'AC1', text: 'One main heading', check: { type: 'deterministic', assert: 'count', selector: 'h1', min: 1, max: 1 } },
|
|
41
|
+
{ id: 'AC2', text: 'The purpose is clear on the first screen', check: { type: 'semantic', question: `Does the first screen make clear${f.description ? ` that ${f.description.replace(/[.?!]+$/, '')}` : ' what this page offers'}, and what the visitor can do next?`, scope: 'main, body' } }
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
// every site has an error page: an address that cannot exist must answer 404 with a way back
|
|
46
|
+
let nf = 'not-found'; while (ids.has(nf)) nf += '-page'
|
|
47
|
+
nodes.push({
|
|
48
|
+
id: nf, kind: 'screen', title: 'Page not found', path: '/no-such-page-{random}',
|
|
49
|
+
purpose: 'A visitor who follows a broken link gets a real 404 with a way back to the site.',
|
|
50
|
+
packs: ['errors'],
|
|
51
|
+
execution: { strategy: 'playwright', script: 'steps/open.mjs', instructions: 'Open an address that does not exist ({random} makes it unique).' },
|
|
52
|
+
criteria: []
|
|
53
|
+
})
|
|
54
|
+
const scenarioId = id || `SITE-${slug(host).toUpperCase()}`
|
|
55
|
+
const scenario = {
|
|
56
|
+
schema: 'qawalk.scenario.v1', id: scenarioId, title: title || `Public pages of ${host}`, kind: 'flow', version: 1,
|
|
57
|
+
locales: [...new Set(pages.map((p) => p.facts?.lang).filter(Boolean))].slice(0, 3).concat(pages.some((p) => p.facts?.lang) ? [] : ['en']),
|
|
58
|
+
viewports: { desktop: [1280, 800], mobile: [390, 844] },
|
|
59
|
+
environments: ['production'], readonly: true, lanes: ['Visitor'],
|
|
60
|
+
nodes,
|
|
61
|
+
edges: nodes.slice(1).map((n) => ({ from: nodes[0].id, to: n.id, label: 'visit', kind: 'alt' }))
|
|
62
|
+
}
|
|
63
|
+
const plan = [
|
|
64
|
+
`# Acceptance plan ${scenarioId}`, '',
|
|
65
|
+
`Source of truth for intent. Machine scenario: next to this file. Drafted by \`kaloko draft\` from ${pages.length} page(s) of ${origin} on ${new Date().toISOString().slice(0, 10)}; sharpen purposes and criteria before relying on it.`, '',
|
|
66
|
+
'## Goal and scope', '', `Public pages of ${host} read well, are findable, accessible and fast, as a visitor sees them. Read-only on production; nothing is submitted.`, '',
|
|
67
|
+
'## Steps and criteria', '',
|
|
68
|
+
'Every page step also runs the packs `seo` (AC1xx), `a11y` (AC3xx), `perf` (AC4xx) and `console` (AC501); the error page runs `errors` (AC6xx).', '',
|
|
69
|
+
...nodes.flatMap((n) => [`### ${n.id} — ${n.title} (\`screen\`)`, '', `- Path: \`${n.path}\``, `- Purpose: ${n.purpose}`, ...n.criteria.map((c) => `- ${c.id}: ${c.text}.`), '']),
|
|
70
|
+
'## Environments and limits', '', `production (read-only, as a visitor): add \`environments.production: { base_url: ${origin}, readonly: true }\` to kaloko.config.yml if it is not there.`, '',
|
|
71
|
+
'## Change log', '', '| Date | Commit | Change |', '|---|---|---|', `| ${new Date().toISOString().slice(0, 10)} | — | Drafted from the pages by kaloko draft. |`, ''
|
|
72
|
+
].join('\n')
|
|
73
|
+
return { scenario, plan, origin }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const scenarioYaml = (scenario) => yaml.dump(scenario, { lineWidth: 160, noRefs: true, flowLevel: 4 })
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Checks of an implementation against its design reference (#42, pack `design`): pixels, structure and
|
|
2
|
+
// token use. They run outside the page, after the capture: the reference comes from the service (the
|
|
3
|
+
// accepted design version), the token set from the project. Results land in capture.checks like the
|
|
4
|
+
// in-page checks, so combine() turns them into verdicts the usual way.
|
|
5
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
6
|
+
import { join, resolve } from 'node:path'
|
|
7
|
+
import { flattenTokens, tokenIndex, checkDeclaration } from './tokens.mjs'
|
|
8
|
+
import { sha256 } from './util.mjs'
|
|
9
|
+
|
|
10
|
+
export const DRIFT_ASSERTS = new Set(['design_pixels', 'design_structure', 'uses_tokens'])
|
|
11
|
+
export const needsDrift = (node) => (node.criteria || []).some((c) => c.check.type === 'deterministic' && DRIFT_ASSERTS.has(c.check.assert))
|
|
12
|
+
|
|
13
|
+
/** Structure difference between an implementation and the reference: a list of human-readable findings. */
|
|
14
|
+
export function structureDiff (mine, ref) {
|
|
15
|
+
const out = []
|
|
16
|
+
if (!mine || !ref) return ['structure missing (capture again with the current CLI)']
|
|
17
|
+
const norm = (s) => String(s || '').toLowerCase().replace(/\s+/g, ' ').trim()
|
|
18
|
+
const outline = (h) => h.map((x) => `h${x.level}`).join(' ')
|
|
19
|
+
if (outline(mine.headings) !== outline(ref.headings)) out.push(`heading outline ${outline(mine.headings) || '—'} vs reference ${outline(ref.headings) || '—'}`)
|
|
20
|
+
const refH1 = ref.headings.find((h) => h.level === 1), myH1 = mine.headings.find((h) => h.level === 1)
|
|
21
|
+
if (refH1 && myH1 && norm(refH1.text) !== norm(myH1.text)) out.push(`h1 "${myH1.text}" vs reference "${refH1.text}"`)
|
|
22
|
+
const missingLm = ref.landmarks.filter((l) => !mine.landmarks.includes(l))
|
|
23
|
+
if (missingLm.length) out.push(`missing landmarks: ${missingLm.join(', ')}`)
|
|
24
|
+
const myActions = new Set(mine.actions.map(norm))
|
|
25
|
+
const missingActions = ref.actions.filter((a) => !myActions.has(norm(a)))
|
|
26
|
+
if (missingActions.length) out.push(`missing actions: ${missingActions.slice(0, 5).map((a) => `"${a}"`).join(', ')}`)
|
|
27
|
+
const myFields = new Set(mine.fields.map((f) => `${f.type}|${norm(f.label)}`))
|
|
28
|
+
const missingFields = ref.fields.filter((f) => !myFields.has(`${f.type}|${norm(f.label)}`))
|
|
29
|
+
if (missingFields.length) out.push(`missing fields: ${missingFields.slice(0, 5).map((f) => `${f.type} "${f.label}"`).join(', ')}`)
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Style values of a capture that are not in the token set: [{ property, value, suggest, example }]. */
|
|
34
|
+
export function tokenOffenders (styles, tokens, mode = null, allowed = null) {
|
|
35
|
+
if (!styles) return null
|
|
36
|
+
const index = tokenIndex(flattenTokens(tokens, mode))
|
|
37
|
+
const out = []
|
|
38
|
+
for (const [prop, values] of Object.entries(styles)) {
|
|
39
|
+
const cssProp = prop.replace(/^(padding|margin)-(top|right|bottom|left)$/, '$1').replace('border-top-color', 'border-color')
|
|
40
|
+
for (const [value, info] of Object.entries(values)) {
|
|
41
|
+
// a raw value the accepted design itself uses is not drift of the implementation
|
|
42
|
+
if (allowed?.[prop]?.[value]) continue
|
|
43
|
+
for (const o of checkDeclaration(cssProp, value, index)) out.push({ property: prop, value: o.value, suggest: o.suggest, count: info.count, example: info.example })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out.sort((a, b) => b.count - a.count)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Pixel difference ratio of two images in a browser page (the viewer's algorithm, so numbers match the canvas). */
|
|
50
|
+
export async function pixelRatio (page, a, b, width) {
|
|
51
|
+
return page.evaluate(async ({ a, b, W }) => {
|
|
52
|
+
const load = (src) => new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = src })
|
|
53
|
+
const [x, y] = await Promise.all([load(a), load(b)])
|
|
54
|
+
const ha = Math.round(x.naturalHeight * W / x.naturalWidth), hb = Math.round(y.naturalHeight * W / y.naturalWidth), H = Math.max(ha, hb)
|
|
55
|
+
const draw = (img, h) => { const c = document.createElement('canvas'); c.width = W; c.height = H; const g = c.getContext('2d', { willReadFrequently: true }); g.fillStyle = '#fff'; g.fillRect(0, 0, W, H); g.drawImage(img, 0, 0, W, h); return g.getImageData(0, 0, W, H).data }
|
|
56
|
+
const da = draw(x, ha), db = draw(y, hb)
|
|
57
|
+
let n = 0
|
|
58
|
+
for (let i = 0; i < da.length; i += 4) if (Math.abs(da[i] - db[i]) + Math.abs(da[i + 1] - db[i + 1]) + Math.abs(da[i + 2] - db[i + 2]) > 48) n++
|
|
59
|
+
return { ratio: n / (W * H), heights: [ha, hb] }
|
|
60
|
+
}, { a, b, W: width })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fills capture.checks for the drift criteria of one capture. ctx: { client, org, scenarioId, tokens, mode,
|
|
65
|
+
* page (browser page for pixel diff), cacheDir, isDesign, references: Map }.
|
|
66
|
+
*/
|
|
67
|
+
export async function driftForCapture ({ node, entry, capture, runDir, ctx }) {
|
|
68
|
+
const results = {}
|
|
69
|
+
const crit = (node.criteria || []).filter((c) => c.check.type === 'deterministic' && DRIFT_ASSERTS.has(c.check.assert))
|
|
70
|
+
if (!crit.length) return results
|
|
71
|
+
let ref = null
|
|
72
|
+
const needRef = crit.some((c) => c.check.assert !== 'uses_tokens')
|
|
73
|
+
if (needRef && !ctx.isDesign) ref = await referenceStep(ctx, node.id, entry.locale)
|
|
74
|
+
for (const c of crit) {
|
|
75
|
+
const a = c.check.assert
|
|
76
|
+
if (a === 'uses_tokens') {
|
|
77
|
+
if (!ctx.tokens) { results[c.id] = { ok: null, evidence: 'No token set: push one with kaloko tokens push, or keep tokens.json in the design folder.' }; continue }
|
|
78
|
+
const refStyles = !ctx.isDesign ? (await referenceStep(ctx, node.id, entry.locale))?.captures?.[entry.viewport]?.design?.styles : null
|
|
79
|
+
const off = tokenOffenders(capture.design?.styles, ctx.tokens, ctx.mode, refStyles)
|
|
80
|
+
if (off == null) { results[c.id] = { ok: null, evidence: 'Capture has no style summary; capture this step again.' }; continue }
|
|
81
|
+
results[c.id] = off.length
|
|
82
|
+
? { ok: false, evidence: `${off.length} value(s) outside the token set: ${off.slice(0, 5).map((o) => `${o.property} ${o.value}${o.suggest ? ` → ${o.suggest}` : ''}`).join('; ')}` }
|
|
83
|
+
: { ok: true, evidence: 'every color, font, size, spacing and radius comes from the token set' }
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (ctx.isDesign) { results[c.id] = { ok: null, evidence: 'This is the design itself; the check applies to implementation runs.' }; continue }
|
|
87
|
+
if (!ref) { results[c.id] = { ok: null, evidence: ctx.referenceError || 'No design reference for this scenario yet: accept a version and set it as the design reference.' }; continue }
|
|
88
|
+
const rc = ref.captures?.[entry.viewport]
|
|
89
|
+
if (!rc) { results[c.id] = { ok: null, evidence: `The design reference has no ${entry.viewport} capture for ${entry.locale}.` }; continue }
|
|
90
|
+
if (a === 'design_structure') {
|
|
91
|
+
const diff = structureDiff(capture.design?.structure, rc.design?.structure)
|
|
92
|
+
results[c.id] = diff.length ? { ok: false, evidence: `vs design v${ctx.referenceVersion ?? '?'}: ${diff.join('; ')}` } : { ok: true, evidence: `same headings, landmarks, actions and fields as design v${ctx.referenceVersion ?? '?'}` }
|
|
93
|
+
}
|
|
94
|
+
if (a === 'design_pixels') {
|
|
95
|
+
if (!ctx.page || !rc.image) { results[c.id] = { ok: null, evidence: 'Pixel comparison needs the reference screenshot and a browser.' }; continue }
|
|
96
|
+
const refPath = await cachedImage(ctx, rc.image)
|
|
97
|
+
const mine = join(runDir, capture.files.image || capture.files.png)
|
|
98
|
+
const toData = async (p) => `data:image/${p.endsWith('.png') ? 'png' : 'webp'};base64,${(await readFile(p)).toString('base64')}`
|
|
99
|
+
const { ratio } = await pixelRatio(ctx.page, await toData(mine), await toData(refPath), Math.min(1000, capture.size?.viewport?.[0] || 1280))
|
|
100
|
+
const max = c.check.ratio ?? 0.03
|
|
101
|
+
results[c.id] = { ok: ratio <= max, evidence: `${(ratio * 100).toFixed(1)} % of pixels differ from design v${ctx.referenceVersion ?? '?'} (limit ${(max * 100).toFixed(1)} %)`, ratio }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return results
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function referenceStep (ctx, nodeId, locale) {
|
|
108
|
+
const key = `${nodeId}|${locale}`
|
|
109
|
+
if (ctx.references.has(key)) return ctx.references.get(key)
|
|
110
|
+
let step = null
|
|
111
|
+
try {
|
|
112
|
+
// an implementation on a git branch is checked against the reference of the design branch linked to it
|
|
113
|
+
const d = await ctx.client.request('GET', `/orgs/${ctx.org}/scenarios/${encodeURIComponent(ctx.scenarioId)}/steps/${encodeURIComponent(nodeId)}?ref=design&locale=${encodeURIComponent(locale)}${ctx.git ? `&git=${encodeURIComponent(ctx.git)}` : ''}`)
|
|
114
|
+
step = d.steps.find((s) => s.locale === locale) || null
|
|
115
|
+
if (d.run_id && ctx.referenceVersion == null) ctx.referenceVersion = d.run_id.match(/-v(\d+)-/)?.[1] ?? null
|
|
116
|
+
} catch (e) { ctx.referenceError = /404/.test(e.message) ? null : `Design reference unavailable: ${e.message.split('\n')[0]}` }
|
|
117
|
+
ctx.references.set(key, step)
|
|
118
|
+
return step
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function cachedImage (ctx, url) {
|
|
122
|
+
// the signed URL changes; the cache key is the stable part of the path
|
|
123
|
+
const stable = url.replace(/\/f\/\d+\.[^/]+\//, '/f/')
|
|
124
|
+
const file = join(ctx.cacheDir, sha256(stable).slice(0, 24) + '.webp')
|
|
125
|
+
try { await readFile(file); return file } catch {}
|
|
126
|
+
const res = url.includes('/api/v1/') ? await ctx.client.fetch(url.replace(/^.*\/api\/v1/, '')) : await fetch(url)
|
|
127
|
+
if (!res.ok) throw new Error(`reference screenshot: HTTP ${res.status}`)
|
|
128
|
+
await mkdir(ctx.cacheDir, { recursive: true })
|
|
129
|
+
await writeFile(file, Buffer.from(await res.arrayBuffer()))
|
|
130
|
+
return file
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The token set the drift checks use: the project's latest on the service, else tokens.json of a design folder. */
|
|
134
|
+
export async function loadTokens ({ client, config, root, designDir }) {
|
|
135
|
+
if (client && config.project) {
|
|
136
|
+
try { return (await client.request('GET', `/orgs/${config.org}/projects/${encodeURIComponent(config.project)}/tokens`)).tokens } catch {}
|
|
137
|
+
}
|
|
138
|
+
if (designDir) {
|
|
139
|
+
// a DTCG resolver (sets and a theme modifier) wins over a plain tokens.json
|
|
140
|
+
try { const { inlineResolver } = await import('./tokens.mjs'); return await inlineResolver(JSON.parse(await readFile(join(designDir, 'tokens.resolver.json'), 'utf8')), (ref) => readFile(join(designDir, ref), 'utf8')) } catch {}
|
|
141
|
+
try { return JSON.parse(await readFile(join(designDir, 'tokens.json'), 'utf8')) } catch {}
|
|
142
|
+
}
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
export const driftCacheDir = (root, output) => resolve(root, output, '.cache', 'design-ref')
|