kaloko 0.20.1 → 0.21.1

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.20.1",
3
+ "version": "0.21.1",
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",
@@ -998,7 +998,7 @@
998
998
  },
999
999
  "full_page": {
1000
1000
  "type": "boolean",
1001
- "description": "Screenshot of the whole page (default true; false for stack pages: the first screen only)"
1001
+ "description": "Screenshot of the whole page (default true, stack pages too); false keeps the first screen only"
1002
1002
  },
1003
1003
  "screenshot": {
1004
1004
  "enum": ["full", "thumb"],
@@ -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.
@@ -59,7 +59,7 @@ step script only to prepare each page, for example to dismiss a dialog
59
59
  - { id: author, label: Author, from: .author }
60
60
  - { id: cover, label: Cover, from: "article img@src" }
61
61
  - { id: links, label: Links, from: "count:main a" }
62
- # capture: { full_page: true } # default for stacks is the first screen only
62
+ # capture: { screenshot: thumb, full_page: false } # whole pages by default; this is the smallest
63
63
  criteria:
64
64
  - { id: AC1, text: Has a cover image, check: { type: deterministic, assert: exists, selector: "article img" } }
65
65
  - { id: AC2, text: Reads as an article, check: { type: semantic, question: Does the page read as a complete magazine article? } }
@@ -253,20 +253,32 @@ stack, set:
253
253
  caps the calls of a whole evaluation (evaluation.md). Raise the sample only when the question
254
254
  really differs between pages, and tell the person what it costs
255
255
  (one call per page and viewport).
256
- - Stacks capture the first screen of each page (fast, small). Set
257
- `capture: { full_page: true }` when the criteria are about the whole page.
258
- - Walking takes about 1–2 s per page. For a first look, use
256
+ - Stacks capture the whole page, like every step (since 0.21.0; before, the
257
+ first screen only). Vision checks see what is below the fold, and so does a
258
+ reviewer. A typical long page then stores a screenshot of about 600 KB
259
+ (about 70 KB for the first screen) and a thumbnail of about 180 KB (about
260
+ 25 KB), because the thumbnail is as tall as the page.
261
+ - To keep a stack small, set on the step:
262
+ - `capture: { full_page: false }`: a sharp first screen of each page;
263
+ - `capture: { screenshot: thumb, full_page: false }`: the smallest, a
264
+ 640 px thumbnail of the first screen. Recommend it for big monitors
265
+ (SEO crawls, catalogues of thousands of pages) whose checks read the
266
+ page and do not need the picture below the fold.
267
+ - `capture: { screenshot: thumb }` alone keeps a thumbnail of the whole
268
+ page, still several times the first screen.
269
+ - Walking takes about 1–2 s per page, a little more for long pages captured
270
+ whole. For a first look, use
259
271
  `kaloko walk --limit 50`. Re-walk chosen pages with
260
272
  `--items clanek-7,clanek-12` (matches the id or the path; picked from
261
273
  every matching page, also outside this week's rotation, and such a
262
274
  partial walk leaves the rotation state alone).
263
275
  - A shared run holds at most 20000 files and 300 MB as stored (HTML and JSON
264
276
  travel gzip-compressed, about a tenth of their size on disk). Each page
265
- stores about four files, so a stack of a few thousand first-screen pages
266
- fits. For SEO crawls where the checks read the page, not the picture, set
267
- `capture: { screenshot: thumb }`: only the 640 px thumbnail is kept. Split
268
- larger catalogues by section (`match`) or walk a rotating sample
269
- (`rotate: week`).
277
+ stores about four files. Whole pages fill 300 MB at roughly 400 pages;
278
+ `screenshot: thumb, full_page: false` lets the 20000 files be the limit
279
+ (about 5000 pages). Split larger catalogues by section (`match`) or walk a
280
+ rotating sample (`rotate: week`). When a whole-page stack fills most of a
281
+ run near the limit, `kaloko share` names it and suggests these settings.
270
282
 
271
283
  ## Reading the results
272
284
 
package/src/cli.mjs CHANGED
@@ -48,6 +48,7 @@ import { login } from './lib/login.mjs'
48
48
  import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
49
49
  import { specCmd, driftCmd } from './lib/spec-cli.mjs'
50
50
  import { hashFiles, missingHashes, uploadRunFiles, PROGRESS_FILE, fmtBytes } from './lib/upload.mjs'
51
+ import { biggestSteps, stackSizeAdvice } from './lib/run-size.mjs'
51
52
  import { affectedFor, carryCaptures } from './lib/affected.mjs'
52
53
  import { formatAffected, selectedIds } from './lib/affected-core.mjs'
53
54
  import { healStep, formatHeal } from './lib/heal.mjs'
@@ -1178,14 +1179,6 @@ async function projectsWrite (sub, args) {
1178
1179
  if (sub !== 'sync' && slug === config.project && name && config.project_name && name !== config.project_name) say(`Note: kaloko.config.yml still says project_name: ${config.project_name}; change it there too, or kaloko projects sync would rename the project back.`)
1179
1180
  }
1180
1181
 
1181
- /** The steps that take most of a run, as stored: " Largest steps: products 212 MB (1 390 files), …" or ''. */
1182
- function biggestSteps (entries, n = 3) {
1183
- const by = new Map()
1184
- for (const e of entries) { const k = e.path.match(/^steps\/([^/]+)\//)?.[1]; if (!k) continue; const x = by.get(k) || { bytes: 0, files: 0 }; x.bytes += e.stored ?? e.bytes; x.files++; by.set(k, x) }
1185
- const top = [...by].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, n)
1186
- return top.length ? ` Largest steps: ${top.map(([k, x]) => `${k} ${fmtBytes(x.bytes)} (${x.files} files)`).join(', ')}.` : ''
1187
- }
1188
-
1189
1182
  const commands = {
1190
1183
  async init (args) {
1191
1184
  const cwd = process.cwd()
@@ -2245,7 +2238,10 @@ const commands = {
2245
2238
  const resumed = (await readJson(join(run.dir, PROGRESS_FILE), null).catch(() => null))?.run === manifest.id
2246
2239
  try {
2247
2240
  if (!u) throw new Error('usage unavailable')
2248
- if (total > u.limits.max_run_bytes) throw new UserError(`This run is ${fmtBytes(total)}; the limit per run is ${fmtBytes(u.limits.max_run_bytes)}.${biggestSteps(entries)} Capture fewer viewports or locales; for a stack, lower items.limit, walk a rotating sample (items.rotate: week) or keep thumbnails only (capture: { screenshot: thumb }). An admin can also ask for a higher limit.`)
2241
+ if (total > u.limits.max_run_bytes) throw new UserError(`This run is ${fmtBytes(total)}; the limit per run is ${fmtBytes(u.limits.max_run_bytes)}.${biggestSteps(entries)}${stackSizeAdvice({ entries, scenario, limit: u.limits.max_run_bytes, over: true })} Capture fewer viewports or locales; for a stack, lower items.limit, walk a rotating sample (items.rotate: week), keep the first screen only (capture: { full_page: false }) or, smallest, keep thumbnails of it only (capture: { screenshot: thumb, full_page: false }). An admin can also ask for a higher limit.`)
2242
+ // a stack captured whole (the default) that fills most of a run close to the limit: say so before the next walk grows it
2243
+ const near = stackSizeAdvice({ entries, scenario, limit: u.limits.max_run_bytes })
2244
+ if (near) say(near)
2249
2245
  if (u.usage.bytes + fresh > u.limits.quota_bytes) throw new UserError(`Storage quota would be exceeded: ${fmtBytes(u.usage.bytes)} used + ${fmtBytes(fresh)} > ${fmtBytes(u.limits.quota_bytes)}. Delete old runs in Settings or ask for a higher quota.`)
2250
2246
  const recBytes = entries.filter((e) => /\.rec\./.test(e.path)).reduce((n, e) => n + e.bytes, 0)
2251
2247
  say(`${resumed ? 'Resuming the interrupted share: ' : ''}${files.size} files, ${fmtBytes(total)}${recBytes ? ` (recordings ${fmtBytes(recBytes)})` : ''}${missing ? `; ${fmtBytes(fresh)} new, the rest already stored` : ''} (organization uses ${fmtBytes(u.usage.bytes)} of ${fmtBytes(u.limits.quota_bytes)})`)
@@ -88,8 +88,9 @@ export function captureOptions (scenario, node) {
88
88
  mask: [...new Set(masks.flatMap((m) => m.selectors))],
89
89
  maskAuto: masks.some((m) => m.auto),
90
90
  annotate: o.annotate === true,
91
- // stack pages default to the first screen: hundreds of full-page shots would swamp storage
92
- fullPage: o.full_page != null ? o.full_page !== false : node?.kind !== 'stack',
91
+ // the whole page by default, stack pages too: a review needs what is below the fold (full_page: false for the
92
+ // first screen only; screenshot: thumb when a crawl of thousands of pages needs no picture)
93
+ fullPage: o.full_page !== false,
93
94
  // screenshot: thumb (#150): only the card thumbnail is stored, as the step's image too (SEO crawls of thousands of
94
95
  // pages need the page facts, not a sharp picture of each)
95
96
  thumbOnly: o.screenshot === 'thumb'
@@ -138,9 +139,45 @@ export async function settle (page) {
138
139
  await page.evaluate(async (overlay) => {
139
140
  const open = [...document.querySelectorAll(overlay)].some((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 })
140
141
  if (open) return
141
- const step = innerHeight * 0.8
142
- for (let y = 0; y < document.documentElement.scrollHeight; y += step) { scrollTo(0, y); await new Promise((r) => setTimeout(r, 60)) }
143
- scrollTo(0, 0)
142
+ // what scrolling changes on the fixed and sticky parts (a header that floats, a search panel that fades in):
143
+ // the capture waits until they are back as they were at the top, or until they stopped changing for 1 s (a
144
+ // widget revealed on scroll for good), at most 3 s
145
+ // fixed, sticky or inside such a part; asked once per element (the observer sees every class change of the page)
146
+ const floats = new WeakMap()
147
+ const floating = (el) => {
148
+ if (!el || el === document.body || el === document.documentElement) return false
149
+ if (!floats.has(el)) { const p = getComputedStyle(el).position; floats.set(el, p === 'fixed' || p === 'sticky' || floating(el.parentElement)) }
150
+ return floats.get(el)
151
+ }
152
+ const before = new Map()
153
+ let changed = performance.now()
154
+ const mo = new MutationObserver((list) => {
155
+ for (const m of list) {
156
+ const el = m.target
157
+ if (!(el === document.documentElement || el === document.body || floating(el))) continue
158
+ changed = performance.now()
159
+ const key = `${m.attributeName}`
160
+ const seen = before.get(el) || before.set(el, {}).get(el)
161
+ if (!(key in seen)) seen[key] = m.oldValue
162
+ }
163
+ })
164
+ // an empty class or style attribute looks the same as none
165
+ const same = (name, a, b) => name === 'class' || name === 'style' ? (a || '') === (b || '') : a === b
166
+ try {
167
+ mo.observe(document.documentElement, { subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ['class', 'style', 'hidden', 'aria-expanded'] })
168
+ const step = innerHeight * 0.8
169
+ for (let y = 0; y < document.documentElement.scrollHeight; y += step) { scrollTo(0, y); await new Promise((r) => setTimeout(r, 60)) }
170
+ scrollTo(0, 0)
171
+ const start = performance.now()
172
+ // the quiet second counts from the return to the top: a header that changed once on the first scroll and goes
173
+ // back a moment after the page is at the top again is not quiet yet
174
+ changed = Math.max(changed, start)
175
+ const back = () => [...before].every(([el, attrs]) => Object.entries(attrs).every(([name, value]) => same(name, el.getAttribute(name), value)))
176
+ // only what moves on the fixed and sticky parts: a carousel or spinner elsewhere does not hold the capture
177
+ const moving = () => (document.getAnimations?.() || []).some((a) => a.playState === 'running' && a.effect?.getComputedTiming?.().iterations !== Infinity && floating(a.effect?.target))
178
+ const quiet = () => performance.now() - changed >= 1000
179
+ while (performance.now() - start < 3000 && (moving() || !(back() || quiet()))) await new Promise((r) => setTimeout(r, 50))
180
+ } finally { mo.disconnect() }
144
181
  }, OVERLAY)
145
182
  } catch {}
146
183
  await page.waitForTimeout(250)
@@ -229,18 +266,21 @@ export async function captureStep ({ page, node, locale, viewports, run, console
229
266
  // (or 2× for documentation), so the canvas, boxes and diffs work the same for every device
230
267
  const dpr = await page.evaluate(() => devicePixelRatio).catch(() => 1)
231
268
  let shotScale = opts.scale
269
+ // a full-page capture resizes the page; resize handlers restart CSS transitions (a panel fading out again), and
270
+ // the image would catch them halfway. Transitions end at once and finite animations finish for the capture
232
271
  if (opts.scale === 2 && engine === 'chromium') {
233
272
  const cdp = await page.context().newCDPSession(page)
273
+ const still = await page.addStyleTag({ content: '*, *::before, *::after { transition: none !important }' }).catch(() => null)
234
274
  try {
235
275
  const doc = await page.evaluate(() => ({ w: Math.ceil(document.documentElement.scrollWidth), h: Math.ceil(document.documentElement.scrollHeight) }))
236
276
  const r = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: opts.fullPage, clip: { x: 0, y: 0, width: opts.fullPage ? doc.w : width, height: opts.fullPage ? doc.h : height, scale: 2 / (dpr || 1) } })
237
277
  shot = Buffer.from(r.data, 'base64')
238
- } finally { await cdp.detach().catch(() => {}) }
278
+ } finally { await cdp.detach().catch(() => {}); await still?.evaluate((el) => el.remove()).catch(() => {}) }
239
279
  } else {
240
280
  // WebKit and Firefox have no 2× capture of their own: 2× when the context renders at 2× or more, else 1×
241
281
  // (exactly 2×: a 3× or 2.625× device would give an image the masks and marks are not drawn for)
242
282
  if (opts.scale === 2 && dpr !== 2) shotScale = 1
243
- shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css' })
283
+ shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css', animations: 'disabled' })
244
284
  }
245
285
  // secrets the CLI used (gate, accounts) never land in a capture, even if the page echoes them
246
286
  await writeFile(html, redact(maskHtml(await page.content(), masking?.attrs)))
@@ -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
 
@@ -0,0 +1,45 @@
1
+ // What takes the room in a run before `kaloko share` sends it: the largest steps, and the advice for a stack whose
2
+ // pages are captured whole (the default since 0.21.0) when it fills most of the per-run limit.
3
+ import { fmtBytes } from './upload.mjs'
4
+ import { captureOptions } from './capture.mjs'
5
+
6
+ /** Bytes and files per step, as stored (entries from hashFiles: { path, bytes, stored? }), largest first. */
7
+ export function stepSizes (entries) {
8
+ const by = new Map()
9
+ for (const e of entries) {
10
+ const k = e.path.match(/^steps\/([^/]+)\//)?.[1]
11
+ if (!k) continue
12
+ const x = by.get(k) || { bytes: 0, files: 0 }
13
+ x.bytes += e.stored ?? e.bytes; x.files++
14
+ by.set(k, x)
15
+ }
16
+ return [...by].sort((a, b) => b[1].bytes - a[1].bytes)
17
+ }
18
+
19
+ /** The steps that take most of a run: " Largest steps: products 212 MB (1 390 files), …" or ''. */
20
+ export function biggestSteps (entries, n = 3) {
21
+ const top = stepSizes(entries).slice(0, n)
22
+ return top.length ? ` Largest steps: ${top.map(([k, x]) => `${k} ${fmtBytes(x.bytes)} (${x.files} files)`).join(', ')}.` : ''
23
+ }
24
+
25
+ /** Stack steps of the scenario whose pages are captured whole and kept sharp (no full_page: false, no thumb). */
26
+ export function wholePageStacks (scenario) {
27
+ return new Set((scenario?.nodes || []).filter((n) => n.kind === 'stack').filter((n) => { const o = captureOptions(scenario, n); return o.fullPage && !o.thumbOnly }).map((n) => n.id))
28
+ }
29
+
30
+ const SMALL = 'capture: { full_page: false } keeps the first screen of each page, capture: { screenshot: thumb, full_page: false } is the smallest'
31
+
32
+ /**
33
+ * Advice when a whole-page stack makes up most of a run that is near or over the limit, or ''.
34
+ * `over` words it for the refusal (appended to it); otherwise it is a warning of its own.
35
+ */
36
+ export function stackSizeAdvice ({ entries, scenario, limit, over = false }) {
37
+ const total = entries.reduce((n, e) => n + (e.stored ?? e.bytes), 0)
38
+ if (!limit || total < limit * 0.8) return ''
39
+ const whole = wholePageStacks(scenario)
40
+ const hit = stepSizes(entries).find(([k, x]) => whole.has(k) && x.bytes > total / 2)
41
+ if (!hit) return ''
42
+ const [id, x] = hit
43
+ if (over) return ` The stack ${id} is ${fmtBytes(x.bytes)} of it because each page is captured whole.`
44
+ return `This run is ${fmtBytes(total)} of the ${fmtBytes(limit)} allowed per run, and the stack ${id} is ${fmtBytes(x.bytes)} of it because each page is captured whole. If its pictures below the fold are not needed, ${SMALL}.`
45
+ }
@@ -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. */
package/viewer/index.html CHANGED
@@ -2136,7 +2136,7 @@ function hlLocal () {
2136
2136
  return { checks, counts, findings, legacy: !!RUN.health_legacy };
2137
2137
  }
2138
2138
  /** The step's health checks that apply here, and how many of them are findings in this capture. */
2139
- function hlStepList (n) { return n.item || n.kind === 'decision' ? [] : (n.health || []).filter((c) => !c.not_applicable); }
2139
+ function hlStepList (n) { return n.kind === 'decision' ? [] : (n.health || []).filter((c) => !c.not_applicable); }
2140
2140
  const hlCellSev = (c, v) => hlSev((c.by_locale?.[S.loc]?.[v.name] || (c.counts ? c : {})).verdict, c);
2141
2141
  const hlStepBad = (n, v) => hlStepList(n).filter((c) => hlCellSev(c, v)).length;
2142
2142
  // the service's answer adds what changed since the previous run, the adoptions and whether this person may adopt
@@ -2203,11 +2203,11 @@ function hlRow (n, v, cap, c) {
2203
2203
  const st = cellSt(r);
2204
2204
  const sev = hlSev(r.verdict, c);
2205
2205
  const hasBox = (cap?.boxes?.[c.id] || []).some((b) => b.w > 0 && b.h > 0);
2206
- const ad = hlAdoption(n.id, c.id);
2206
+ const ad = hlAdoption(n.stack || n.id, c.id);
2207
2207
  // a cross-page check of a stack (#151) compares pages with each other: no single page can adopt it
2208
2208
  const adopt = c.cross_page ? '' : ad ? `<p class="note">${esc(T(ad.applied_at ? 'hlApplied' : 'hlAdopted')(ad.criterion?.id || hlAcId(c.id)))}</p>`
2209
2209
  : hlCanAdopt() ? `<div data-hl-adopt-box="${esc(c.id)}"><button type="button" class="btn" data-hl-adopt="${esc(c.id)}">${T('hlAdopt')}</button></div>`
2210
- : !APP() ? `<details class="fixhelp"><summary>${T('hlAdopt')}</summary><p>${esc(T('hlAdoptLocal')(hlAcId(c.id)))}</p><code class="ev hl-cmd">kaloko health adopt ${esc(n.id)}:${esc(c.id)} --text "${esc(c.text)}"</code></details>` : '';
2210
+ : !APP() ? `<details class="fixhelp"><summary>${T('hlAdopt')}</summary><p>${esc(T('hlAdoptLocal')(hlAcId(c.id)))}</p><code class="ev hl-cmd">kaloko health adopt ${esc(n.stack || n.id)}:${esc(c.id)} --text "${esc(c.text)}"</code></details>` : '';
2211
2211
  return `<li class="st-${st}" data-hl="${esc(c.id)}" data-where="${esc(hasBox ? 'page' : c.target?.kind || '')}" tabindex="0"><span class="mark">${MARK[st]}</span><span><span class="id">${esc(c.id)}</span>${esc(critText(c))}${sev ? `<span class="hl-sev hl-sev--${sev}">${T('hls_' + sev)}</span>` : ''}${c.pack ? `<span class="acb st-skip" style="margin-left:6px">${esc(c.pack)}</span>` : ''}</span><div class="acbody">${r.evidence ? (humanEvidence(r.evidence) ? `<span class="ev ev--text">${esc(humanEvidence(r.evidence))}</span>` : `<code class="ev">${esc(r.evidence)}</code>`) : r.reason ? `<span class="ev ev--text">${esc(r.reason)}</span>` : ''}${c.urls?.length && sev ? `<p class="note hl-urls">${c.urls.map(esc).join(', ')}</p>` : ''}${isFocusCheck(c) ? focusStrip(n, v, cap) : ''}${helpBlock(c, st)}${adopt}</div></li>`;
2212
2212
  }
2213
2213
  /** The step's Health tab: its health checks, findings first, each with its evidence, help and Adopt. */
@@ -2257,13 +2257,15 @@ document.addEventListener('submit', async (e) => {
2257
2257
  const text = f.querySelector('textarea').value.replace(/\s+/g, ' ').trim();
2258
2258
  if (!text) { f.querySelector('textarea').focus(); return; }
2259
2259
  const btn = f.querySelector('[type=submit]'); btn.disabled = true;
2260
+ // a page of a stack adopts for its stack step
2261
+ const step = n.stack || n.id;
2260
2262
  try {
2261
- const res = await fetch(`${APP().api}/orgs/${APP().org}/runs/${APP().run}/health/adopt`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ step: n.id, check: c.id, ...(text !== c.text ? { text } : {}) }) });
2263
+ const res = await fetch(`${APP().api}/orgs/${APP().org}/runs/${APP().run}/health/adopt`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ step, check: c.id, ...(text !== c.text ? { text } : {}) }) });
2262
2264
  if (res.status === 401) throw sessionLost();
2263
2265
  const data = await res.json().catch(() => ({}));
2264
2266
  if (!res.ok) { toast(data.error || `HTTP ${res.status}`); btn.disabled = false; return; }
2265
2267
  const ad = data.adoption || data;
2266
- S.hl ||= { adoptions: [] }; S.hl.adoptions = [...(S.hl.adoptions || []).filter((a) => !(a.step === n.id && hlId(a.check) === c.id)), { id: ad.id, step: n.id, check: c.id, text: ad.text || text, criterion: ad.criterion || null, applied_at: ad.applied_at || null }];
2268
+ S.hl ||= { adoptions: [] }; S.hl.adoptions = [...(S.hl.adoptions || []).filter((a) => !(a.step === step && hlId(a.check) === c.id)), { id: ad.id, step, check: c.id, text: ad.text || text, criterion: ad.criterion || null, applied_at: ad.applied_at || null }];
2267
2269
  toast(T('hlAdopted')(ad.criterion?.id || hlAcId(c.id)));
2268
2270
  openLb(S.open, S.lbVp, { keepFrame: true });
2269
2271
  } catch (err) { toast(err.message); btn.disabled = false; }
@@ -2512,9 +2514,20 @@ function ensureItemNode (n, data, it) {
2512
2514
  const crit = (data.criteria || n.criteria).map((c) => {
2513
2515
  const by_locale = {};
2514
2516
  for (const [key, res] of Object.entries(it.results || {})) { const [l, vp] = key.split('|'); const r = res[c.id]; if (r) ((by_locale[l] ||= {})[vp] = { verdict: r[0], evidence: r[1], score: r[2] }); }
2515
- return { id: c.id, text: c.text, type: c.type, pack: c.pack || null, by_locale, evidence: null };
2517
+ return { id: c.id, text: c.text, type: c.type, pack: c.pack || null, ...(c.level ? { level: c.level } : {}), ...(c.acceptance ? { acceptance: true } : {}), ...(c.health ? { health: true } : {}), by_locale, evidence: null };
2516
2518
  }).filter((c) => Object.keys(c.by_locale).length);
2517
- const node = { id, item: true, itemId: it.id, stack: n.id, kind: 'screen', title: it.title || it.path, path: it.path, edit_url: it.edit_url || null, content_ref: it.content_ref || null, purpose: n.purpose, captures: it.captures || {}, criteria: crit, by_locale: it.by_locale || {}, status: it.status, attachments: it.attachments || [], semantic: {}, metadata: {}, execution: n.execution, session: n.session };
2519
+ // the page's health checks get the Health tab, as on a step: kaloko marks them (health: true);
2520
+ // runs from before the mark tell them by the pack outside acceptance or by the HC id
2521
+ const marked = (data.criteria || n.criteria).some((c) => 'health' in c);
2522
+ const isHealth = (c) => marked ? !!c.health : hlLegacyCrit(c) || /^HC[0-9]/.test(c.id);
2523
+ const captures = it.captures || {};
2524
+ const health = crit.filter(isHealth).map((c) => {
2525
+ const hid = hlId(c.id); if (hid === c.id) return c;
2526
+ // a run from before the split names the check AC…: its highlight boxes answer to the HC id too
2527
+ for (const vps of Object.values(captures)) for (const cap of Object.values(vps || {})) if (cap?.boxes?.[c.id] && !cap.boxes[hid]) cap.boxes[hid] = cap.boxes[c.id];
2528
+ return { ...c, sid: c.id, id: hid };
2529
+ });
2530
+ const node = { id, item: true, itemId: it.id, stack: n.id, kind: 'screen', title: it.title || it.path, path: it.path, edit_url: it.edit_url || null, content_ref: it.content_ref || null, purpose: n.purpose, captures, criteria: crit.filter((c) => !isHealth(c)), health, by_locale: it.by_locale || {}, status: it.status, attachments: it.attachments || [], semantic: {}, metadata: {}, execution: n.execution, session: n.session };
2518
2531
  ITEM_NODES.set(id, node);
2519
2532
  return node;
2520
2533
  }