kaloko 0.8.0 → 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 +1 -1
- package/src/cli.mjs +24 -17
- 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/changelog.mjs +2 -2
- package/src/lib/design-export.mjs +2 -2
- package/src/lib/doctor.mjs +72 -10
- package/src/lib/evaluate.mjs +1 -1
- 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/run.mjs +1 -1
- package/src/lib/service.mjs +39 -9
- package/src/lib/stability.mjs +1 -1
- package/src/lib/stack.mjs +11 -1
- package/src/lib/util.mjs +6 -0
- package/src/lib/video.mjs +11 -2
- package/src/lib/vision.mjs +1 -1
- package/viewer/index.html +111 -13
package/README.md
CHANGED
|
@@ -182,7 +182,9 @@ npx kaloko import walkthrough/ --adhoc # a Cursor or Antigravity walkthroug
|
|
|
182
182
|
lays screenshots, Playwright reports and traces out as a run: matched to the
|
|
183
183
|
scenario's steps (by step id in `steps.json`, URL or title), or as an ad-hoc
|
|
184
184
|
flow when there is no scenario. Build and share it like any run. When something does not work (Chrome,
|
|
185
|
-
token, environment URL, evaluator key), `npx kaloko doctor` says what to fix
|
|
185
|
+
token, environment URL, evaluator key), `npx kaloko doctor` says what to fix, and
|
|
186
|
+
`npx kaloko doctor --fix` installs a missing browser, creates missing folders and fills in `org:` from your token.
|
|
187
|
+
Every error the CLI prints says what to do next; if you are still stuck, write to support@sinfin.cz.
|
|
186
188
|
|
|
187
189
|
## Review only what changed
|
|
188
190
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
|
|
|
20
20
|
import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
|
|
21
21
|
import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
|
|
22
22
|
import { signoffCmd } from './lib/signoff.mjs'
|
|
23
|
-
import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote } from './lib/util.mjs'
|
|
23
|
+
import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG } from './lib/util.mjs'
|
|
24
24
|
import { packageRoot } from './lib/schema.mjs'
|
|
25
25
|
import { compareRuns, formatComparison } from './lib/compare.mjs'
|
|
26
26
|
import { doctor, formatDoctor } from './lib/doctor.mjs'
|
|
@@ -29,7 +29,7 @@ import { calibrate, formatCalibration, casesFromGolden, casesFromRun } from './l
|
|
|
29
29
|
import { pageFacts, draftScenario, scenarioYaml } from './lib/draft.mjs'
|
|
30
30
|
import { detectProject, initScenario, formatDetection } from './lib/init-detect.mjs'
|
|
31
31
|
import { recheckCaptures } from './lib/recheck.mjs'
|
|
32
|
-
import { resolveItems, fieldsOf, DEFAULT_LIMIT } from './lib/stack.mjs'
|
|
32
|
+
import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft } from './lib/stack.mjs'
|
|
33
33
|
import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
|
|
34
34
|
import { expandPacks } from './lib/packs.mjs'
|
|
35
35
|
import { upsertPrComment } from './lib/pr.mjs'
|
|
@@ -94,7 +94,9 @@ Project
|
|
|
94
94
|
the organization and what the token may do; the token (at most 30 days) goes to
|
|
95
95
|
~/.config/kaloko/.env, or is printed with --print
|
|
96
96
|
doctor [--json] [--fix] Check Node, Chrome, config, environments, browser ports, token, evaluator key
|
|
97
|
-
and app tools; --fix
|
|
97
|
+
and app tools; --fix installs missing Playwright browsers, creates the run and
|
|
98
|
+
scenario folders, writes org: from the token and asks macOS for the desktop
|
|
99
|
+
capture permissions
|
|
98
100
|
validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]]
|
|
99
101
|
Validate scenarios (schema + references); --coverage compares criteria with the plan
|
|
100
102
|
With a token it also warns about scenarios the service still lists whose file is gone (--offline skips it)
|
|
@@ -449,7 +451,7 @@ async function runContext (args) {
|
|
|
449
451
|
}
|
|
450
452
|
|
|
451
453
|
function stepOf (scenario, id) {
|
|
452
|
-
if (!id) throw new UserError(
|
|
454
|
+
if (!id) throw new UserError(`Which step? Pass --step <id>; this scenario has: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}.`)
|
|
453
455
|
const node = scenario.nodes.find((n) => n.id === id)
|
|
454
456
|
if (!node) throw new UserError(`Unknown step "${id}". Steps: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}`)
|
|
455
457
|
return node
|
|
@@ -655,6 +657,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
|
|
|
655
657
|
page.on('response', onResponse)
|
|
656
658
|
const streak = failureStreak(5)
|
|
657
659
|
const failed = []; let ok = 0
|
|
660
|
+
const started = Date.now(); let told = started
|
|
658
661
|
try {
|
|
659
662
|
for (const [i, item] of chosen.entries()) {
|
|
660
663
|
if (streak.stopped('stack')) { failed.push(`${item.id}: not walked after repeated failures`); continue }
|
|
@@ -671,7 +674,8 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
|
|
|
671
674
|
failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
|
|
672
675
|
if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
|
|
673
676
|
}
|
|
674
|
-
|
|
677
|
+
// long stacks: progress every 25 pages (or 30 s) with the time left, estimated from the pages so far
|
|
678
|
+
if (i + 1 < chosen.length && ((i + 1) % 25 === 0 || Date.now() - told > 30000)) { told = Date.now(); const left = timeLeft(i + 1, chosen.length, told - started); say(` ${node.id} ${locale}: ${i + 1}/${chosen.length}${left ? ` · ${left}` : ''}`) }
|
|
675
679
|
}
|
|
676
680
|
} finally { page.off('response', onResponse) }
|
|
677
681
|
say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}${tag}: ${ok}/${chosen.length} page(s) captured`)
|
|
@@ -2106,9 +2110,9 @@ const commands = {
|
|
|
2106
2110
|
}
|
|
2107
2111
|
let runId = args.run
|
|
2108
2112
|
if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
|
|
2109
|
-
if (!runId) throw new UserError('--run <id
|
|
2113
|
+
if (!runId) throw new UserError('No current run here. Pass --run <id>, or walk the scenario first (kaloko start, then kaloko walk).')
|
|
2110
2114
|
const run = await load(runId)
|
|
2111
|
-
if (!run) throw new UserError(`Run ${runId}
|
|
2115
|
+
if (!run) throw new UserError(`Run ${runId} is neither in ${config.output} nor on the service. Check the id (kaloko runs lists the local ones; the run's address on Kaloko shows a shared one).`)
|
|
2112
2116
|
let baseId = typeof args.baseline === 'string' ? args.baseline : null
|
|
2113
2117
|
// --design: against the design reference of the scenario (#42)
|
|
2114
2118
|
if (args.design && run.scenario?.id) { client ||= service(config); baseId = (await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(run.scenario.id)}/references`)).design; if (!baseId) throw new UserError('This scenario has no design reference yet (kaloko reference --design --version <n>).') }
|
|
@@ -2120,7 +2124,7 @@ const commands = {
|
|
|
2120
2124
|
}
|
|
2121
2125
|
if (!baseId) throw new UserError('No baseline or earlier run of this scenario to compare with; pass --baseline <id>.')
|
|
2122
2126
|
const base = await load(baseId)
|
|
2123
|
-
if (!base) throw new UserError(`
|
|
2127
|
+
if (!base) throw new UserError(`Baseline run ${baseId} is neither in ${config.output} nor on the service. Check the id, or pass another --baseline <id>.`)
|
|
2124
2128
|
const d = compareRuns(run, base)
|
|
2125
2129
|
if (args.json) { say(JSON.stringify(d, null, 2)); return }
|
|
2126
2130
|
say(formatComparison(d))
|
|
@@ -2199,7 +2203,7 @@ const commands = {
|
|
|
2199
2203
|
if (typeof args.scenario === 'string') return exportScenario({ args, config, root, client })
|
|
2200
2204
|
if (typeof args.format === 'string') {
|
|
2201
2205
|
// living docs and the changelog as files (docs/DOCS-CHANGELOG.md → Exports)
|
|
2202
|
-
if (!config.org) throw new UserError(
|
|
2206
|
+
if (!config.org) throw new UserError(NO_ORG)
|
|
2203
2207
|
const { pickProduct } = await import('./lib/changelog.mjs')
|
|
2204
2208
|
const { exportDocs } = await import('./lib/docs.mjs')
|
|
2205
2209
|
const product = pickProduct(config, root, args.product)
|
|
@@ -2220,7 +2224,7 @@ const commands = {
|
|
|
2220
2224
|
}
|
|
2221
2225
|
let runId = args.run
|
|
2222
2226
|
if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
|
|
2223
|
-
if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id>
|
|
2227
|
+
if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('Which run? Pass --run <id> with the id of a shared run (kaloko share prints it), or run this where kaloko start made a current run.')
|
|
2224
2228
|
const out = typeof args.out === 'string' ? resolve(args.out) : resolve(root, config.output, `${runId}.zip`)
|
|
2225
2229
|
const { createWriteStream } = await import('node:fs')
|
|
2226
2230
|
const { Readable } = await import('node:stream')
|
|
@@ -2236,7 +2240,7 @@ const commands = {
|
|
|
2236
2240
|
const client = service(config)
|
|
2237
2241
|
let runId = args.run
|
|
2238
2242
|
if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
|
|
2239
|
-
if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id>
|
|
2243
|
+
if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('Which run? Pass --run <id> with the id of a shared run (kaloko share prints it), or run this where kaloko start made a current run.')
|
|
2240
2244
|
const r = await client.request('POST', `/orgs/${config.org}/runs/${runId}/public`, { public: !args.off })
|
|
2241
2245
|
say(r.public ? `Public: ${r.url} (readable without sign-in; only one public run per scenario)` : `Private again: ${runId}`)
|
|
2242
2246
|
},
|
|
@@ -2284,7 +2288,7 @@ const commands = {
|
|
|
2284
2288
|
const product = pickProduct(config, root, args.product)
|
|
2285
2289
|
const version = typeof args.version === 'string' ? args.version : 'latest'
|
|
2286
2290
|
if (sub === 'publish') {
|
|
2287
|
-
if (!config.org && !args['dry-run']) throw new UserError(
|
|
2291
|
+
if (!config.org && !args['dry-run']) throw new UserError(NO_ORG)
|
|
2288
2292
|
const r = await docs.publishDocs({ root, config, product, client: args['dry-run'] ? null : service(config), version, reviewed: !!args.reviewed, dryRun: !!args['dry-run'] })
|
|
2289
2293
|
if (args.json) { say(JSON.stringify(r, null, 2)); return }
|
|
2290
2294
|
if (r.dry_run) { say(`${product.slug}: ${r.pages} page(s) from ${r.dir} would go to Kaloko as ${r.version} (${r.languages.join(', ')}); dry run, nothing sent.${r.broken.length ? `\n ! broken step references: ${r.broken.join('; ')}` : ''}`); return }
|
|
@@ -2312,12 +2316,12 @@ const commands = {
|
|
|
2312
2316
|
const { config, root } = await context(args)
|
|
2313
2317
|
const lib = await import('./lib/changelog.mjs')
|
|
2314
2318
|
if (sub === 'list') {
|
|
2315
|
-
if (!config.org) throw new UserError(
|
|
2319
|
+
if (!config.org) throw new UserError(NO_ORG)
|
|
2316
2320
|
const r = await lib.listProducts({ root, config, client: service(config) })
|
|
2317
2321
|
say(args.json ? JSON.stringify(r, null, 2) : lib.formatList(r))
|
|
2318
2322
|
return
|
|
2319
2323
|
}
|
|
2320
|
-
if (!config.org && !args['dry-run']) throw new UserError(
|
|
2324
|
+
if (!config.org && !args['dry-run']) throw new UserError(NO_ORG)
|
|
2321
2325
|
const r = await lib.syncProducts({ root, config, client: args['dry-run'] ? null : service(config), only: typeof args.product === 'string' ? args.product : null, dryRun: !!args['dry-run'] })
|
|
2322
2326
|
say(args.json ? JSON.stringify(r, null, 2) : lib.formatSync(r))
|
|
2323
2327
|
if (r.products.some((p) => p.ok === false)) process.exitCode = 1
|
|
@@ -2326,7 +2330,7 @@ const commands = {
|
|
|
2326
2330
|
/** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
|
|
2327
2331
|
async release (args) {
|
|
2328
2332
|
const { config, root } = await context(args)
|
|
2329
|
-
if (!config.org) throw new UserError(
|
|
2333
|
+
if (!config.org) throw new UserError(NO_ORG)
|
|
2330
2334
|
const { pickProduct, releaseProduct } = await import('./lib/changelog.mjs')
|
|
2331
2335
|
const product = pickProduct(config, root, args.product)
|
|
2332
2336
|
const client = args['dry-run'] ? null : service(config)
|
|
@@ -2360,10 +2364,13 @@ export async function main (argv) {
|
|
|
2360
2364
|
const args = parse(argv)
|
|
2361
2365
|
const name = args._.shift() || 'help'
|
|
2362
2366
|
const cmd = commands[name] || (args.help ? commands.help : null)
|
|
2363
|
-
if (!cmd) { console.error(`
|
|
2367
|
+
if (!cmd) { console.error(`There is no command "${name}". The commands are below; kaloko help <command> explains one.\n`); say(HELP); process.exitCode = 2; return }
|
|
2364
2368
|
try {
|
|
2365
2369
|
await cmd(args)
|
|
2366
2370
|
} catch (error) {
|
|
2367
|
-
if (error instanceof UserError) { console.error(error.message); process.exitCode = 1 } else
|
|
2371
|
+
if (error instanceof UserError) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = 1 } else {
|
|
2372
|
+
console.error(`Kaloko stopped on an unexpected error (a bug on our side). Send the output below to ${SUPPORT_EMAIL} with the command you ran.`)
|
|
2373
|
+
throw error
|
|
2374
|
+
}
|
|
2368
2375
|
}
|
|
2369
2376
|
}
|
package/src/lib/access.mjs
CHANGED
|
@@ -167,7 +167,7 @@ export function totp (base32, { step = 30, digits = 6, now = Date.now() } = {})
|
|
|
167
167
|
let bits = ''
|
|
168
168
|
for (const ch of base32.replace(/[\s=]/g, '').toUpperCase()) {
|
|
169
169
|
const v = alphabet.indexOf(ch)
|
|
170
|
-
if (v < 0) throw new UserError('The TOTP secret is not base32.')
|
|
170
|
+
if (v < 0) throw new UserError('The TOTP secret is not base32 (letters A–Z and digits 2–7). Copy the secret again from the setup screen of the account, not the six-digit code.')
|
|
171
171
|
bits += v.toString(2).padStart(5, '0')
|
|
172
172
|
}
|
|
173
173
|
const key = Buffer.from((bits.match(/.{8}/g) || []).map((b) => parseInt(b, 2)))
|
package/src/lib/apps/drivers.mjs
CHANGED
|
@@ -78,7 +78,7 @@ function android (cfg, ctx) {
|
|
|
78
78
|
platform: 'android',
|
|
79
79
|
async open ({ locale, reset, attach = false } = {}) {
|
|
80
80
|
const { stdout } = await adb(['get-state']).catch((e) => { throw new UserError(`No Android device: ${e.message}. Start an emulator or connect a device (adb devices).`) })
|
|
81
|
-
if (!/device/.test(stdout)) throw new UserError('The Android device is not ready (adb get-state).')
|
|
81
|
+
if (!/device/.test(stdout)) throw new UserError('The Android device is connected but not ready (adb get-state). Unlock it and allow USB debugging, or wait until the emulator has booted, then run the command again.')
|
|
82
82
|
const dens = (await shell('wm', 'density')).stdout.match(/(\d+)\s*$/m)
|
|
83
83
|
density = dens ? Number(dens[1]) / 160 : 1
|
|
84
84
|
if (!cfg.device) device = (await shell('getprop', 'ro.product.model')).stdout.trim() || 'android'
|
package/src/lib/apps/webview.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export function webviewSockets (procNetUnix) {
|
|
|
27
27
|
* local port and connects Playwright over CDP. Returns { browser, page, pages, close }.
|
|
28
28
|
*/
|
|
29
29
|
export async function attachAndroidWebView (cfg, { timeout = 15000 } = {}) {
|
|
30
|
-
const adb = (args) => run(cfg.adb || 'adb', [...(cfg.serial ? ['-s', cfg.serial] : []), ...args], { timeout: 30000 }).then((r) => r.stdout).catch((e) => { if (e.code === 'ENOENT') throw new UserError('adb is not installed or not on PATH.'); throw e })
|
|
30
|
+
const adb = (args) => run(cfg.adb || 'adb', [...(cfg.serial ? ['-s', cfg.serial] : []), ...args], { timeout: 30000 }).then((r) => r.stdout).catch((e) => { if (e.code === 'ENOENT') throw new UserError('adb is not installed or not on PATH. Install Android platform-tools (kaloko doctor names the command for this system) and open a new terminal.'); throw e })
|
|
31
31
|
const end = Date.now() + timeout
|
|
32
32
|
let socket = null
|
|
33
33
|
while (!socket && Date.now() < end) {
|
|
@@ -43,12 +43,12 @@ export async function attachAndroidWebView (cfg, { timeout = 15000 } = {}) {
|
|
|
43
43
|
let browser
|
|
44
44
|
try { browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout }) } catch (e) {
|
|
45
45
|
await adb(['forward', '--remove', `tcp:${port}`]).catch(() => {})
|
|
46
|
-
throw new UserError(`Could not connect to the WebView (${socket.socket}): ${e.message.split('\n')[0]}
|
|
46
|
+
throw new UserError(`Could not connect to the WebView (${socket.socket}): ${e.message.split('\n')[0]}. Bring the app to the front and run the command again.`)
|
|
47
47
|
}
|
|
48
48
|
const pages = browser.contexts().flatMap((c) => c.pages())
|
|
49
49
|
// the visible page first: a WebView app often keeps hidden ones around
|
|
50
50
|
let page = pages[0] || null
|
|
51
51
|
for (const p of pages) { if (await p.evaluate(() => document.visibilityState === 'visible').catch(() => false)) { page = p; break } }
|
|
52
|
-
if (!page) { await browser.close().catch(() => {}); throw new UserError('The WebView has no page open.') }
|
|
52
|
+
if (!page) { await browser.close().catch(() => {}); throw new UserError('The WebView has no page open. Open the screen with the WebView in the app, then run the command again.') }
|
|
53
53
|
return { browser, page, pages, socket: socket.socket, close: async () => { await browser.close().catch(() => {}); await adb(['forward', '--remove', `tcp:${port}`]).catch(() => {}) } }
|
|
54
54
|
}
|
package/src/lib/changelog.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { promisify } from 'node:util'
|
|
|
8
8
|
import { join, resolve, relative, dirname, basename } from 'node:path'
|
|
9
9
|
import { UserError, sha256 } from './util.mjs'
|
|
10
10
|
import { repoSlug } from './git.mjs'
|
|
11
|
-
import { cleanVersion, compareVersions, tagFor, versionOfTag, latestVersionIn, sectionOf, applyDraft, finalizeRelease, draftEntry,
|
|
11
|
+
import { cleanVersion, compareVersions, tagFor, versionOfTag, latestVersionIn, sectionOf, applyDraft, finalizeRelease, draftEntry, parseEntry, findStepRefs, CHANGELOG_SKELETON } from './changelog-core.mjs'
|
|
12
12
|
|
|
13
13
|
const run = promisify(execFile)
|
|
14
14
|
async function out (cmd, args, cwd, timeout = 20000) {
|
|
@@ -236,7 +236,7 @@ export async function releaseProduct ({ root, config, product, version = null, d
|
|
|
236
236
|
const src = await detectVersionSource(root, product)
|
|
237
237
|
const v = cleanVersion(version || src.version || '')
|
|
238
238
|
if (!v) throw new UserError(`Which version? Pass it (kaloko release 2.4.0); ${src.source === 'git-tag' ? `no tag matches ${src.pattern}` : `${src.path || src.source} names none`}.`)
|
|
239
|
-
if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,79}$/.test(v)) throw new UserError(
|
|
239
|
+
if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,79}$/.test(v)) throw new UserError(`${v} is not a version Kaloko can use: letters, digits, dots, + and -, at most 80 characters (e.g. 2.4.0).`)
|
|
240
240
|
const notes = []
|
|
241
241
|
if (src.version && src.version !== v && src.source !== 'git-tag' && src.source !== 'semantic-release') notes.push(`${src.tool || src.source} (${src.path}) says ${src.version}; recording ${v} as you asked.`)
|
|
242
242
|
const file = changelogFile(root, product)
|
|
@@ -89,7 +89,7 @@ async function runIdOf ({ client, org, scenarioId, ref, branch }) {
|
|
|
89
89
|
}
|
|
90
90
|
if (/^v\d+$/.test(ref)) {
|
|
91
91
|
const v = (await client.request('GET', `/orgs/${org}/scenarios/${enc(scenarioId)}/versions`)).find((x) => String(x.version) === ref.slice(1))
|
|
92
|
-
if (!v) throw new UserError(`${scenarioId} has no version ${ref}.`)
|
|
92
|
+
if (!v) throw new UserError(`${scenarioId} has no version ${ref}. kaloko history lists its versions.`)
|
|
93
93
|
return v.id
|
|
94
94
|
}
|
|
95
95
|
return ref
|
|
@@ -111,7 +111,7 @@ export async function exportDesign ({ config, scenarioId, client, ref = 'design'
|
|
|
111
111
|
const sink = exportSink(out)
|
|
112
112
|
const fileOf = async (path) => {
|
|
113
113
|
const res = draft ? await fetch(manifest.files_base + path) : await client.fetch(`/orgs/${org}/runs/${runId}/files/${path.split('/').map(enc).join('/')}`)
|
|
114
|
-
if (!res.ok) throw new UserError(
|
|
114
|
+
if (!res.ok) throw new UserError(`Downloading ${path} failed (HTTP ${res.status}). Run the export again; if it keeps failing, the file may have expired with its run.`)
|
|
115
115
|
return Buffer.from(await res.arrayBuffer())
|
|
116
116
|
}
|
|
117
117
|
// the live files keep the design folder layout: links between steps work from disk exactly as in the folder
|
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
|
@@ -288,7 +288,7 @@ function hostedSender (config, report) {
|
|
|
288
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' } }
|
|
289
289
|
try { return { response: await client.request('POST', `/orgs/${config.org}/semantic`, JSON.parse(body)) } } catch (e) {
|
|
290
290
|
const m = String(e.message || e)
|
|
291
|
-
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 } }
|
|
292
292
|
return { error: m }
|
|
293
293
|
}
|
|
294
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)
|
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/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]
|
package/src/lib/stack.mjs
CHANGED
|
@@ -66,7 +66,7 @@ export async function resolveItems ({ node, base, page, scenarioDir, env, locale
|
|
|
66
66
|
if (first.index) { locs = []; for (const u of first.locs.slice(0, 50)) locs.push(...sitemapLocs(await get(u)).locs) }
|
|
67
67
|
raw = locs.map((u) => ({ path: u }))
|
|
68
68
|
} else if (src.crawl) {
|
|
69
|
-
if (!page) throw new UserError('items.crawl needs
|
|
69
|
+
if (!page) throw new UserError('items.crawl needs the browser. Walk the step with kaloko walk (not offline), or list the pages with items.file or items.sitemap.')
|
|
70
70
|
let url = toUrl(src.crawl.from)
|
|
71
71
|
for (let i = 0; i < (src.crawl.pages || 1) && url; i++) {
|
|
72
72
|
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
|
@@ -129,3 +129,13 @@ export function readFields (specs) {
|
|
|
129
129
|
}
|
|
130
130
|
return out
|
|
131
131
|
}
|
|
132
|
+
|
|
133
|
+
/** "about 4 min left" after `done` of `total` pages took `ms`; '' before the first page or after the last. */
|
|
134
|
+
export function timeLeft (done, total, ms) {
|
|
135
|
+
if (!(done > 0) || done >= total || !(ms >= 0)) return ''
|
|
136
|
+
const s = Math.round((ms / done) * (total - done) / 1000)
|
|
137
|
+
if (s < 60) return `about ${Math.max(5, Math.round(s / 5) * 5)} s left`
|
|
138
|
+
if (s < 3600) return `about ${Math.round(s / 60)} min left`
|
|
139
|
+
const h = Math.floor(s / 3600); const m = Math.round((s % 3600) / 60)
|
|
140
|
+
return `about ${h} h${m ? ` ${m} min` : ''} left`
|
|
141
|
+
}
|
package/src/lib/util.mjs
CHANGED
|
@@ -67,6 +67,12 @@ export function globToRegExp (glob) {
|
|
|
67
67
|
|
|
68
68
|
export class UserError extends Error {}
|
|
69
69
|
|
|
70
|
+
/** Every CLI error ends with a way to a person (docs/HARDENING.md phase 6, item 12). */
|
|
71
|
+
export const SUPPORT_EMAIL = 'support@sinfin.cz'
|
|
72
|
+
/** No `org:` in the config: where the slug comes from. */
|
|
73
|
+
export const NO_ORG = 'No organization in kaloko.config.yml. Add org: <slug> (the part after kaloko.app/ when you are signed in), or run kaloko login.'
|
|
74
|
+
export const SUPPORT_LINE = `Stuck? Write to ${SUPPORT_EMAIL} with the command you ran and this message.`
|
|
75
|
+
|
|
70
76
|
/**
|
|
71
77
|
* Tracks consecutive step failures per session. When `limit` steps in a row fail with the same first
|
|
72
78
|
* error line, the remaining steps of that session are almost certainly failing on a shared
|
package/src/lib/video.mjs
CHANGED
|
@@ -188,7 +188,12 @@ function install () {
|
|
|
188
188
|
if (this.enc) {
|
|
189
189
|
const f = new VideoFrame(this.canvas, { timestamp: Math.round(tk.t * 1000), duration: Math.round(1e6 / c.fps) })
|
|
190
190
|
this.enc.encode(f, { keyFrame: this.n % c.keyEvery === 0 }); f.close(); this.n++
|
|
191
|
-
|
|
191
|
+
// a stalled encoder (seen under heavy load) must not hold the walk: after 20 s the recording gives up
|
|
192
|
+
const until = Date.now() + 20000
|
|
193
|
+
while (this.enc && this.enc.encodeQueueSize > 6) {
|
|
194
|
+
if (Date.now() > until) { this.error = 'the video encoder stopped responding'; try { this.enc.close() } catch {} this.enc = null; break }
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
196
|
+
}
|
|
192
197
|
}
|
|
193
198
|
if (tk.preview) {
|
|
194
199
|
this.pg.drawImage(this.canvas, 0, 0, c.pw, c.ph)
|
|
@@ -199,7 +204,11 @@ function install () {
|
|
|
199
204
|
return { ...out, chunks: this.chunks.splice(0), error: this.error }
|
|
200
205
|
},
|
|
201
206
|
async finish () {
|
|
202
|
-
if (this.enc) {
|
|
207
|
+
if (this.enc) {
|
|
208
|
+
// flush has no time limit of its own: a stalled encoder ends the recording with an error instead of hanging
|
|
209
|
+
try { await Promise.race([this.enc.flush(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('the video encoder did not finish within 30 s')), 30000))]) } catch (e) { this.error = String(e?.message || e) }
|
|
210
|
+
try { this.enc.close() } catch {}
|
|
211
|
+
}
|
|
203
212
|
for (const bm of this.bitmaps.values()) bm.close()
|
|
204
213
|
this.bitmaps.clear()
|
|
205
214
|
return { chunks: this.chunks.splice(0), error: this.error }
|
package/src/lib/vision.mjs
CHANGED
|
@@ -120,7 +120,7 @@ export function hostedVisionSender (config, report) {
|
|
|
120
120
|
try { if (!client) { const { service } = await import('./service.mjs'); client = service(config) } } catch { return { skipped: 'no vision key (ANTHROPIC_API_KEY) and no service token (KALOKO_TOKEN) for hosted evaluation' } }
|
|
121
121
|
try { return { response: await client.request('POST', `/orgs/${config.org}/semantic/vision`, payload) } } catch (e) {
|
|
122
122
|
const m = String(e.message || e)
|
|
123
|
-
if (/HTTP (402|403|404|503)/.test(m)) { report.visionStop = m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.visionStop } }
|
|
123
|
+
if (/HTTP (402|403|404|503)/.test(m)) { report.visionStop = e.serviceMessage ?? m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.visionStop } }
|
|
124
124
|
return { error: m }
|
|
125
125
|
}
|
|
126
126
|
}
|
package/viewer/index.html
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
@font-face{font-family:"Instrument Sans Fallback";src:local("Arial Bold"),local("Arial");font-weight:700;size-adjust:98.1%;ascent-override:99%;descent-override:25%;line-gap-override:0%}
|
|
15
15
|
:root{
|
|
16
16
|
--ground:#ecece8; --dots:#c9c9c3; --panel:#ffffff; --panel-2:#f5f5f2;
|
|
17
|
-
--ink:#151514; --muted:#6a6a64; --line:#d7d7d1; --line-strong:#151514;
|
|
17
|
+
--ink:#151514; --muted:#6a6a64; --line:#d7d7d1; --line-strong:#151514; --field:#7a7a73;
|
|
18
18
|
--accent:#ffe452; --accent-ink:#151514; --focus:#151514;
|
|
19
19
|
--pass:#1d8249; --fail:#c8102e; --partial:#b86e00; --skip:#8d8d86;
|
|
20
20
|
--pass-bg:#e3f3e9; --fail-bg:#fbe5e8; --partial-bg:#fbf0dc; --skip-bg:#ececea;
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
|
|
27
27
|
color-scheme:dark;
|
|
28
28
|
--ground:#151514; --dots:#2c2c29; --panel:#1f1f1d; --panel-2:#262624;
|
|
29
|
-
--ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --line-strong:#f0f0ec;
|
|
29
|
+
--ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --field:#7d7d76; --line-strong:#f0f0ec;
|
|
30
30
|
--pass:#52c784; --fail:#ff6070; --partial:#f0a83c; --skip:#8d8d86;
|
|
31
31
|
--pass-bg:#18311f; --fail-bg:#3a1a1f; --partial-bg:#372a14; --skip-bg:#2a2a27;
|
|
32
32
|
--pass-ink:#52c784; --fail-ink:#ff6070; --partial-ink:#f0a83c; --skip-ink:#a9a9a2;
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
:root[data-theme="dark"]{
|
|
36
36
|
color-scheme:dark;
|
|
37
37
|
--ground:#151514; --dots:#2c2c29; --panel:#1f1f1d; --panel-2:#262624;
|
|
38
|
-
--ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --line-strong:#f0f0ec;
|
|
38
|
+
--ink:#f0f0ec; --muted:#9d9d96; --line:#35352f; --field:#7d7d76; --line-strong:#f0f0ec;
|
|
39
39
|
--pass:#52c784; --fail:#ff6070; --partial:#f0a83c; --skip:#8d8d86;
|
|
40
40
|
--pass-bg:#18311f; --fail-bg:#3a1a1f; --partial-bg:#372a14; --skip-bg:#2a2a27;
|
|
41
41
|
--pass-ink:#52c784; --fail-ink:#ff6070; --partial-ink:#f0a83c; --skip-ink:#a9a9a2;
|
|
@@ -124,6 +124,8 @@ svg.i{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.7;stro
|
|
|
124
124
|
.picker .kchip{font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;padding:2px 6px;border-radius:4px;background:var(--accent);color:var(--accent-ink);flex:none}
|
|
125
125
|
.picker .kchip--flow{background:var(--ink);color:var(--panel)}
|
|
126
126
|
.picker kbd,.kbd{font-family:var(--mono);font-size:10.5px;border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 5px;color:var(--muted);flex:none}
|
|
127
|
+
/* a key inside a button takes the button's text colour: grey on the yellow primary button is unreadable */
|
|
128
|
+
.btn .kbd{color:inherit}
|
|
127
129
|
.tog{width:30px;height:30px;display:inline-grid;place-items:center;border-radius:6px;border:1px solid var(--line);background:var(--panel)}
|
|
128
130
|
.tog[aria-pressed="true"]{background:var(--ink);color:var(--panel);border-color:var(--ink)}
|
|
129
131
|
.tog svg.i{width:16px;height:16px}
|
|
@@ -295,6 +297,9 @@ table.runs{border-collapse:collapse;width:100%;font-size:12.5px}
|
|
|
295
297
|
|
|
296
298
|
.toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);background:var(--ink);color:var(--panel);padding:8px 14px;border-radius:999px;font-size:13px;z-index:30;box-shadow:var(--shadow)}
|
|
297
299
|
.sess-lost{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:60;display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;background:var(--panel);color:var(--ink);border:1px solid var(--fail);border-radius:10px;padding:10px 14px;font-size:13px;box-shadow:var(--shadow);width:max-content;max-width:calc(100vw - 32px)}
|
|
300
|
+
.net-bar{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:59;display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;background:var(--panel);color:var(--ink);border:1px solid var(--partial);border-radius:10px;padding:10px 14px;font-size:13px;box-shadow:var(--shadow);width:max-content;max-width:calc(100vw - 32px)}
|
|
301
|
+
.net-bar .note{color:var(--muted)}
|
|
302
|
+
.sess-lost ~ .net-bar{top:76px}
|
|
298
303
|
|
|
299
304
|
@media (max-width:1100px){
|
|
300
305
|
.main{grid-template-columns:minmax(0,1fr)}
|
|
@@ -784,7 +789,7 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
784
789
|
.c-note{margin:4px 0 2px;font-size:13.5px;overflow-wrap:anywhere}.c-at{color:var(--ink);background:color-mix(in srgb,var(--accent,#ffe452) 45%,transparent);border-radius:3px;padding:0 2px;font-weight:600}
|
|
785
790
|
.c-acts{display:flex;gap:10px;font-size:12px}.c-acts button,.linkish{all:unset;cursor:pointer;color:var(--muted);text-decoration:underline;text-underline-offset:3px}.c-acts button:hover,.linkish:hover{color:var(--ink)}
|
|
786
791
|
.c-pin{all:unset;cursor:pointer;min-width:18px;height:18px;padding:0 4px;border-radius:9px 9px 9px 2px;background:#e4572e;color:#fff;font:600 10.5px/18px system-ui,sans-serif;text-align:center}
|
|
787
|
-
.c-compose{margin-top:6px}.c-compose .c-text{width:100%;box-sizing:border-box;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:4px 0 8px;resize:vertical}
|
|
792
|
+
.c-compose{margin-top:6px}.c-compose .c-text{width:100%;box-sizing:border-box;font:inherit;padding:8px;border:1px solid var(--field,var(--line));border-radius:6px;background:var(--panel);color:var(--ink);margin:4px 0 8px;resize:vertical}
|
|
788
793
|
.c-hint{margin-left:auto;font-size:11.5px}@media (hover:none) and (pointer:coarse){.c-hint{display:none}}.c-pinrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px}.c-pinned{display:inline-flex;gap:5px;align-items:center}.c-pinbtn[aria-pressed="true"]{background:var(--ink);color:var(--panel)}
|
|
789
794
|
.c-tabs{margin:0 0 10px}.cmt{display:inline-flex;align-items:center;gap:3px;font:600 11px/1 system-ui,sans-serif;color:var(--ink);padding:2px 6px;border-radius:999px;background:color-mix(in srgb,var(--partial) 22%,transparent)}.cmt .i{width:12px;height:12px}
|
|
790
795
|
.lb__img.is-pinning,.lb__img.is-pinning img{cursor:crosshair}.pinmark.pin-xy{border:0;cursor:pointer;transform:translate(-2px,-100%)}.pinmark.pin-xy.is-done{background:#8a8a84}.pinmark.pin-xy.is-new{background:var(--ink)}.pinmark.flash{animation:pinflash 1s 2}
|
|
@@ -869,6 +874,10 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
869
874
|
.rvm__side{overflow:auto;background:var(--panel);border-left:1px solid var(--line)}
|
|
870
875
|
.rvm__foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 16px calc(10px + env(safe-area-inset-bottom));background:var(--panel);border-top:1px solid var(--line)}
|
|
871
876
|
.rvm__foot .btn .kbd,.rvm__top .kbd{margin-left:4px}
|
|
877
|
+
.rvm__flash{flex:1 1 100%;margin:0;padding:8px 12px;border-radius:8px;font-size:14px;animation:rvflash .25s ease-out}
|
|
878
|
+
.rvm__flash--approved{background:var(--pass-bg);color:var(--pass-ink)}.rvm__flash--returned{background:var(--fail-bg);color:var(--fail-ink)}
|
|
879
|
+
@keyframes rvflash{from{opacity:0;transform:translateY(6px)}}
|
|
880
|
+
@media (prefers-reduced-motion:reduce){.rvm__flash{animation:none}}
|
|
872
881
|
.rvm__why{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px}
|
|
873
882
|
.rvm__dec{display:grid;gap:6px}
|
|
874
883
|
.rvm__dec p{margin:0}
|
|
@@ -883,6 +892,8 @@ details summary{cursor:pointer;font-size:12.5px;font-weight:500}
|
|
|
883
892
|
#rvBtn .rv-count{display:inline-grid;place-items:center;min-width:18px;height:18px;padding:0 5px;margin-left:4px;border-radius:999px;background:var(--ink);color:var(--panel);font-size:11px;font-weight:600}
|
|
884
893
|
.badge.rv-carry{margin-left:auto}
|
|
885
894
|
@media (max-width:900px){.rvm__body{grid-template-columns:1fr;grid-template-rows:minmax(220px,55%) 1fr}.rvm__side{border-left:0;border-top:1px solid var(--line)}.rvm__stage{padding:12px}.rvm__bar{width:72px}.rvm__keys{display:none}}
|
|
895
|
+
/* a phone: the decisions sit in the bottom row, under the thumb (Approve on the right, wide), moving on above them */
|
|
896
|
+
@media (max-width:560px){.rvm__foot--act{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));grid-template-areas:"flash flash flash" "prev cmt next" "ret ok ok"}.rvm__foot--act .rvm__flash{grid-area:flash}.rvm__foot--act #rvPrev{grid-area:prev}.rvm__foot--act #rvCmt{grid-area:cmt}.rvm__foot--act #rvNext{grid-area:next}.rvm__foot--act #rvRet{grid-area:ret}.rvm__foot--act #rvOk{grid-area:ok}.rvm__foot--act #rvRet,.rvm__foot--act #rvOk{min-height:52px;font-size:16px}.rvm__foot .btn{min-height:44px}.rvm__foot--act .btn{min-width:0}}
|
|
886
897
|
@media (max-width:560px){.rvm__foot{gap:6px;padding:8px 10px calc(8px + env(safe-area-inset-bottom))}.rvm__foot .btn{flex:1 1 auto;justify-content:center;padding:9px 8px}.rvm__foot .spacer{display:none}.rvm__top{padding:8px 10px}.rvm__top .brand small{display:none}}
|
|
887
898
|
@media (hover:none) and (pointer:coarse){.rvm .kbd,.rvm__keys{display:none}}
|
|
888
899
|
@media (max-width:1400px){#rvBtn span[data-t]{display:none}#rvBtn{padding:5px 8px}}
|
|
@@ -1102,6 +1113,9 @@ Object.assign(I18N.cs, { devMode:'Dev mode', devTab:'Dev', devTitle:'Míry, toke
|
|
|
1102
1113
|
Object.assign(I18N.en, { devMode:'Dev mode', devTab:'Dev', devTitle:'Measurements, tokens and states of elements for development', devTabEl:'Element', devTabTokens:'Tokens', devEmpty:'Hover an element in the preview and click it. Shift + arrows move through the element tree; hold Alt to see the distances to another element.', devStart:'Select the main content', devNavLabel:'Move through elements', navParent:'Parent', navChild:'Child', navPrev:'Previous', navNext:'Next', g_type:'Typography', g_color:'Colors', g_space:'Spacing', g_size:'Size', g_shape:'Shape', g_layout:'Layout', notToken:'not a token', nearest:'nearest', typedRaw:'The CSS has this value typed raw although a token exists; use the custom property.', typedRawShort:'typed raw', copyTokenPath:'Copy the token path', contrast:'Contrast', aaPass:'passes AA', aaFail:'fails AA', aaNeed:'needs', largeText:'large text', contrastApprox:'There is an image behind the text; the contrast is approximate.', states:'States', stDefault:'default', stNone:'The page styles define no states for this element.', changedInState:'Highlighted values change in this state.', distances:'Distances', toParent:'To the parent', toTarget:'To', measure:'Measure', measureHint:'Hold Alt and hover another element, or turn on Measure and tap it.', assets:'Images and icons', download:'Download', assetFail:'The file could not be downloaded.', copyCssVars:'Copy CSS with variables', copyTokens:'Copy tokens (JSON)', copyPrompt:'Copy prompt for an agent', copyLine:'Copy the prompt line for this value', pinFromDev:'Comment on this element', tokUsed:'Used on this step', tokOff:'Values outside the token set', tokHard:'Typed raw in the CSS instead of a token', tokUnused:'Not used', tokNone:'This step has no token set (tokens.json).', tokHlN:'Elements highlighted:', tokHint:'Click a token to highlight the elements that use it.', devShotNote:'Measurements and tokens come from the screenshot as the CLI captured it; states and image downloads need the live page.', devNoSpec:'This capture has no spec. Walk the step again with the current CLI (kaloko walk).', devLocalLive:'From disk, Dev mode reads the screenshot; the live page is measured on kaloko.app.', devAgentHint:'An agent gets the same: kaloko spec %N or MCP get_step_spec.', devLoading:'Loading…', keyDev:'Dev mode in the step detail: measurements, tokens and states of elements (M measures, Shift + arrows move through elements)', promptHead:'Build %E as approved in Kaloko (step %S).', promptUse:'Use token %T for the %W.', promptOff:'%P %V is not a token; the nearest is %T. Use the raw value only if the designer agrees.', promptOffNone:'%P %V is not a token.', promptSpec:'Spec of the whole step: kaloko spec %N' });
|
|
1103
1114
|
Object.assign(I18N.cs, { sessionLost:'Přihlášení vypršelo. Přihlaste se znovu, rozepsaný text tu na vás počká.', signInAgain:'Přihlásit se znovu' });
|
|
1104
1115
|
Object.assign(I18N.en, { sessionLost:'Your session has expired. Sign in again; what you were writing waits here.', signInAgain:'Sign in again' });
|
|
1116
|
+
// the connection drops or the service fails while the canvas is open (NET, netSend)
|
|
1117
|
+
Object.assign(I18N.cs, { netOffline:'Jste offline. Co napíšete, tu počká, než se připojení vrátí.', netOfflineN:(n) => `Jste offline. ${n === 1 ? '1 změna čeká' : n < 5 ? `${n} změny čekají` : `${n} změn čeká`} tady a odešle se, až se připojení vrátí.`, netFailedN:(n, why) => `Kaloko ${n === 1 ? '1 změnu' : n < 5 ? `${n} změny` : `${n} změn`} zatím neuložilo (${why}). Nic se neztratilo.`, netNoAnswer:'služba neodpovídá', netError:(c) => c === 429 ? 'příliš mnoho požadavků' : `chyba ${c}`, netAgainIn:(s) => `Zkusím to znovu za ${s} s.`, netRetry:'Zkusit znovu', netSending:'Odesílám…', netBack:'Připojení je zpět, všechno se odeslalo.', netLeave:'Některé změny ještě nejsou odeslané.' });
|
|
1118
|
+
Object.assign(I18N.en, { netOffline:'You are offline. What you write waits here until the connection is back.', netOfflineN:(n) => `You are offline. ${n === 1 ? '1 change waits here and goes' : `${n} changes wait here and go`} out when the connection is back.`, netFailedN:(n, why) => `Kaloko has not saved ${n === 1 ? '1 change' : `${n} changes`} yet (${why}). Nothing is lost.`, netNoAnswer:'the service does not answer', netError:(c) => c === 429 ? 'too many requests' : `error ${c}`, netAgainIn:(s) => `Trying again in ${s} s.`, netRetry:'Try again', netSending:'Sending…', netBack:'Back online; everything has been sent.', netLeave:'Some changes have not been sent yet.' });
|
|
1105
1119
|
// back after signing in again: the session expired on the canvas (sessionLost), the server kept the path, but the
|
|
1106
1120
|
// sign-in mail cannot carry the fragment (the open step, the comments), so the canvas stored it for this moment
|
|
1107
1121
|
(() => { try { const r = JSON.parse(localStorage.getItem('qawalk-return') || 'null'); if (!r) return; localStorage.removeItem('qawalk-return'); if (r.path === location.pathname && r.hash && !location.hash && Date.now() - r.at < 864e5) history.replaceState(null, '', location.pathname + location.search + r.hash); } catch {} })();
|
|
@@ -1431,7 +1445,8 @@ function commentsBase (scope) {
|
|
|
1431
1445
|
return scope === 'flow' ? `${a.api}/orgs/${a.org}/scenarios/${encodeURIComponent(RUN.scenario.id)}/comments` : `${a.api}/orgs/${a.org}/runs/${a.run}/comments`;
|
|
1432
1446
|
}
|
|
1433
1447
|
async function cApi (scope, path, method, body) {
|
|
1434
|
-
const
|
|
1448
|
+
const init = { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify({ ...body, ...(isDraft() ? { branch: RUN.draft.branch } : {}) }) : undefined };
|
|
1449
|
+
const res = method === 'GET' ? await fetch(commentsBase(scope) + path, init) : await netSend(commentsBase(scope) + path, init, method === 'POST' && !path ? { find: commentSent(scope, body) } : {});
|
|
1435
1450
|
if (res.status === 401) throw sessionLost();
|
|
1436
1451
|
const data = await res.json().catch(() => ({}));
|
|
1437
1452
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
@@ -3780,13 +3795,88 @@ function sessionLost () {
|
|
|
3780
3795
|
return new Error(T('sessionLost'));
|
|
3781
3796
|
}
|
|
3782
3797
|
async function apiJson (method, url, body) {
|
|
3783
|
-
const
|
|
3798
|
+
const init = { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined };
|
|
3799
|
+
const res = method === 'GET' ? await fetch(url, init) : await netSend(url, init);
|
|
3784
3800
|
if (res.status === 401) throw sessionLost();
|
|
3785
3801
|
const data = await res.json().catch(() => ({}));
|
|
3786
3802
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
3787
3803
|
return data;
|
|
3788
3804
|
}
|
|
3789
3805
|
|
|
3806
|
+
/**
|
|
3807
|
+
* Writes survive a dropped connection. A request that cannot reach the service (offline, a dropped connection) or that
|
|
3808
|
+
* the service could not handle (5xx, 429, 408) waits in a queue the bar at the top names; it goes out again when the
|
|
3809
|
+
* browser is back online, on a timer (Retry-After when the service sends one) or on "Try again". The caller's promise
|
|
3810
|
+
* only settles with a real answer, so its button stays busy and nothing is lost silently; leaving the page while
|
|
3811
|
+
* something waits asks first. `find` looks for what the first attempt may already have created (a new comment), so a
|
|
3812
|
+
* request that did arrive is not sent twice.
|
|
3813
|
+
*/
|
|
3814
|
+
const NET = { pending: [], timer: null, tick: null, was: false };
|
|
3815
|
+
const netTrouble = (res) => !res || res.status >= 500 || res.status === 429 || res.status === 408;
|
|
3816
|
+
async function netSend (url, init, { find } = {}) {
|
|
3817
|
+
const started = Date.now();
|
|
3818
|
+
for (let attempt = 0; ; attempt++) {
|
|
3819
|
+
if (attempt && find) {
|
|
3820
|
+
let hit;
|
|
3821
|
+
try { hit = await find(started); } catch { hit = undefined; }
|
|
3822
|
+
if (hit) return new Response(JSON.stringify(hit), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
3823
|
+
if (hit === undefined) { await netWait({ status: 0, attempt }); continue; } // cannot look yet: wait again
|
|
3824
|
+
}
|
|
3825
|
+
let res = null;
|
|
3826
|
+
if (navigator.onLine !== false) { try { res = await fetch(url, init); } catch { res = null; } }
|
|
3827
|
+
if (!netTrouble(res)) { if (NET.was && !NET.pending.length) { NET.was = false; toast(T('netBack')); } return res; }
|
|
3828
|
+
const after = Number(res?.headers.get('retry-after'));
|
|
3829
|
+
await netWait({ status: res?.status || 0, attempt, after: Number.isFinite(after) && after > 0 ? Math.min(after, 300) : 0 });
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
function netWait (entry) {
|
|
3833
|
+
return new Promise((resolve) => {
|
|
3834
|
+
entry.go = () => { NET.pending = NET.pending.filter((x) => x !== entry); resolve(); };
|
|
3835
|
+
// the service failed or did not answer: try again by itself a few times (3, 10, 30 s), then wait for a person
|
|
3836
|
+
const delay = entry.after ? entry.after * 1000 : [3000, 10000, 30000][entry.attempt];
|
|
3837
|
+
entry.at = navigator.onLine === false || !delay ? 0 : Date.now() + delay;
|
|
3838
|
+
NET.pending.push(entry); NET.was = true;
|
|
3839
|
+
netBar();
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
/** Sends everything waiting now (back online, "Try again", or a timer that came due). */
|
|
3843
|
+
function netFlush (only) {
|
|
3844
|
+
const due = NET.pending.filter((x) => !only || only(x));
|
|
3845
|
+
for (const x of due) x.go();
|
|
3846
|
+
netBar();
|
|
3847
|
+
}
|
|
3848
|
+
function netBar () {
|
|
3849
|
+
clearTimeout(NET.timer); clearInterval(NET.tick); NET.tick = null;
|
|
3850
|
+
const offline = navigator.onLine === false;
|
|
3851
|
+
const n = NET.pending.length;
|
|
3852
|
+
let bar = $('netBar');
|
|
3853
|
+
if (!n && !offline) { bar?.remove(); return; }
|
|
3854
|
+
if (!bar) { document.body.insertAdjacentHTML('beforeend', '<div id="netBar" class="net-bar" role="status" aria-live="polite"><span class="net-bar__msg"></span><span class="note net-bar__when"></span><button type="button" class="btn net-bar__retry"></button></div>'); bar = $('netBar'); bar.querySelector('.net-bar__retry').onclick = () => { bar.querySelector('.net-bar__when').textContent = T('netSending'); netFlush(); }; }
|
|
3855
|
+
const last = NET.pending.at(-1);
|
|
3856
|
+
bar.querySelector('.net-bar__msg').textContent = offline ? (n ? T('netOfflineN')(n) : T('netOffline')) : T('netFailedN')(n, last?.status ? T('netError')(last.status) : T('netNoAnswer'));
|
|
3857
|
+
const btn = bar.querySelector('.net-bar__retry'); btn.textContent = T('netRetry'); btn.hidden = !n;
|
|
3858
|
+
const next = offline ? 0 : Math.min(...NET.pending.map((x) => x.at || Infinity));
|
|
3859
|
+
const when = bar.querySelector('.net-bar__when');
|
|
3860
|
+
const show = () => { const s = Math.max(1, Math.ceil((next - Date.now()) / 1000)); when.textContent = T('netAgainIn')(s); };
|
|
3861
|
+
if (Number.isFinite(next) && next > 0) {
|
|
3862
|
+
show(); NET.tick = setInterval(show, 1000);
|
|
3863
|
+
NET.timer = setTimeout(() => netFlush((x) => x.at && x.at <= Date.now() + 50), Math.max(0, next - Date.now()));
|
|
3864
|
+
} else when.textContent = '';
|
|
3865
|
+
}
|
|
3866
|
+
addEventListener('online', () => { if (NET.pending.length) netFlush(); else netBar(); });
|
|
3867
|
+
addEventListener('offline', () => { if (APP()) netBar(); });
|
|
3868
|
+
addEventListener('beforeunload', (e) => { if (NET.pending.length) { e.preventDefault(); e.returnValue = T('netLeave'); return T('netLeave'); } });
|
|
3869
|
+
/** A new comment the first attempt may have created already: same step, text, status and thread, by this person. */
|
|
3870
|
+
function commentSent (scope, body) {
|
|
3871
|
+
return async (since) => {
|
|
3872
|
+
const me = APP()?.user?.email;
|
|
3873
|
+
const res = await fetch(commentsBase(scope) + (isDraft() ? branchQ() : ''), { credentials: 'same-origin' });
|
|
3874
|
+
if (!res.ok) return undefined;
|
|
3875
|
+
const list = await res.json();
|
|
3876
|
+
return (Array.isArray(list) ? list : []).find((c) => c.node_id === body.node_id && (c.note || '') === (body.note || '') && (c.status || 'comment') === (body.status || 'comment') && (c.parent_id || null) === (body.parent_id || null) && (!me || c.email === me) && Date.parse(c.created_at) >= since - 120000) || null;
|
|
3877
|
+
};
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3790
3880
|
// the draft is live: re-read it when someone (a person or an agent) changes it
|
|
3791
3881
|
let reloadTimer = null;
|
|
3792
3882
|
function reloadDraftSoon () { clearTimeout(reloadTimer); reloadTimer = setTimeout(reloadDraft, 350); }
|
|
@@ -4784,7 +4874,7 @@ Object.assign(I18N.cs, {
|
|
|
4784
4874
|
rvCarriedWhy: 'Snímky i kritéria jsou stejné jako v přijatém běhu a automatické kontroly prošly.',
|
|
4785
4875
|
rvRevoke: 'Zrušit převzaté schválení', rvRevoked: 'Převzaté schválení je zrušené; krok čeká na kontrolu.',
|
|
4786
4876
|
rvUnconfirmed: 'Rozhodnutí agenta nebo tokenu; za rozhodnutí člověka se nepočítá', rvChangedIn: 'Změněné snímky', rvCritChanged: 'Kritéria se od přijatého běhu změnila.',
|
|
4787
|
-
rvApprovedToast: 'Schváleno', rvReturnedToast: 'Vráceno autorovi běhu', rvReadOnly: 'Jen ke čtení', rvLocal: 'Rozhodovat se dá na kaloko.app, až se běh nasdílí.',
|
|
4877
|
+
rvApprovedToast: 'Schváleno', rvFlashNext: 'další:', rvReturnedToast: 'Vráceno autorovi běhu', rvReadOnly: 'Jen ke čtení', rvLocal: 'Rozhodovat se dá na kaloko.app, až se běh nasdílí.',
|
|
4788
4878
|
rvDone: 'Všechny kroky jsou rozhodnuté', rvLeft: (n) => `Na rozhodnutí ${n === 1 ? 'čeká 1 krok' : n < 5 ? `čekají ${n} kroky` : `čeká ${n} kroků`}`,
|
|
4789
4879
|
rvNothing: 'Nic nečeká na člověka: kroky prošly a od přijatého běhu se nezměnily.', rvSumApproved: 'schváleno', rvSumReturned: 'vráceno', rvSumClear: 'beze změny a bez připomínek',
|
|
4790
4880
|
rvCarriedTitle: 'Převzatá schválení', rvCarriedLead: 'Tyto kroky vypadají stejně jako v přijatém běhu a jejich automatické kontroly prošly. U každého je, kdo ho schválil.',
|
|
@@ -4805,14 +4895,14 @@ Object.assign(I18N.en, {
|
|
|
4805
4895
|
rvCarriedWhy: 'The screenshots and criteria are the same as in the accepted run and the automatic checks passed.',
|
|
4806
4896
|
rvRevoke: 'Revoke the carried approval', rvRevoked: 'Carried approval revoked; the step waits for review.',
|
|
4807
4897
|
rvUnconfirmed: 'An agent’s or token’s decision; it does not count as a person’s', rvChangedIn: 'Changed screenshots', rvCritChanged: 'The criteria changed since the accepted run.',
|
|
4808
|
-
rvApprovedToast: 'Approved', rvReturnedToast: 'Returned to the author of the run', rvReadOnly: 'Read only', rvLocal: 'Decisions are made on kaloko.app once the run is shared.',
|
|
4898
|
+
rvApprovedToast: 'Approved', rvFlashNext: 'next:', rvReturnedToast: 'Returned to the author of the run', rvReadOnly: 'Read only', rvLocal: 'Decisions are made on kaloko.app once the run is shared.',
|
|
4809
4899
|
rvDone: 'Every step is decided', rvLeft: (n) => `${n} step${n === 1 ? '' : 's'} still wait${n === 1 ? 's' : ''} for a decision`,
|
|
4810
4900
|
rvNothing: 'Nothing waits for a person: the steps passed and did not change since the accepted run.', rvSumApproved: 'approved', rvSumReturned: 'returned', rvSumClear: 'unchanged, nothing to look at',
|
|
4811
4901
|
rvCarriedTitle: 'Carried-over approvals', rvCarriedLead: 'These steps look the same as in the accepted run and their automatic checks passed. Each says who approved it.',
|
|
4812
4902
|
rvShow: 'Show', rvVerdictLead: 'Carried approvals do not count as accepting the run: a person accepts the run as a whole.',
|
|
4813
4903
|
rvReturnedHint: 'The author of the run sees returned steps in the inbox, the agent in kaloko feedback.', rvCarriedShort: 'carried over', rvToEnd: 'Summary', rvHint: 'review step by step', rvQueue: 'Steps to review', rvRefFailed: 'The accepted run could not be loaded.', rvComparedWith: 'Compared with the accepted run'
|
|
4814
4904
|
})
|
|
4815
|
-
const RV = { open: false, data: null, idx: 0, view: null, vp: null, refBase: {}, ret: false, busy: false, extra: [] };
|
|
4905
|
+
const RV = { open: false, data: null, idx: 0, view: null, vp: null, refBase: {}, ret: false, busy: false, extra: [], flash: null };
|
|
4816
4906
|
const rvService = () => !!(APP()?.user && APP()?.run && !isDraft());
|
|
4817
4907
|
const rvWritable = () => rvService() && canWrite();
|
|
4818
4908
|
const rvItems = () => [...(RV.data?.items || []), ...RV.extra];
|
|
@@ -4973,7 +5063,9 @@ function rvFootHtml (it) {
|
|
|
4973
5063
|
const items = rvItems();
|
|
4974
5064
|
const act = it && rvWritable() ? `<button class="btn" id="rvRet">${esc(T('rvReturn'))} <span class="kbd">R</span></button><button class="btn" id="rvCmt">${ICON.comment} ${esc(T('comment'))} <span class="kbd">C</span></button><button class="btn btn--primary" id="rvOk">${esc(T('rvApprove'))} <span class="kbd">A</span></button>`
|
|
4975
5065
|
: it ? `<span class="note">${esc(T(rvService() ? 'rvReadOnly' : 'rvLocal'))}</span>` : '';
|
|
4976
|
-
|
|
5066
|
+
const f = RV.flash && Date.now() - RV.flash.at < 4000 ? RV.flash : null;
|
|
5067
|
+
const flash = f ? `<p class="rvm__flash rvm__flash--${f.state}" id="rvFlash" role="status">${f.state === 'approved' ? '✓' : '↩'} ${esc(T(f.state === 'approved' ? 'rvApprovedToast' : 'rvReturnedToast'))}: <b>${esc(f.title)}</b>${it ? ` · ${esc(T('rvFlashNext'))} ${esc(loc(byId(it.step)?.title) || it.step)}` : ''}</p>` : '';
|
|
5068
|
+
return `<div class="rvm__foot${it && rvWritable() ? ' rvm__foot--act' : ''}">${flash}<button class="btn" id="rvPrev" ${RV.idx <= 0 ? 'disabled' : ''}>${ICON.prev} ${esc(T('rvPrev'))} <span class="kbd">K</span></button><span class="spacer"></span>${act}${RV.idx < items.length ? `<button class="btn" id="rvNext">${esc(RV.idx === items.length - 1 ? T('rvToEnd') : T('rvNext'))} ${ICON.next} <span class="kbd">J</span></button>` : ''}</div>`;
|
|
4977
5069
|
}
|
|
4978
5070
|
function rvEndHtml () {
|
|
4979
5071
|
const d = RV.data, c = d.counts || {}, items = d.items || [];
|
|
@@ -5074,10 +5166,16 @@ async function rvApprove () {
|
|
|
5074
5166
|
// the person's look answers what the evaluator could not decide: open criteria pass, then the step is approved
|
|
5075
5167
|
if (n.kind !== 'stack') for (const c of (n.criteria || []).filter((x) => x.type !== 'deterministic' && rvOpenCrit(x))) await postJson('/criteria', { node_id: n.id, criterion_id: c.id, verdict: 'pass', note: '' });
|
|
5076
5168
|
await cApi('step', '', 'POST', { node_id: n.id, status: 'approved', note: '' });
|
|
5077
|
-
|
|
5169
|
+
rvFlash('approved', n);
|
|
5078
5170
|
await rvAfter(it.step);
|
|
5079
5171
|
} catch (e) { toast(e.message); if (b) b.disabled = false; } finally { RV.busy = false; }
|
|
5080
5172
|
}
|
|
5173
|
+
/** Feedback after a decision that stays readable on a phone: a line above the buttons (with the next step), a short buzz. */
|
|
5174
|
+
function rvFlash (state, n) {
|
|
5175
|
+
RV.flash = { state, title: loc(n?.title) || '', at: Date.now() };
|
|
5176
|
+
try { if (lbPhone()) navigator.vibrate?.(state === 'approved' ? 15 : [15, 60, 15]); } catch {}
|
|
5177
|
+
clearTimeout(RV.flashT); RV.flashT = setTimeout(() => { RV.flash = null; $('rvFlash')?.remove(); }, 4000);
|
|
5178
|
+
}
|
|
5081
5179
|
function rvReturn () { if (!rvCur() || !rvWritable()) return; RV.ret = true; rvRender(); }
|
|
5082
5180
|
async function rvReturnSend () {
|
|
5083
5181
|
const it = rvCur(); const t = $('rvRetNote'); if (!it || !t || RV.busy) return;
|
|
@@ -5087,7 +5185,7 @@ async function rvReturnSend () {
|
|
|
5087
5185
|
try {
|
|
5088
5186
|
await cApi('step', '', 'POST', { node_id: it.step, status: 'rejected', note, locale: S.loc, viewport: RV.vp || null });
|
|
5089
5187
|
saveDraft(draftKey('step', it.step, 'return'), '');
|
|
5090
|
-
|
|
5188
|
+
rvFlash('returned', byId(it.step) || { title: it.title });
|
|
5091
5189
|
await rvAfter(it.step);
|
|
5092
5190
|
} catch (e) { toast(e.message); $('rvRetSend') && ($('rvRetSend').disabled = false); } finally { RV.busy = false; }
|
|
5093
5191
|
}
|
|
@@ -5186,7 +5284,7 @@ function applyReviewerVerdicts () {
|
|
|
5186
5284
|
}
|
|
5187
5285
|
async function postJson (path, body) {
|
|
5188
5286
|
const a = APP();
|
|
5189
|
-
const res = await
|
|
5287
|
+
const res = await netSend(`${a.api}/orgs/${a.org}/runs/${a.run}${path}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
5190
5288
|
if (res.status === 401) throw sessionLost();
|
|
5191
5289
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
|
5192
5290
|
return res.json();
|