kaloko 0.7.1 → 0.9.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 +3 -1
- package/package.json +2 -2
- package/schemas/scenario.v1.json +15 -1
- package/skills/kaloko/reference/acceptance.md +32 -2
- package/src/cli.mjs +30 -21
- package/src/lib/access.mjs +1 -1
- package/src/lib/apps/drivers.mjs +1 -1
- package/src/lib/apps/webview.mjs +3 -3
- package/src/lib/browser.mjs +14 -2
- package/src/lib/build.mjs +8 -5
- package/src/lib/changelog.mjs +30 -9
- package/src/lib/design-export.mjs +2 -2
- package/src/lib/dimensions.mjs +2 -1
- package/src/lib/docs-core.mjs +9 -6
- package/src/lib/docs.mjs +4 -2
- package/src/lib/doctor.mjs +72 -10
- package/src/lib/evaluate.mjs +11 -6
- package/src/lib/figma.mjs +1 -1
- package/src/lib/integrations-cli.mjs +3 -3
- package/src/lib/login.mjs +1 -1
- package/src/lib/mail.mjs +1 -1
- package/src/lib/manifest-core.mjs +11 -6
- package/src/lib/pack-help.mjs +5 -0
- package/src/lib/packs.mjs +32 -8
- package/src/lib/run.mjs +1 -1
- package/src/lib/scenario.mjs +63 -1
- package/src/lib/service.mjs +39 -9
- package/src/lib/stability.mjs +2 -2
- package/src/lib/stack.mjs +11 -1
- package/src/lib/unzip.mjs +22 -4
- package/src/lib/util.mjs +6 -0
- package/src/lib/video.mjs +11 -2
- package/src/lib/vision.mjs +1 -1
- package/templates/scenario.yml +1 -0
- package/viewer/index.html +154 -28
package/src/lib/doctor.mjs
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// Every check returns { name, ok, detail, hint } so the CLI and tests can read it.
|
|
3
3
|
import { appTools, macPermissions } from './apps/drivers.mjs'
|
|
4
4
|
import { createServer } from 'node:net'
|
|
5
|
-
import { access as exists, stat } from 'node:fs/promises'
|
|
6
|
-
import { resolve } from 'node:path'
|
|
5
|
+
import { access as exists, stat, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { resolve, join, dirname } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
7
9
|
import { environment } from './config.mjs'
|
|
8
10
|
import { gateOf, gateHeaders, accountFor } from './access.mjs'
|
|
9
11
|
import { missing } from './secrets.mjs'
|
|
@@ -38,21 +40,38 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
|
|
|
38
40
|
* @param {Function} [o.launchEngine] async (engine) => { version } — throws when the engine is not installed
|
|
39
41
|
* @param {object} [o.tools] app tools found (default: looked up on PATH)
|
|
40
42
|
* @param {object[]} [o.scenarios] { file, product, module } of the scenario files (products checks)
|
|
43
|
+
* @param {boolean} [o.fix] repair what can be repaired here: install missing Playwright browsers, create the run and
|
|
44
|
+
* scenario folders, write org: from the token into the config, ask macOS for permissions
|
|
45
|
+
* @param {Function} [o.installBrowser] async (engine) => void — installs a Playwright engine (--fix)
|
|
41
46
|
*/
|
|
42
|
-
export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [] }) {
|
|
47
|
+
export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [], installBrowser = defaultInstall }) {
|
|
43
48
|
const checks = []
|
|
44
|
-
const add = (name, ok, detail = '', hint = '') => checks.push({ name, ok, detail, hint })
|
|
49
|
+
const add = (name, ok, detail = '', hint = '', fixed = null) => checks.push({ name, ok, detail, hint, ...(fixed ? { fixed } : {}) })
|
|
50
|
+
// --fix: install an engine, then look again; returns the version or throws what the install said
|
|
51
|
+
const installed = async (engine, retry) => { await installBrowser(engine); return retry() }
|
|
45
52
|
|
|
46
53
|
const major = Number(process.versions.node.split('.')[0])
|
|
47
54
|
add('node', major >= 20, `Node ${process.versions.node}`, major >= 20 ? '' : 'Kaloko needs Node 20 or newer.')
|
|
48
55
|
|
|
49
56
|
add('config', true, `${file || `${root}/kaloko.config.yml`} · org ${config.org || '—'} · ${Object.keys(config.environments || {}).length} environments`)
|
|
50
|
-
if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the
|
|
57
|
+
if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the part after kaloko.app/ when you are signed in), or run kaloko doctor --fix with a token to fill it in.')
|
|
58
|
+
await folderChecks({ config, root, fix, add })
|
|
51
59
|
|
|
52
|
-
try { const c = await launchChrome(config); add('chrome', true, c.version ? `Chrome ${c.version}` : 'Chrome found') } catch (e) {
|
|
60
|
+
try { const c = await launchChrome(config); add('chrome', true, c.version ? `Chrome ${c.version}` : 'Chrome found') } catch (e) {
|
|
61
|
+
let done = null
|
|
62
|
+
// the bundled Chromium can be installed from here; a branded Chrome (browser.channel) is the person's to install
|
|
63
|
+
if (fix && !config.browser?.channel) { try { done = await installed('chromium', () => launchChrome(config)) } catch {} }
|
|
64
|
+
if (done) add('chrome', true, `Chromium ${done.version || ''}`.trim(), '', 'installed Chromium for Playwright')
|
|
65
|
+
else 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 (kaloko browser install).' : fix ? 'Installing Chromium did not work; run kaloko browser install and read its output (on Linux add --with-deps).' : 'Run kaloko doctor --fix (or kaloko browser install) to install Chromium for Playwright.')
|
|
66
|
+
}
|
|
53
67
|
// cross-browser runs (batch E): the Playwright engines the scenarios and walk.browsers name
|
|
54
68
|
for (const engine of [...new Set([...(engines || []), ...(config.walk?.browsers || [])])].filter((e) => e !== 'chromium')) {
|
|
55
|
-
try { const v = await launchEngine(engine); add(`browser ${engine}`, true, `${engine} ${v.version || ''}`.trim()) } catch {
|
|
69
|
+
try { const v = await launchEngine(engine); add(`browser ${engine}`, true, `${engine} ${v.version || ''}`.trim()) } catch {
|
|
70
|
+
let done = null
|
|
71
|
+
if (fix) { try { done = await installed(engine, () => launchEngine(engine)) } catch {} }
|
|
72
|
+
if (done) add(`browser ${engine}`, true, `${engine} ${done.version || ''}`.trim(), '', `installed ${engine} for Playwright`)
|
|
73
|
+
else add(`browser ${engine}`, false, `${engine} is not installed for Playwright`, `Run kaloko doctor --fix, or kaloko browser install --browsers ${engine}${os === 'linux' ? ' --with-deps' : ''}`)
|
|
74
|
+
}
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
for (const name of Object.keys(config.environments || {})) {
|
|
@@ -132,16 +151,21 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
|
|
|
132
151
|
else {
|
|
133
152
|
try {
|
|
134
153
|
const res = await fetchImpl(`${service}/api/v1/me`, { headers: { Authorization: `Bearer ${token}` } })
|
|
135
|
-
if (!res.ok) add('token', false, `${service} → HTTP ${res.status}`, res.status === 401 ? `${tokenEnv} is invalid, expired or revoked
|
|
154
|
+
if (!res.ok) add('token', false, `${service} → HTTP ${res.status}`, res.status === 401 ? `${tokenEnv} is invalid, expired or revoked. Run kaloko login for a new one (it replaces the old token), or create one in Settings → API tokens.` : `The service did not accept the request; try again in a few minutes (${service}/status).`)
|
|
136
155
|
else {
|
|
137
156
|
const me = await res.json()
|
|
157
|
+
// --fix: a config without org: takes the token's organization
|
|
158
|
+
if (!config.org && fix && me.org && file) {
|
|
159
|
+
const wrote = await writeOrg(file, me.org).catch(() => false)
|
|
160
|
+
if (wrote) { config.org = me.org; const i = checks.findIndex((c) => c.name === 'org'); if (i >= 0) checks[i] = { name: 'org', ok: true, detail: `org ${me.org}`, hint: '', fixed: `wrote org: ${me.org} into ${file}` } }
|
|
161
|
+
}
|
|
138
162
|
const sameOrg = !config.org || me.org === config.org
|
|
139
163
|
// sharing runs needs the Developer / Tester role (or admin) and, on tokens with scope sets, the upload scope
|
|
140
164
|
const canShare = ['tester', 'admin'].includes(me.role) && (!Array.isArray(me.scopes) || me.scopes.includes('upload'))
|
|
141
165
|
add('token', sameOrg, `${me.email} · ${me.role} · org ${me.org}${canShare ? '' : ' (this token cannot share runs)'}`, sameOrg ? '' : `The token belongs to organization "${me.org}" but the config says "${config.org}".`)
|
|
142
166
|
if (!canShare) add('token role', false, Array.isArray(me.scopes) ? `${me.role} · ${me.scopes.join(', ')}` : me.role, 'kaloko share needs a Developer / Tester token with the upload scope.')
|
|
143
167
|
}
|
|
144
|
-
} catch (e) { add('token', false, `${service} unreachable: ${e.message}`, 'Check your network; KALOKO_URL
|
|
168
|
+
} catch (e) { add('token', false, `${service} unreachable: ${e.cause?.code || e.message}`, 'Check your network or VPN; KALOKO_URL points the CLI to another service.') }
|
|
145
169
|
}
|
|
146
170
|
|
|
147
171
|
const jev = config.evaluators?.jev
|
|
@@ -173,6 +197,44 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
|
|
|
173
197
|
return { ok: checks.every((c) => c.ok), checks }
|
|
174
198
|
}
|
|
175
199
|
|
|
200
|
+
/** The run folder and the folders the scenario globs start in: reported, and created with --fix. */
|
|
201
|
+
async function folderChecks ({ config, root, fix, add }) {
|
|
202
|
+
const there = (d) => exists(d).then(() => true, () => false)
|
|
203
|
+
const out = resolve(root, config.output || 'tmp/kaloko')
|
|
204
|
+
if (!await there(out)) {
|
|
205
|
+
if (fix && await mkdir(out, { recursive: true }).then(() => true, () => false)) add('folders', true, `${config.output} created`, '', `created ${config.output}`)
|
|
206
|
+
}
|
|
207
|
+
for (const pattern of config.scenarios || []) {
|
|
208
|
+
const fixed = []
|
|
209
|
+
for (const p of pattern.split('/')) { if (/[*?{[]/.test(p)) break; fixed.push(p) }
|
|
210
|
+
if (!fixed.length || fixed.length === pattern.split('/').length) continue
|
|
211
|
+
const dir = resolve(root, ...fixed)
|
|
212
|
+
if (await there(dir)) continue
|
|
213
|
+
if (fix && await mkdir(dir, { recursive: true }).then(() => true, () => false)) add(`scenarios ${fixed.join('/')}`, true, 'folder created; it has no scenarios yet', 'Draft a first one: kaloko draft <url>', `created ${fixed.join('/')}`)
|
|
214
|
+
else add(`scenarios ${fixed.join('/')}`, false, `${fixed.join('/')} does not exist (scenarios: ${pattern})`, 'Run kaloko doctor --fix to create it, then kaloko draft <url> for a first scenario.')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Writes org: <slug> into the config file, keeping its comments and layout. */
|
|
219
|
+
export async function writeOrg (file, org) {
|
|
220
|
+
if (!/^[a-z0-9-]+$/.test(org)) return false
|
|
221
|
+
const text = await readFile(file, 'utf8')
|
|
222
|
+
let next
|
|
223
|
+
if (/^org:.*$/m.test(text)) next = text.replace(/^org:.*$/m, `org: ${org}`)
|
|
224
|
+
else if (/^schema:.*$/m.test(text)) next = text.replace(/^(schema:.*)$/m, `$1\norg: ${org}`)
|
|
225
|
+
else next = `org: ${org}\n${text}`
|
|
226
|
+
if (next === text) return false
|
|
227
|
+
await writeFile(file, next)
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Installs a Playwright engine with the Playwright that kaloko itself uses (same as kaloko browser install). */
|
|
232
|
+
async function defaultInstall (engine) {
|
|
233
|
+
const cli = join(dirname(fileURLToPath(import.meta.resolve('playwright/package.json'))), 'cli.js')
|
|
234
|
+
const code = await new Promise((done) => spawn(process.execPath, [cli, 'install', engine], { stdio: 'inherit' }).on('close', done).on('error', () => done(1)))
|
|
235
|
+
if (code) throw new Error(`playwright install ${engine} exited with ${code}`)
|
|
236
|
+
}
|
|
237
|
+
|
|
176
238
|
async function defaultEngine (engine) {
|
|
177
239
|
const pw = await import('playwright')
|
|
178
240
|
const browser = await pw[engine].launch({ headless: true })
|
|
@@ -189,4 +251,4 @@ async function defaultLaunch (config) {
|
|
|
189
251
|
return { version }
|
|
190
252
|
}
|
|
191
253
|
|
|
192
|
-
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')
|
|
254
|
+
export const formatDoctor = ({ ok, checks }) => [...checks.map((c) => `${c.ok ? '✓' : '✕'} ${c.name.padEnd(16)} ${c.detail}${c.fixed ? ` (fixed: ${c.fixed})` : ''}${(!c.ok || c.fixed) && c.hint ? `\n → ${c.hint}` : ''}`), ok ? 'All good.' : checks.some((c) => !c.ok && /doctor --fix/.test(c.hint || '')) ? 'Fix the ✕ items above; kaloko doctor --fix repairs the ones that say so.' : 'Fix the ✕ items above.'].join('\n')
|
package/src/lib/evaluate.mjs
CHANGED
|
@@ -17,6 +17,8 @@ export function latestCaptures (entries) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const criteriaHash = (node) => sha256(node.criteria || [])
|
|
20
|
+
/** A criterion with `environments` (e.g. [production]) applies only there; without a known environment it applies. */
|
|
21
|
+
export const appliesOn = (c, environment) => !environment || !Array.isArray(c.environments) || !c.environments.length || c.environments.includes(environment)
|
|
20
22
|
/** Folder of a capture under steps/: the node, or steps/<node>/items/<item> for a page of a stack step. */
|
|
21
23
|
export const stepIdOf = (e) => (e.item ? `${e.node}/items/${e.item}` : e.node)
|
|
22
24
|
export const evaluationSource = (node, capture) => sha256({ capture, criteria: node.criteria || [], purpose: node.purpose || '' })
|
|
@@ -26,8 +28,9 @@ export const evaluationSource = (node, capture) => sha256({ capture, criteria: n
|
|
|
26
28
|
* asked, the vision evaluator. Every semantic verdict with a score carries a one-sentence `reason`: the vision
|
|
27
29
|
* model's own, or a sentence saying what the text evaluator read (it returns a probability only).
|
|
28
30
|
*/
|
|
29
|
-
export function combine ({ node, capture, jev, vision = null, thresholds }) {
|
|
31
|
+
export function combine ({ node, capture, jev, vision = null, thresholds, environment = null }) {
|
|
30
32
|
return (node.criteria || []).map((c) => {
|
|
33
|
+
if (!appliesOn(c, environment)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies on ${c.environments.join(', ')} only; this walk ran on ${environment}.`, na: true, not_applicable: 'environment' }
|
|
31
34
|
if (c.check.type === 'deterministic') {
|
|
32
35
|
const r = capture.checks?.[c.id]
|
|
33
36
|
const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
|
|
@@ -64,6 +67,8 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
64
67
|
const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
|
|
65
68
|
const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
|
|
66
69
|
const paths = runPaths(run.dir)
|
|
70
|
+
// criteria limited to other environments (environments: [production]) are reported as not applicable here
|
|
71
|
+
const environment = run.meta?.environment?.name || null
|
|
67
72
|
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: [] }
|
|
68
73
|
// the vision evaluator (#17): flagged and uncertain criteria, a bounded number of calls per evaluation
|
|
69
74
|
const vset = visionSettings(config)
|
|
@@ -101,7 +106,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
101
106
|
const current = capture.criteria_hash === criteriaHash(node)
|
|
102
107
|
// console triage needs the evaluator only when the capture has console errors
|
|
103
108
|
const outOfSample = entry.item && !sampled.get(entry.node)?.has(entry.item)
|
|
104
|
-
const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
109
|
+
const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && appliesOn(c, environment) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
105
110
|
// quick signals ride along on every captured page state (also steps without semantic criteria)
|
|
106
111
|
const signals = settings.signals !== false && !outOfSample && !!capture.state
|
|
107
112
|
// an imported screen without page text (#11) has nothing to ask about: a person decides on the canvas
|
|
@@ -126,7 +131,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
126
131
|
const source = evaluationSource(node, capture)
|
|
127
132
|
if (drift && current && needsDrift(node)) { capture.checks = { ...capture.checks, ...await driftForCapture({ node, entry, capture, runDir: run.dir, ctx: drift }) }; report.drift++ }
|
|
128
133
|
if (styleTokens?.tokens && current && tokenCriteria(node).length) capture.checks = { ...capture.checks, ...storedValueChecks(node, capture, { ...styleTokens, recorded: !!capture.rechecked_at, only: (c) => !!c.check.token && STYLE_ASSERTS.has(c.check.assert) }) }
|
|
129
|
-
let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds })
|
|
134
|
+
let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds, environment })
|
|
130
135
|
// pages outside the semantic sample carry only the deterministic results
|
|
131
136
|
if (outOfSample) criteria = criteria.filter((c) => c.type !== 'semantic' || c.evidence === 'no console errors')
|
|
132
137
|
const evaluation = {
|
|
@@ -175,7 +180,7 @@ function signalsOf (jev) {
|
|
|
175
180
|
return Object.keys(out).length ? out : null
|
|
176
181
|
}
|
|
177
182
|
|
|
178
|
-
export function semanticPrefetch ({ scenario, config, root }) {
|
|
183
|
+
export function semanticPrefetch ({ scenario, config, root, environment = null }) {
|
|
179
184
|
const settings = { ...DEFAULTS, ...(config.evaluators?.jev || {}), thresholds: { ...DEFAULTS.thresholds, ...(config.evaluators?.jev?.thresholds || {}) } }
|
|
180
185
|
const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
|
|
181
186
|
const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
|
|
@@ -197,7 +202,7 @@ export function semanticPrefetch ({ scenario, config, root }) {
|
|
|
197
202
|
for (const e of [].concat(entries || [])) {
|
|
198
203
|
if (e?.type !== 'capture' || e.item) continue
|
|
199
204
|
const node = nodes.get(e.node)
|
|
200
|
-
const semantic = (node?.criteria || []).filter((c) => c.check.type === 'semantic')
|
|
205
|
+
const semantic = (node?.criteria || []).filter((c) => c.check.type === 'semantic' && appliesOn(c, environment))
|
|
201
206
|
if (!semantic.length && settings.signals === false) continue
|
|
202
207
|
queue.push(async () => {
|
|
203
208
|
const capture = await readJson(join(runDir, e.file))
|
|
@@ -283,7 +288,7 @@ function hostedSender (config, report) {
|
|
|
283
288
|
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' } }
|
|
284
289
|
try { return { response: await client.request('POST', `/orgs/${config.org}/semantic`, JSON.parse(body)) } } catch (e) {
|
|
285
290
|
const m = String(e.message || e)
|
|
286
|
-
if (/HTTP (402|403|503)/.test(m)) { report.hostedStop = m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.hostedStop } }
|
|
291
|
+
if (/HTTP (402|403|503)/.test(m)) { report.hostedStop = e.serviceMessage ?? m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.hostedStop } }
|
|
287
292
|
return { error: m }
|
|
288
293
|
}
|
|
289
294
|
}
|
package/src/lib/figma.mjs
CHANGED
|
@@ -63,7 +63,7 @@ export function figmaClient ({ token, fetch: f = globalThis.fetch, base = figmaA
|
|
|
63
63
|
variables: (key) => get(`/v1/files/${key}/variables/local`, { optional: true }),
|
|
64
64
|
async download (url) {
|
|
65
65
|
const res = await f(url)
|
|
66
|
-
if (!res.ok) throw new UserError(`Downloading a frame image failed
|
|
66
|
+
if (!res.ok) throw new UserError(`Downloading a frame image from Figma failed (HTTP ${res.status}). Figma's image links expire after a while; run the import again.`)
|
|
67
67
|
return Buffer.from(await res.arrayBuffer())
|
|
68
68
|
}
|
|
69
69
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
5
5
|
import { existsSync } from 'node:fs'
|
|
6
6
|
import { resolve, relative, dirname, join } from 'node:path'
|
|
7
|
-
import { UserError } from './util.mjs'
|
|
7
|
+
import { UserError, NO_ORG } from './util.mjs'
|
|
8
8
|
import { gateOf, accountFor } from './access.mjs'
|
|
9
9
|
import { secret } from './secrets.mjs'
|
|
10
10
|
import { runner } from './ci.mjs'
|
|
@@ -23,7 +23,7 @@ const issueLine = (i) => `${i.key.padEnd(10)} ${String(i.state).padEnd(8)} step
|
|
|
23
23
|
export async function issueCmd ({ args, config, client, runId, say }) {
|
|
24
24
|
const sub = args._[0]
|
|
25
25
|
const org = config.org
|
|
26
|
-
if (!org) throw new UserError(
|
|
26
|
+
if (!org) throw new UserError(NO_ORG)
|
|
27
27
|
if (sub === 'list') {
|
|
28
28
|
const q = new URLSearchParams(Object.entries({ run: typeof args.run === 'string' ? args.run : null, scenario: typeof args.scenario === 'string' ? args.scenario : null, state: typeof args.state === 'string' ? args.state : null }).filter(([, v]) => v))
|
|
29
29
|
const items = await client.request('GET', `/orgs/${org}/issues${q.size ? `?${q}` : ''}`)
|
|
@@ -169,7 +169,7 @@ export async function scheduleCmd ({ args, config, root, say, deps }) {
|
|
|
169
169
|
say(`Wrote ${relative(process.cwd(), out)}: ${scenario.id} on ${envName} ${every === 'week' ? 'every week' : 'every day'} at ${String(atHour(args.at)).padStart(2, '0')}:00 UTC with the full walk (clicks, forms, every criterion).\nNext: add the repository secret KALOKO_TOKEN${secretNames(env, scenario).length ? ` and ${secretNames(env, scenario).join(', ')}` : ''}, then commit the workflow.`)
|
|
170
170
|
return { file: out }
|
|
171
171
|
}
|
|
172
|
-
if (!org) throw new UserError(
|
|
172
|
+
if (!org) throw new UserError(NO_ORG)
|
|
173
173
|
if (sub === 'list' || !sub) {
|
|
174
174
|
const data = await client().request('GET', `/orgs/${org}/schedules`)
|
|
175
175
|
if (args.json) { say(JSON.stringify(data, null, 2)); return data }
|
package/src/lib/login.mjs
CHANGED
|
@@ -34,7 +34,7 @@ export async function login (base, { org = null, print = false, open = true, tok
|
|
|
34
34
|
base = base.replace(/\/$/, '')
|
|
35
35
|
const post = async (path, body) => {
|
|
36
36
|
let res
|
|
37
|
-
try { res = await fetchImpl(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }) } catch (e) { throw new UserError(`Cannot reach ${base}
|
|
37
|
+
try { res = await fetchImpl(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }) } catch (e) { throw new UserError(`Cannot reach ${base} (${e.cause?.code || e.message}). Check your network or VPN and run kaloko login again; --url or KALOKO_URL points it to another service.`) }
|
|
38
38
|
return { status: res.status, body: await res.json().catch(() => ({})) }
|
|
39
39
|
}
|
|
40
40
|
const start = await post('/cli/device', { client: `kaloko${version ? ` ${version}` : ''} on ${hostname()}`.slice(0, 60) })
|
package/src/lib/mail.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const address = (a) => (a?.Address ? (a.Name ? `${a.Name} <${a.Address}>` : a.Ad
|
|
|
13
13
|
|
|
14
14
|
/** Mailpit REST client (https://mailpit.axllent.org/docs/api-v1/). */
|
|
15
15
|
export function mailpit (mail) {
|
|
16
|
-
if (!mail || mail.provider !== 'mailpit' || !mail.url) throw new UserError('
|
|
16
|
+
if (!mail || mail.provider !== 'mailpit' || !mail.url) throw new UserError('This environment has no Mailpit mailbox. Add mail: { provider: mailpit, url: <Mailpit address> } to it in kaloko.config.yml.')
|
|
17
17
|
const headers = {}
|
|
18
18
|
// `auth.basic` (older configs: user_env / password_env next to url), plus any headers of a gate in front of it
|
|
19
19
|
const basic = mail.auth?.basic || (mail.user_env ? { user_env: mail.user_env, password_env: mail.password_env } : null)
|
|
@@ -99,24 +99,29 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
|
|
|
99
99
|
// semantic answers carry the thresholds they were judged by and where they were evaluated (own key or Kaloko)
|
|
100
100
|
// semantic answers carry their one-sentence reason (#17) and whether the evaluator saw the screenshot
|
|
101
101
|
const seen = r.provider && /-vision$/.test(r.provider)
|
|
102
|
-
loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(r.na ? { na: true } : {}), ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && (seen ? ev.vision?.via : ev.semantic?.via) ? { via: seen ? ev.vision.via : ev.semantic.via } : {}), ...(r.reason ? { reason: r.reason, ...(r.reason_source === 'model' ? { reason_by: 'model' } : {}) } : {}), ...(seen ? { vision: true, ...(r.text_score != null ? { text_score: r.text_score } : {}) } : {}) }
|
|
102
|
+
loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(r.na ? { na: true } : {}), ...(r.not_applicable ? { not_applicable: r.not_applicable } : {}), ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && (seen ? ev.vision?.via : ev.semantic?.via) ? { via: seen ? ev.vision.via : ev.semantic.via } : {}), ...(r.reason ? { reason: r.reason, ...(r.reason_source === 'model' ? { reason_by: 'model' } : {}) } : {}), ...(seen ? { vision: true, ...(r.text_score != null ? { text_score: r.text_score } : {}) } : {}) }
|
|
103
103
|
if (r.evidence && (!agg.evidence || r.verdict !== 'pass')) agg.evidence = r.evidence
|
|
104
104
|
if (r.reason && (!agg.reason || (r.verdict !== 'pass' && agg.reason_verdict === 'pass'))) { agg.reason = r.reason; agg.reason_verdict = r.verdict }
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
const criteria = [...perCriterion.values()].map(({ reason_verdict: _rv, ...agg }) => {
|
|
108
108
|
for (const l of applicable) for (const vp of viewportNames) ((agg.by_locale[l] ||= {})[vp] ||= { verdict: 'not_reached' })
|
|
109
|
-
const
|
|
110
|
-
|
|
109
|
+
const entries = applicable.flatMap((l) => viewportNames.map((vp) => agg.by_locale[l][vp]))
|
|
110
|
+
const verdicts = counted(entries)
|
|
111
|
+
// a criterion limited to other environments (environments: [production]) is not applicable on this walk:
|
|
112
|
+
// reported as such, and it does not make the step partial
|
|
113
|
+
const offEnv = entries.some((e) => e.not_applicable) && entries.every((e) => e.not_applicable || e.verdict === 'not_reached')
|
|
114
|
+
return { ...agg, verdict: aggregate(verdicts), ...(offEnv ? { not_applicable: 'environment' } : {}) }
|
|
111
115
|
})
|
|
116
|
+
const counting = criteria.filter((c) => !c.not_applicable)
|
|
112
117
|
const byLocale = {}
|
|
113
118
|
for (const l of locales) {
|
|
114
119
|
const captured = !!capturesOut[l]
|
|
115
|
-
const vs =
|
|
120
|
+
const vs = counting.map((c) => aggregate(counted(Object.values(c.by_locale[l] || {}))))
|
|
116
121
|
if (applicable.includes(l) && viewportNames.some((vp) => !capturesOut[l]?.[vp])) vs.push('not_reached')
|
|
117
122
|
byLocale[l] = statusOf(vs, captured)
|
|
118
123
|
}
|
|
119
|
-
const verdicts =
|
|
124
|
+
const verdicts = counting.map((c) => c.verdict)
|
|
120
125
|
if (applicable.some((l) => viewportNames.some((vp) => !capturesOut[l]?.[vp]))) verdicts.push('not_reached')
|
|
121
126
|
return {
|
|
122
127
|
...base,
|
|
@@ -137,7 +142,7 @@ export function summarize (nodes, sessions = null) {
|
|
|
137
142
|
const screens = nodes.filter((n) => n.kind !== 'decision')
|
|
138
143
|
const summary = { pass: 0, partial: 0, fail: 0, skip: 0, criteria: {} }
|
|
139
144
|
for (const n of screens) summary[n.status]++
|
|
140
|
-
for (const n of screens) for (const c of n.criteria) summary.criteria[c.verdict] = (summary.criteria[c.verdict] || 0) + 1
|
|
145
|
+
for (const n of screens) for (const c of n.criteria) if (!c.not_applicable) summary.criteria[c.verdict] = (summary.criteria[c.verdict] || 0) + 1
|
|
141
146
|
if (sessions) {
|
|
142
147
|
summary.by_session = {}
|
|
143
148
|
for (const key of Object.keys(sessions)) summary.by_session[key] = { pass: 0, partial: 0, fail: 0, skip: 0 }
|
package/src/lib/pack-help.mjs
CHANGED
|
@@ -18,6 +18,11 @@ export const PACK_HELP = {
|
|
|
18
18
|
AC202: { cs: 'Jedna jasná hlavní akce', why: ['Several equally loud buttons make people hesitate; none leaves them without a next step.', 'Několik stejně výrazných tlačítek lidi zdrží; žádné je nechá bez dalšího kroku.'], fix: ['Give one action the primary style; make the others secondary or links.', 'Jedné akci dejte hlavní styl, ostatní udělejte vedlejší nebo jako odkazy.'] },
|
|
19
19
|
AC203: { cs: 'Formulářová pole mají viditelný popisek', why: ['A placeholder disappears as people type; they forget what the field was.', 'Placeholder při psaní zmizí a lidé zapomenou, co do pole patří.'], fix: ['Add a <label for="…"> (or aria-label) to every visible input.', 'Ke každému viditelnému poli doplňte <label for="…"> (nebo aria-label).'] },
|
|
20
20
|
AC204: { cs: 'Odkazy a tlačítka říkají, co udělají', why: ['"Click here" or "More" does not say where it leads, especially out of context or to a screen reader.', '„Klikněte zde“ nebo „Více“ neřekne, kam vede, zvlášť mimo kontext nebo ve čtečce.'], fix: ['Name the result: "Download the price list", "Continue to payment".', 'Pojmenujte výsledek: „Stáhnout ceník“, „Pokračovat k platbě“.'] },
|
|
21
|
+
AC205: { cs: 'Z nadpisu je jasné, k čemu obrazovka je', why: ['People switch between screens all day; a vague heading makes them check where they are.', 'Lidé mezi obrazovkami přepínají celý den; s mlhavým nadpisem musí hledat, kde vlastně jsou.'], fix: ['Name the screen after what it shows or does: "Runs of the checkout flow", not "Dashboard".', 'Pojmenujte obrazovku podle toho, co ukazuje nebo dělá: „Běhy flow pokladny“, ne „Nástěnka“.'] },
|
|
22
|
+
AC206: { cs: 'Je jasné, jak formulář odeslat a co bude dál', why: ['When the send button or the next step is unclear, people submit twice or give up.', 'Když není jasné odesílací tlačítko nebo další krok, lidé odešlou dvakrát nebo to vzdají.'], fix: ['Label the button with the result ("Send the link") and say in one sentence what happens next.', 'Tlačítko pojmenujte podle výsledku („Poslat odkaz“) a jednou větou řekněte, co bude dál.'] },
|
|
23
|
+
AC207: { cs: 'Každá položka seznamu říká, co je zač a jak ji otevřít', why: ['A row with only an id or a date makes people open items one by one to find the right one.', 'Řádek jen s identifikátorem nebo datem nutí lidi otevírat položky jednu po druhé.'], fix: ['Show a name and a state in every row and make the name the link.', 'V každém řádku ukažte název a stav a z názvu udělejte odkaz.'] },
|
|
24
|
+
AC208: { cs: 'Každé nastavení říká, co změní a jak se uloží', why: ['People leave settings unsure whether the change took effect.', 'Lidé odcházejí z nastavení a nevědí, jestli se změna projevila.'], fix: ['Add a short hint under each setting and either a save button or a visible "Saved".', 'Pod každé nastavení přidejte krátkou nápovědu a buď tlačítko Uložit, nebo viditelné „Uloženo“.'] },
|
|
25
|
+
AC209: { cs: 'Text jde rychle projít: nadpisy a krátké odstavce', why: ['Readers of documentation scan for their question; a wall of text hides the answer.', 'Čtenář dokumentace hledá svou otázku očima; souvislý text odpověď schová.'], fix: ['Split the text under headings that name the question and keep paragraphs to a few sentences.', 'Rozdělte text pod nadpisy, které pojmenují otázku, a odstavce držte na pár větách.'] },
|
|
21
26
|
AC301: { cs: 'Obrázky mají alternativní text', why: ['Without alt text, blind visitors and search engines do not know what an image shows.', 'Bez alt textu nevidomí ani vyhledávače nevědí, co obrázek ukazuje.'], fix: ['Add alt="…" describing the image; decorative images get alt="".', 'Doplňte alt="…" s popisem obrázku; dekorativní obrázky dostanou alt="".'] },
|
|
22
27
|
AC302: { cs: 'Odkazy a tlačítka mají přístupný název', why: ['An icon button without a name is read as just "button".', 'Tlačítko s ikonou bez názvu čtečka přečte jen jako „tlačítko“.'], fix: ['Add visible text or aria-label to icon-only links and buttons.', 'Odkazům a tlačítkům jen s ikonou doplňte text nebo aria-label.'] },
|
|
23
28
|
AC303: { cs: 'Formulářová pole mají popisky', why: ['Screen reader users hear only "edit text" for a field without a label.', 'Uživatel čtečky uslyší u pole bez popisku jen „editační pole“.'], fix: ['Connect a <label> to each field (for/id) or use aria-label / aria-labelledby.', 'Ke každému poli připojte <label> (for/id) nebo použijte aria-label / aria-labelledby.'] },
|
package/src/lib/packs.mjs
CHANGED
|
@@ -33,13 +33,23 @@ export const PACKS = {
|
|
|
33
33
|
},
|
|
34
34
|
usability: {
|
|
35
35
|
base: 200,
|
|
36
|
-
title: 'Usability: purpose, primary action, forms,
|
|
37
|
-
criteria: () =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
title: 'Usability by page type (`page` on the step or scenario): purpose, a primary action on landing pages, forms, lists, settings, link texts',
|
|
37
|
+
criteria: ({ page = 'landing' } = {}) => {
|
|
38
|
+
const sem = (question) => ({ type: 'semantic', question, scope: 'main, body' })
|
|
39
|
+
const on = (...types) => types.includes(page)
|
|
40
|
+
return [
|
|
41
|
+
...(on('landing', 'doc') ? [{ id: 'AC201', text: 'The purpose of the page is clear within the first screen', check: sem('Is the purpose of this page clear from its heading and first paragraph alone?') }] : []),
|
|
42
|
+
...(on('work', 'list', 'settings', 'form') ? [{ id: 'AC205', text: 'The heading says what the screen is for', check: sem('Is it clear from the heading and the content of this screen what it is for?') }] : []),
|
|
43
|
+
// one loud call to action is a landing-page rule; work screens, lists and settings rightly offer several actions
|
|
44
|
+
...(on('landing') ? [{ id: 'AC202', text: 'One clear primary action', check: sem('Is there exactly one clearly primary call to action on the page?') }] : []),
|
|
45
|
+
...(on('form') ? [{ id: 'AC206', text: 'It is clear how to send the form and what happens next', check: sem('Is it clear which button sends the form and what happens after it is sent?') }] : []),
|
|
46
|
+
...(on('list') ? [{ id: 'AC207', text: 'Each item in the list says what it is and how to open it', check: sem('Does each item in the list show what it is (for example a name and its state) and how to open it?') }] : []),
|
|
47
|
+
...(on('settings') ? [{ id: 'AC208', text: 'Each setting says what it changes and how it is saved', check: sem('Does each setting say what it changes, and is it clear whether a change is saved by itself or with a button?') }] : []),
|
|
48
|
+
...(on('doc') ? [{ id: 'AC209', text: 'The text can be scanned: headings and short paragraphs', check: sem('Is the text split into headings and short paragraphs that a reader can scan?') }] : []),
|
|
49
|
+
{ id: 'AC203', text: 'Form fields have visible labels', check: det('count', { selector: 'input:not([type=hidden]):not([type=submit]):not([aria-label]):not([id])', min: 0, max: 0 }) },
|
|
50
|
+
{ id: 'AC204', text: 'Links and buttons say what they do', check: sem('Do the link and button texts describe what happens when they are used, rather than generic words like "click here"?') }
|
|
51
|
+
]
|
|
52
|
+
}
|
|
43
53
|
},
|
|
44
54
|
a11y: {
|
|
45
55
|
base: 300,
|
|
@@ -103,6 +113,20 @@ export const PACKS = {
|
|
|
103
113
|
}
|
|
104
114
|
}
|
|
105
115
|
|
|
116
|
+
/** Page types a step (or the whole scenario) declares with `page`; the usability pack asks per type. */
|
|
117
|
+
export const PAGE_TYPES = ['landing', 'work', 'list', 'settings', 'form', 'doc']
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The page type of a node: its own `page`, then the scenario's `page`, else inferred. A step with the seo pack or a
|
|
121
|
+
* scenario allowed on production is a public page (landing); anything else is an app screen (work).
|
|
122
|
+
*/
|
|
123
|
+
export function pageTypeOf (node, scenario = {}) {
|
|
124
|
+
if (node?.page) return node.page
|
|
125
|
+
if (scenario?.page) return scenario.page
|
|
126
|
+
if ((node?.packs || []).includes('seo') || (scenario?.environments || []).includes('production')) return 'landing'
|
|
127
|
+
return 'work'
|
|
128
|
+
}
|
|
129
|
+
|
|
106
130
|
/**
|
|
107
131
|
* Adds the criteria of each pack listed on a node (`node.packs`) that the node does not already define.
|
|
108
132
|
* Mutates and returns the scenario; safe to call twice.
|
|
@@ -117,7 +141,7 @@ export function expandPacks (scenario) {
|
|
|
117
141
|
for (const name of node.packs) {
|
|
118
142
|
const pack = PACKS[name]
|
|
119
143
|
if (!pack) throw new Error(`node "${node.id}": unknown pack "${name}" (known: ${Object.keys(PACKS).join(', ')})`)
|
|
120
|
-
for (const c of pack.criteria({ locales })) {
|
|
144
|
+
for (const c of pack.criteria({ locales, page: pageTypeOf(node, scenario) })) {
|
|
121
145
|
if (have.has(c.id)) continue
|
|
122
146
|
node.criteria.push({ ...c, pack: name })
|
|
123
147
|
have.add(c.id)
|
package/src/lib/run.mjs
CHANGED
|
@@ -64,7 +64,7 @@ export async function resolveRun ({ root, output, run }) {
|
|
|
64
64
|
dir = join(base, id)
|
|
65
65
|
}
|
|
66
66
|
const meta = await readJson(join(dir, 'meta.json'), null)
|
|
67
|
-
if (!meta) throw new UserError(
|
|
67
|
+
if (!meta) throw new UserError(`${dir} is not a complete run (meta.json is missing). Start a new one with kaloko start, or delete the folder if it is left over.`)
|
|
68
68
|
return { dir, meta, paths: runPaths(dir) }
|
|
69
69
|
}
|
|
70
70
|
|
package/src/lib/scenario.mjs
CHANGED
|
@@ -5,8 +5,31 @@ import { UserError } from './util.mjs'
|
|
|
5
5
|
import { expandPacks } from './packs.mjs'
|
|
6
6
|
import { jsGuard } from './assertions.mjs'
|
|
7
7
|
|
|
8
|
+
// a scenario holds thousands of values, not millions: aliases (&a, *a) can expand a few kilobytes of YAML into
|
|
9
|
+
// billions that validation and its JSON copy would walk ("billion laughs"), so the expanded size has a ceiling
|
|
10
|
+
const MAX_VALUES = 1_000_000
|
|
11
|
+
const MAX_DEPTH = 64
|
|
12
|
+
|
|
13
|
+
/** The data of a scenario's YAML text; a UserError when it is no YAML or expands beyond MAX_VALUES or MAX_DEPTH. */
|
|
14
|
+
export function parseScenario (text, file = 'scenario') {
|
|
15
|
+
let data
|
|
16
|
+
try { data = yaml.load(text) } catch (e) { throw new UserError(`Scenario ${file} is not valid YAML: ${String(e?.message || e).split('\n')[0]}`) }
|
|
17
|
+
let n = 0
|
|
18
|
+
const stack = [[data, 0]]
|
|
19
|
+
while (stack.length) {
|
|
20
|
+
const [v, depth] = stack.pop()
|
|
21
|
+
if (!v || typeof v !== 'object') continue
|
|
22
|
+
if (depth >= MAX_DEPTH) throw new UserError(`Scenario ${file} is nested deeper than ${MAX_DEPTH} levels.`)
|
|
23
|
+
const kids = Object.values(v)
|
|
24
|
+
// counted before they are pushed, so a shared alias cannot grow the stack past the ceiling either
|
|
25
|
+
if ((n += kids.length) > MAX_VALUES) throw new UserError(`Scenario ${file} expands to more than ${MAX_VALUES} values (YAML aliases?).`)
|
|
26
|
+
for (const k of kids) stack.push([k, depth + 1])
|
|
27
|
+
}
|
|
28
|
+
return data
|
|
29
|
+
}
|
|
30
|
+
|
|
8
31
|
export async function loadScenario (file) {
|
|
9
|
-
const data =
|
|
32
|
+
const data = parseScenario(await readFile(file, 'utf8'), file)
|
|
10
33
|
const problems = validateScenario(data)
|
|
11
34
|
if (!problems.length) expandPacks(data)
|
|
12
35
|
if (problems.length) throw new UserError(`Scenario ${file} is invalid:\n - ${problems.join('\n - ')}`)
|
|
@@ -54,6 +77,45 @@ export function validateScenario (data) {
|
|
|
54
77
|
return problems
|
|
55
78
|
}
|
|
56
79
|
|
|
80
|
+
const TEXT_ASSERTS = new Set(['text_matches', 'text_absent', 'title_matches', 'meta_matches'])
|
|
81
|
+
const ACCENTED = /[áčďéěíňóřšťúůýžäöüßąćęłńśźżàâçèêëîïôûùÿœñ]/i
|
|
82
|
+
const EN_WORDS = new Set(['the', 'and', 'of', 'to', 'in', 'for', 'with', 'your', 'you', 'is', 'are', 'on', 'by', 'or', 'not', 'no', 'an', 'this', 'what', 'how'])
|
|
83
|
+
const CS_WORDS = new Set(['a', 'i', 'v', 've', 'na', 'do', 'se', 'je', 'jsou', 'pro', 's', 'z', 'k', 'od', 'po', 'jak', 'co', 'nebo', 'ani', 'už', 'vaše', 'váš'])
|
|
84
|
+
|
|
85
|
+
/** Language of one alternative of a text pattern: 'other' (diacritics or a Czech word), 'en' (an English word) or null. */
|
|
86
|
+
function branchLanguage (branch) {
|
|
87
|
+
const text = branch.replace(/\\./g, ' ').replace(/\[[^\]]*\]/g, ' ')
|
|
88
|
+
if (ACCENTED.test(text)) return 'other'
|
|
89
|
+
const words = (text.match(/\p{L}+/gu) || []).map((w) => w.toLowerCase())
|
|
90
|
+
if (words.some((w) => CS_WORDS.has(w) && w.length > 1)) return 'other'
|
|
91
|
+
if (words.some((w) => EN_WORDS.has(w))) return 'en'
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Warnings that do not make a scenario invalid. A text criterion in a scenario with several locales whose pattern
|
|
97
|
+
* reads in one language only fails (or passes by accident) in the others. The heuristic is deliberately simple:
|
|
98
|
+
* the pattern is split at `|` and each alternative gets a language: a letter with a diacritic or a common Czech
|
|
99
|
+
* word means not English, a common English word ("the", "and", "your") means English, anything else (a brand,
|
|
100
|
+
* one plain word, "SOC 2") is unknown. It warns only when every alternative has a language and they all agree, so
|
|
101
|
+
* "Plans and payment" or "Vaše data" warn and "Your data|Vaše data", "Slack" or "Design drafts" do not.
|
|
102
|
+
* Pack criteria are left out.
|
|
103
|
+
*/
|
|
104
|
+
export function scenarioWarnings (data) {
|
|
105
|
+
const out = []
|
|
106
|
+
if (!data || (data.locales || []).length < 2 || !Array.isArray(data.nodes)) return out
|
|
107
|
+
for (const node of data.nodes) {
|
|
108
|
+
for (const c of node.criteria || []) {
|
|
109
|
+
const k = c.check || {}
|
|
110
|
+
if (c.pack || k.type !== 'deterministic' || !TEXT_ASSERTS.has(k.assert) || typeof k.pattern !== 'string') continue
|
|
111
|
+
const langs = k.pattern.split('|').map(branchLanguage)
|
|
112
|
+
if (langs.some((l) => !l) || new Set(langs).size !== 1) continue
|
|
113
|
+
out.push(`node "${node.id}": ${c.id} pattern /${k.pattern}/ reads in one language only, but the scenario walks ${data.locales.join(', ')}; add the other language as an alternative (English|Czech) or move the check to a per-locale step`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
57
119
|
/**
|
|
58
120
|
* Layered layout: column = longest path from a root, lane = explicit `lane`
|
|
59
121
|
* or inherited from the parent (+1 for each extra `alt` branch).
|
package/src/lib/service.mjs
CHANGED
|
@@ -19,19 +19,49 @@ export function ciName (vars = process.env) {
|
|
|
19
19
|
return vars.CI && vars.CI !== 'false' ? 'ci' : null
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** What to do after the service answered `status`; '' when its own message says enough. */
|
|
23
|
+
export function nextStepFor (status, { tokenEnv = 'KALOKO_TOKEN', url = 'https://kaloko.app' } = {}) {
|
|
24
|
+
if (status === 401) return `The token in ${tokenEnv} is invalid, expired or revoked. Run kaloko login for a new one (or put a fresh token from Settings → API tokens into .env).`
|
|
25
|
+
if (status === 403) return 'Your role or the token\'s scopes do not allow this; ask an admin of the organization, or use a token that may do it.'
|
|
26
|
+
if (status === 413) return 'The file is larger than the service takes; leave it out of the run or make it smaller.'
|
|
27
|
+
if (status === 429) return 'The service is limiting requests from this token. Wait a minute and run the command again.'
|
|
28
|
+
if (status >= 500) return `The service had a problem on its side. Try again in a few minutes; ${url}/status says whether it is a known outage.`
|
|
29
|
+
return ''
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A UserError for an HTTP answer: what failed, the service's message, what to do next; `status` kept for callers. */
|
|
33
|
+
export function httpError (what, status, message, opts) {
|
|
34
|
+
const err = new UserError([`${what} → HTTP ${status}: ${message}`, nextStepFor(status, opts)].filter(Boolean).join(' '))
|
|
35
|
+
err.status = status
|
|
36
|
+
err.serviceMessage = message
|
|
37
|
+
return err
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A UserError for a request that got no answer at all (offline, DNS, refused, TLS). */
|
|
41
|
+
export function unreachable (url, e) {
|
|
42
|
+
const code = e?.cause?.code || e?.code || e?.message || 'no answer'
|
|
43
|
+
const err = new UserError(`Cannot reach ${url} (${code}). Check your network or VPN and run the command again; KALOKO_URL points the CLI to another service.`)
|
|
44
|
+
err.offline = true
|
|
45
|
+
return err
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
export function service (config) {
|
|
23
|
-
const
|
|
49
|
+
const root = serviceUrl(config)
|
|
50
|
+
const base = root + '/api/v1'
|
|
24
51
|
const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
|
|
25
52
|
const token = process.env[tokenEnv]
|
|
26
|
-
if (!token) throw new UserError(
|
|
53
|
+
if (!token) throw new UserError(`${tokenEnv} is not set, so the CLI cannot reach your organization on ${root}. Run kaloko login, or create a token in Settings → API tokens and put ${tokenEnv}=… into .env.`)
|
|
54
|
+
const opts = { tokenEnv, url: root }
|
|
27
55
|
// an agent names itself (KALOKO_AGENT, e.g. "claude-code"); the service shows it in presence, history and audit (#42)
|
|
28
56
|
const ci = ciName()
|
|
29
57
|
const headers = { Authorization: `Bearer ${token}`, ...(process.env.KALOKO_AGENT ? { 'X-QAwalk-Agent': process.env.KALOKO_AGENT.slice(0, 60) } : {}), ...(ci ? { 'X-Kaloko-CI': ci } : {}) }
|
|
30
58
|
const check = async (res, what) => {
|
|
31
59
|
if (res.ok) return res.json()
|
|
32
60
|
const body = await res.json().catch(() => ({}))
|
|
33
|
-
throw
|
|
61
|
+
throw httpError(what, res.status, body.error || res.statusText, opts)
|
|
34
62
|
}
|
|
63
|
+
// fetch rejects without an answer (offline, refused, DNS): say so instead of a stack trace
|
|
64
|
+
const call = (send) => send().catch((e) => { if (e instanceof UserError) throw e; throw unreachable(root, e) })
|
|
35
65
|
// the service limits requests per token (HTTP 429, nothing was processed): wait as told and try again
|
|
36
66
|
// read-your-writes across requests: the service answers with a database bookmark the next request sends back
|
|
37
67
|
let bookmark = null
|
|
@@ -46,18 +76,18 @@ export function service (config) {
|
|
|
46
76
|
}
|
|
47
77
|
return {
|
|
48
78
|
async request (method, path, body) {
|
|
49
|
-
const res = await limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }))
|
|
79
|
+
const res = await call(() => limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })))
|
|
50
80
|
return check(res, `${method} ${path}`)
|
|
51
81
|
},
|
|
52
82
|
/** A request whose answer is not JSON (an export): the response, or an error with the service's message. */
|
|
53
83
|
async raw (method, path, body) {
|
|
54
|
-
const res = await limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }))
|
|
55
|
-
if (!res.ok) { const b = await res.json().catch(() => ({})); throw
|
|
84
|
+
const res = await call(() => limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })))
|
|
85
|
+
if (!res.ok) { const b = await res.json().catch(() => ({})); throw httpError(`${method} ${path}`, res.status, b.error || res.statusText, opts) }
|
|
56
86
|
return res
|
|
57
87
|
},
|
|
58
88
|
async fetch (path) {
|
|
59
|
-
const res = await fetch(base + path, { headers })
|
|
60
|
-
if (!res.ok) { const body = await res.json().catch(() => ({})); throw
|
|
89
|
+
const res = await call(() => limited(() => fetch(base + path, { headers })))
|
|
90
|
+
if (!res.ok) { const body = await res.json().catch(() => ({})); throw httpError(`GET ${path}`, res.status, body.error || res.statusText, opts) }
|
|
61
91
|
return res
|
|
62
92
|
},
|
|
63
93
|
async upload (path, data, name, extra = {}) {
|
|
@@ -65,7 +95,7 @@ export function service (config) {
|
|
|
65
95
|
// a dropped connection (flaky Wi-Fi, CI runner hiccup) is tried again like a 5xx; the next share resumes otherwise
|
|
66
96
|
let res
|
|
67
97
|
try { res = await limited(() => fetch(base + path, { method: 'PUT', headers: { ...headers, ...extra }, body: data })) } catch (e) {
|
|
68
|
-
if (attempt >= 3) throw new UserError(`upload ${name}
|
|
98
|
+
if (attempt >= 3) throw new UserError(`The upload of ${name} broke off (${e.cause?.code || e.message}). Check your network and run kaloko share again: files already stored are not sent twice.`)
|
|
69
99
|
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
|
|
70
100
|
continue
|
|
71
101
|
}
|
package/src/lib/stability.mjs
CHANGED
|
@@ -129,7 +129,7 @@ export async function detectUnstable ({ root, config, run, scenario, last = 4, r
|
|
|
129
129
|
const others = runs ? (await Promise.all(runs.map(async (id) => {
|
|
130
130
|
const dir = id.includes('/') ? resolve(id) : join(resolve(root, config.output), id)
|
|
131
131
|
const meta = await readJson(join(dir, 'meta.json'), null)
|
|
132
|
-
if (!meta) throw new UserError(`Run ${id} not
|
|
132
|
+
if (!meta) throw new UserError(`Run ${id} is not in ${config.output}. kaloko runs lists the runs here.`)
|
|
133
133
|
return { dir, meta }
|
|
134
134
|
}))) : (await siblingRuns({ root, config, run, sameCommit })).slice(0, Math.max(0, last - 1))
|
|
135
135
|
const all = [{ dir: run.dir, meta: run.meta }, ...others]
|
|
@@ -292,7 +292,7 @@ export async function pullDrawn ({ root, config, run, args, say, client }) {
|
|
|
292
292
|
const scenarioId = typeof args.scenario === 'string' ? args.scenario : run.meta.scenario.id
|
|
293
293
|
const data = await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(scenarioId)}/ignore-regions`)
|
|
294
294
|
const regions = data.regions || []
|
|
295
|
-
if (!regions.length) { say(`No drawn regions wait for ${scenarioId}. Draw them on a screenshot on kaloko.app (step detail →
|
|
295
|
+
if (!regions.length) { say(`No drawn regions wait for ${scenarioId}. Draw them on a screenshot on kaloko.app (step detail → Mark a changing region).`); return { applied: [], skipped: [], regions: [] } }
|
|
296
296
|
const byNode = new Map()
|
|
297
297
|
for (const r of regions) (byNode.get(r.node_id) || byNode.set(r.node_id, []).get(r.node_id)).push(r.scenario_entry)
|
|
298
298
|
const file = resolve(root, run.meta.scenario_file)
|