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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
5
5
  "keywords": [
6
6
  "qa",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "scripts": {
35
- "test": "node --test --test-timeout=900000 test/*.test.mjs worker/test/*.test.mjs"
35
+ "test": "node scripts/test.mjs"
36
36
  },
37
37
  "dependencies": {
38
38
  "ajv": "^8.20.0",
@@ -84,6 +84,10 @@
84
84
  "default": "web",
85
85
  "description": "What the flow runs on. web: pages in a browser (an installed PWA too: display_modes). android/ios/electron/desktop/windows: an app, driven by the environment's app block (adb, iOS Simulator or device, Playwright Electron, macOS window, Windows UI Automation) or captured with kaloko capture --image. Node paths are deep links for apps."
86
86
  },
87
+ "page": {
88
+ "enum": ["landing", "work", "list", "settings", "form", "doc"],
89
+ "description": "Default page type of every step (a step may set its own). landing: a public or marketing page; work: an app screen where people work (overview, canvas, inbox); list: a list or table of items; settings: settings or billing; form: a form to fill in; doc: documentation or long text. The usability pack picks its questions by type. Not set: landing for steps with the seo pack or scenarios allowed on production, work otherwise."
90
+ },
87
91
  "tags": {
88
92
  "type": "array",
89
93
  "items": {
@@ -307,6 +311,10 @@
307
311
  ],
308
312
  "description": "For `kaloko walk --affected`: globs of repository files (relative to the config) that shape this step, next to the routes and imports Kaloko finds itself; `always` walks the step on every affected walk (sign-in, consent)."
309
313
  },
314
+ "page": {
315
+ "enum": ["landing", "work", "list", "settings", "form", "doc"],
316
+ "description": "What kind of page this step is (see the scenario's page); the usability pack asks per type, e.g. one primary call to action only on landing pages."
317
+ },
310
318
  "packs": {
311
319
  "type": "array",
312
320
  "items": {
@@ -321,7 +329,7 @@
321
329
  "design"
322
330
  ]
323
331
  },
324
- "description": "Check packs whose criteria are added to this node (ids AC1xx seo, AC2xx usability, AC3xx a11y, AC4xx perf, AC5xx console, AC6xx design: checks against the design reference)."
332
+ "description": "Check packs whose criteria are added to this node (ids AC1xx seo, AC2xx usability, AC3xx a11y, AC4xx perf, AC5xx console, AC6xx errors, AC7xx app, AC8xx design: checks against the design reference)."
325
333
  },
326
334
  "capture": {
327
335
  "$ref": "#/$defs/capture"
@@ -528,6 +536,12 @@
528
536
  "pack": {
529
537
  "type": "string",
530
538
  "description": "Set by the CLI on criteria that came from a pack."
539
+ },
540
+ "environments": {
541
+ "type": "array",
542
+ "minItems": 1,
543
+ "items": { "type": "string" },
544
+ "description": "Environments where this criterion applies, e.g. [production] for hreflang to the live domain, the live demo or the sitemap. On any other environment the walk reports it as not applicable (not_evaluated with na: environment) and it does not make the step partial."
531
545
  }
532
546
  }
533
547
  },
@@ -42,12 +42,42 @@ sharpen each purpose and add the criteria the page must really meet.
42
42
  criteria (ids `AC1xx` for `seo`, `AC2xx` for `usability`) at load time:
43
43
  `seo` checks one H1, title and description length, canonical, hreflang for
44
44
  every locale, Open Graph, indexing, viewport, `html lang`, overflow and
45
- console; `usability` asks the evaluator about purpose, one primary action
46
- and link texts and checks unlabeled inputs. A node's own criterion with the
45
+ console; `usability` checks unlabeled inputs and link texts and asks the
46
+ evaluator questions that fit the step's page type. A node's own criterion with the
47
47
  same id wins. Pack criteria are marked `pack` on the canvas and do not count
48
48
  as drift in `--coverage`; the plan may still mention them by id. Use packs
49
49
  for public pages and landings; task flows keep their own criteria.
50
50
 
51
+ Set `page` on every step (or once on the scenario) so the usability pack
52
+ asks the right questions:
53
+
54
+ | `page` | for | asks |
55
+ | --- | --- | --- |
56
+ | `landing` | public and marketing pages | purpose in the first screen (`AC201`), exactly one primary call to action (`AC202`) |
57
+ | `work` | app screens where people work: overview, canvas, detail, inbox item | the heading says what the screen is for (`AC205`) |
58
+ | `list` | lists and tables: runs, projects, inbox | `AC205`, each item says what it is and how to open it (`AC207`) |
59
+ | `settings` | settings, billing, tokens | `AC205`, each setting says what it changes and how it is saved (`AC208`) |
60
+ | `form` | sign-up, sign-in, a form to fill in | `AC205`, how to send it and what happens next (`AC206`) |
61
+ | `doc` | documentation, guides, terms | `AC201`, headings and short paragraphs (`AC209`) |
62
+
63
+ Every type also gets `AC203` and `AC204`. Without `page` a step with the
64
+ `seo` pack, or any step of a scenario allowed on production, is `landing`;
65
+ everything else is `work`. Do not put `landing` on signed-in screens: "one
66
+ primary call to action" is a marketing rule, and a work screen rightly offers
67
+ several actions.
68
+
69
+ A criterion that only makes sense on production (hreflang pairs pointing at
70
+ the live domain, an embedded live demo, the production sitemap) carries
71
+ `environments: [production]`. Walked anywhere else it is reported as not
72
+ applicable to that environment (`not_evaluated` with `not_applicable:
73
+ environment`) and does not make the step partial.
74
+
75
+ When a scenario walks several locales, a text check (`text_matches`,
76
+ `title_matches`, …) must match every language: write
77
+ `pattern: "(Your data|Vaše data)"`. `kaloko validate` warns when a pattern
78
+ reads in one language only (it looks for diacritics and common Czech or
79
+ English words; one plain word such as a brand name is left alone).
80
+
51
81
  - `a11y` (`AC3xx`): images with alt, links and buttons with a name, labelled
52
82
  fields, heading levels not skipped, WCAG AA text contrast, a `main`
53
83
  landmark, unique ids (`AC301`–`AC307`, read from the page). After the
package/src/cli.mjs CHANGED
@@ -6,7 +6,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'
6
6
  import { availableParallelism } from 'node:os'
7
7
  import yaml from 'js-yaml'
8
8
  import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FILE } from './lib/config.mjs'
9
- import { loadScenario, validateScenario } from './lib/scenario.mjs'
9
+ import { loadScenario, parseScenario, validateScenario, scenarioWarnings } from './lib/scenario.mjs'
10
10
  import { createRun, resolveRun, readCaptures, listRuns } from './lib/run.mjs'
11
11
  import { gitMeta, projectAndIssue, appVersion } from './lib/git.mjs'
12
12
  import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
@@ -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 asks macOS for the desktop capture permissions
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)
@@ -233,7 +235,7 @@ Results
233
235
  signoff --verify <file.json|file.pdf> [--key file|url] [--json]
234
236
  Check a record someone handed over (no token needed); exit 1 when it does not verify
235
237
  stability --pull [--scenario id] [--json]
236
- Regions people drew on screenshots on kaloko.app (step detail → Page → Draw a
238
+ Regions people drew on screenshots on kaloko.app (step detail → Mark a changing
237
239
  region) go into the scenario's ignore: of their steps; walk them again after
238
240
  screens [words] [--project p] [--product p] [--module m] [--scenario id] [--locale l] [--viewport v] [--device d]
239
241
  [--status pass|partial|fail|skip] [--environment e] [--designs] [--all] [--json] [--cursor c]
@@ -449,7 +451,7 @@ async function runContext (args) {
449
451
  }
450
452
 
451
453
  function stepOf (scenario, id) {
452
- if (!id) throw new UserError('--step <id> is required.')
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
- if ((i + 1) % 25 === 0 && i + 1 < chosen.length) say(` ${node.id} ${locale}: ${i + 1}/${chosen.length}`)
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`)
@@ -987,10 +991,12 @@ const commands = {
987
991
  if (!args._.length) throw new UserError('Usage: kaloko validate <scenario.yml>...')
988
992
  let failed = 0
989
993
  for (const file of args._) {
990
- const scenario = yaml.load(await readFile(file, 'utf8'))
994
+ let scenario
995
+ try { scenario = parseScenario(await readFile(file, 'utf8'), file) } catch (e) { if (!(e instanceof UserError)) throw e; failed++; say(`✕ ${e.message}`); continue }
991
996
  const problems = validateScenario(scenario)
992
997
  if (!problems.length) expandPacks(scenario)
993
998
  if (problems.length) { failed++; say(`✕ ${file}\n - ${problems.join('\n - ')}`) } else say(`✓ ${file}`)
999
+ if (!problems.length) for (const w of scenarioWarnings(scenario)) say(` ! ${w}`)
994
1000
  if (args.coverage) {
995
1001
  // the plan is scenario.source (relative to the working directory or to the scenario file), or --source
996
1002
  const candidates = typeof args.source === 'string' ? [resolve(args.source)] : scenario.source ? [resolve(scenario.source), resolve(file, '..', scenario.source), resolve(file, '..', '..', scenario.source)] : []
@@ -1223,7 +1229,7 @@ const commands = {
1223
1229
  const parallel = concurrency > 1 && locales.length > 1
1224
1230
  const scenarioDir = resolve(root, run.meta.scenario_file, '..')
1225
1231
  // semantic questions of every new capture start right away; the walk ends with the verdicts (--no-evaluate: skip)
1226
- const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root })
1232
+ const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root, environment: env?.name || null })
1227
1233
  const walked = await readCaptures(run.dir)
1228
1234
  // recordings and traces (batch D, #12): opt-in recordings, traces unless turned off; encoded in an own headless Chrome
1229
1235
  const recSettings = recordSettings({ args, config, scenario })
@@ -2104,9 +2110,9 @@ const commands = {
2104
2110
  }
2105
2111
  let runId = args.run
2106
2112
  if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
2107
- if (!runId) throw new UserError('--run <id> is required when there is no current run.')
2113
+ if (!runId) throw new UserError('No current run here. Pass --run <id>, or walk the scenario first (kaloko start, then kaloko walk).')
2108
2114
  const run = await load(runId)
2109
- if (!run) throw new UserError(`Run ${runId} not found locally or on the service.`)
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).`)
2110
2116
  let baseId = typeof args.baseline === 'string' ? args.baseline : null
2111
2117
  // --design: against the design reference of the scenario (#42)
2112
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>).') }
@@ -2118,7 +2124,7 @@ const commands = {
2118
2124
  }
2119
2125
  if (!baseId) throw new UserError('No baseline or earlier run of this scenario to compare with; pass --baseline <id>.')
2120
2126
  const base = await load(baseId)
2121
- if (!base) throw new UserError(`Run ${baseId} not found locally or on the service.`)
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>.`)
2122
2128
  const d = compareRuns(run, base)
2123
2129
  if (args.json) { say(JSON.stringify(d, null, 2)); return }
2124
2130
  say(formatComparison(d))
@@ -2197,7 +2203,7 @@ const commands = {
2197
2203
  if (typeof args.scenario === 'string') return exportScenario({ args, config, root, client })
2198
2204
  if (typeof args.format === 'string') {
2199
2205
  // living docs and the changelog as files (docs/DOCS-CHANGELOG.md → Exports)
2200
- if (!config.org) throw new UserError('Set org: in the config first.')
2206
+ if (!config.org) throw new UserError(NO_ORG)
2201
2207
  const { pickProduct } = await import('./lib/changelog.mjs')
2202
2208
  const { exportDocs } = await import('./lib/docs.mjs')
2203
2209
  const product = pickProduct(config, root, args.product)
@@ -2218,7 +2224,7 @@ const commands = {
2218
2224
  }
2219
2225
  let runId = args.run
2220
2226
  if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
2221
- if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id> is required (a shared 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.')
2222
2228
  const out = typeof args.out === 'string' ? resolve(args.out) : resolve(root, config.output, `${runId}.zip`)
2223
2229
  const { createWriteStream } = await import('node:fs')
2224
2230
  const { Readable } = await import('node:stream')
@@ -2234,7 +2240,7 @@ const commands = {
2234
2240
  const client = service(config)
2235
2241
  let runId = args.run
2236
2242
  if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
2237
- if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id> is required (a shared 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.')
2238
2244
  const r = await client.request('POST', `/orgs/${config.org}/runs/${runId}/public`, { public: !args.off })
2239
2245
  say(r.public ? `Public: ${r.url} (readable without sign-in; only one public run per scenario)` : `Private again: ${runId}`)
2240
2246
  },
@@ -2282,7 +2288,7 @@ const commands = {
2282
2288
  const product = pickProduct(config, root, args.product)
2283
2289
  const version = typeof args.version === 'string' ? args.version : 'latest'
2284
2290
  if (sub === 'publish') {
2285
- if (!config.org && !args['dry-run']) throw new UserError('Set org: in the config first.')
2291
+ if (!config.org && !args['dry-run']) throw new UserError(NO_ORG)
2286
2292
  const r = await docs.publishDocs({ root, config, product, client: args['dry-run'] ? null : service(config), version, reviewed: !!args.reviewed, dryRun: !!args['dry-run'] })
2287
2293
  if (args.json) { say(JSON.stringify(r, null, 2)); return }
2288
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 }
@@ -2310,12 +2316,12 @@ const commands = {
2310
2316
  const { config, root } = await context(args)
2311
2317
  const lib = await import('./lib/changelog.mjs')
2312
2318
  if (sub === 'list') {
2313
- if (!config.org) throw new UserError('Set org: in the config first.')
2319
+ if (!config.org) throw new UserError(NO_ORG)
2314
2320
  const r = await lib.listProducts({ root, config, client: service(config) })
2315
2321
  say(args.json ? JSON.stringify(r, null, 2) : lib.formatList(r))
2316
2322
  return
2317
2323
  }
2318
- if (!config.org && !args['dry-run']) throw new UserError('Set org: in the config first.')
2324
+ if (!config.org && !args['dry-run']) throw new UserError(NO_ORG)
2319
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'] })
2320
2326
  say(args.json ? JSON.stringify(r, null, 2) : lib.formatSync(r))
2321
2327
  if (r.products.some((p) => p.ok === false)) process.exitCode = 1
@@ -2324,7 +2330,7 @@ const commands = {
2324
2330
  /** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
2325
2331
  async release (args) {
2326
2332
  const { config, root } = await context(args)
2327
- if (!config.org) throw new UserError('Set org: in the config first.')
2333
+ if (!config.org) throw new UserError(NO_ORG)
2328
2334
  const { pickProduct, releaseProduct } = await import('./lib/changelog.mjs')
2329
2335
  const product = pickProduct(config, root, args.product)
2330
2336
  const client = args['dry-run'] ? null : service(config)
@@ -2358,10 +2364,13 @@ export async function main (argv) {
2358
2364
  const args = parse(argv)
2359
2365
  const name = args._.shift() || 'help'
2360
2366
  const cmd = commands[name] || (args.help ? commands.help : null)
2361
- if (!cmd) { console.error(`Unknown command "${name}".\n`); say(HELP); process.exitCode = 2; return }
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 }
2362
2368
  try {
2363
2369
  await cmd(args)
2364
2370
  } catch (error) {
2365
- if (error instanceof UserError) { console.error(error.message); process.exitCode = 1 } else throw error
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
+ }
2366
2375
  }
2367
2376
  }
@@ -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)))
@@ -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'
@@ -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
  }
@@ -138,10 +138,22 @@ export async function currentPage (context, match) {
138
138
  return real[real.length - 1] || pages[pages.length - 1] || context.newPage()
139
139
  }
140
140
 
141
+ /**
142
+ * Launches a browser with a time limit and one more try: on a busy machine a browser now and then starts but never
143
+ * answers, and Playwright would wait for it forever. Playwright ends the process of a launch that timed out.
144
+ * KALOKO_LAUNCH_TIMEOUT (seconds, default 45) sets the limit.
145
+ */
146
+ export async function launchWithRetry (browserType, options = {}) {
147
+ const timeout = (Number(process.env.KALOKO_LAUNCH_TIMEOUT) || 45) * 1000
148
+ try { return await browserType.launch({ timeout, ...options }) } catch (e) {
149
+ if (!/Timeout/i.test(String(e?.name) + String(e?.message))) throw e
150
+ return browserType.launch({ timeout, ...options })
151
+ }
152
+ }
153
+
141
154
  export async function launchEphemeral (config) {
142
155
  const { chromium } = await import('playwright')
143
- const browser = await chromium.launch({ headless: true, channel: config.browser.channel || undefined })
144
- return browser
156
+ return launchWithRetry(chromium, { headless: true, channel: config.browser.channel || undefined })
145
157
  }
146
158
 
147
159
  /**
package/src/lib/build.mjs CHANGED
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
4
4
  import { join, resolve } from 'node:path'
5
5
  import { readJson, writeJson } from './util.mjs'
6
6
  import { readCaptures, runPaths, listRuns } from './run.mjs'
7
- import { latestCaptures, evaluationSource, stepIdOf } from './evaluate.mjs'
7
+ import { latestCaptures, evaluationSource, stepIdOf, appliesOn } from './evaluate.mjs'
8
8
  import { fieldsOf, DEFAULT_SEMANTIC_SAMPLE } from './stack.mjs'
9
9
  import { layout } from './scenario.mjs'
10
10
  import { assembleNode, summarize, statusOf, worst } from './manifest-core.mjs'
@@ -149,7 +149,10 @@ async function runMedia ({ run, entries }) {
149
149
  async function buildStack ({ run, node, base, captures, attachments }) {
150
150
  const paths = runPaths(run.dir)
151
151
  const fields = fieldsOf(node).map((f) => ({ id: f.id, label: f.label || f.id }))
152
- const crit = (node.criteria || []).map((c) => ({ id: c.id, text: c.text, type: c.check.type, pack: c.pack || null }))
152
+ const environment = run.meta?.environment?.name || null
153
+ const crit = (node.criteria || []).map((c) => ({ id: c.id, text: c.text, type: c.check.type, pack: c.pack || null, ...(appliesOn(c, environment) ? {} : { not_applicable: 'environment' }) }))
154
+ // criteria limited to other environments do not decide the status of a page
155
+ const live = new Set(crit.filter((c) => !c.not_applicable).map((c) => c.id))
153
156
  const byItem = new Map()
154
157
  for (const c of captures.filter((x) => x.node === node.id && x.item)) {
155
158
  const cap = await readJson(join(run.dir, c.file))
@@ -165,10 +168,10 @@ async function buildStack ({ run, node, base, captures, attachments }) {
165
168
  const items = [...byItem.values()].sort((a, b) => a.order - b.order).map(({ order, ...it }) => {
166
169
  const per = Object.values(it.results)
167
170
  const verdictOf = (id) => worst(per.map((r) => r[id]?.[0]).filter(Boolean))
168
- const verdicts = crit.map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
171
+ const verdicts = crit.filter((c) => live.has(c.id)).map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
169
172
  const status = statusOf(verdicts, per.length > 0)
170
- const by_locale = Object.fromEntries(Object.keys(it.captures).map((l) => [l, statusOf(Object.entries(it.results).filter(([k]) => k.startsWith(`${l}|`)).flatMap(([, r]) => Object.values(r).map((x) => x[0])), true)]))
171
- return { ...it, status, by_locale, fails: crit.filter((c) => verdictOf(c.id) === 'fail').map((c) => c.id), review: crit.filter((c) => ['needs_review', 'not_evaluated'].includes(verdictOf(c.id))).map((c) => c.id) }
173
+ const by_locale = Object.fromEntries(Object.keys(it.captures).map((l) => [l, statusOf(Object.entries(it.results).filter(([k]) => k.startsWith(`${l}|`)).flatMap(([, r]) => Object.entries(r).filter(([id]) => live.has(id)).map(([, x]) => x[0])), true)]))
174
+ return { ...it, status, by_locale, fails: crit.filter((c) => live.has(c.id) && verdictOf(c.id) === 'fail').map((c) => c.id), review: crit.filter((c) => live.has(c.id) && ['needs_review', 'not_evaluated'].includes(verdictOf(c.id))).map((c) => c.id) }
172
175
  })
173
176
  const summary = { pass: 0, partial: 0, fail: 0, skip: 0 }
174
177
  for (const it of items) summary[it.status]++
@@ -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, groupsMarkdown, parseEntry, findStepRefs, CHANGELOG_SKELETON } from './changelog-core.mjs'
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(`Not a version: ${v}`)
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)
@@ -361,14 +361,35 @@ export function docsConfigBlock ({ products = null, full = false } = {}) {
361
361
  return `${lines.join('\n')}\n`
362
362
  }
363
363
 
364
- /** Apps of a monorepo that look like products (apps/* with a package.json), for kaloko init. */
364
+ /**
365
+ * Apps of a monorepo that look like products, for kaloko init: the folders of the workspaces (package.json
366
+ * `workspaces`, pnpm-workspace.yaml `packages`), apps/* and packages/*. A folder needs a package.json; outside apps/*
367
+ * it must also run as an app (a dev, start or serve script), so shared libraries and configs are left out. Only
368
+ * offered when there are at least two.
369
+ */
365
370
  export async function detectProducts (root) {
366
- const out = []
367
- for (const base of ['apps']) {
368
- const names = await readdir(join(root, base)).catch(() => [])
369
- for (const n of names.sort()) {
370
- const pkg = await readJson(join(root, base, n, 'package.json'))
371
- if (pkg) out.push({ slug: slugOf(n), name: pkg.name ? String(pkg.name).replace(/^@[^/]+\//, '') : n, dir: `${base}/${n}`, path: `${base}/${n}/package.json` })
371
+ const globs = new Set(['apps/*', 'packages/*'])
372
+ const rootPkg = await readJson(join(root, 'package.json'))
373
+ const ws = Array.isArray(rootPkg?.workspaces) ? rootPkg.workspaces : Array.isArray(rootPkg?.workspaces?.packages) ? rootPkg.workspaces.packages : []
374
+ for (const g of ws) globs.add(String(g))
375
+ const pnpm = await readFile(join(root, 'pnpm-workspace.yaml'), 'utf8').catch(() => '')
376
+ for (const m of pnpm.matchAll(/^\s*-\s*['"]?([^'"\n#]+?)['"]?\s*$/gm)) globs.add(m[1].trim())
377
+ const out = []; const seen = new Set()
378
+ for (const g of [...globs].sort()) {
379
+ if (g.startsWith('!')) continue
380
+ // "dir/*" (one level) or a plain folder; deeper patterns are left to the person
381
+ const m = g.replace(/\/+$/, '').match(/^([^*]+?)(\/\*)?$/)
382
+ if (!m) continue
383
+ const dirs = m[2] ? (await readdir(join(root, m[1])).catch(() => [])).sort().map((n) => `${m[1]}/${n}`) : [m[1]]
384
+ for (const dir of dirs) {
385
+ if (seen.has(dir)) continue
386
+ const pkg = await readJson(join(root, dir, 'package.json'))
387
+ if (!pkg) continue
388
+ const runs = ['dev', 'start', 'serve'].some((k) => pkg.scripts?.[k])
389
+ if (!dir.startsWith('apps/') && !runs) continue
390
+ seen.add(dir)
391
+ const n = basename(dir)
392
+ out.push({ slug: slugOf(n), name: pkg.name ? String(pkg.name).replace(/^@[^/]+\//, '') : n, dir, path: `${dir}/package.json` })
372
393
  }
373
394
  }
374
395
  return out.length > 1 ? out : []
@@ -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(`${path}: HTTP ${res.status}`)
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
@@ -8,6 +8,7 @@
8
8
  // `scope.variants` names the dimensions of each key; `scope.dimensions` lists the values for the canvas switchers.
9
9
  import { UserError } from './util.mjs'
10
10
  import { devicePreset, emulationOf, presetSummary, presetNames } from './devices.mjs'
11
+ import { launchWithRetry } from './browser.mjs'
11
12
 
12
13
  export const BROWSERS = ['chromium', 'webkit', 'firefox']
13
14
  export const COLOR_SCHEMES = ['light', 'dark']
@@ -318,7 +319,7 @@ export async function launchEngine (engine, { channel, headless = true } = {}) {
318
319
  const type = pw[engine]
319
320
  if (!type) throw new UserError(`Unknown browser engine ${engine}. Use: ${BROWSERS.join(', ')}.`)
320
321
  try {
321
- return await type.launch({ headless, ...(engine === 'chromium' && channel ? { channel } : {}) })
322
+ return await launchWithRetry(type, { headless, ...(engine === 'chromium' && channel ? { channel } : {}) })
322
323
  } catch (error) {
323
324
  if (/Executable doesn't exist|install/i.test(error.message)) throw new UserError(`${engine} is not installed for Playwright. Run: kaloko browser install --browsers ${engine}`)
324
325
  throw error
@@ -288,11 +288,12 @@ export const escapeHtml = esc
288
288
 
289
289
  /** Inline Markdown: code, bold, italics, links (safe schemes; `link(href)` may rewrite or drop them). Images are removed. */
290
290
  export function inlineHtml (s, { link = null } = {}) {
291
- let out = esc(String(s).replace(/!\[[^\]]*\]\([^)]*\)/g, ''))
291
+ let out = esc(String(s).replace(/!\[[^[\]]*\]\([^()]*\)/g, ''))
292
292
  const codes = []
293
293
  out = out.replace(/`([^`]+)`/g, (_, c) => { codes.push(`<code>${c}</code>`); return `\u0000${codes.length - 1}\u0000` })
294
294
  out = out.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/__([^_]+)__/g, '<b>$1</b>').replace(/(^|[\s(])_([^_]+)_(?=$|[\s).,;:!?])/g, '$1<i>$2</i>').replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=$|[\s).,;:!?])/g, '$1<i>$2</i>')
295
- out = out.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, (_, t, href) => {
295
+ // no [ inside the brackets, at most one pair of ( ) in the target: a long run of [ stays linear
296
+ out = out.replace(/\[([^[\]]+)\]\(([^()[\]\s]+(?:\([^()\s]*\)[^()[\]\s]*)?)(?:\s+&quot;[^&]*&quot;)?\)/g, (_, t, href) => {
296
297
  const raw = href.replace(/&amp;/g, '&')
297
298
  const target = link ? link(raw) : raw
298
299
  if (target == null) return t
@@ -302,7 +303,7 @@ export function inlineHtml (s, { link = null } = {}) {
302
303
  }
303
304
 
304
305
  const LIST = /^(\s{0,3})([-*+]|\d{1,9}[.)])\s+(.*)$/
305
- const IMG = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
306
+ const IMG = /!\[([^[\]]*)\]\(([^()[\]\s]+)(?:\s+"[^"]*")?\)/g
306
307
 
307
308
  /**
308
309
  * Markdown to HTML for docs: headings (with ids), paragraphs, lists (nested by indentation), fenced code,
@@ -361,16 +362,18 @@ function blocks (lines, o) {
361
362
  continue
362
363
  }
363
364
  if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(l)) { flush(); out.push('<hr>'); i++; continue }
364
- if (/^\s{0,3}>/.test(l)) {
365
+ // quotes nest at most 16 deep; deeper > are text (100 000 levels would overflow the stack)
366
+ if (/^\s{0,3}>/.test(l) && (o.quoteDepth || 0) < 16) {
365
367
  flush()
366
368
  const buf = []
367
369
  while (i < lines.length && /^\s{0,3}>/.test(lines[i])) buf.push(lines[i++].replace(/^\s{0,3}>\s?/, ''))
368
- out.push(`<blockquote>${blocks(buf, { ...o, notice: null })}</blockquote>`)
370
+ out.push(`<blockquote>${blocks(buf, { ...o, notice: null, quoteDepth: (o.quoteDepth || 0) + 1 })}</blockquote>`)
369
371
  continue
370
372
  }
371
373
  if (/^\s*\|.*\|\s*$/.test(l) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(lines[i + 1])) {
372
374
  flush()
373
- const row = (x) => x.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
375
+ // GFM: `\|` is a pipe inside a cell (also inside code), not a column break
376
+ const row = (x) => x.trim().replace(/^\||(?<!\\)\|$/g, '').split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'))
374
377
  const head = row(l); i += 2
375
378
  const rows = []
376
379
  while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(row(lines[i++]))
package/src/lib/docs.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import { readFile, readdir, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
6
6
  import { execFile } from 'node:child_process'
7
7
  import { promisify } from 'node:util'
8
- import { join, resolve, relative, dirname } from 'node:path'
8
+ import { join, resolve, relative, dirname, sep, isAbsolute } from 'node:path'
9
9
  import { tmpdir } from 'node:os'
10
10
  import { UserError, sha256 } from './util.mjs'
11
11
  import { findStepRefs, cleanVersion } from './changelog-core.mjs'
@@ -210,7 +210,9 @@ export async function unpack (buf, dir) {
210
210
  for (const [name, read] of entries) {
211
211
  if (name.endsWith('/')) continue
212
212
  const target = resolve(dir, name)
213
- if (!target.startsWith(resolve(dir) + '/')) throw new UserError(`The export holds a path outside its folder: ${name}`)
213
+ // relative() rather than a '/' prefix test, so the check also holds with Windows separators (a\..\..\x)
214
+ const rel = relative(resolve(dir), target)
215
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) || name.includes('\0')) throw new UserError(`The export holds a path outside its folder: ${name}`)
214
216
  await mkdir(dirname(target), { recursive: true })
215
217
  await writeFile(target, read())
216
218
  n++