kaloko 0.20.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schemas/scenario.v1.json +1 -1
- package/skills/kaloko/reference/stacks.md +21 -9
- package/src/cli.mjs +5 -9
- package/src/lib/capture.mjs +47 -7
- package/src/lib/run-size.mjs +45 -0
- package/viewer/index.html +20 -7
package/package.json
CHANGED
package/schemas/scenario.v1.json
CHANGED
|
@@ -998,7 +998,7 @@
|
|
|
998
998
|
},
|
|
999
999
|
"full_page": {
|
|
1000
1000
|
"type": "boolean",
|
|
1001
|
-
"description": "Screenshot of the whole page (default true; false
|
|
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"],
|
|
@@ -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:
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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)})`)
|
package/src/lib/capture.mjs
CHANGED
|
@@ -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
|
|
92
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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)))
|
|
@@ -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
|
+
}
|
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.
|
|
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
|
|
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 ===
|
|
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
|
-
|
|
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
|
}
|