kaloko 0.21.0 → 0.21.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "description": "Your AI agents build; Kaloko walks every screen, puts it on one canvas and records who approved what.",
5
5
  "keywords": [
6
6
  "qa",
@@ -102,9 +102,12 @@ kaloko schedule hook <id> # URL + secret that start the run after a deploy
102
102
  - Findings: `GET …/scenarios/<id>/findings` (MCP `list_findings`) groups a
103
103
  scenario's open results by check, site, locale and step, with a status
104
104
  people set (acknowledged, ignored with a reason) and fixed when no page
105
- fails. A scenario may open one Jira / Linear issue per new finding
106
- (`PUT …/findings/sync { tracker, min_severity }`, Developer / Tester): at
107
- most 20 per run, a comment on big changes, closed when fixed. Switching it
105
+ fails. A scenario may open one issue per new finding in Jira, Linear,
106
+ GitHub Issues or Plane (`PUT …/findings/sync { tracker, min_severity,
107
+ github?, plane?, routes? }`, Developer / Tester; routes send checks or
108
+ packs to another tracker): at most 20 per run, a comment on big changes,
109
+ closed when fixed. With `traffic:`, findings and issues carry clicks,
110
+ impressions and impact. Switching it
108
111
  on or ignoring findings is the team's call, not an agent's.
109
112
  - Does not work for: native apps, local or private addresses (localhost, IPs,
110
113
  `.local`), header credentials, stacks listed by crawl, script or file.
package/src/cli.mjs CHANGED
@@ -12,7 +12,7 @@ import { startRefs, normalizePurpose, RUN_PURPOSES } from './lib/run-refs.mjs'
12
12
  import { gitMeta, projectAndIssue, appVersion } from './lib/git.mjs'
13
13
  import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, launchWithRetry, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
14
14
  import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
15
- import { observeConsole } from './lib/console.mjs'
15
+ import { observeConsole, pageTarget } from './lib/console.mjs'
16
16
  import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.mjs'
17
17
  import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher, gateForRequests } from './lib/access.mjs'
18
18
  import { configureGate, disposeShared } from './lib/linkcheck.mjs'
@@ -810,7 +810,7 @@ async function exportScenario ({ args, config, root, client }) {
810
810
  * Walks a stack step: resolves its pages, then opens and captures each one with the step's criteria and index
811
811
  * fields (HTTP status of the main document included). A step script, when given, runs per page with `item`.
812
812
  */
813
- async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, args, keys = null, tag = '', cache = null, gate = null }) {
813
+ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, consoleBound = null, args, keys = null, tag = '', cache = null, gate = null }) {
814
814
  // a rotating sample (items.rotate) remembers between runs how many runs walked it and when each page was checked
815
815
  const statePath = (l) => join(run.dir, '..', '.cache', 'stack', scenario.id, `${node.id}.${l}.json`)
816
816
  const state = node.items?.rotate ? await rotationState(statePath(locale), run.meta.id) : null
@@ -854,13 +854,17 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
854
854
  const streak = failureStreak(5)
855
855
  const failed = []; let ok = 0; const checkedNow = []
856
856
  const started = Date.now(); let told = started
857
+ let from = consoleBound?.since(page) || new Date().toISOString()
857
858
  try {
858
859
  for (const [i, item] of chosen.entries()) {
859
860
  if (streak.stopped('stack')) { failed.push(`${item.id}: not walked after repeated failures`); continue }
860
861
  status = null
861
- const since = Date.now()
862
+ // the console counts from the previous page's last capture on this tab (an ISO time: a number never matched an
863
+ // entry's time, so stack pages never reported a console error)
864
+ const since = from
862
865
  let captured = false
863
- const capture = (opts = {}) => { captured = true; return captureStep({ page, node, locale, viewports: opts.viewports ? scopeKeys(run.meta.scope, passKeys, opts.viewports) : viewports, run, consoleFile, since, item, fields, maxChars: config.evaluators?.jev?.max_state_chars, options: captureOptions(scenario, node), extra: { http_status: status } }) }
866
+ let lastCapture = null
867
+ const capture = (opts = {}) => { captured = true; lastCapture = new Date().toISOString(); return captureStep({ page, node, locale, viewports: opts.viewports ? scopeKeys(run.meta.scope, passKeys, opts.viewports) : viewports, run, consoleFile, since, item, fields, maxChars: config.evaluators?.jev?.max_state_chars, options: captureOptions(scenario, node), extra: { http_status: status } }) }
864
868
  try {
865
869
  if (script) await script.default({ page, item, url: () => item.url, locale, env, run: run.meta, capture, acceptDialogs: () => acceptDialogs(page) })
866
870
  else await page.goto(item.url, { waitUntil: 'domcontentloaded', timeout: 30000 })
@@ -870,10 +874,11 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
870
874
  failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
871
875
  if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
872
876
  }
877
+ from = lastCapture || new Date().toISOString()
873
878
  // long stacks: progress every 25 pages (or 30 s) with the time left, estimated from the pages so far
874
879
  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}` : ''}`) }
875
880
  }
876
- } finally { page.off('response', onResponse) }
881
+ } finally { page.off('response', onResponse); consoleBound?.done(page, from) }
877
882
  // the rotation of this locale goes into the run (the stack index shows it) and the pages checked into the state
878
883
  if (state && rotation) {
879
884
  const checked = await state.save(checkedNow, all)
@@ -1531,7 +1536,11 @@ const commands = {
1531
1536
  say(`Attached ${entry.file} to ${node.id}`)
1532
1537
  return
1533
1538
  }
1534
- const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
1539
+ // the console counts from the run's start, or from the run's last capture on this tab (#197): what the shared
1540
+ // browser logged before belongs to another run or to the step captured then. A capture that names no tab (an
1541
+ // own browser, an older version) bounds every tab; one on another tab does not (its errors are not ours)
1542
+ const tab = conn ? await pageTarget(page) : null
1543
+ const since = (await readCaptures(run.dir)).filter((c) => c.type !== 'attachment' && c.at && !c.carried_from && (!c.tab || !tab || c.tab === tab)).map((c) => c.at).reduce((a, b) => (b > a ? b : a), run.meta.started_at || '') || undefined
1535
1544
  const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: inState(run, node, manualKeys(run, list(args.viewports)), args.state), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, stability: Number(args.stability) || 0 })
1536
1545
  for (const r of results) {
1537
1546
  const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
@@ -1689,6 +1698,10 @@ const commands = {
1689
1698
  const page = await lctx.newPage()
1690
1699
  const shared = conn && !parallel && plain // the shared browser's own context logs its console to a file
1691
1700
  if (!shared) observeConsole(page)
1701
+ // where each page's console counts from (#197): from the walk's start on it, then from the previous step's
1702
+ // last capture on it, so what a page logs between two steps counts in the next one and nothing older does
1703
+ const consoleFrom = new WeakMap([[page, new Date().toISOString()]])
1704
+ const consoleBound = { since: (p) => consoleFrom.get(p) || run.meta.started_at, done: (p, at) => consoleFrom.set(p, at || new Date().toISOString()) }
1692
1705
  // one recorder per page (the walk's tab and each named session's), started on its first step;
1693
1706
  // recordings and traces need CDP: Chromium passes only
1694
1707
  const recorders = new Map()
@@ -1699,7 +1712,7 @@ const commands = {
1699
1712
  }
1700
1713
  try {
1701
1714
  // every locale starts as a new visitor on its first walk in the run (an own context is new already)
1702
- if (shared && !walked.some((c) => c.locale === locale)) { await resetSession(lctx, page, baseUrl(env, locale)); say(`session reset (new visitor, ${locale}${tag})`) }
1715
+ if (shared && !walked.some((c) => c.locale === locale)) { await resetSession(lctx, page, baseUrl(env, locale)); consoleBound.done(page); say(`session reset (new visitor, ${locale}${tag})`) }
1703
1716
  for (const node of scenario.nodes) {
1704
1717
  if (node.kind === 'decision' || (pick && !pick.includes(node.id))) continue
1705
1718
  if (node.kind !== 'stack' && node.path && !urlFor(env, node, locale)) continue // step does not exist in this locale
@@ -1710,7 +1723,7 @@ const commands = {
1710
1723
  const consoleFile = shared && !sess ? consoleLog(root, config.output, env.name) : null
1711
1724
  if (node.kind === 'stack') {
1712
1725
  try {
1713
- const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, args, keys: pass.keys, tag, cache: stackCache, gate: stackGate })
1726
+ const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, consoleBound, args, keys: pass.keys, tag, cache: stackCache, gate: stackGate })
1714
1727
  for (const b of bad) failed.push(`${node.id} (${locale}${tag}) ${b}`)
1715
1728
  } catch (error) {
1716
1729
  failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
@@ -1745,11 +1758,15 @@ const commands = {
1745
1758
  const rec = state === 'default' ? rec0 : null
1746
1759
  rec?.beforeStep(node)
1747
1760
  const records = []
1748
- 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 }) }
1761
+ // the console counts from the previous step's last capture on this page (#197), for every capture the
1762
+ // step takes: a later capture of the same view replaces the earlier one and must still name what it logged
1763
+ const since = consoleBound.since(sPage)
1764
+ let lastCapture = null
1765
+ const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); lastCapture = new Date().toISOString(); 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, since, 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 }) }
1749
1766
  try {
1750
1767
  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) })
1751
1768
  if (!captured) await capture()
1752
- } catch (error) { await rec?.afterStep(node, { error }); throw error }
1769
+ } catch (error) { await rec?.afterStep(node, { error }); throw error } finally { consoleBound.done(sPage, lastCapture) }
1753
1770
  await rec?.afterStep(node, { records })
1754
1771
  }
1755
1772
  try {
@@ -91,7 +91,7 @@ export async function prepareContext (context, env, { log } = {}) {
91
91
  const request = route.request()
92
92
  const reason = policyViolation(env, { method: request.method(), url: request.url() })
93
93
  if (reason) {
94
- log?.({ type: 'blocked', url: request.url(), text: reason })
94
+ log?.({ type: 'blocked', url: request.url(), text: reason }, request)
95
95
  return route.abort('blockedbyclient')
96
96
  }
97
97
  if (toGate(request.url())) {
@@ -3,9 +3,10 @@
3
3
  // policy and gate on every request and records console errors for captures.
4
4
  // Secrets are resolved here from the inherited environment, never passed in argv.
5
5
  import { chromium } from 'playwright'
6
- import { writeJson, appendJsonl } from './util.mjs'
6
+ import { writeJson } from './util.mjs'
7
7
  import { prepareContext, clientCertificates } from './access.mjs'
8
8
  import { launchPersistentWithRetry } from './browser.mjs'
9
+ import { consoleWriter, pageTarget } from './console.mjs'
9
10
 
10
11
  const opts = JSON.parse(process.argv[2])
11
12
  const certs = opts.env ? clientCertificates(opts.env, opts.root) : []
@@ -18,10 +19,29 @@ const context = await launchPersistentWithRetry(chromium, opts.profile, {
18
19
  args: [`--remote-debugging-port=${opts.port}`]
19
20
  })
20
21
 
21
- const log = (entry) => appendJsonl(opts.consoleFile, { ts: new Date().toISOString(), ...entry }).catch(() => {})
22
+ // the console log starts empty with the browser (once it is up): what an earlier one logged belongs to no page of it
23
+ const consoleLog = await consoleWriter(opts.consoleFile)
24
+
25
+ // every entry names its tab (#197): a capture counts only the errors of the page it captures, not of other tabs
26
+ const tabs = new WeakMap()
27
+ const tabOf = (page) => {
28
+ if (!page) return Promise.resolve(null)
29
+ // a lookup that failed or timed out is tried again with the next entry
30
+ if (!tabs.has(page)) tabs.set(page, pageTarget(page).then((id) => { if (!id) tabs.delete(page); return id }))
31
+ return tabs.get(page)
32
+ }
33
+ const pageOf = (request) => { try { return request.frame().page() } catch { return null } }
34
+ const log = (entry, request = null) => {
35
+ const ts = new Date().toISOString()
36
+ return consoleLog.append(tabOf(entry.page || pageOf(request)).then((tab) => {
37
+ const { page, ...rest } = entry
38
+ return { ts, ...rest, ...(tab ? { tab } : {}) }
39
+ }))
40
+ }
22
41
  const watch = (page) => {
23
- page.on('console', (m) => { if (m.type() === 'error') log({ type: 'console', url: page.url(), text: m.text(), src: m.location()?.url || null }) })
24
- page.on('pageerror', (e) => log({ type: 'pageerror', url: page.url(), text: e.message }))
42
+ tabOf(page)
43
+ page.on('console', (m) => { if (m.type() === 'error') log({ page, type: 'console', url: page.url(), text: m.text(), src: m.location()?.url || null }) })
44
+ page.on('pageerror', (e) => log({ page, type: 'pageerror', url: page.url(), text: e.message }))
25
45
  }
26
46
  context.pages().forEach(watch)
27
47
  context.on('page', watch)
@@ -3,7 +3,7 @@ import { dirname, relative, join } from 'node:path'
3
3
  import { buildHtmlView } from './htmlview.mjs'
4
4
  import { probe } from './probe.mjs'
5
5
  import { pageSpecScript } from './devspec.mjs'
6
- import { writeJson, readJsonl } from './util.mjs'
6
+ import { writeJson } from './util.mjs'
7
7
  import { addCapture, runPaths } from './run.mjs'
8
8
  import { maskSpec, maskList, inPage, maskInPage, rememberPii, knownPii, maskMailHeaders, maskHtml, scrubDeep, scrubText, updateMasks, loadKnownPii, saveKnownPii, RECORD_PLAIN_KEYS } from './pii.mjs'
9
9
 
@@ -98,7 +98,7 @@ export function captureOptions (scenario, node) {
98
98
  }
99
99
 
100
100
  import { criteriaHash } from './evaluate.mjs'
101
- import { consoleEntriesFor } from './console.mjs'
101
+ import { consoleEntriesFor, readConsoleLog, pageTarget, entriesSince } from './console.mjs'
102
102
  import { redact, redactDeep } from './secrets.mjs'
103
103
  import { readFields } from './stack.mjs'
104
104
  import { checkLinks, checkHreflang, checkLlmsTxt, requester, walkCache, NETWORK_ASSERTS, isolatedRequest, anonymousRequest, cacheScope } from './linkcheck.mjs'
@@ -203,6 +203,10 @@ export async function captureStep ({ page, node, locale, viewports, run, console
203
203
  // batch E: the engine of this page, and per key its colour scheme, motion and display mode (run scope variants)
204
204
  const engine = engineOf(page)
205
205
  const scope = run.meta?.scope
206
+ // console entries (#197): only those since `since` (an ISO time; a number is a time in ms) and, in the shared
207
+ // browser, only those of this page's tab
208
+ if (typeof since === 'number') since = new Date(since).toISOString()
209
+ const tab = consoleFile ? await pageTarget(page) : null
206
210
  for (const [viewport, [width, height]] of Object.entries(viewports)) {
207
211
  const now = page.viewportSize()
208
212
  if (!now || now.width !== width || now.height !== height) await page.setViewportSize({ width, height })
@@ -230,8 +234,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
230
234
  Object.assign(result.checks, localeChecks(node.criteria, { locale, meta: result.meta, url: page.url(), state: result.state }))
231
235
  // the console is read before the mask: the page's own reaction to masked text never counts as its error
232
236
  const url = page.url()
233
- const collected = consoleFile ? await readJsonl(consoleFile) : consoleEntriesFor(page)
234
- const consoleEntries = (collected || []).filter((e) => !since || e.ts >= since)
237
+ const collected = consoleFile ? await readConsoleLog(consoleFile) : consoleEntriesFor(page)
238
+ const consoleEntries = entriesSince(collected, { since, tab })
235
239
  // requests blocked by the readonly policy surface as ERR_BLOCKED_BY_CLIENT; they are reported separately
236
240
  // an error page's own status ("Failed to load resource: … 404" for the document itself) is not a console error
237
241
  const ownStatus = (e) => result.http_status >= 400 && /^Failed to load resource: the server responded with a status of \d+/.test(e.text) && e.src && sameDocument(e.src, url)
@@ -386,7 +390,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
386
390
  if (mask.active) await saveKnownPii(run.dir)
387
391
  const file = paths.step(stepId, locale, viewport, 'capture.json')
388
392
  await writeJson(file, stored)
389
- await addCapture(run.dir, { type: 'capture', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, file: relative(run.dir, file), at: record.captured_at })
393
+ await addCapture(run.dir, { type: 'capture', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, file: relative(run.dir, file), at: record.captured_at, ...(tab ? { tab } : {}) })
390
394
  results.push(stored)
391
395
  } finally {
392
396
  // the page gets its own text back, also when the capture failed half-way
@@ -1,3 +1,6 @@
1
+ import { appendFile, mkdir, readFile, rename, rm } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+
1
4
  // In-memory collection for browsers that are not managed by the daemon.
2
5
  const logs = new WeakMap()
3
6
 
@@ -11,3 +14,77 @@ export function observeConsole (page) {
11
14
  }
12
15
 
13
16
  export const consoleEntriesFor = (page) => logs.get(page) ?? null
17
+
18
+ // The shared browser's console log (#197): one file per environment, written by the browser daemon only. It starts
19
+ // empty with every browser start and keeps at most two parts of CONSOLE_LIMIT bytes (the current one and the one
20
+ // before), so a browser that runs for days does not grow it without end and a capture reads a few MB at most.
21
+ export const CONSOLE_LIMIT = 2 * 1024 * 1024
22
+ const previousPart = (file) => file.replace(/(\.jsonl)?$/, '.old.jsonl')
23
+
24
+ /** Appends entries in call order; starts the log afresh and moves it aside as the previous part past `limit` bytes. */
25
+ export async function consoleWriter (file, { limit = CONSOLE_LIMIT } = {}) {
26
+ await mkdir(dirname(file), { recursive: true })
27
+ await rm(previousPart(file), { force: true })
28
+ await rm(file, { force: true })
29
+ let size = 0
30
+ let queue = Promise.resolve()
31
+ const write = async (entry) => {
32
+ const line = JSON.stringify(entry) + '\n'
33
+ const bytes = Buffer.byteLength(line)
34
+ if (size > 0 && size + bytes > limit) { await rename(file, previousPart(file)).catch(() => {}); size = 0 }
35
+ await appendFile(file, line)
36
+ size += bytes
37
+ }
38
+ // an entry may be a promise (its tab is looked up first): the file keeps the order of the calls
39
+ const append = (entry) => { queue = queue.then(async () => write(await entry)).catch(() => {}); return queue }
40
+ return { append, flushed: () => queue }
41
+ }
42
+
43
+ // one part of the log, line by line: a line cut short (the daemon was writing it) is skipped, never the whole part
44
+ async function readPart (file) {
45
+ const text = await readFile(file, 'utf8').catch(() => '')
46
+ const lines = []
47
+ for (const line of text.split('\n')) {
48
+ if (!line) continue
49
+ try { lines.push({ line, entry: JSON.parse(line) }) } catch {}
50
+ }
51
+ return lines
52
+ }
53
+
54
+ /** Every entry the shared browser's console log still holds, the previous part first. */
55
+ export async function readConsoleLog (file) {
56
+ // the current part first: when the daemon moves it aside between the two reads, its entries are in the previous
57
+ // part then and are read twice (and kept once), never lost
58
+ const current = await readPart(file)
59
+ const previous = await readPart(previousPart(file))
60
+ const moved = new Set(previous.map(({ line }) => line))
61
+ return [...previous, ...current.filter(({ line }) => !moved.has(line))].map(({ entry }) => entry)
62
+ }
63
+
64
+ /**
65
+ * The browser's own id of a page's tab (a CDP target id). The daemon and every process connected over CDP see the
66
+ * same id, so a capture keeps only the entries of the tab it captures. Null outside Chromium.
67
+ */
68
+ export async function pageTarget (page, { timeout = 3000 } = {}) {
69
+ let session, timer
70
+ const lookup = (async () => {
71
+ session = await page.context().newCDPSession(page)
72
+ const { targetInfo } = await session.send('Target.getTargetInfo')
73
+ return targetInfo?.targetId || null
74
+ })()
75
+ try {
76
+ // a page that hangs (a modal dialog, a busy renderer) leaves its entries untagged rather than holding the log
77
+ return await Promise.race([lookup, new Promise((resolve) => { timer = setTimeout(() => resolve(null), timeout) })])
78
+ } catch { return null } finally {
79
+ clearTimeout(timer)
80
+ lookup.then(() => session?.detach(), () => {}).catch(() => {})
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The entries a capture may count: written at or after `since` (an ISO time) and, when the tab is known, on that
86
+ * tab. Entries without a tab (a browser started by an older version) are kept: the time bound still holds for them.
87
+ */
88
+ export function entriesSince (entries, { since = null, tab = null } = {}) {
89
+ return (entries || []).filter((e) => (!since || e.ts >= since) && (!tab || !e.tab || e.tab === tab))
90
+ }
@@ -175,6 +175,9 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
175
175
  // sharing runs needs the Developer / Tester role (or admin) and, on tokens with scope sets, the upload scope
176
176
  const canShare = ['tester', 'admin'].includes(me.role) && (!Array.isArray(me.scopes) || me.scopes.includes('upload'))
177
177
  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}".`)
178
+ // doctor --json carries the scopes, so a script can tell before a long walk whether the token may also make
179
+ // a run public (full), not only share it (upload): scripts/demo-run.mjs (#62)
180
+ if (Array.isArray(me.scopes)) checks[checks.length - 1].scopes = me.scopes
178
181
  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.')
179
182
  }
180
183
  } 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.') }
@@ -449,7 +449,7 @@ function triageContext ({ config, root, settings, report, fetchImpl, uncertain =
449
449
  const video = side?.files?.webm || side?.files?.mp4
450
450
  // a recording that cannot be read costs its frames only, never the screen's findings
451
451
  if (video) try {
452
- const frames = await chooseFrames({ page: await page(), file: join(runDir, video), taps: (side.taps || []).map((t) => t.t ?? t), max: Math.min(settings.frames_per_step, framesLeft), maxW: settings.frame_width || 640 })
452
+ const frames = await chooseFrames({ page: await page(), file: join(runDir, video), taps: (side.taps || []).map((t) => t.t ?? t), max: Math.min(settings.frames_per_step, framesLeft), maxW: settings.frame_width || 640, cacheDir })
453
453
  framesLeft -= frames.length; report.triage.frames += frames.length
454
454
  for (const batch of frameBatches(frames)) {
455
455
  const r = await ask(w, { kind: 'motion', context: { ...context, frames: batch.map((f) => ({ t_ms: f.t, why: f.why })) }, images: batch.map((f) => f.data) }, { frames: batch.map((f) => f.t) })
@@ -8,6 +8,7 @@
8
8
  // Pure: no Node APIs. The shapes are documented in help/reference/health-api.md.
9
9
  import { parseHealthKey } from './health-index.mjs'
10
10
  import { severityRank, SEVERITIES } from './health-core.mjs'
11
+ import { impactOf } from './traffic-core.mjs'
11
12
 
12
13
  export const HEALTH_FINDINGS_SCHEMA = 'kaloko.health-findings.v1'
13
14
  /** What a person may set; fixed is never set, it is derived. */
@@ -45,10 +46,13 @@ export function findingId (fp) {
45
46
  /**
46
47
  * Findings of a flow from its open results. rows: health_open rows ({ key, severity, url, first_run, first_at, misses
47
48
  * }); checks: { HC…: { text, pack } } of the newest run's index; edit: "step|page" → edit_url; seen: { run, at } of
48
- * the newest run (last seen of what it checked). Returns findings, worst first: { id, fingerprint, check, text, pack,
49
- * site, locale, step, severity, count, unchecked, samples: [{ page, url, edit_url }], first_seen, last_seen }.
49
+ * the newest run (last seen of what it checked); traffic: "step|page" → the page's Search Console and GA4 numbers
50
+ * (#157). Returns findings, worst first: { id, fingerprint, check, text, pack, site, locale, step, severity, count,
51
+ * unchecked, samples: [{ page, url, edit_url, traffic? }], first_seen, last_seen, traffic?, impact? }. With traffic,
52
+ * a finding adds up clicks and impressions of its pages that have numbers (`traffic.pages` of them) and its impact
53
+ * (the sum of each page's impact, traffic-core.mjs impactOf); without, it has neither.
50
54
  */
51
- export function groupFindings (rows, { checks = {}, edit = {}, seen = null } = {}) {
55
+ export function groupFindings (rows, { checks = {}, edit = {}, seen = null, traffic = null } = {}) {
52
56
  const out = new Map()
53
57
  for (const r of rows || []) {
54
58
  const k = parseHealthKey(r.key)
@@ -64,9 +68,15 @@ export function groupFindings (rows, { checks = {}, edit = {}, seen = null } = {
64
68
  if (r.misses > 0) f.unchecked++
65
69
  else if (seen) f.last_seen = seen
66
70
  if (r.first_at && (!f.first_seen || r.first_at < f.first_seen.at)) f.first_seen = { run: r.first_run, at: r.first_at }
71
+ const ek = `${k.step}|${k.page}`
72
+ const t = traffic && Object.hasOwn(traffic, ek) && traffic[ek] && typeof traffic[ek] === 'object' ? traffic[ek] : null
73
+ if (t) {
74
+ const sum = (f.traffic ||= { clicks: 0, impressions: 0, pages: 0 })
75
+ sum.clicks += Number(t.clicks) || 0; sum.impressions += Number(t.impressions) || 0; sum.pages++
76
+ f.impact = (f.impact || 0) + impactOf(t, r.severity)
77
+ }
67
78
  if (f.samples.length < SAMPLE_MAX) {
68
- const ek = `${k.step}|${k.page}`
69
- f.samples.push({ page: k.page || null, url: r.url || null, edit_url: (edit && Object.hasOwn(edit, ek) ? edit[ek] : null) || null })
79
+ f.samples.push({ page: k.page || null, url: r.url || null, edit_url: (edit && Object.hasOwn(edit, ek) ? edit[ek] : null) || null, ...(t ? { traffic: { clicks: Number(t.clicks) || 0, impressions: Number(t.impressions) || 0 } } : {}) })
70
80
  }
71
81
  }
72
82
  return [...out.values()].sort(worstFirst)
@@ -79,8 +89,8 @@ export const FINDINGS_SUMMARY_SCHEMA = 'kaloko.health-findings-summary.v1'
79
89
  * the flow keeps open, plus the findings whose every page the run fixed (`fixed`: the diff's fixed results as rows;
80
90
  * count 0, last seen `previous`). An open finding counts the pages the run fixed in `fixed` too.
81
91
  */
82
- export function summarizeFindings (rows, { checks = {}, edit = {}, seen = null, fixed = [], previous = null } = {}) {
83
- const list = groupFindings(rows, { checks, edit, seen })
92
+ export function summarizeFindings (rows, { checks = {}, edit = {}, seen = null, fixed = [], previous = null, traffic = null } = {}) {
93
+ const list = groupFindings(rows, { checks, edit, seen, traffic })
84
94
  const at = new Map(list.map((f) => [f.id, f]))
85
95
  for (const g of groupFindings(fixed, { checks, edit })) {
86
96
  const f = at.get(g.id)
@@ -114,3 +124,54 @@ export const atLeastSeverity = (severity, min) => SEVERITIES.includes(min) && se
114
124
  * the issue last said.
115
125
  */
116
126
  export const countChanged = (before, now) => before != null && Math.abs(now - before) >= Math.max(3, Math.ceil(before * 0.25))
127
+
128
+ // ---------- where issues go (#191) ----------
129
+
130
+ /** Trackers the findings sync can open issues in. */
131
+ export const SYNC_TRACKERS = ['jira', 'linear', 'github', 'plane']
132
+ /** Routes a scenario may have; checks and packs per route. */
133
+ export const ROUTE_MAX = 20
134
+ const ROUTE_LIST_MAX = 50
135
+ const CHECK_PATTERN = /^[A-Z]{1,6}[0-9]{0,6}\*?$/
136
+ const PACK = /^[a-z][a-z0-9_-]{0,30}$/
137
+
138
+ /**
139
+ * Routes of a sync setting from the API: [{ tracker, checks?: ['HC127', 'HC1*'], packs?: ['aeo'] }], in order. A check
140
+ * ends with * to take every check that starts so (HC1* is HC100 to HC199 and HC1000…). Throws an Error that says
141
+ * what is wrong; [] for none.
142
+ */
143
+ export function cleanRoutes (list) {
144
+ if (list == null) return []
145
+ if (!Array.isArray(list)) throw new Error('routes is a list of { tracker, checks, packs }.')
146
+ if (list.length > ROUTE_MAX) throw new Error(`At most ${ROUTE_MAX} routes.`)
147
+ return list.map((r, i) => {
148
+ if (!r || typeof r !== 'object') throw new Error(`Route ${i + 1} is not an object.`)
149
+ const tracker = String(r.tracker || '')
150
+ if (!SYNC_TRACKERS.includes(tracker)) throw new Error(`Route ${i + 1}: tracker is ${SYNC_TRACKERS.join(', ')}.`)
151
+ const words = (v, name) => {
152
+ if (v == null) return []
153
+ if (!Array.isArray(v) || v.length > ROUTE_LIST_MAX) throw new Error(`Route ${i + 1}: ${name} is a list of at most ${ROUTE_LIST_MAX}.`)
154
+ return [...new Set(v.map((x) => String(x ?? '').trim()))]
155
+ }
156
+ const checks = words(r.checks, 'checks').map((c) => c.toUpperCase())
157
+ const packs = words(r.packs, 'packs').map((p) => p.toLowerCase())
158
+ const badCheck = checks.find((c) => !CHECK_PATTERN.test(c) || c === '*')
159
+ if (badCheck !== undefined) throw new Error(`Route ${i + 1}: ${badCheck.slice(0, 20) || '(empty)'} is not a check id (HC127) or a prefix (HC1*).`)
160
+ const badPack = packs.find((p) => !PACK.test(p))
161
+ if (badPack !== undefined) throw new Error(`Route ${i + 1}: ${badPack.slice(0, 20) || '(empty)'} is not a pack name (seo, aeo).`)
162
+ if (!checks.length && !packs.length) throw new Error(`Route ${i + 1}: name checks or packs.`)
163
+ return { tracker, ...(checks.length ? { checks } : {}), ...(packs.length ? { packs } : {}) }
164
+ })
165
+ }
166
+
167
+ const checkMatches = (pattern, check) => pattern.endsWith('*') ? String(check).startsWith(pattern.slice(0, -1)) : pattern === check
168
+ /**
169
+ * The tracker a finding's issue goes to: the first route that names its check (or a prefix of it) or its pack, else
170
+ * the scenario's tracker.
171
+ */
172
+ export function routeOf (f, routes = [], fallback = null) {
173
+ for (const r of routes || []) {
174
+ if ((r.checks || []).some((c) => checkMatches(c, f.check)) || (f.pack && (r.packs || []).includes(String(f.pack).toLowerCase()))) return r.tracker
175
+ }
176
+ return fallback
177
+ }
@@ -8,6 +8,7 @@
8
8
  // (worker/src/health-index.js); the shapes are documented in help/reference/health-api.md.
9
9
  import { healthOfNode, levelOf, findingSeverity, severityRank } from './health-core.mjs'
10
10
  import { worst } from './manifest-core.mjs'
11
+ import { trafficView } from './traffic-core.mjs'
11
12
 
12
13
  export const HEALTH_INDEX_SCHEMA = 'kaloko.health-index.v1'
13
14
  export const HEALTH_AGGREGATE_SCHEMA = 'kaloko.health-aggregate.v1'
@@ -42,14 +43,16 @@ const firstUrl = (vps) => { for (const c of Object.values(vps || {})) if (c?.url
42
43
  * [{ locale, step, check, counts, top: [{ page, path, url, verdict, evidence }] }], open: [[key, severity, url]],
43
44
  * open_total, truncated, passed, edit }. passed says which checks passed on which page: { "step|locale": { sigs:
44
45
  * ["HC103,HC112", …], pages: { <page>: <index into sigs> } } }; only a result that passed counts as fixed later
45
- * (passedIn). edit maps "step|page" to the edit_url of a stack page with open results (findings link it).
46
+ * (passedIn). edit maps "step|page" to the edit_url of a stack page with open results (findings link it); traffic
47
+ * maps "step|page" to the Search Console and GA4 numbers of such a page when its stack has `traffic:` (#157), for the
48
+ * same pages and bound (findings add them up, an issue shows them).
46
49
  */
47
50
  export function buildHealthIndex (manifest, stacks = {}) {
48
51
  const checks = Object.create(null); const groups = new Map(); const open = []; const pages = new Set(); const ok = new Map()
49
52
  // where a page with open results is edited (a stack item's edit_url): "step|page" → URL, kept below for the open list only
50
- const edits = new Map()
53
+ const edits = new Map(); const visits = new Map()
51
54
  const totals = { results: 0, ...blank() }
52
- const add = (step, locale, page, path, url, check, verdict, evidence, editUrl = null) => {
55
+ const add = (step, locale, page, path, url, check, verdict, evidence, editUrl = null, traffic = null) => {
53
56
  if (!VERDICTS.includes(verdict)) return
54
57
  pages.add(`${step}|${page}`)
55
58
  const gk = `${locale}\u0000${step}\u0000${check}`
@@ -63,6 +66,7 @@ export function buildHealthIndex (manifest, stacks = {}) {
63
66
  if (list.length < TOP_KEEP) list.push({ page, ...(path ? { path } : {}), url: url || null, verdict, ...(evidence ? { evidence: cut(evidence) } : {}) })
64
67
  open.push([healthKey(step, locale, check, page), severity, url || null])
65
68
  if (editUrl && typeof editUrl === 'string' && /^https?:\/\//i.test(editUrl)) edits.set(`${step}|${page}`, cut(editUrl, 500))
69
+ if (traffic && typeof traffic === 'object') { const t = trafficView(traffic); if (t && Object.keys(t).length) visits.set(`${step}|${page}`, t) }
66
70
  }
67
71
  for (const n of manifest?.nodes || []) {
68
72
  if (n.kind === 'decision') continue
@@ -85,7 +89,7 @@ export function buildHealthIndex (manifest, stacks = {}) {
85
89
  if (!was || worst([was.v, x[0]]) !== was.v) per.set(k, { locale, id, v: x[0], e: x[1] })
86
90
  }
87
91
  }
88
- for (const { locale, id, v, e } of per.values()) add(n.id, locale, String(it.id), it.path || null, it.url || firstUrl(it.captures?.[locale]), id, v, e, it.edit_url)
92
+ for (const { locale, id, v, e } of per.values()) add(n.id, locale, String(it.id), it.path || null, it.url || firstUrl(it.captures?.[locale]), id, v, e, it.edit_url, it.traffic)
89
93
  }
90
94
  continue
91
95
  }
@@ -115,16 +119,18 @@ export function buildHealthIndex (manifest, stacks = {}) {
115
119
  // edit links of the pages in the kept open list only, at most EDIT_MAX of them (worst first, like the list)
116
120
  const kept = open.slice(0, OPEN_MAX)
117
121
  const edit = Object.create(null); let links = 0
122
+ const traffic = Object.create(null); let numbers = 0
118
123
  for (const [key] of kept) {
119
- if (links >= EDIT_MAX) break
124
+ if (links >= EDIT_MAX && numbers >= EDIT_MAX) break
120
125
  const { step, page } = parseHealthKey(key)
121
126
  const k = `${step}|${page}`
122
- if (edits.has(k) && !Object.hasOwn(edit, k)) { edit[k] = edits.get(k); links++ }
127
+ if (links < EDIT_MAX && edits.has(k) && !Object.hasOwn(edit, k)) { edit[k] = edits.get(k); links++ }
128
+ if (numbers < EDIT_MAX && visits.has(k) && !Object.hasOwn(traffic, k)) { traffic[k] = visits.get(k); numbers++ }
123
129
  }
124
130
  return {
125
131
  schema: HEALTH_INDEX_SCHEMA, checks, totals: { ...totals, pages: pages.size },
126
132
  groups: [...groups.values()].map(({ fails, unsure, ...g }) => ({ ...g, top: [...fails, ...unsure].slice(0, TOP_KEEP) })),
127
- open: kept, open_total: open.length, truncated: open.length > OPEN_MAX, passed, edit
133
+ open: kept, open_total: open.length, truncated: open.length > OPEN_MAX, passed, edit, ...(numbers ? { traffic } : {})
128
134
  }
129
135
  }
130
136
 
@@ -4,6 +4,7 @@
4
4
  // CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID straight to Workers AI. Findings never change a verdict.
5
5
  import { join } from 'node:path'
6
6
  import { readFile } from 'node:fs/promises'
7
+ import { createHash } from 'node:crypto'
7
8
  import { readJson, writeJson, sha256 } from './util.mjs'
8
9
  import { buildTriageRequest, parseTriage, mergeFindings, clefModelId, TRIAGE_PROMPT_VERSION, MAX_TRIAGE_IMAGES } from './triage-core.mjs'
9
10
 
@@ -77,15 +78,24 @@ export function hostedTriageSender (config, report) {
77
78
  return send
78
79
  }
79
80
 
81
+ // bump when the choice of frames or their encoding changes, so cached frames are chosen again
82
+ const FRAMES_VERSION = 1
83
+
80
84
  /**
81
85
  * Frames of a recording for the motion questions, chosen in a browser page: around each tap, where the picture changes
82
86
  * most between samples (a jump, a flash, a message), and the last frame. Returns [{ t, why, data }] in time order,
83
- * at most `max` frames, each a WebP data URL `maxW` wide.
87
+ * at most `max` frames, each a WebP data URL `maxW` wide. With `cacheDir` the frames are kept by the recording's
88
+ * content and the settings, so evaluating the same recording again sends the very same frames (and its triage answers
89
+ * from the cache) even when the browser decodes or encodes a frame differently the second time.
84
90
  */
85
- export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 1024 }) {
91
+ export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 1024, cacheDir = null }) {
86
92
  if (!(max > 0)) return []
87
- const src = `data:video/${file.endsWith('.mp4') ? 'mp4' : 'webm'};base64,${(await readFile(file)).toString('base64')}`
88
- return page.evaluate(async ({ src, taps, max, maxW }) => {
93
+ const bytes = await readFile(file)
94
+ const cacheFile = cacheDir && join(cacheDir, `frames-${createHash('sha256').update(bytes).update(JSON.stringify({ taps, max, maxW, v: FRAMES_VERSION })).digest('hex')}.json`)
95
+ const kept = cacheFile && await readJson(cacheFile, null)
96
+ if (Array.isArray(kept)) return kept
97
+ const src = `data:video/${file.endsWith('.mp4') ? 'mp4' : 'webm'};base64,${bytes.toString('base64')}`
98
+ const frames = await page.evaluate(async ({ src, taps, max, maxW }) => {
89
99
  const v = document.createElement('video'); v.muted = true; v.preload = 'auto'; v.src = src
90
100
  // every wait has a limit: a stalled video must not hang the evaluation
91
101
  const within = (p, ms) => Promise.race([p, new Promise((ok, no) => setTimeout(() => no(new Error('the recording did not answer in time')), ms))])
@@ -96,13 +106,25 @@ export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 102
96
106
  const dur = Number.isFinite(v.duration) ? v.duration : 0
97
107
  if (!dur) return []
98
108
  const seek = (t) => within(new Promise((ok) => { v.onseeked = ok; v.currentTime = Math.min(Math.max(0, t), Math.max(0, dur - 0.01)) }), 5000)
109
+ // `seeked` (even at readyState 4) does not mean the frame can be painted yet: on a busy machine the first draw
110
+ // sometimes paints nothing, which read as a change from an empty picture and moved the chosen frames. A decoded
111
+ // frame is opaque, so the draw is repeated until its middle row holds one.
112
+ const draw = async (g, w, h) => {
113
+ const painted = () => { const row = g.getImageData(0, h >> 1, w, 1).data; for (let i = 3; i < row.length; i += 4) if (row[i]) return true; return false }
114
+ for (let tries = 0; ; tries++) {
115
+ g.clearRect(0, 0, w, h); g.drawImage(v, 0, 0, w, h)
116
+ if (painted()) return
117
+ if (tries >= 100) throw new Error('a frame of the recording could not be drawn')
118
+ await new Promise((ok) => setTimeout(ok, 20))
119
+ }
120
+ }
99
121
  const small = document.createElement('canvas'); small.width = 64; small.height = Math.max(1, Math.round(64 * v.videoHeight / v.videoWidth))
100
122
  const sg = small.getContext('2d', { willReadFrequently: true })
101
123
  // sampled differences: 4 samples a second, at most 240
102
124
  const step = Math.max(0.25, dur / 240)
103
125
  const diffs = []; let prev = null
104
126
  for (let t = 0; t <= dur; t += step) {
105
- await seek(t); sg.drawImage(v, 0, 0, small.width, small.height)
127
+ await seek(t); await draw(sg, small.width, small.height)
106
128
  const px = sg.getImageData(0, 0, small.width, small.height).data
107
129
  if (prev) { let d = 0; for (let i = 0; i < px.length; i += 4) d += Math.abs(px[i] - prev[i]) + Math.abs(px[i + 1] - prev[i + 1]) + Math.abs(px[i + 2] - prev[i + 2]); diffs.push({ t, d: d / (px.length / 4) / 765 }) }
108
130
  prev = px
@@ -117,11 +139,13 @@ export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 102
117
139
  const list = chosen.length > max ? [...chosen.slice(0, max - 1), chosen.at(-1)] : chosen
118
140
  const big = document.createElement('canvas'); const s = Math.min(1, maxW / v.videoWidth)
119
141
  big.width = Math.round(v.videoWidth * s); big.height = Math.round(v.videoHeight * s)
120
- const bg = big.getContext('2d')
142
+ const bg = big.getContext('2d', { willReadFrequently: true })
121
143
  const out = []
122
- for (const f of list) { await seek(f.t); bg.drawImage(v, 0, 0, big.width, big.height); out.push({ t: Math.round(f.t * 1000), why: f.why, data: big.toDataURL('image/webp', 0.8) }) }
144
+ for (const f of list) { await seek(f.t); await draw(bg, big.width, big.height); out.push({ t: Math.round(f.t * 1000), why: f.why, data: big.toDataURL('image/webp', 0.8) }) }
123
145
  return out
124
146
  }, { src, taps, max, maxW })
147
+ if (cacheFile && frames.length) await writeJson(cacheFile, frames)
148
+ return frames
125
149
  }
126
150
 
127
151
  /** Frames in requests of MAX_TRIAGE_IMAGES, each overlapping the previous by one frame so a change at the seam is seen. */