kaloko 0.10.0 → 0.12.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
@@ -219,6 +219,10 @@ Every AI verdict comes with one sentence saying why. Criteria marked
219
219
  `vision: true`, and answers the text evaluator is unsure about, get a second
220
220
  look at the screenshot from a vision model (`ANTHROPIC_API_KEY`, or through
221
221
  Kaloko); `kaloko build` lists what to act on with the score and the reason.
222
+ As an experiment you can switch on in Settings, Clef on Cloudflare also names
223
+ the kind of problem each screen and its recording show (overlap, cut-off text,
224
+ a raw translation key, an endless spinner); reviewers turn the right findings
225
+ into comments with one click, and findings never change a verdict.
222
226
  The `a11y` pack walks the page with Tab (order, visible focus, traps, skip
223
227
  link) and reads names from the accessibility tree. Pixel diffs skip the
224
228
  regions a step ignores; `kaloko walk --stability 3` and `kaloko stability`
@@ -410,6 +414,16 @@ captured HTML, page text and console output.
410
414
  `npx kaloko doctor` checks that every variable the config names is set and that
411
415
  each environment lets you in.
412
416
 
417
+ **Personal data.** `capture.mask` (CSS selectors) hides personal data in
418
+ everything a step stores: a striped box on the screenshot, `[hidden]` of the
419
+ same length in the HTML and the page outline, and the same places in traces,
420
+ recordings and captured e-mails. `pii: redact` on a scenario finds e-mails,
421
+ phone numbers, birth numbers, IBANs and addresses by itself; `pii:
422
+ pseudonymize` puts consistent fake values in their place instead. An
423
+ environment with `pii: required` gets no run without it, and `npx kaloko share`
424
+ refuses a run that still holds an unmasked e-mail or phone number, naming the
425
+ step and element (`--allow-pii` overrides). Checks always read the real page.
426
+
413
427
  Set `browser.channel: chrome` to use an installed Chrome, or run
414
428
  `npx kaloko browser install` for the Chromium of kaloko's Playwright
415
429
  (`KALOKO_BROWSER_CHANNEL=chromium` then uses it, which is how CI runs).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.10.0",
3
+ "version": "0.12.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",
@@ -78,6 +78,28 @@
78
78
  }
79
79
  }
80
80
  ]
81
+ },
82
+ "triage": {
83
+ "description": "Triage (#65, experimental): Cloudflare's Clef names the kind of problem on each evaluated screen and in chosen frames of its recording (overlap, cut-off text, raw key, endless spinner…). Findings are advisory and never change a verdict. Runs through the organization's service once an admin switches the experiment on (Settings → Security → Experiments), or with CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. false turns it off.",
84
+ "oneOf": [
85
+ { "const": false },
86
+ {
87
+ "type": "object",
88
+ "additionalProperties": false,
89
+ "properties": {
90
+ "model": { "enum": ["clef", "clef-flash"], "default": "clef", "description": "clef found more on the first bench; clef-flash answers about twice as fast." },
91
+ "keep": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.7, "description": "Findings at or above this probability show on the canvas; lower ones fold under Less likely." },
92
+ "maybe": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5, "description": "Findings below this probability are dropped." },
93
+ "max_width": { "type": "integer", "minimum": 320, "maximum": 2048, "default": 1024 },
94
+ "tile_height": { "type": "integer", "minimum": 400, "maximum": 4000, "default": 1400 },
95
+ "max_tiles": { "type": "integer", "minimum": 1, "maximum": 4, "default": 4 },
96
+ "frames_per_step": { "type": "integer", "minimum": 0, "maximum": 12, "default": 6, "description": "Frames chosen from a step's recording (around taps, sharp changes and the end); 0 skips recordings." },
97
+ "frames_per_run": { "type": "integer", "minimum": 0, "default": 60 },
98
+ "max_calls": { "type": "integer", "minimum": 0, "default": 200, "description": "Uncached triage requests per evaluation." },
99
+ "cascade": { "type": "boolean", "default": false, "description": "Let Clef settle semantic criteria the text evaluator is unsure about when its answer is clear (#71); the vision evaluator is then asked only for the rest. Off: Clef's answer is recorded beside the verdict (clef_score) for comparison." }
100
+ }
101
+ }
102
+ ]
81
103
  }
82
104
  }
83
105
  },
@@ -102,6 +124,14 @@
102
124
  "trace": { "type": "boolean", "default": true, "description": "Keep a trace per walked step (actions, network requests, console) shown under Technical; false or walk --no-trace turns it off." }
103
125
  }
104
126
  },
127
+ "pii": {
128
+ "type": "object",
129
+ "additionalProperties": false,
130
+ "description": "Personal data (#64).",
131
+ "properties": {
132
+ "allow": { "type": "array", "items": { "type": "string", "minLength": 1 }, "description": "Values that look like personal data but are not, such as the company's contact e-mail or phone number. * matches any text (*@acme.example). Neither masked nor stopped by kaloko share." }
133
+ }
134
+ },
105
135
  "share": {
106
136
  "type": "object",
107
137
  "additionalProperties": false,
@@ -205,6 +235,7 @@
205
235
  "description": "Base URL, or per-locale base URLs (key \"default\" is the fallback). ${VAR} is replaced from the environment, e.g. https://${REVIEW_HOST} for review apps."
206
236
  },
207
237
  "readonly": { "type": "boolean", "default": false },
238
+ "pii": { "enum": ["required", "redact", "pseudonymize", "off"], "description": "Personal data in runs of this environment. redact or pseudonymize: every run hides it (placeholders, or consistent fake values). required: a run is only created when the scenario or kaloko start --pii hides it, e.g. for production or a local copy of production data." },
208
239
  "app": {
209
240
  "type": "object",
210
241
  "additionalProperties": false,
@@ -358,6 +358,13 @@
358
358
  "type": "object",
359
359
  "description": "node|locale → revision, entry, status and author of a draft head."
360
360
  },
361
+ "pii": {
362
+ "type": [
363
+ "object",
364
+ "null"
365
+ ],
366
+ "description": "Personal data hidden in this run (#64): { hidden: true, mode: redact | pseudonymize, auto, captures }. The canvas says so, so a blank place is not read as a bug."
367
+ },
361
368
  "app_version": {
362
369
  "type": [
363
370
  "string",
@@ -497,6 +504,10 @@
497
504
  "quality": {
498
505
  "type": "object",
499
506
  "description": "Capture quality when not the canvas default: format, scale, masked (count), annotations [{n, criterion, x, y}]"
507
+ },
508
+ "pii": {
509
+ "type": "object",
510
+ "description": "Personal data hidden on this capture (#64): mode (redact, pseudonymize), auto (detection on), kinds {kind: count}, rects [{x, y, w, h, kind}] in CSS px of the page, marked on the canvas."
500
511
  }
501
512
  }
502
513
  }
@@ -186,6 +186,10 @@
186
186
  },
187
187
  "capture": {
188
188
  "$ref": "#/$defs/capture"
189
+ },
190
+ "pii": {
191
+ "enum": ["redact", "pseudonymize"],
192
+ "description": "Personal data in every capture of the flow: redact hides it behind placeholders and boxes, pseudonymize replaces it with fake values that stay the same for one value throughout the run. Both detect e-mails, phone numbers, birth dates, birth numbers, IBANs, account numbers and addresses; capture.mask adds selectors."
189
193
  }
190
194
  },
191
195
  "$defs": {
@@ -849,11 +853,11 @@
849
853
  "description": "WebP quality"
850
854
  },
851
855
  "mask": {
852
- "type": "array",
853
- "items": {
854
- "type": "string"
855
- },
856
- "description": "CSS selectors covered by a solid box on the image (personal data)"
856
+ "oneOf": [
857
+ { "type": "array", "items": { "type": "string", "minLength": 1 } },
858
+ { "const": "auto" }
859
+ ],
860
+ "description": "Personal data to hide in every output of the capture (screenshot, HTML, page outline, evaluators, trace, recording): CSS selectors, auto (detect e-mails, phone numbers, birth dates, birth numbers, IBANs, account numbers and addresses), or selectors with auto among them. The scenario's and the step's masks add up."
857
861
  },
858
862
  "annotate": {
859
863
  "type": "boolean",
@@ -102,7 +102,9 @@ step or accept a run on a person's behalf, and never hand out a signed
102
102
  acceptance record (`kaloko signoff`) for a run a person has not accepted; report what waits for them
103
103
  (`kaloko review`, MCP `get_review_queue`) instead. Ask the person
104
104
  for secret values (passwords, tokens, gate credentials) and for sign-ins only
105
- they can do; never write secrets into the config, a scenario or a script.
105
+ they can do; never write secrets into the config, a scenario or a script. When
106
+ `kaloko share` stops on personal data, mask it and capture again; `--allow-pii`
107
+ only when a person says that value may be uploaded.
106
108
 
107
109
  ## Where to look
108
110
 
@@ -153,7 +153,7 @@ documentation, manuals and training material set print quality on the scenario o
153
153
  capture:
154
154
  format: png # lossless
155
155
  scale: 2 # 2× device pixels, crisp in a PDF
156
- mask: ["[data-qa=email]", ".customer-name"] # solid box over personal data (image only; the HTML keeps it)
156
+ mask: ["[data-qa=email]", ".customer-name"] # personal data hidden in the image, HTML, outline, trace and video
157
157
  annotate: true # numbered marks on the elements of criteria with `highlight`, in criteria order
158
158
  ```
159
159
 
@@ -161,7 +161,13 @@ A node's `capture:` overrides the scenario's. Crops for a manual take padding in
161
161
  `await crop('#checkout-summary', 'Order summary', { padding: 16 })`. Print quality costs storage (a
162
162
  2× PNG is several times a 1× WebP), so keep it for documentation scenarios, not for every run. The
163
163
  canvas still loads a small WebP thumbnail; the lightbox and `latest.image` serve the full file.
164
- Masks hide pixels, not the captured HTML: do not use production personal data in documentation runs.
164
+ Masks cover every output of the step: the screenshot gets a striped box, the HTML and the page outline get
165
+ `[hidden]` of the same length, traces, recordings and captured e-mails are covered too; checks still read the real
166
+ page. On data copied from production add `pii: redact` to the scenario (automatic detection of e-mails, phones,
167
+ birth numbers, IBANs, addresses) or `pii: pseudonymize` (consistent fake values, readable screens). An environment
168
+ with `pii: required` refuses a run without it, and `kaloko share` stops on an unmasked e-mail or phone number and
169
+ names the step and element: mask it and capture again, list a company contact under `pii.allow` in the config, and
170
+ use `--allow-pii` only when a person says the value may go up.
165
171
 
166
172
  ## Keeping plan and scenario aligned
167
173
 
@@ -68,6 +68,18 @@ evaluators:
68
68
  Report the reason with each `needs_review` or failing criterion; when the
69
69
  evaluator saw the screenshot, say so.
70
70
 
71
+ ## Triage findings (experimental)
72
+
73
+ When the organization switched the experiment on, `kaloko evaluate` also asks
74
+ Clef, a model on Cloudflare, what kind of problem each screen and a few frames
75
+ of its recording show: overlap, cut-off text, raw keys, placeholders, errors,
76
+ endless loading, layout jumps and more. They land in the eval file and the run
77
+ as `findings` (kind, probability, severity, frame times) and never change a
78
+ verdict. Mention the shown ones (`shown: true`) as "possible problems", apart
79
+ from the verdicts, and fix the clear ones; `get_findings` lists them with the
80
+ answers reviewers gave. Do not answer findings on a reviewer's behalf unless
81
+ asked. `evaluators.triage: false` turns it off.
82
+
71
83
  When a criterion comes back `needs_review`, read the reduced state the
72
84
  evaluator saw (`steps/<node>/<locale>/<viewport>.capture.json`, key `state`)
73
85
  and the screenshot. Either the page is genuinely unclear (report it), the
package/src/cli.mjs CHANGED
@@ -9,7 +9,7 @@ import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FI
9
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
- import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
12
+ import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, launchWithRetry, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
13
13
  import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
14
14
  import { observeConsole } from './lib/console.mjs'
15
15
  import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.mjs'
@@ -62,6 +62,7 @@ import { passesOf, enginesOf, contextOptionsOf, launchEngine, pickKeys, variantF
62
62
  import { startScope, scopeKeys, dimensionLine, baseViewports, engineKeys, appDeviceGroups } from './lib/walk-dimensions.mjs'
63
63
  import { deviceConfig, prepareAndroid, readyAndroid, finishAndroid, prepareSimulator, applyAppAppearance } from './lib/apps/devices.mjs'
64
64
  import { attachAndroidWebView } from './lib/apps/webview.mjs'
65
+ import { piiSettings, piiFindings, guardAllow, formatFindings } from './lib/pii.mjs'
65
66
 
66
67
 
67
68
  const HELP = `kaloko — walk, assess and approve what your agents built, on one flow canvas
@@ -117,6 +118,8 @@ Run
117
118
  [--states empty,error] Only these step states (states: on a step; keys like mobile.state-empty)
118
119
  [--token-modes brand-a,brand-b | --token-modes none]
119
120
  Design environments: the token modes to render (default every mode of the set)
121
+ [--pii redact|pseudonymize|off] Hide personal data in every capture (placeholders or consistent fake values);
122
+ default from the environment's or the scenario's pii
120
123
  browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
121
124
  Shared Chromium with CDP (agents, scripts and people drive it)
122
125
  browser install [--with-deps] [--browsers chromium,webkit,firefox]
@@ -198,6 +201,8 @@ Results
198
201
  hash, so unchanged captures are not sent again and a broken-off share resumes;
199
202
  --pr adds or updates one comment on the branch's pull request via gh (KALOKO_PR in
200
203
  CI), or a note on the GitLab merge request (KALOKO_GITLAB_TOKEN)
204
+ [--allow-pii] An unmasked e-mail, phone number, birth number or IBAN in the run stops the upload
205
+ and names the step and element; --allow-pii uploads anyway
201
206
  feedback [--run id] [--scenario id] [--json]
202
207
  Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies;
203
208
  returned steps first, with their notes
@@ -518,7 +523,7 @@ async function designImport (args) {
518
523
  await commands.start({ ...args, _: [], scenario: scenarioFile, env: env.name })
519
524
  const current = await resolveRun({ root, output: config.output })
520
525
  await commands.walk({ _: [], run: current.dir })
521
- await commands.share({ _: [], run: current.dir, ...(typeof args.notes === 'string' ? { notes: args.notes } : {}), ...(typeof args.name === 'string' ? { name: args.name } : {}), ...(typeof args.branch === 'string' ? { branch: args.branch } : {}) })
526
+ await commands.share({ _: [], run: current.dir, ...(args['allow-pii'] ? { 'allow-pii': true } : {}), ...(typeof args.notes === 'string' ? { notes: args.notes } : {}), ...(typeof args.name === 'string' ? { name: args.name } : {}), ...(typeof args.branch === 'string' ? { branch: args.branch } : {}) })
522
527
  return r
523
528
  }
524
529
 
@@ -606,7 +611,7 @@ async function catalogFor (config, root) {
606
611
  const expandRun = (value, short) => (value || '').replace('{run}', short)
607
612
 
608
613
  // Finds the e-mail (or takes the one given) and captures it as the node's screens.
609
- async function captureMail ({ config, root, run, env, node, locale, to, subject, timeoutMs = 60000, viewports, message = null, since, id = null }) {
614
+ async function captureMail ({ config, root, run, env, scenario = null, node, locale, to, subject, timeoutMs = 60000, viewports, message = null, since, id = null }) {
610
615
  if (!message) {
611
616
  const client = mailClient(env.mail, root, env)
612
617
  // only messages that arrived during this run unless told otherwise (#52): stale e-mails are not captured silently
@@ -631,8 +636,9 @@ async function captureMail ({ config, root, run, env, node, locale, to, subject,
631
636
  // Remote images are allowed for the screenshot but must not hold the capture hostage.
632
637
  await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 20000 })
633
638
  await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {})
634
- const mail = { id: message.id, subject: message.subject, from: message.from || '', to: (message.to || []).join(', '), date: message.date }
635
- const results = await captureStep({ page, node, locale, viewports: scopeViewports(run, viewports), run, maxChars: config.evaluators?.jev?.max_state_chars, extra: { mail } })
639
+ const mail = { id: message.id, subject: message.subject, from: message.from || '', to: (message.to || []).join(', '), ...(message.cc?.length ? { cc: message.cc.join(', ') } : {}), date: message.date }
640
+ // the scenario's masks apply to e-mails too (#64); recipients are masked whenever anything is
641
+ const results = await captureStep({ page, node, locale, viewports: scopeViewports(run, viewports), run, maxChars: config.evaluators?.jev?.max_state_chars, options: captureOptions(scenario, node), extra: { mail } })
636
642
  return { subject: mail.subject, count: results.length }
637
643
  } finally {
638
644
  await browser.close()
@@ -803,7 +809,7 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
803
809
  if (node.kind === 'stack') { pending.push(`${node.id} (${locale}): stack steps list web pages; not walked on ${platform}`); continue }
804
810
  if (node.kind === 'email' && group !== groups[0]) continue // one message, captured on the first device
805
811
  if (node.kind === 'email' && node.mail?.to) {
806
- try { const r = await captureMail({ config, root, run, env, node, locale, to: expandRun(node.mail.to, run.meta.short), subject: localized(node.mail?.subject, locale), timeoutMs: 45000 }); say(`✓ ${node.id} ${locale}: "${r.subject}"`) } catch (error) { failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`); say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`) }
812
+ try { const r = await captureMail({ config, root, run, env, scenario, node, locale, to: expandRun(node.mail.to, run.meta.short), subject: localized(node.mail?.subject, locale), timeoutMs: 45000 }); say(`✓ ${node.id} ${locale}: "${r.subject}"`) } catch (error) { failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`); say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`) }
807
813
  continue
808
814
  }
809
815
  const scripted = ['playwright', 'script'].includes(node.execution?.strategy) && node.execution?.script
@@ -1077,6 +1083,8 @@ const commands = {
1077
1083
  const env = environment(config, args.env)
1078
1084
  if (scenario.environments && !scenario.environments.includes(env.name)) throw new UserError(`Scenario ${scenario.id} is not allowed on ${env.name} (allowed: ${scenario.environments.join(', ')}).`)
1079
1085
  if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
1086
+ // personal data (#64): an environment with pii: required gets no run that would store it unmasked
1087
+ const pii = piiSettings({ config, env, scenario, args })
1080
1088
  const locales = list(args.locales) || scenario.locales || ['default']
1081
1089
  // design environments render every mode of their token set (#46): the modes come from the folder's set
1082
1090
  const tokenModes = env.kind === 'design' ? await designTokenModesOf(config, root, env) : []
@@ -1089,8 +1097,9 @@ const commands = {
1089
1097
  const appVer = typeof args['app-version'] === 'string' ? args['app-version'].slice(0, 80) : await appVersion(root)
1090
1098
  // the product the flow documents (visual changelog): its tag, or the only product of the config
1091
1099
  const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
1092
- const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product })
1100
+ const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product, pii })
1093
1101
  say(`Run ${meta.id}\n dir: ${relative(process.cwd(), dir)}\n env: ${env.name}${env.readonly ? ' (readonly)' : ''}\n locales: ${locales.join(', ')} viewports: ${vpNames.length > 12 ? `${vpNames.slice(0, 12).join(', ')} … (${vpNames.length})` : vpNames.join(', ')}${dimensionLine(scope)}\n project: ${project.slug}${issue ? ` issue: ${issue.repo ? issue.repo + '#' : '#'}${issue.number}` : ''}`)
1102
+ if (pii) say(` personal data: ${pii.mode === 'pseudonymize' ? 'replaced with fake values' : 'hidden'} in every capture`)
1094
1103
  const addr = recipient(env.mail, meta.short)
1095
1104
  if (addr) say(` test e-mail: ${addr}`)
1096
1105
  say(` other agents may start runs here too: pin this one with --run ${meta.id} or KALOKO_RUN=${meta.id}`)
@@ -1189,7 +1198,7 @@ const commands = {
1189
1198
  const target = args.url === true || (!conn && !args.url) ? urlFor(env, node, locale) : args.url
1190
1199
  if (target) await page.goto(target, { waitUntil: 'domcontentloaded' })
1191
1200
  if (args.crop) {
1192
- const entry = await captureCrop({ page, node, locale, selector: args.crop, title: args.title, run })
1201
+ const entry = await captureCrop({ page, node, locale, selector: args.crop, title: args.title, run, options: captureOptions(scenario, node) })
1193
1202
  say(`Attached ${entry.file} to ${node.id}`)
1194
1203
  return
1195
1204
  }
@@ -1222,7 +1231,7 @@ const commands = {
1222
1231
  let since
1223
1232
  if (args['any-time']) since = null
1224
1233
  else if (typeof args.since === 'string') { const t = Date.parse(args.since); if (Number.isNaN(t)) throw new UserError(`--since ${args.since} is not a date (ISO 8601, e.g. 2026-09-27T10:00).`); since = new Date(t).toISOString() }
1225
- const results = await captureMail({ config, root, run, env, node, locale, to, subject, timeoutMs: (Number(args.timeout) || 60) * 1000, viewports: list(args.viewports), message, since, id: typeof args.id === 'string' ? args.id : null })
1234
+ const results = await captureMail({ config, root, run, env, scenario, node, locale, to, subject, timeoutMs: (Number(args.timeout) || 60) * 1000, viewports: list(args.viewports), message, since, id: typeof args.id === 'string' ? args.id : null })
1226
1235
  say(`✓ ${node.id} ${locale}: "${results.subject}" → ${results.count} viewport(s)`)
1227
1236
  },
1228
1237
 
@@ -1382,7 +1391,7 @@ const commands = {
1382
1391
  // an e-mail node with a recipient is walked like a step: wait for the message and capture it
1383
1392
  try {
1384
1393
  const to = expandRun(node.mail?.to || sessionInfo(node.session).mailbox, run.meta.short)
1385
- const results = await captureMail({ config, root, run, env, node, locale, to, subject: localized(node.mail?.subject, locale), timeoutMs: 45000 })
1394
+ const results = await captureMail({ config, root, run, env, scenario, node, locale, to, subject: localized(node.mail?.subject, locale), timeoutMs: 45000 })
1386
1395
  say(`✓ ${node.id} ${locale}${tag}: "${results.subject}"`)
1387
1396
  } catch (error) {
1388
1397
  failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
@@ -1406,7 +1415,7 @@ const commands = {
1406
1415
  const records = []
1407
1416
  const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeKeys(run.meta.scope, stateKeys(run.meta.scope, Object.keys(scopeKeys(run.meta.scope, pass.keys, opts.viewports || null)), state)), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); records.push(...r); return r }) }
1408
1417
  try {
1409
- await mod.default({ page: sPage, context: sCtx, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(run.meta.scope, node) } : {}) }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0 }), acceptDialogs: () => acceptDialogs(sPage) })
1418
+ await mod.default({ page: sPage, context: sCtx, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(run.meta.scope, node) } : {}) }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0, options: captureOptions(scenario, node) }), acceptDialogs: () => acceptDialogs(sPage) })
1410
1419
  if (!captured) await capture()
1411
1420
  } catch (error) { await rec?.afterStep(node, { error }); throw error }
1412
1421
  await rec?.afterStep(node, { records })
@@ -1663,7 +1672,7 @@ const commands = {
1663
1672
  if (!account.configured) throw new UserError(`Add the account first: environments.${env.name}.accounts.${args.account} in ${CONFIG_FILE} (it may be empty: {}).`)
1664
1673
  if (!process.stdin.isTTY) throw new UserError('kaloko auth save needs a person at the terminal: they sign in in the browser window and press Enter.')
1665
1674
  const { chromium } = await import('playwright')
1666
- const browser = await chromium.launch({ headless: false, channel: config.browser.channel || undefined })
1675
+ const browser = await launchWithRetry(chromium, { headless: false, channel: config.browser.channel || undefined })
1667
1676
  try {
1668
1677
  const page = await (await newContext(browser, env, { root, config })).newPage()
1669
1678
  await page.goto(typeof args.url === 'string' ? args.url : baseUrl(env), { waitUntil: 'domcontentloaded' })
@@ -1735,6 +1744,7 @@ const commands = {
1735
1744
  const via = report.via.qawalk ? ` via Kaloko${report.hosted?.limit != null ? ` (${report.hosted.questions} of ${report.hosted.limit} questions this month)` : ''}` : report.via['own-key'] ? ' with your key' : ''
1736
1745
  say(`Evaluated ${report.evaluated} capture(s); semantic evaluator ${report.model}${via}: ${report.semantic_calls} call(s), ${report.semantic_cached} cached.`)
1737
1746
  if (report.vision?.asked) say(`Vision evaluator ${report.vision.model}: ${report.vision.asked} capture(s) with flagged or uncertain criteria, ${report.vision.calls} call(s), ${report.vision.cached} cached${report.vision.skipped.length ? `; not asked: ${[...new Set(report.vision.skipped.map((x) => x.split(': ').slice(1).join(': ')))].slice(0, 2).join('; ')}` : ''}.`)
1747
+ if (report.triage) say(report.triage.screens ? `Triage (experimental, ${report.triage.model}): ${report.triage.screens} screen(s)${report.triage.frames ? ` and ${report.triage.frames} recording frame(s)` : ''}, ${report.triage.shown} finding(s) to look at${report.triage.findings > report.triage.shown ? ` and ${report.triage.findings - report.triage.shown} less likely` : ''}; they never change a verdict.${report.triage.skipped ? ` Stopped early: ${report.triage.skipped}` : ''}` : `Triage (experimental): not run: ${report.triage.skipped}`)
1738
1748
  if (report.skipped.length) {
1739
1749
  // one line per reason: "12 captures: TYPESAFE_API_KEY is not set …"
1740
1750
  const byReason = new Map()
@@ -1800,6 +1810,11 @@ const commands = {
1800
1810
  for (const it of idx.items) for (const byVp of Object.values(it.captures || {})) for (const c of Object.values(byVp)) [c.png, c.thumb, c.html, c.eval].filter(Boolean).forEach((p) => files.add(p))
1801
1811
  }
1802
1812
  }
1813
+ // personal data (#64): nothing goes up with an unmasked e-mail, phone number, birth number or IBAN in it
1814
+ const views = manifest.nodes.flatMap((n) => Object.values(n.captures || {}).flatMap((vps) => Object.values(vps).map((c) => c.view?.revision).filter(Boolean))).map((id) => `live/${id}/index.html`)
1815
+ const findings = await piiFindings({ dir: run.dir, files: [...files, ...views], manifest, allow: guardAllow({ config, env, scenario, run }) })
1816
+ if (findings.length && !args['allow-pii']) throw new UserError(`Not shared: the run holds ${findings.length} unmasked personal data value(s):\n${formatFindings(findings)}\nMask them (capture.mask in the scenario, or pii: redact for automatic detection) and capture the steps again. Values that are not personal data, such as a company contact, go under pii.allow in kaloko.config.yml. To share anyway: kaloko share --allow-pii.`)
1817
+ if (findings.length) say(`Sharing ${findings.length} unmasked personal data value(s), as --allow-pii says:\n${formatFindings(findings, 5)}`)
1803
1818
  const days = Number(args.expires) || config.share?.expires_days || 30
1804
1819
  // by content hash (#28): files the organization already stores (earlier runs, an interrupted share) are not sent
1805
1820
  let entries = await hashFiles(run.dir, [...files])
@@ -2274,7 +2289,8 @@ const commands = {
2274
2289
  const product = pickProduct(config, root, args.product)
2275
2290
  const printPdf = async (htmlFile, outFile) => {
2276
2291
  const { chromium } = await import('playwright')
2277
- const browser = await chromium.launch(config.browser?.channel ? { channel: config.browser.channel } : {}).catch(() => chromium.launch())
2292
+ // the configured channel when it is installed, else Playwright's Chromium; a browser that stalls is not retried twice over
2293
+ const browser = await launchWithRetry(chromium, config.browser?.channel ? { channel: config.browser.channel } : {}).catch((e) => e?.name === 'TimeoutError' || !config.browser?.channel ? Promise.reject(e) : launchWithRetry(chromium, {}))
2278
2294
  try {
2279
2295
  const page = await browser.newPage()
2280
2296
  await page.goto(pathToFileURL(htmlFile).href, { waitUntil: 'load', timeout: 60000 })
@@ -200,10 +200,12 @@ export async function jsValues (page, checks, timeout = JS_TIMEOUT) {
200
200
  return { type, value }
201
201
  })()`
202
202
  try {
203
+ // the page's own timer limits the expression; this one only guards against a page that stopped answering, with
204
+ // room for a slow round trip on a busy machine (a 1 s margin failed true checks under load)
203
205
  let timer
204
206
  const result = await Promise.race([
205
207
  page.evaluate(wrapped),
206
- new Promise((_, no) => { timer = setTimeout(() => no(new Error(`did not finish within ${timeout} ms`)), timeout + 1000) })
208
+ new Promise((_, no) => { timer = setTimeout(() => no(new Error(`the page did not answer within ${Math.round((timeout + 15000) / 1000)} s`)), timeout + 15000) })
207
209
  ]).finally(() => clearTimeout(timer))
208
210
  const text = JSON.stringify(result.value)
209
211
  out[id] = { expression, result: text && text.length > 2000 ? { type: result.type, value: `${text.slice(0, 2000)}…` } : result }
@@ -5,10 +5,12 @@
5
5
  import { chromium } from 'playwright'
6
6
  import { writeJson, appendJsonl } from './util.mjs'
7
7
  import { prepareContext, clientCertificates } from './access.mjs'
8
+ import { launchPersistentWithRetry } from './browser.mjs'
8
9
 
9
10
  const opts = JSON.parse(process.argv[2])
10
11
  const certs = opts.env ? clientCertificates(opts.env, opts.root) : []
11
- const context = await chromium.launchPersistentContext(opts.profile, {
12
+ // a time limit and one more try (KALOKO_LAUNCH_TIMEOUT): a start that stalls on a busy machine does not hang here
13
+ const context = await launchPersistentWithRetry(chromium, opts.profile, {
12
14
  headless: !opts.headed,
13
15
  channel: opts.channel || undefined,
14
16
  viewport: { width: 1280, height: 800 },
@@ -69,12 +69,16 @@ export async function startBrowser ({ root, config, env, headed }) {
69
69
  })
70
70
  const child = spawn(process.execPath, [join(here, 'browser-daemon.mjs'), args], { detached: true, stdio: 'ignore' })
71
71
  child.unref()
72
- for (let i = 0; i < 100; i++) {
72
+ // the daemon launches with launchLimit() and tries once more after a timeout
73
+ const wait = 2 * launchLimit() + 10000
74
+ for (const until = Date.now() + wait; Date.now() < until;) {
73
75
  await new Promise((r) => setTimeout(r, 200))
74
76
  const state = await readJson(stateFile, null)
75
77
  if (state && state.pid === child.pid) return state
78
+ if (!await alive(child.pid)) throw new UserError('Browser did not start (its process ended). Try "kaloko browser start --headed" to see what happens, or "kaloko doctor".')
76
79
  }
77
- throw new UserError('Browser did not start within 20 s. Try "kaloko browser start --headed" to see what happens.')
80
+ try { process.kill(child.pid, 'SIGTERM') } catch {} // Playwright ends the browser it was starting
81
+ throw new UserError(`Browser did not start within ${Math.round(wait / 1000)} s. Try "kaloko browser start --headed" to see what happens; on a busy machine raise KALOKO_LAUNCH_TIMEOUT (seconds).`)
78
82
  }
79
83
 
80
84
  /** Stops the browser of one environment, or every shared browser of the project. */
@@ -102,7 +106,7 @@ export async function connectBrowser ({ root, config, env }) {
102
106
  if (!state) return null
103
107
  assertPolicy(state, env)
104
108
  const { chromium } = await import('playwright')
105
- const browser = await chromium.connectOverCDP(state.cdp)
109
+ const browser = await connectWithRetry(chromium, state.cdp)
106
110
  return { browser, context: browser.contexts()[0], state }
107
111
  }
108
112
 
@@ -138,17 +142,82 @@ export async function currentPage (context, match) {
138
142
  return real[real.length - 1] || pages[pages.length - 1] || context.newPage()
139
143
  }
140
144
 
145
+ /** The time limit of a browser launch or connection: KALOKO_LAUNCH_TIMEOUT seconds, default 45. */
146
+ export const launchLimit = () => (Number(process.env.KALOKO_LAUNCH_TIMEOUT) || 45) * 1000
147
+ /** The time limit of the first context of a new browser and of every later newContext/newPage: 20 s, or KALOKO_LAUNCH_TIMEOUT when set. */
148
+ export const answerLimit = () => (Number(process.env.KALOKO_LAUNCH_TIMEOUT) > 0 ? launchLimit() : 20000)
149
+
150
+ const isTimeout = (e) => /Timeout|did not (finish|answer)/i.test(String(e?.name) + ' ' + String(e?.message))
151
+
152
+ /** `promise`, or a TimeoutError naming `what` when it has not settled within `ms`. */
153
+ export function withTimeout (promise, ms, what) {
154
+ let timer
155
+ const limit = new Promise((resolve, reject) => {
156
+ timer = setTimeout(() => reject(Object.assign(new Error(`${what} did not answer within ${Math.round(ms / 1000)} s (KALOKO_LAUNCH_TIMEOUT)`), { name: 'TimeoutError' })), ms)
157
+ })
158
+ return Promise.race([promise, limit]).finally(() => clearTimeout(timer))
159
+ }
160
+
161
+ /** Runs `attempt` and, when it timed out, once more; any other error reaches the caller. */
162
+ export async function retryOnTimeout (attempt) {
163
+ try { return await attempt() } catch (e) {
164
+ if (!isTimeout(e)) throw e
165
+ return attempt()
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Puts a time limit on newContext and newPage of a browser: a browser that stopped answering (seen on loaded
171
+ * machines: launched fine, then Target.createBrowserContext never returned) fails the command instead of holding it
172
+ * forever. A context that arrives after the limit is closed.
173
+ */
174
+ export function limitBrowser (browser, ms = answerLimit()) {
175
+ if (!browser || typeof browser.newContext !== 'function' || browser.__kalokoLimited) return browser
176
+ for (const name of ['newContext', 'newPage']) {
177
+ const call = browser[name].bind(browser)
178
+ browser[name] = (...a) => {
179
+ const pending = call(...a)
180
+ return withTimeout(pending, ms, `browser.${name}`).catch((e) => {
181
+ if (isTimeout(e)) pending.then((x) => x?.close?.().catch(() => {}), () => {})
182
+ throw e
183
+ })
184
+ }
185
+ }
186
+ browser.__kalokoLimited = true
187
+ return browser
188
+ }
189
+
141
190
  /**
142
191
  * 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.
192
+ * answers, and Playwright would wait for it forever. Playwright ends the process of a launch that timed out. A
193
+ * launched browser must open a context within answerLimit(); one that does not is closed (Playwright kills it when it
194
+ * does not close within 30 s) and the launch is tried once more. KALOKO_LAUNCH_TIMEOUT (seconds, default 45).
145
195
  */
146
196
  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
- }
197
+ const timeout = launchLimit()
198
+ return retryOnTimeout(async () => {
199
+ const browser = await browserType.launch({ timeout, ...options })
200
+ if (typeof browser?.newContext !== 'function') return browser
201
+ try {
202
+ await withTimeout(browser.newContext().then((c) => c.close()), answerLimit(), 'the new browser')
203
+ } catch (e) {
204
+ browser.close().catch(() => {}) // not awaited: the next try starts while Playwright closes or kills this one
205
+ throw e
206
+ }
207
+ return limitBrowser(browser)
208
+ })
209
+ }
210
+
211
+ /** chromium.connectOverCDP with a time limit and one more try; the browser's newContext/newPage are limited too. */
212
+ export async function connectWithRetry (browserType, endpoint, options = {}) {
213
+ const timeout = launchLimit()
214
+ return limitBrowser(await retryOnTimeout(() => browserType.connectOverCDP(endpoint, { timeout, ...options })))
215
+ }
216
+
217
+ /** chromium.launchPersistentContext with a time limit and one more try (the shared browser's daemon). */
218
+ export async function launchPersistentWithRetry (browserType, profile, options = {}) {
219
+ const timeout = launchLimit()
220
+ return retryOnTimeout(() => browserType.launchPersistentContext(profile, { timeout, ...options }))
152
221
  }
153
222
 
154
223
  export async function launchEphemeral (config) {
package/src/lib/build.mjs CHANGED
@@ -6,7 +6,7 @@ import { readJson, writeJson } from './util.mjs'
6
6
  import { readCaptures, runPaths, listRuns } from './run.mjs'
7
7
  import { latestCaptures, evaluationSource, stepIdOf } from './evaluate.mjs'
8
8
  import { layout } from './scenario.mjs'
9
- import { assembleNode, assembleStack, summarize } from './manifest-core.mjs'
9
+ import { assembleNode, assembleStack, summarize, runPii } from './manifest-core.mjs'
10
10
  import { packageRoot } from './schema.mjs'
11
11
  import { variantOf } from './dimensions.mjs'
12
12
 
@@ -82,6 +82,8 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
82
82
  nodes,
83
83
  edges: scenario.edges.map((e) => ({ from: e.from, to: e.to, label: e.label || '', kind: e.kind || 'main' })),
84
84
  summary,
85
+ // "personal data hidden" (#64): the canvas says so, so a blank is not taken for a bug
86
+ ...(runPii(meta, nodes) ? { pii: runPii(meta, nodes) } : {}),
85
87
  coverage: cov,
86
88
  history,
87
89
  catalog,