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 +1 -1
- package/skills/kaloko/reference/integrations.md +6 -3
- package/src/cli.mjs +27 -10
- package/src/lib/access.mjs +1 -1
- package/src/lib/browser-daemon.mjs +24 -4
- package/src/lib/capture.mjs +9 -5
- package/src/lib/console.mjs +77 -0
- package/src/lib/doctor.mjs +3 -0
- package/src/lib/evaluate.mjs +1 -1
- package/src/lib/health-findings.mjs +68 -7
- package/src/lib/health-index.mjs +13 -7
- package/src/lib/triage.mjs +31 -7
package/package.json
CHANGED
|
@@ -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
|
|
106
|
-
(`PUT …/findings/sync { tracker, min_severity
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/lib/access.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
page.on('
|
|
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)
|
package/src/lib/capture.mjs
CHANGED
|
@@ -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
|
|
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
|
|
234
|
-
const consoleEntries = (collected
|
|
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
|
package/src/lib/console.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/doctor.mjs
CHANGED
|
@@ -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.') }
|
package/src/lib/evaluate.mjs
CHANGED
|
@@ -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)
|
|
49
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/health-index.mjs
CHANGED
|
@@ -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
|
|
package/src/lib/triage.mjs
CHANGED
|
@@ -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
|
|
88
|
-
|
|
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
|
|
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
|
|
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. */
|