kaloko 0.20.0 → 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 +33 -12
- package/src/cli.mjs +10 -13
- package/src/lib/capture.mjs +47 -7
- package/src/lib/run-size.mjs +45 -0
- package/src/lib/scenario.mjs +0 -1
- package/src/lib/stack.mjs +96 -59
- package/src/lib/variants-core.mjs +88 -5
- 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? } }
|
|
@@ -80,6 +80,12 @@ Item sources:
|
|
|
80
80
|
`sitemap-de.xml`; `en_US` finds `en-us`, `en` does not), only that
|
|
81
81
|
locale's sitemaps are read; otherwise all of them. Use it when a site
|
|
82
82
|
lists one sitemap per language, so the scenario does not hard-code paths.
|
|
83
|
+
- `kaloko walk` reads robots.txt and sitemaps with the environment's gate
|
|
84
|
+
(headers, HTTP Basic, client certificates) and the walk's cookies, on the
|
|
85
|
+
environment's own origins only, so a staging behind Basic auth or mTLS
|
|
86
|
+
needs no public sitemap. A sitemap on another host (a CDN, an index
|
|
87
|
+
pointing elsewhere) or a redirect to another host gets none of them.
|
|
88
|
+
Redirects are followed by hand (at most 5); 60 s and 60 MB per file.
|
|
83
89
|
- `crawl: { from: /clanky, links: "a.card", next: "a.next", pages: 20 }`
|
|
84
90
|
collects links from a listing and follows its pagination.
|
|
85
91
|
- `script: qa/steps/items.mjs` uses the default export
|
|
@@ -229,9 +235,12 @@ stack, set:
|
|
|
229
235
|
locale).
|
|
230
236
|
- A script that lists pages per locale instead: give each page the same
|
|
231
237
|
`key` in every locale (for example the source path), so they are one item.
|
|
232
|
-
- Hosted runs (`kaloko schedule add`)
|
|
233
|
-
|
|
234
|
-
|
|
238
|
+
- Hosted runs (`kaloko schedule add`) group variants the same way: same
|
|
239
|
+
ids, window, `missing` and HC1005. They read each source page's head for
|
|
240
|
+
its hreflang on the environment's own addresses (with the schedule's
|
|
241
|
+
password or cookies), and each read counts as one hosted page of the
|
|
242
|
+
monthly budget. Budget a stack of N source pages in L locales as
|
|
243
|
+
N reads + N × L pages at most.
|
|
235
244
|
|
|
236
245
|
## Cost and size: choose deliberately
|
|
237
246
|
|
|
@@ -244,20 +253,32 @@ stack, set:
|
|
|
244
253
|
caps the calls of a whole evaluation (evaluation.md). Raise the sample only when the question
|
|
245
254
|
really differs between pages, and tell the person what it costs
|
|
246
255
|
(one call per page and viewport).
|
|
247
|
-
- Stacks capture the
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
250
271
|
`kaloko walk --limit 50`. Re-walk chosen pages with
|
|
251
272
|
`--items clanek-7,clanek-12` (matches the id or the path; picked from
|
|
252
273
|
every matching page, also outside this week's rotation, and such a
|
|
253
274
|
partial walk leaves the rotation state alone).
|
|
254
275
|
- A shared run holds at most 20000 files and 300 MB as stored (HTML and JSON
|
|
255
276
|
travel gzip-compressed, about a tenth of their size on disk). Each page
|
|
256
|
-
stores about four files
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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.
|
|
261
282
|
|
|
262
283
|
## Reading the results
|
|
263
284
|
|
package/src/cli.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import { detectProject, initScenario, skeletonScenario, scenarioHeader, formatDe
|
|
|
33
33
|
import { checkSlug, checkName, setConfigKeys, createProject, renameProject, syncProject, shareProjectNote } from './lib/projects-cli.mjs'
|
|
34
34
|
import { recheckCaptures } from './lib/recheck.mjs'
|
|
35
35
|
import { runFindings, findingsText } from './lib/findings.mjs'
|
|
36
|
-
import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage, sourceLocaleOf,
|
|
36
|
+
import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage, sourceLocaleOf, stackReader, readsOutside } from './lib/stack.mjs'
|
|
37
37
|
import { walkSiteChecks, siteChecksOn } from './lib/site.mjs'
|
|
38
38
|
import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
|
|
39
39
|
import { expandPacks } from './lib/packs.mjs'
|
|
@@ -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'
|
|
@@ -818,8 +819,9 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
|
|
|
818
819
|
const sourceLocale = node.items?.variants ? sourceLocaleOf(node, scenario, locale) : locale
|
|
819
820
|
const counter = state && node.items.variants && node.items.rotate === 'run' ? await rotationState(statePath('variants'), run.meta.id) : null
|
|
820
821
|
const round = counter ? counter.run : state?.run || 0
|
|
821
|
-
//
|
|
822
|
-
|
|
822
|
+
// sitemaps, robots.txt and the hreflang of source pages are read with the environment's access (stackReader): its
|
|
823
|
+
// gate and the walk's cookies on its own origins only
|
|
824
|
+
const reader = readsOutside(node.items) ? await stackReader({ page, gate, origins: envOrigins(env, scenario, locale) }) : null
|
|
823
825
|
let resolved
|
|
824
826
|
try { resolved = await resolveItems({ node, base: baseUrl(env, locale), sourceBase: baseUrl(env, sourceLocale), page, scenarioDir, env, locale, scenario, limit: Number(args.limit) || undefined, rotation: { run: round }, checked: state?.checked || null, only: list(args.items), cache, readPage: reader?.read || null }) } finally { await reader?.dispose() }
|
|
825
827
|
const { items, total, truncated, all, rotation, partial, variants, resolve_failed: unresolved, resolve_error: unresolvedWhy } = resolved
|
|
@@ -1177,14 +1179,6 @@ async function projectsWrite (sub, args) {
|
|
|
1177
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.`)
|
|
1178
1180
|
}
|
|
1179
1181
|
|
|
1180
|
-
/** The steps that take most of a run, as stored: " Largest steps: products 212 MB (1 390 files), …" or ''. */
|
|
1181
|
-
function biggestSteps (entries, n = 3) {
|
|
1182
|
-
const by = new Map()
|
|
1183
|
-
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) }
|
|
1184
|
-
const top = [...by].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, n)
|
|
1185
|
-
return top.length ? ` Largest steps: ${top.map(([k, x]) => `${k} ${fmtBytes(x.bytes)} (${x.files} files)`).join(', ')}.` : ''
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
1182
|
const commands = {
|
|
1189
1183
|
async init (args) {
|
|
1190
1184
|
const cwd = process.cwd()
|
|
@@ -1610,7 +1604,7 @@ const commands = {
|
|
|
1610
1604
|
const pick = list(args.steps)
|
|
1611
1605
|
// stacks with locale variants read their source list and each page's hreflang once per walk, for every locale
|
|
1612
1606
|
const stackCache = new Map()
|
|
1613
|
-
const stackGate = await gateForRequests(env, { root }) // the gate for requests outside the browser: link checks,
|
|
1607
|
+
const stackGate = await gateForRequests(env, { root }) // the gate for requests outside the browser: link checks, stack sitemaps and hreflang
|
|
1614
1608
|
const locales = list(args.locales) || run.meta.scope.locales
|
|
1615
1609
|
const scope = run.meta.scope
|
|
1616
1610
|
// browser engines and device profiles (batch E): one pass each, capturing only its own keys; a run without
|
|
@@ -2244,7 +2238,10 @@ const commands = {
|
|
|
2244
2238
|
const resumed = (await readJson(join(run.dir, PROGRESS_FILE), null).catch(() => null))?.run === manifest.id
|
|
2245
2239
|
try {
|
|
2246
2240
|
if (!u) throw new Error('usage unavailable')
|
|
2247
|
-
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)
|
|
2248
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.`)
|
|
2249
2246
|
const recBytes = entries.filter((e) => /\.rec\./.test(e.path)).reduce((n, e) => n + e.bytes, 0)
|
|
2250
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/src/lib/scenario.mjs
CHANGED
|
@@ -120,7 +120,6 @@ export function scenarioWarnings (data, { projectRequired = null } = {}) {
|
|
|
120
120
|
const out = []
|
|
121
121
|
if (!data || !Array.isArray(data.nodes)) return out
|
|
122
122
|
for (const node of data.nodes) {
|
|
123
|
-
if (node.kind === 'stack' && node.items?.variants) out.push(`node "${node.id}": hosted runs (kaloko schedule add) do not group locale variants yet: there each language reads its own list and missing translations (HC1005) are not reported; kaloko walk and the scheduled GitHub workflow do`)
|
|
124
123
|
if (node.kind === 'stack' && node.items?.variants && (data.locales || []).length < 2) out.push(`node "${node.id}": items.variants: hreflang groups the language versions of a page, but the scenario walks ${(data.locales || []).length ? `one locale (${data.locales[0]})` : 'no locales'}; list the other locales under locales:`)
|
|
125
124
|
if (node.kind === 'stack' && (node.packs || []).includes('errors')) out.push(`node "${node.id}": the errors pack is meant for error pages (404, 500, maintenance); on a stack of normal pages HC601 and HC605 stay not applicable. Remove it, or give the step expect_status when its pages are error pages`)
|
|
126
125
|
}
|
package/src/lib/stack.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { pathToFileURL } from 'node:url'
|
|
|
8
8
|
import { gunzipSync } from 'node:zlib'
|
|
9
9
|
import { UserError, localized, readJson, writeJson } from './util.mjs'
|
|
10
10
|
import { STACK_DEFAULT_FIELDS, STACK_SEMANTIC_SAMPLE, stackFieldsOf } from './manifest-core.mjs'
|
|
11
|
-
import { variantItems,
|
|
11
|
+
import { variantItems, sitemapAlternates, pageAlternates, readHeadFrom } from './variants-core.mjs'
|
|
12
12
|
import { DEFAULT_LIMIT, SITEMAP_MAX_BYTES, SITEMAP_MAX_CHILDREN, ROTATE_MODES, itemId, sitemapLocs, robotsSitemaps, sitemapsForLocale, handoffOf, isoWeek, rotateWindow, patternFor as corePatternFor, withLocale, sitemapPages, selectItems } from './stack-core.mjs'
|
|
13
13
|
|
|
14
14
|
/** How long items.resolve may take for one page (ms) before the page is walked without its edit link. */
|
|
@@ -94,7 +94,8 @@ export function rotationCoverage (all, checked) {
|
|
|
94
94
|
* source page; `checked` and `only` use it). `variants` in the result: { mode, source_locale, locale, pages,
|
|
95
95
|
* missing: [{ id, path, url, reason }], shared: [{ url, ids, paths }] } (`source: true` in the source locale). `cache`
|
|
96
96
|
* (a Map the walk keeps for all its locales) reads the source list and each page's alternates once; `readPage` (a
|
|
97
|
-
*
|
|
97
|
+
* stackReader's read: the environment's gate and the walk's cookies) reads the sitemaps, robots.txt and the pages,
|
|
98
|
+
* else a plain pageReader over `fetchImpl`.
|
|
98
99
|
*/
|
|
99
100
|
export async function resolveItems ({ node, base, page, scenarioDir, env, locale, limit, fetchImpl = fetch, rotation = {}, checked = null, scenario = null, only = null, resolveTimeout = RESOLVE_TIMEOUT, sourceBase = null, cache = null, readPage = null }) {
|
|
100
101
|
const memo = cache || new Map()
|
|
@@ -111,7 +112,7 @@ export async function resolveItems ({ node, base, page, scenarioDir, env, locale
|
|
|
111
112
|
const listLocale = mapped ? sourceLocale : locale
|
|
112
113
|
const listBase = mapped ? (sourceBase || base) : base
|
|
113
114
|
const listSrc = mapped ? withLocale(node.items || {}, listLocale) : src
|
|
114
|
-
const read = () => sourceEntries({ src: listSrc, locale: listLocale, base: listBase, page, scenarioDir, env, info: infoFor(listLocale), fetchImpl, withAlternates: variants })
|
|
115
|
+
const read = () => sourceEntries({ src: listSrc, locale: listLocale, base: listBase, page, scenarioDir, env, info: infoFor(listLocale), fetchImpl, readSource: readPage, withAlternates: variants })
|
|
115
116
|
const { raw, alternates } = variants ? await cached(memo, `list|${scenario?.id || ''}|${node.id}|${listLocale}|${listBase}`, read) : await read()
|
|
116
117
|
// 1. and 2.: match, exclude, deduplication, the window (stack-core.mjs, shared with hosted runs); with variants in
|
|
117
118
|
// the source locale, so every locale walks the same window with the same ids
|
|
@@ -146,7 +147,7 @@ const urlKey = (u) => { try { return new URL(u).href } catch { return String(u)
|
|
|
146
147
|
* { raw: [{ path, title?, key?, purpose?, edit_url?, content_ref? }], alternates: Map(url → the sitemap's xhtml:link
|
|
147
148
|
* alternates of that page, read when `withAlternates`) }.
|
|
148
149
|
*/
|
|
149
|
-
async function sourceEntries ({ src, locale, base, page, scenarioDir, env, info, fetchImpl, withAlternates = false }) {
|
|
150
|
+
async function sourceEntries ({ src, locale, base, page, scenarioDir, env, info, fetchImpl, readSource = null, withAlternates = false }) {
|
|
150
151
|
const origin = new URL(base).origin
|
|
151
152
|
const abs = (p) => new URL(p, base.endsWith('/') ? base : base + '/').href.replace(/^(https?:\/\/[^/]+)\/\//, '$1/')
|
|
152
153
|
const toUrl = (p) => (/^https?:\/\//.test(p) ? p : new URL(p, origin).href)
|
|
@@ -154,11 +155,15 @@ async function sourceEntries ({ src, locale, base, page, scenarioDir, env, info,
|
|
|
154
155
|
if (src.urls) raw = src.urls.map((p) => ({ path: p }))
|
|
155
156
|
else if (src.file) raw = await listFile(resolve(scenarioDir, src.file))
|
|
156
157
|
else if (src.sitemap || src.robots) {
|
|
158
|
+
// through the environment's gate: its headers, Basic, client certificates and the walk's cookies on its own
|
|
159
|
+
// origins only; a sitemap on another host, or a redirect there, gets none of them
|
|
160
|
+
const reader = readSource || pageReader({ fetchImpl })
|
|
157
161
|
const get = async (u, what = 'items.sitemap') => {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
+
let r
|
|
163
|
+
try { r = await reader(u, { bytes: true }) } catch (e) { throw new UserError(`${what} ${u}: ${String(e?.message || e).split('\n')[0]}`) }
|
|
164
|
+
if (r.status < 200 || r.status >= 300) throw new UserError(`${what} ${u}: HTTP ${r.status}${r.url && r.url !== u ? ` (after a redirect to ${r.url})` : ''}`)
|
|
165
|
+
if (what !== 'items.sitemap') return r.body.toString('utf8')
|
|
166
|
+
const text = sitemapText(r.body, u)
|
|
162
167
|
// with locale variants the xhtml:link alternates of each <url> are kept for pages that name none themselves
|
|
163
168
|
if (withAlternates) for (const [k, v] of sitemapAlternates(text)) alternates.set(urlKey(k), v)
|
|
164
169
|
return text
|
|
@@ -206,57 +211,76 @@ export const ALTERNATES_MAX = 1024 * 1024
|
|
|
206
211
|
|
|
207
212
|
/**
|
|
208
213
|
* A pageReader over a Playwright request client (APIRequestContext), for gates with client certificates, which fetch
|
|
209
|
-
* cannot present: the client brings the certificates
|
|
210
|
-
* `origins`, and redirects are followed by hand (at most 5).
|
|
214
|
+
* cannot present: the client brings the certificates (Playwright presents each only to its origin); the gate's headers,
|
|
215
|
+
* HTTP Basic and the walk's cookies (`cookiesFor`) go only to `origins`, and redirects are followed by hand (at most 5).
|
|
216
|
+
* `want` as in pageReader (a sitemap: `{ bytes: true }`).
|
|
211
217
|
*/
|
|
212
|
-
export function contextReader (request, { gate = null, origins = [], timeout = ALTERNATES_TIMEOUT, max = ALTERNATES_MAX } = {}) {
|
|
213
|
-
return async (url) => {
|
|
218
|
+
export function contextReader (request, { gate = null, origins = [], cookiesFor = null, timeout = ALTERNATES_TIMEOUT, max = ALTERNATES_MAX } = {}) {
|
|
219
|
+
return async (url, want = {}) => {
|
|
220
|
+
const limit = want.max || (want.bytes ? SITEMAP_MAX_BYTES : max)
|
|
221
|
+
// one time limit for a sitemap with all its redirects, as with fetch; a page head has it per request
|
|
222
|
+
const deadline = want.bytes ? Date.now() + (want.timeout || SITEMAP_TIMEOUT) : null
|
|
214
223
|
let at = url
|
|
215
224
|
for (let hop = 0; hop <= 5; hop++) {
|
|
216
|
-
const
|
|
217
|
-
|
|
225
|
+
const left = deadline ? deadline - Date.now() : (want.timeout || timeout)
|
|
226
|
+
if (left <= 0) throw new Error(`no answer in ${Math.round((want.timeout || SITEMAP_TIMEOUT) / 1000)} s (timeout)`)
|
|
227
|
+
const headers = { ...readHeaders(want), ...gateHeadersFor(gate, at, origins) }
|
|
228
|
+
if (cookiesFor && origins.includes(new URL(at).origin)) { const c = await cookiesFor(at); if (c) headers.Cookie = c }
|
|
229
|
+
const r = await request.get(at, { headers, timeout: Math.max(1, left), maxRedirects: 0 })
|
|
218
230
|
const h = r.headers()
|
|
219
|
-
if (r.status() >= 300 && r.status() < 400 && h.location) { await r.dispose().catch(() => {}); at =
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
231
|
+
if (r.status() >= 300 && r.status() < 400 && h.location) { await r.dispose().catch(() => {}); at = nextHop(h.location, at); continue }
|
|
232
|
+
try {
|
|
233
|
+
if (!want.bytes) return { status: r.status(), url: at, link: h.link || null, html: r.ok() ? (await r.body()).subarray(0, limit).toString('utf8') : '' }
|
|
234
|
+
// the client reads the whole body before it answers: the limit is checked on what came
|
|
235
|
+
const body = r.ok() ? await r.body() : Buffer.alloc(0)
|
|
236
|
+
if (body.length > limit) throw new Error(`larger than ${Math.round(limit / 1048576)} MB`)
|
|
237
|
+
return { status: r.status(), url: at, link: h.link || null, body }
|
|
238
|
+
} finally { await r.dispose().catch(() => {}) }
|
|
223
239
|
}
|
|
224
240
|
throw new Error('more than 5 redirects')
|
|
225
241
|
}
|
|
226
242
|
}
|
|
227
243
|
|
|
228
|
-
/**
|
|
229
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Options of the request client stackReader makes for client certificates: the certificates only. The walk's cookies
|
|
246
|
+
* go as a header on the environment's own origins (contextReader), not into the client's jar, which would send a
|
|
247
|
+
* domain cookie to a sibling host after a redirect.
|
|
248
|
+
*/
|
|
249
|
+
export const variantRequestOptions = (gate) => ({ clientCertificates: gate.clientCertificates })
|
|
250
|
+
|
|
251
|
+
/** The next address of a redirect: http(s) only. */
|
|
252
|
+
const nextHop = (location, from) => {
|
|
253
|
+
const u = new URL(location, from)
|
|
254
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error(`a redirect to ${u.protocol} is not followed`)
|
|
255
|
+
return u.href
|
|
256
|
+
}
|
|
230
257
|
|
|
231
258
|
/**
|
|
232
|
-
* How a stack with locale variants
|
|
233
|
-
* headers and HTTP Basic, and the walk's cookies, only on `origins`. A gate with
|
|
234
|
-
* client of its own (`factory`: Playwright's `request`), made with the certificates
|
|
235
|
-
* the browser context of a shared browser (connected over CDP) has
|
|
259
|
+
* How a stack reads its sitemaps, robots.txt and (with locale variants) its source pages for their hreflang:
|
|
260
|
+
* { read, dispose }. With the gate's headers and HTTP Basic, and the walk's cookies, only on `origins`. A gate with
|
|
261
|
+
* client certificates gets a request client of its own (`factory`: Playwright's `request`), made with the certificates:
|
|
262
|
+
* the browser context of a shared browser (connected over CDP) has none, they live where the browser was started.
|
|
236
263
|
* `dispose` closes it after the reads.
|
|
237
264
|
*/
|
|
238
|
-
export async function
|
|
265
|
+
export async function stackReader ({ page = null, gate = null, origins = [], factory = null, fetchImpl = fetch }) {
|
|
266
|
+
const cookiesFor = page ? async (url) => (await page.context().cookies(url).catch(() => [])).map((c) => `${c.name}=${c.value}`).join('; ') : null
|
|
239
267
|
if (gate?.clientCertificates?.length) {
|
|
240
|
-
const cookies = page ? await page.context().cookies().catch(() => []) : []
|
|
241
268
|
const pw = factory || (await import('playwright')).request
|
|
242
|
-
const request = await pw.newContext(variantRequestOptions(gate
|
|
243
|
-
return { kind: 'certificates', read: contextReader(request, { gate, origins }), dispose: () => request.dispose().catch(() => {}) }
|
|
269
|
+
const request = await pw.newContext(variantRequestOptions(gate))
|
|
270
|
+
return { kind: 'certificates', read: contextReader(request, { gate, origins, cookiesFor }), dispose: () => request.dispose().catch(() => {}) }
|
|
244
271
|
}
|
|
245
|
-
const cookiesFor = page ? async (url) => (await page.context().cookies(url).catch(() => [])).map((c) => `${c.name}=${c.value}`).join('; ') : null
|
|
246
272
|
return { kind: 'fetch', read: pageReader({ fetchImpl, gate, origins, cookiesFor }), dispose: async () => {} }
|
|
247
273
|
}
|
|
274
|
+
/** The name of before, when only locale variants read through the gate. */
|
|
275
|
+
export const variantReader = stackReader
|
|
276
|
+
|
|
277
|
+
/** Whether a stack reads anything outside the browser: a sitemap, robots.txt or the hreflang of its pages. */
|
|
278
|
+
export const readsOutside = (items = {}) => !!(items.sitemap || items.robots || items.variants)
|
|
248
279
|
|
|
249
280
|
/** The head of a page as text: read until </head> (outside comments and scripts) or `max` bytes, then stopped. */
|
|
250
281
|
export async function readHead (r, max = ALTERNATES_MAX) {
|
|
251
282
|
if (!r.body?.getReader) return String(await r.text()).slice(0, max)
|
|
252
|
-
|
|
253
|
-
for (;;) {
|
|
254
|
-
const { done, value } = await reader.read()
|
|
255
|
-
if (done) break
|
|
256
|
-
bytes += value.length; text += dec.decode(value, { stream: true })
|
|
257
|
-
if (bytes >= max || /<\/head\s*>|<body[\s>]/i.test(stripInert(text))) { try { await reader.cancel() } catch {} break }
|
|
258
|
-
}
|
|
259
|
-
return text.slice(0, max)
|
|
283
|
+
return readHeadFrom(r.body.getReader(), max)
|
|
260
284
|
}
|
|
261
285
|
|
|
262
286
|
/**
|
|
@@ -272,21 +296,49 @@ export function gateHeadersFor (gate, url, origins = []) {
|
|
|
272
296
|
return h
|
|
273
297
|
}
|
|
274
298
|
|
|
299
|
+
/** How long reading one sitemap or robots.txt may take (ms); its size is capped by SITEMAP_MAX_BYTES. */
|
|
300
|
+
export const SITEMAP_TIMEOUT = 60000
|
|
301
|
+
|
|
302
|
+
const readHeaders = (want) => want.bytes
|
|
303
|
+
? { 'User-Agent': 'Kaloko', Accept: 'application/xml,text/xml,text/plain;q=0.9,*/*;q=0.8', 'Accept-Encoding': 'gzip' }
|
|
304
|
+
: { 'User-Agent': 'Kaloko', Accept: 'text/html,application/xhtml+xml' }
|
|
305
|
+
|
|
306
|
+
/** The whole body as bytes, at most `max` (else an error, the rest is not read). */
|
|
307
|
+
export async function readBytes (r, max = SITEMAP_MAX_BYTES) {
|
|
308
|
+
const big = () => new Error(`larger than ${Math.round(max / 1048576)} MB`)
|
|
309
|
+
if (!r.body?.getReader) { const b = Buffer.from(await r.arrayBuffer()); if (b.length > max) throw big(); return b }
|
|
310
|
+
const reader = r.body.getReader(); const parts = []; let bytes = 0
|
|
311
|
+
for (;;) {
|
|
312
|
+
const { done, value } = await reader.read()
|
|
313
|
+
if (done) break
|
|
314
|
+
bytes += value.length
|
|
315
|
+
if (bytes > max) { try { await reader.cancel() } catch {} throw big() }
|
|
316
|
+
parts.push(value)
|
|
317
|
+
}
|
|
318
|
+
return Buffer.concat(parts)
|
|
319
|
+
}
|
|
320
|
+
|
|
275
321
|
/**
|
|
276
322
|
* Reads a page for its hreflang: { status, url (after redirects), link (the Link header), html (the head) }. The
|
|
277
323
|
* gate (headers, Basic) and the walk's cookies (`cookiesFor(url)` → Cookie header) go only to `origins`; redirects
|
|
278
|
-
* are followed by hand (at most 5) so they never carry them elsewhere; each request has `timeout` ms.
|
|
324
|
+
* are followed by hand (at most 5) so they never carry them elsewhere; each request has `timeout` ms. `want.bytes`
|
|
325
|
+
* reads a sitemap or robots.txt instead: the whole body as `body` (bytes, at most SITEMAP_MAX_BYTES, all hops within
|
|
326
|
+
* SITEMAP_TIMEOUT ms; `want.max` and `want.timeout` change them).
|
|
279
327
|
*/
|
|
280
328
|
export function pageReader ({ fetchImpl = fetch, gate = null, origins = [], cookiesFor = null, timeout = ALTERNATES_TIMEOUT, max = ALTERNATES_MAX } = {}) {
|
|
281
|
-
return async (url) => {
|
|
329
|
+
return async (url, want = {}) => {
|
|
282
330
|
let at = url
|
|
331
|
+
const whole = want.bytes ? AbortSignal.timeout(want.timeout || SITEMAP_TIMEOUT) : null
|
|
283
332
|
for (let hop = 0; hop <= 5; hop++) {
|
|
284
|
-
const headers = {
|
|
333
|
+
const headers = { ...readHeaders(want), ...gateHeadersFor(gate, at, origins) }
|
|
285
334
|
if (cookiesFor && origins.includes(new URL(at).origin)) { const c = await cookiesFor(at); if (c) headers.Cookie = c }
|
|
286
|
-
const r = await fetchImpl(at, { redirect: 'manual', headers, signal: AbortSignal.timeout(timeout) })
|
|
335
|
+
const r = await fetchImpl(at, { redirect: 'manual', headers, signal: whole || AbortSignal.timeout(timeout) })
|
|
287
336
|
const loc = r.headers?.get?.('location')
|
|
288
|
-
if (r.status >= 300 && r.status < 400 && loc) { try { await r.body?.cancel() } catch {} at =
|
|
289
|
-
|
|
337
|
+
if (r.status >= 300 && r.status < 400 && loc) { try { await r.body?.cancel() } catch {} at = nextHop(loc, at); continue }
|
|
338
|
+
const link = r.headers?.get?.('link') || null
|
|
339
|
+
if (!want.bytes) return { status: r.status, url: at, link, html: r.ok ? await readHead(r, max) : '' }
|
|
340
|
+
if (!r.ok) { try { await r.body?.cancel() } catch {} return { status: r.status, url: at, link, body: Buffer.alloc(0) } }
|
|
341
|
+
return { status: r.status, url: at, link, body: await readBytes(r, want.max || SITEMAP_MAX_BYTES) }
|
|
290
342
|
}
|
|
291
343
|
throw new Error('more than 5 redirects')
|
|
292
344
|
}
|
|
@@ -298,22 +350,7 @@ export function pageReader ({ fetchImpl = fetch, gate = null, origins = [], cook
|
|
|
298
350
|
* a timeout or a 5xx is tried once more; a result with an error is not kept, so another locale asks again.
|
|
299
351
|
*/
|
|
300
352
|
export async function readAlternates (read, url, fallback = null, cache = new Map()) {
|
|
301
|
-
const got = await cached(cache, `alt|${url}`,
|
|
302
|
-
let alternates = []; let error = null; let at = url
|
|
303
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
304
|
-
try {
|
|
305
|
-
const r = await read(url)
|
|
306
|
-
at = r.url || url
|
|
307
|
-
if (r.status < 200 || r.status >= 300) { error = `HTTP ${r.status}`; if (r.status >= 500) continue; break }
|
|
308
|
-
error = null
|
|
309
|
-
alternates = htmlAlternates(r.html, at)
|
|
310
|
-
if (!alternates.length) alternates = headerAlternates(r.link, at)
|
|
311
|
-
break
|
|
312
|
-
} catch (e) { error = String(e?.message || e).split('\n')[0] }
|
|
313
|
-
}
|
|
314
|
-
if (!alternates.length && fallback?.length) return { alternates: fallback, error: null, url: at, from: 'sitemap' }
|
|
315
|
-
return { alternates, error, url: at }
|
|
316
|
-
})
|
|
353
|
+
const got = await cached(cache, `alt|${url}`, () => pageAlternates(read, url, fallback))
|
|
317
354
|
if (got.error) cache.delete(`alt|${url}`)
|
|
318
355
|
return got
|
|
319
356
|
}
|
|
@@ -24,6 +24,53 @@ const unique = (list) => { const seen = new Set(); return list.filter((a) => a &
|
|
|
24
24
|
*/
|
|
25
25
|
export const stripInert = (html) => String(html || '').replace(/<!--[\s\S]*?(?:-->|$)/g, '').replace(/<script\b[\s\S]*?(?:<\/script\s*>|$)/gi, '').replace(/<style\b[\s\S]*?(?:<\/style\s*>|$)/gi, '')
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Where the head of a page ends, found as the text arrives: `feed(chunk)` is true once `</head>` or `<body>` shows up
|
|
29
|
+
* outside comments, scripts and styles (the same rule as stripInert, then the search in htmlAlternates). Each call
|
|
30
|
+
* scans only the new text and a few characters before it, so a long head read in small chunks stays linear.
|
|
31
|
+
*/
|
|
32
|
+
export function headEnd () {
|
|
33
|
+
let text = ''; let pos = 0; let mode = null
|
|
34
|
+
const OPEN = /<!--|<script\b|<style\b|<\/head\s*>|<body[\s>]/gi
|
|
35
|
+
const CLOSE = { comment: /-->/g, script: /<\/script\s*>/gi, style: /<\/style\s*>/gi }
|
|
36
|
+
return (chunk) => {
|
|
37
|
+
text += chunk
|
|
38
|
+
for (;;) {
|
|
39
|
+
const re = mode ? CLOSE[mode] : OPEN
|
|
40
|
+
re.lastIndex = pos
|
|
41
|
+
const m = re.exec(text)
|
|
42
|
+
// nothing yet: keep the last characters, a token may be split between two chunks
|
|
43
|
+
if (!m) { pos = Math.max(pos, text.length - 16); return false }
|
|
44
|
+
// `<script` at the very end may still turn into `<scripts`: wait for the next character
|
|
45
|
+
if (!mode && m.index + m[0].length >= text.length && /^<(script|style)/i.test(m[0])) { pos = m.index; return false }
|
|
46
|
+
pos = m.index + m[0].length
|
|
47
|
+
if (mode) { mode = null; continue }
|
|
48
|
+
const t = m[0].toLowerCase()
|
|
49
|
+
if (t === '<!--') mode = 'comment'
|
|
50
|
+
else if (t.startsWith('<script')) mode = 'script'
|
|
51
|
+
else if (t.startsWith('<style')) mode = 'style'
|
|
52
|
+
else return true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The head of a page from a stream reader (`body.getReader()`): read until the head ends (headEnd) or `max` bytes,
|
|
59
|
+
* then the stream is cancelled. Shared by the CLI (stack.mjs readHead) and hosted runs (hosted-stacks.js).
|
|
60
|
+
*/
|
|
61
|
+
export async function readHeadFrom (reader, max) {
|
|
62
|
+
const dec = new TextDecoder(); const ended = headEnd(); let text = ''; let bytes = 0
|
|
63
|
+
for (;;) {
|
|
64
|
+
const { done, value } = await reader.read()
|
|
65
|
+
if (done) break
|
|
66
|
+
bytes += value.byteLength
|
|
67
|
+
const chunk = dec.decode(value, { stream: true })
|
|
68
|
+
text += chunk
|
|
69
|
+
if (bytes >= max || ended(chunk)) { try { await reader.cancel() } catch {} break }
|
|
70
|
+
}
|
|
71
|
+
return text.slice(0, max)
|
|
72
|
+
}
|
|
73
|
+
|
|
27
74
|
/** hreflang alternates in the head of an HTML page: [{ lang (lowercase, en-us), href (absolute) }]. */
|
|
28
75
|
export function htmlAlternates (html, url) {
|
|
29
76
|
const text = stripInert(html)
|
|
@@ -49,18 +96,30 @@ export function headerAlternates (link, url) {
|
|
|
49
96
|
return unique(out)
|
|
50
97
|
}
|
|
51
98
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
99
|
+
/**
|
|
100
|
+
* The `<url>` entries of a sitemap that name alternates, unparsed: Map(loc → the entry's markup). The service keeps
|
|
101
|
+
* these for one sitemap at a time and parses only the pages it takes (urlAlternates).
|
|
102
|
+
*/
|
|
103
|
+
export function sitemapUrlEntries (xml) {
|
|
54
104
|
const out = new Map()
|
|
55
105
|
for (const m of String(xml || '').matchAll(/<url(?:\s[^>]*)?>([\s\S]*?)<\/url>/gi)) {
|
|
106
|
+
if (!/<(?:xhtml:)?link\b/i.test(m[1])) continue
|
|
56
107
|
const loc = m[1].match(LOC)?.[1]
|
|
57
|
-
if (
|
|
58
|
-
const alts = unique([...m[1].matchAll(/<(?:xhtml:)?link\b[^>]*>/gi)].map((l) => { const a = attrsOf(l[0]); return /(^|\s)alternate(\s|$)/i.test(a.rel || '') ? alternate(a.hreflang, a.href) : null }))
|
|
59
|
-
if (alts.length) out.set(decode(loc), alts)
|
|
108
|
+
if (loc) out.set(decode(loc), m[1])
|
|
60
109
|
}
|
|
61
110
|
return out
|
|
62
111
|
}
|
|
63
112
|
|
|
113
|
+
/** The `xhtml:link` alternates of one `<url>` entry of a sitemap: [{ lang, href }]. */
|
|
114
|
+
export const urlAlternates = (entry) => unique([...String(entry || '').matchAll(/<(?:xhtml:)?link\b[^>]*>/gi)].map((l) => { const a = attrsOf(l[0]); return /(^|\s)alternate(\s|$)/i.test(a.rel || '') ? alternate(a.hreflang, a.href) : null }))
|
|
115
|
+
|
|
116
|
+
/** The `<url>` entries of a sitemap with their `xhtml:link` alternates: Map(loc → [{ lang, href }]). */
|
|
117
|
+
export function sitemapAlternates (xml) {
|
|
118
|
+
const out = new Map()
|
|
119
|
+
for (const [loc, entry] of sitemapUrlEntries(xml)) { const alts = urlAlternates(entry); if (alts.length) out.set(loc, alts) }
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
|
|
64
123
|
/**
|
|
65
124
|
* The alternate that stands for `locale`: the same tag (en-us for en_US), else the bare language (en) for a locale
|
|
66
125
|
* with a region, else the first regional tag of the language (sk-sk) for a locale without one. A locale with a script
|
|
@@ -84,6 +143,30 @@ export function alternateFor (alts, locale) {
|
|
|
84
143
|
return parts.find(([, q]) => q?.lang === p.lang && !q.script)?.[0] || parts.find(([, q]) => q?.lang === p.lang)?.[0] || null
|
|
85
144
|
}
|
|
86
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The hreflang alternates of one page as `read` (url → { status, url (after redirects), link, html }) gives it: from
|
|
148
|
+
* its HTML head, else its Link header, else `fallback` (the sitemap's xhtml:link entries for it):
|
|
149
|
+
* { alternates: [{ lang, href }], error, url, from? }. A network error, a timeout or a 5xx is tried once more, except
|
|
150
|
+
* an error `noRetry(e)` names (a refused address); an error `rethrow(e)` names (the service's spent round) is not
|
|
151
|
+
* caught. Shared by the CLI (stack.mjs readAlternates) and hosted runs (worker/src/hosted-stacks.js).
|
|
152
|
+
*/
|
|
153
|
+
export async function pageAlternates (read, url, fallback = null, { rethrow = () => false, noRetry = () => false } = {}) {
|
|
154
|
+
let alternates = []; let error = null; let at = url
|
|
155
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
156
|
+
try {
|
|
157
|
+
const r = await read(url)
|
|
158
|
+
at = r.url || url
|
|
159
|
+
if (r.status < 200 || r.status >= 300) { error = `HTTP ${r.status}`; if (r.status >= 500) continue; break }
|
|
160
|
+
error = null
|
|
161
|
+
alternates = htmlAlternates(r.html, at)
|
|
162
|
+
if (!alternates.length) alternates = headerAlternates(r.link, at)
|
|
163
|
+
break
|
|
164
|
+
} catch (e) { if (rethrow(e)) throw e; error = String(e?.message || e).split('\n')[0]; if (noRetry(e)) break }
|
|
165
|
+
}
|
|
166
|
+
if (!alternates.length && fallback?.length) return { alternates: fallback, error: null, url: at, from: 'sitemap' }
|
|
167
|
+
return { alternates, error, url: at }
|
|
168
|
+
}
|
|
169
|
+
|
|
87
170
|
const hostOf = (u) => { try { return new URL(u).host } catch { return null } }
|
|
88
171
|
const sameLanguage = (a, b) => { const x = localeParts(a); const y = localeParts(b); return !!x && !!y && x.lang === y.lang }
|
|
89
172
|
|
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
|
}
|