kaloko 0.15.0 → 0.16.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/config.v1.json +3 -1
- package/schemas/run.v1.json +4 -0
- package/schemas/scenario.v1.json +18 -0
- package/skills/kaloko/SKILL.md +17 -7
- package/skills/kaloko/reference/acceptance.md +4 -2
- package/skills/kaloko/reference/docs.md +24 -0
- package/skills/kaloko/reference/evidence.md +6 -4
- package/skills/kaloko/reference/first-run.md +37 -4
- package/skills/kaloko/reference/mcp.md +2 -1
- package/src/cli.mjs +104 -13
- package/src/lib/build.mjs +2 -3
- package/src/lib/capture.mjs +50 -4
- package/src/lib/changelog-core.mjs +12 -6
- package/src/lib/cli-args.mjs +6 -3
- package/src/lib/detail.mjs +102 -0
- package/src/lib/docs-export.mjs +2 -1
- package/src/lib/docs.mjs +14 -9
- package/src/lib/evidence-core.mjs +1 -1
- package/src/lib/git.mjs +1 -1
- package/src/lib/init-detect.mjs +336 -17
- package/src/lib/manifest-core.mjs +36 -3
- package/src/lib/projects-cli.mjs +88 -0
- package/src/lib/scenario.mjs +8 -0
- package/src/lib/util.mjs +6 -2
- package/templates/kaloko.config.yml +2 -0
- package/viewer/index.html +74 -27
package/src/lib/capture.mjs
CHANGED
|
@@ -16,8 +16,8 @@ export function browserVersion (page) {
|
|
|
16
16
|
try { const b = page.context().browser(); const v = b?.version?.(); return v ? `${b.browserType?.().name?.() || 'browser'} ${v}` : null } catch { return null }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export async function encodeImage (page, png, { format = 'webp', quality = 0.82, thumbWidth = 640, thumbQuality = 0.8, marks = [], scale = 1, masks = [] } = {}) {
|
|
20
|
-
const result = await page.evaluate(async ({ dataUrl, format, quality, thumbWidth, thumbQuality, marks, scale, masks }) => {
|
|
19
|
+
export async function encodeImage (page, png, { format = 'webp', quality = 0.82, thumbWidth = 640, thumbQuality = 0.8, marks = [], scale = 1, masks = [], outline = null } = {}) {
|
|
20
|
+
const result = await page.evaluate(async ({ dataUrl, format, quality, thumbWidth, thumbQuality, marks, scale, masks, outline }) => {
|
|
21
21
|
const img = new Image()
|
|
22
22
|
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = dataUrl })
|
|
23
23
|
const full = document.createElement('canvas'); full.width = img.width; full.height = img.height
|
|
@@ -30,6 +30,12 @@ export async function encodeImage (page, png, { format = 'webp', quality = 0.82,
|
|
|
30
30
|
for (let i = -h; i < w; i += 12 * scale) { g.beginPath(); g.moveTo(x + i, y + h); g.lineTo(x + i + h, y); g.stroke() }
|
|
31
31
|
g.restore()
|
|
32
32
|
}
|
|
33
|
+
// a detail's element (details:): a ring around it, a few pixels out so it never covers the element itself
|
|
34
|
+
if (outline) {
|
|
35
|
+
const gap = 5 * scale; const x = outline.x * scale - gap; const y = outline.y * scale - gap; const w = outline.w * scale + gap * 2; const h = outline.h * scale + gap * 2
|
|
36
|
+
const ring = () => { g.beginPath(); if (g.roundRect) g.roundRect(x, y, w, h, 6 * scale); else g.rect(x, y, w, h) }
|
|
37
|
+
g.save(); ring(); g.strokeStyle = '#151514'; g.lineWidth = 5 * scale; g.stroke(); ring(); g.strokeStyle = '#ffe452'; g.lineWidth = 3 * scale; g.stroke(); g.restore()
|
|
38
|
+
}
|
|
33
39
|
for (const m of marks) {
|
|
34
40
|
const r = 13 * scale; const x = Math.max(r + 2, m.x * scale); const y = Math.max(r + 2, m.y * scale)
|
|
35
41
|
g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fillStyle = '#ffe452'; g.fill(); g.lineWidth = 2 * scale; g.strokeStyle = '#151514'; g.stroke()
|
|
@@ -50,7 +56,7 @@ export async function encodeImage (page, png, { format = 'webp', quality = 0.82,
|
|
|
50
56
|
const t = document.createElement('canvas'); t.width = tw; t.height = th
|
|
51
57
|
const tg = t.getContext('2d'); tg.imageSmoothingEnabled = true; tg.imageSmoothingQuality = 'high'; tg.drawImage(src, 0, 0, tw, th)
|
|
52
58
|
return { image: await blob(full, format === 'png' ? 'image/png' : 'image/webp', quality), thumb: await blob(t, 'image/webp', thumbQuality) }
|
|
53
|
-
}, { dataUrl: `data:image/png;base64,${png.toString('base64')}`, format, quality, thumbWidth, thumbQuality, marks, scale, masks })
|
|
59
|
+
}, { dataUrl: `data:image/png;base64,${png.toString('base64')}`, format, quality, thumbWidth, thumbQuality, marks, scale, masks, outline })
|
|
54
60
|
return { image: Buffer.from(result.image.bytes), thumb: Buffer.from(result.thumb.bytes), imageType: result.image.type, thumbType: result.thumb.type }
|
|
55
61
|
}
|
|
56
62
|
|
|
@@ -95,6 +101,7 @@ import { checkLinks, checkHreflang, requester, walkCache, NETWORK_ASSERTS, isola
|
|
|
95
101
|
import { keyboardChecks, needsKeyboard, KEYBOARD_ASSERTS } from './keyboard.mjs'
|
|
96
102
|
import { repeatRegions, resolveIgnore } from './stability.mjs'
|
|
97
103
|
import { applyVariant, engineOf, markEngineGaps, variantFields } from './dimensions.mjs'
|
|
104
|
+
import { detailsFor, detailFacts, detailBox, detailName, DETAIL_PADDING } from './detail.mjs'
|
|
98
105
|
import { liveValueChecks } from './assertions.mjs'
|
|
99
106
|
|
|
100
107
|
/** Probe arguments derived from the node's criteria. */
|
|
@@ -253,6 +260,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
253
260
|
image = paths.step(stepId, locale, viewport, 'png'); thumbWritten = false
|
|
254
261
|
await writeFile(image, shot)
|
|
255
262
|
}
|
|
263
|
+
// named details for guides (details:): from the same masked page as the screenshot, with the same mask boxes
|
|
264
|
+
const details = item ? [] : await captureDetails({ page, node, locale, viewport, run, stepId, maskRects, scale: shotScale, engine, dpr })
|
|
256
265
|
// network checks (internal links, hreflang pairs) run once per page, outside the page, with a walk-wide cache
|
|
257
266
|
const net = (node.criteria || []).filter((c) => c.check.type === 'deterministic' && NETWORK_ASSERTS.has(c.check.assert))
|
|
258
267
|
if (net.length) {
|
|
@@ -297,7 +306,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
297
306
|
Object.assign(result.checks, kb.checks)
|
|
298
307
|
result.boxes = { ...result.boxes, ...kb.boxes }
|
|
299
308
|
keyboard = kb.keyboard
|
|
300
|
-
for (const s of keyboard?.shots || []) await addCapture(run.dir, { type: 'attachment', kind: 'focus', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, title: scrubText(redact(`Focus ${s.n}: ${s.name}`), mask.auto ? mask : null), file: s.file, at: new Date().toISOString() })
|
|
309
|
+
for (const s of keyboard?.shots || []) await addCapture(run.dir, { type: 'attachment', kind: 'focus', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, n: s.n, title: scrubText(redact(`Focus ${s.n}: ${s.name}`), mask.auto ? mask : null), file: s.file, at: new Date().toISOString() })
|
|
301
310
|
}
|
|
302
311
|
const record = {
|
|
303
312
|
schema: 'qawalk.capture.v1',
|
|
@@ -318,6 +327,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
318
327
|
...(ignore.length ? { ignore } : {}),
|
|
319
328
|
...(unstable ? { unstable } : {}),
|
|
320
329
|
...(keyboard ? { keyboard } : {}),
|
|
330
|
+
...(details.length ? { details } : {}),
|
|
321
331
|
...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) } } : {}),
|
|
322
332
|
...(fields ? { fields: { ...(await page.evaluate(readFields, fields).catch(() => ({}))), ...(fields.some((f) => (f.from || f.id) === 'http') ? Object.fromEntries(fields.filter((f) => (f.from || f.id) === 'http').map((f) => [f.id, extra.http_status ?? result.http_status ?? null])) : {}) } } : {}),
|
|
323
333
|
files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
|
|
@@ -341,6 +351,42 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
341
351
|
return results
|
|
342
352
|
}
|
|
343
353
|
|
|
354
|
+
/**
|
|
355
|
+
* The named details of a step in one capture (details:, detail.mjs): each element in its section or card (detailBox),
|
|
356
|
+
* outlined, as an attachment of kind detail with the element's box inside the picture. The page is masked here as for
|
|
357
|
+
* the screenshot: masked text is already replaced, `maskRects` (document pixels) are drawn as boxes, and a detail whose
|
|
358
|
+
* masks cannot be drawn is not stored. Returns the capture record's `details`: { id, file, box, size, context } or
|
|
359
|
+
* { id, error } for an element that was not found or not visible.
|
|
360
|
+
*/
|
|
361
|
+
async function captureDetails ({ page, node, locale, viewport, run, stepId, maskRects = [], scale = 1, engine = 'chromium', dpr = 1 }) {
|
|
362
|
+
const list = detailsFor(node, viewport)
|
|
363
|
+
const out = []
|
|
364
|
+
for (const d of list) {
|
|
365
|
+
const facts = await page.evaluate(detailFacts, { selector: d.selector, context: d.context || null }).catch((e) => ({ error: String(e.message).split('\n')[0].slice(0, 200) }))
|
|
366
|
+
if (!facts || facts.error) { out.push({ id: d.id, error: `${d.selector}: ${facts?.error || 'not found'}` }); continue }
|
|
367
|
+
const { crop, el, by } = detailBox(facts, { context: d.context || null, padding: d.padding ?? DETAIL_PADDING })
|
|
368
|
+
// documentation runs (scale 2) get 2× details, the way the screenshot is taken
|
|
369
|
+
let png; let k = 1
|
|
370
|
+
if (scale === 2 && engine === 'chromium') {
|
|
371
|
+
const cdp = await page.context().newCDPSession(page)
|
|
372
|
+
try { png = Buffer.from((await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: crop.x, y: crop.y, width: crop.w, height: crop.h, scale: 2 / (dpr || 1) } })).data, 'base64'); k = 2 } finally { await cdp.detach().catch(() => {}) }
|
|
373
|
+
} else png = await page.screenshot({ fullPage: true, clip: { x: crop.x, y: crop.y, width: crop.w, height: crop.h }, scale: 'css', animations: 'disabled', caret: 'hide' })
|
|
374
|
+
const masks = maskRects.map((r) => ({ ...r, x: r.x - crop.x, y: r.y - crop.y })).filter((r) => r.x < crop.w && r.y < crop.h && r.x + r.w > 0 && r.y + r.h > 0)
|
|
375
|
+
let image
|
|
376
|
+
try { image = (await encodeImage(page, png, { format: 'png', masks, outline: el, scale: k })).image } catch (error) {
|
|
377
|
+
if (masks.length) { out.push({ id: d.id, error: `the masks could not be drawn (${String(error.message).slice(0, 120)}); not stored` }); continue }
|
|
378
|
+
image = png
|
|
379
|
+
}
|
|
380
|
+
const file = runPaths(run.dir).step(stepId, locale, viewport, `detail-${d.id}.png`)
|
|
381
|
+
await writeFile(file, image)
|
|
382
|
+
const name = detailName(d, locale)
|
|
383
|
+
const entry = { id: d.id, file: relative(run.dir, file), box: el, size: [crop.w, crop.h], context: by }
|
|
384
|
+
out.push(entry)
|
|
385
|
+
await addCapture(run.dir, { type: 'attachment', kind: 'detail', node: node.id, locale, viewport, id: d.id, name, title: name, file: entry.file, box: el, size: entry.size, at: new Date().toISOString() })
|
|
386
|
+
}
|
|
387
|
+
return out
|
|
388
|
+
}
|
|
389
|
+
|
|
344
390
|
/**
|
|
345
391
|
* A focus shot of the keyboard walk with the mask on: the page masked for the moment of the shot, boxes drawn over
|
|
346
392
|
* the masked places in the clip, the page restored (the focus stays where the walk put it).
|
|
@@ -14,10 +14,13 @@ const PARAMS = {
|
|
|
14
14
|
media: /^(video|image)$/
|
|
15
15
|
}
|
|
16
16
|
export const REF_SCHEME = 'kaloko:'
|
|
17
|
+
/** A named detail of a step (details: in the scenario, src/lib/detail.mjs DETAIL_ID): `kaloko:<scenario>/<step>#<id>`. */
|
|
18
|
+
const DETAIL = /^[A-Za-z0-9][A-Za-z0-9_-]{0,59}$/
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
|
-
* A step reference `kaloko:<scenario>/<step>[?locale=&viewport=&version=&state=&media=]`, or the short
|
|
20
|
-
* that means a step of the product's default scenario.
|
|
21
|
+
* A step reference `kaloko:<scenario>/<step>[?locale=&viewport=&version=&state=&media=][#<detail>]`, or the short
|
|
22
|
+
* `kaloko:<step>` that means a step of the product's default scenario. `#<detail>` asks for a named detail of the step
|
|
23
|
+
* (details: in the scenario) instead of the whole screen. `version` is a released version, `latest` (default) or `prev`
|
|
21
24
|
* (the release before the one the text belongs to); `state` picks a variant of the capture (dark, webkit, reduce: the
|
|
22
25
|
* dimension values in the capture key); `media` is `video` (play the step's recording; docs check warns when the
|
|
23
26
|
* accepted run has none) or `image` (never play it). Without `media` the recording plays when the run has one.
|
|
@@ -26,13 +29,16 @@ export const REF_SCHEME = 'kaloko:'
|
|
|
26
29
|
export function parseStepRef (ref) {
|
|
27
30
|
const s = String(ref || '').trim()
|
|
28
31
|
if (!s.startsWith(REF_SCHEME)) return null
|
|
29
|
-
|
|
32
|
+
let rest = s.slice(REF_SCHEME.length)
|
|
33
|
+
const hi = rest.indexOf('#')
|
|
34
|
+
const detail = hi < 0 ? null : rest.slice(hi + 1)
|
|
35
|
+
if (hi >= 0) { rest = rest.slice(0, hi); if (!DETAIL.test(detail)) return null }
|
|
30
36
|
const qi = rest.indexOf('?')
|
|
31
37
|
const path = qi < 0 ? rest : rest.slice(0, qi)
|
|
32
38
|
let parts
|
|
33
39
|
try { parts = path.split('/').map((p) => decodeURIComponent(p)) } catch { return null }
|
|
34
40
|
if (!parts.length || parts.length > 2 || parts.some((p) => !ID.test(p))) return null
|
|
35
|
-
const out = { scenario: parts.length === 2 ? parts[0] : null, step: parts[parts.length - 1], locale: null, viewport: null, version: null, state: null, media: null }
|
|
41
|
+
const out = { scenario: parts.length === 2 ? parts[0] : null, step: parts[parts.length - 1], locale: null, viewport: null, version: null, state: null, media: null, detail }
|
|
36
42
|
const q = new URLSearchParams(qi < 0 ? '' : rest.slice(qi + 1))
|
|
37
43
|
for (const [k, v] of q) {
|
|
38
44
|
if (!(k in PARAMS)) return null
|
|
@@ -44,10 +50,10 @@ export function parseStepRef (ref) {
|
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/** The reference text of a parsed (or hand-built) reference; parameters in a fixed order. */
|
|
47
|
-
export function formatStepRef ({ scenario = null, step, locale = null, viewport = null, version = null, state = null, media = null }) {
|
|
53
|
+
export function formatStepRef ({ scenario = null, step, locale = null, viewport = null, version = null, state = null, media = null, detail = null }) {
|
|
48
54
|
const path = `${scenario ? `${encodeURIComponent(scenario)}/` : ''}${encodeURIComponent(step)}`
|
|
49
55
|
const q = Object.entries({ locale, viewport, version, state, media }).filter(([, v]) => v != null && v !== '')
|
|
50
|
-
return `${REF_SCHEME}${path}${q.length ? `?${q.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}` : ''}`
|
|
56
|
+
return `${REF_SCHEME}${path}${q.length ? `?${q.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}` : ''}${detail ? `#${detail}` : ''}`
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
/** Every image with a step reference in a Markdown text: { alt, ref, parsed, index }; broken ones have parsed null. */
|
package/src/lib/cli-args.mjs
CHANGED
|
@@ -44,8 +44,11 @@ export const HIDDEN = {
|
|
|
44
44
|
ci: ['wait', 'github-env', 'dotenv']
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Commands whose unknown options stop the command instead of a warning: they delete, publish or change access. A
|
|
49
|
+
* "command sub" entry makes only that sub-command strict (kaloko projects lists, kaloko projects create writes).
|
|
50
|
+
*/
|
|
51
|
+
export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment', 'projects create', 'projects rename', 'projects sync'])
|
|
49
52
|
|
|
50
53
|
/**
|
|
51
54
|
* argv → { _: positional, <option>: value | true }. `--key=value` splits at the first `=` only; `--key value` takes
|
|
@@ -140,6 +143,6 @@ export function checkOptions (help, name, args) {
|
|
|
140
143
|
const unknown = Object.keys(args).filter((k) => k !== '_' && k !== '' && !known.has(k))
|
|
141
144
|
if (!unknown.length) return []
|
|
142
145
|
const lines = unknown.map((k) => { const guess = closest(k, [...known]); return `kaloko ${name} does not take --${k}.${guess ? ` Did you mean --${guess}?` : ''}` })
|
|
143
|
-
if (STRICT.has(name)) throw new UsageError(`${lines.join('\n')}\nNothing was changed. kaloko help ${name} lists its options.`)
|
|
146
|
+
if (STRICT.has(name) || STRICT.has(`${name} ${args._?.[0]}`)) throw new UsageError(`${lines.join('\n')}\nNothing was changed. kaloko help ${name} lists its options.`)
|
|
144
147
|
return lines.map((l) => `! ${l} It is ignored (kaloko help ${name} lists the options).`)
|
|
145
148
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Named details of a step (`details:` in the scenario): pictures for user guides and docs that show one element in
|
|
2
|
+
// the part of the page around it (its section or card), outlined, in every locale and viewport. The capture
|
|
3
|
+
// (capture.mjs captureDetails) reads the element and its ancestors in the page (detailFacts, self-contained: it runs
|
|
4
|
+
// in the browser), chooses the box here (detailBox, plain arithmetic, unit-tested) and stores the picture as an
|
|
5
|
+
// attachment of kind `detail` with the element's box inside the picture, so the canvas and docs can point at it.
|
|
6
|
+
import { stateOfKey } from './manifest-core.mjs'
|
|
7
|
+
import { localized } from './util.mjs'
|
|
8
|
+
|
|
9
|
+
/** The smallest context worth showing in a guide (CSS px); narrower viewports cap the width. */
|
|
10
|
+
export const DETAIL_MIN = { w: 480, h: 240 }
|
|
11
|
+
export const DETAIL_PADDING = 16
|
|
12
|
+
export const DETAIL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,59}$/
|
|
13
|
+
export const CONTEXTS = ['section', 'card', 'page']
|
|
14
|
+
|
|
15
|
+
/** The details of a step that apply to a capture key (a detail with `states:` is taken in those states only). */
|
|
16
|
+
export function detailsFor (node, key) {
|
|
17
|
+
const state = stateOfKey(key)
|
|
18
|
+
return (node?.details || []).filter((d) => d && DETAIL_ID.test(d.id || '') && d.selector && (!Array.isArray(d.states) || !d.states.length || d.states.includes(state)))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A detail's name in a locale (`name` may be one text or one per locale). */
|
|
22
|
+
export const detailName = (d, locale) => localized(d.name, locale) || d.id
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runs in the page (keep it self-contained): the element `selector` names and its ancestors up to <body>, as boxes in
|
|
26
|
+
* document pixels, with what kind of container each is. `landmark`: an HTML section element or landmark role;
|
|
27
|
+
* `card`: an article, list item, fieldset, group, an element whose class says card/tile/panel/box, or one with its own
|
|
28
|
+
* frame (a border, a shadow, or rounded corners on a background). A `context` that is a CSS selector is looked up
|
|
29
|
+
* with closest() and returned as `custom` ('missing' when the element is not inside one, 'invalid' for a bad selector).
|
|
30
|
+
*/
|
|
31
|
+
export function detailFacts ({ selector, context = null }) {
|
|
32
|
+
let el
|
|
33
|
+
try { el = document.querySelector(selector) } catch { return { error: 'invalid selector' } }
|
|
34
|
+
if (!el) return { error: 'not found' }
|
|
35
|
+
const sx = scrollX; const sy = scrollY
|
|
36
|
+
const box = (e) => { const r = e.getBoundingClientRect(); return { x: r.left + sx, y: r.top + sy, w: r.width, h: r.height } }
|
|
37
|
+
const b = box(el)
|
|
38
|
+
if (!b.w || !b.h) return { error: 'not visible' }
|
|
39
|
+
const LANDMARK = /^(main|section|article|aside|nav|header|footer|form|dialog|fieldset)$/
|
|
40
|
+
const ROLES = /^(main|region|dialog|alertdialog|form|navigation|complementary|banner|contentinfo|search)$/
|
|
41
|
+
const CARDROLES = /^(article|listitem|group)$/
|
|
42
|
+
const CARDCLASS = /(^|[-_\s])(card|tile|panel|box|modal|popover|widget)s?([-_\s]|$)/i
|
|
43
|
+
const clear = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(c)
|
|
44
|
+
let custom = null
|
|
45
|
+
if (context && !['section', 'card', 'page'].includes(context)) {
|
|
46
|
+
try { const c = el.closest(context); custom = c ? box(c) : 'missing' } catch { custom = 'invalid' }
|
|
47
|
+
}
|
|
48
|
+
const ancestors = []
|
|
49
|
+
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
|
|
50
|
+
const cs = getComputedStyle(a)
|
|
51
|
+
const tag = a.tagName.toLowerCase(); const role = a.getAttribute('role') || ''
|
|
52
|
+
const cls = typeof a.className === 'string' ? a.className : (a.className?.baseVal || '')
|
|
53
|
+
const framed = (parseFloat(cs.borderTopWidth) > 0 && cs.borderTopStyle !== 'none' && parseFloat(cs.borderBottomWidth) > 0) || cs.boxShadow !== 'none' || (parseFloat(cs.borderTopLeftRadius) > 0 && !clear(cs.backgroundColor))
|
|
54
|
+
ancestors.push({ ...box(a), tag, landmark: LANDMARK.test(tag) || ROLES.test(role), card: ['article', 'li', 'fieldset'].includes(tag) || CARDROLES.test(role) || CARDCLASS.test(cls) || framed })
|
|
55
|
+
}
|
|
56
|
+
return { el: b, ancestors, custom, viewport: { w: innerWidth, h: innerHeight }, doc: { w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight } }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The picture of a detail from the facts of the page: { crop, el, by } in CSS pixels, `crop` in the document, `el`
|
|
61
|
+
* (the element) inside the crop, `by` what chose the box.
|
|
62
|
+
* - `context` unset: the nearest ancestor that is a section or landmark or a card and at least DETAIL_MIN (on a narrow
|
|
63
|
+
* screen at least 90 % of its width); none: the element with room around it, DETAIL_MIN centred on it.
|
|
64
|
+
* - 'section' or 'card': the nearest ancestor of that kind and at least that big; 'page': one screen (the viewport)
|
|
65
|
+
* around the element; a CSS selector: the nearest ancestor that matches it, whatever its size.
|
|
66
|
+
* Then `padding` around it (16 px by default), at most one screen wide and high around the element (the element
|
|
67
|
+
* itself always fits), inside the document.
|
|
68
|
+
*/
|
|
69
|
+
export function detailBox (facts, { context = null, padding = DETAIL_PADDING, min = DETAIL_MIN } = {}) {
|
|
70
|
+
const { el, ancestors = [], viewport, doc } = facts
|
|
71
|
+
const pad = Number.isFinite(padding) && padding >= 0 ? padding : DETAIL_PADDING
|
|
72
|
+
// on a phone a card rarely spans the whole width: 90 % of the screen is wide enough
|
|
73
|
+
const minW = Math.min(min.w, Math.floor(viewport.w * 0.9)); const minH = Math.min(min.h, viewport.h)
|
|
74
|
+
const big = (b) => b.w >= minW && b.h >= minH
|
|
75
|
+
const holds = (b) => b.x <= el.x + 0.5 && b.y <= el.y + 0.5 && b.x + b.w >= el.x + el.w - 0.5 && b.y + b.h >= el.y + el.h - 0.5
|
|
76
|
+
const cx = el.x + el.w / 2; const cy = el.y + el.h / 2
|
|
77
|
+
let ctx = null; let by = null
|
|
78
|
+
if (context === 'page') { ctx = { x: cx - viewport.w / 2, y: cy - viewport.h / 2, w: viewport.w, h: viewport.h }; by = 'page' }
|
|
79
|
+
else if (context && !CONTEXTS.includes(context)) { if (facts.custom && typeof facts.custom === 'object') { ctx = facts.custom; by = 'selector' } }
|
|
80
|
+
else {
|
|
81
|
+
const kind = context === 'section' ? (a) => a.landmark : context === 'card' ? (a) => a.card : (a) => a.landmark || a.card
|
|
82
|
+
const hit = ancestors.find((a) => kind(a) && big(a) && holds(a))
|
|
83
|
+
if (hit) { ctx = hit; by = context || (hit.landmark ? 'section' : 'card') }
|
|
84
|
+
}
|
|
85
|
+
if (!ctx) { const w = Math.max(minW, el.w); const h = Math.max(minH, el.h); ctx = { x: cx - w / 2, y: cy - h / 2, w, h }; by = 'element' }
|
|
86
|
+
let x = ctx.x - pad; let y = ctx.y - pad; let w = ctx.w + pad * 2; let h = ctx.h + pad * 2
|
|
87
|
+
// at most one screen around the element: a long section or a wide page is cut around it, never through it
|
|
88
|
+
const fit = (start, size, cap, center, lo, hi) => {
|
|
89
|
+
if (size <= cap) return [start, size]
|
|
90
|
+
const s = Math.min(Math.max(center - cap / 2, start), start + size - cap)
|
|
91
|
+
return [Math.min(s, lo), Math.max(cap, hi - Math.min(s, lo))]
|
|
92
|
+
}
|
|
93
|
+
;[x, w] = fit(x, w, viewport.w, cx, el.x - pad, el.x + el.w + pad)
|
|
94
|
+
;[y, h] = fit(y, h, viewport.h, cy, el.y - pad, el.y + el.h + pad)
|
|
95
|
+
// inside the document: moved in first, cut only when the document is smaller
|
|
96
|
+
const keep = (start, size, total) => { const s = Math.max(0, Math.min(start, total - size)); return [s, Math.min(size, total - s)] }
|
|
97
|
+
;[x, w] = keep(x, w, doc.w)
|
|
98
|
+
;[y, h] = keep(y, h, doc.h)
|
|
99
|
+
const crop = { x: Math.floor(x), y: Math.floor(y), w: 0, h: 0 }
|
|
100
|
+
crop.w = Math.max(1, Math.min(Math.ceil(x + w), doc.w) - crop.x); crop.h = Math.max(1, Math.min(Math.ceil(y + h), doc.h) - crop.y)
|
|
101
|
+
return { crop, el: { x: Math.round(el.x - crop.x), y: Math.round(el.y - crop.y), w: Math.round(el.w), h: Math.round(el.h) }, by }
|
|
102
|
+
}
|
package/src/lib/docs-export.mjs
CHANGED
|
@@ -15,7 +15,8 @@ export const ZIP_FORMATS = ['md', 'docusaurus', 'vitepress', 'mkdocs', 'html']
|
|
|
15
15
|
export function assetFile (pic) {
|
|
16
16
|
const ext = (String(pic.file || pic.path || '').match(/\.(png|jpe?g|webp|gif)$/i) || [, 'png'])[1].toLowerCase()
|
|
17
17
|
const safe = (s) => String(s || '').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 60)
|
|
18
|
-
|
|
18
|
+
// a named detail (#<detail>) is its own picture next to the screen of the same step
|
|
19
|
+
return `assets/${safe(pic.scenario)}--${safe(pic.step?.id || pic.step)}${pic.detail?.id ? `--${safe(pic.detail.id)}` : ''}--${safe(pic.locale)}--${safe(pic.viewport)}--${safe(String(pic.run?.id || pic.run || '').split('-').pop())}.${ext}`
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
package/src/lib/docs.mjs
CHANGED
|
@@ -81,11 +81,13 @@ export async function collectDocs (root, product) {
|
|
|
81
81
|
* What needs attention in the docs, from the repository alone: broken references (not kaloko:<scenario>/<step>),
|
|
82
82
|
* references to scenarios or steps the scenario files do not have (`scenarios`: Map id → Set of step ids), short
|
|
83
83
|
* references without a default scenario, missing and outdated translations, and user-visible changelog items without a
|
|
84
|
-
* picture
|
|
84
|
+
* picture; with `details` (Map '<scenario>/<step>' → Set of detail ids) also references to a detail the step does not
|
|
85
|
+
* declare (a warning: readers see the whole screen). Returns { pages, broken, unknown, missing_details, translations,
|
|
86
|
+
* changelog_without_pictures }.
|
|
85
87
|
*/
|
|
86
|
-
export async function localCheck ({ root, product, scenarios = null }) {
|
|
88
|
+
export async function localCheck ({ root, product, scenarios = null, details = null }) {
|
|
87
89
|
const { dir, pages } = await collectDocs(root, product)
|
|
88
|
-
const broken = []; const unknown = []
|
|
90
|
+
const broken = []; const unknown = []; const missingDetails = []
|
|
89
91
|
for (const p of pages) {
|
|
90
92
|
for (const r of p.refs) {
|
|
91
93
|
if (!r.parsed) { broken.push({ file: p.file, ref: r.ref }); continue }
|
|
@@ -94,28 +96,29 @@ export async function localCheck ({ root, product, scenarios = null }) {
|
|
|
94
96
|
if (!scenarios) continue
|
|
95
97
|
if (!scenarios.has(sc)) unknown.push({ file: p.file, ref: r.ref, why: `No scenario ${sc} in the scenario files.` })
|
|
96
98
|
else if (!scenarios.get(sc).has(r.parsed.step)) unknown.push({ file: p.file, ref: r.ref, why: `Scenario ${sc} has no step ${r.parsed.step}.` })
|
|
99
|
+
else if (r.parsed.detail && details && !details.get(`${sc}/${r.parsed.step}`)?.has(r.parsed.detail)) missingDetails.push({ file: p.file, ref: r.ref, why: `Step ${sc}/${r.parsed.step} declares no detail ${r.parsed.detail}; add it under details: and walk the step again.` })
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
const languages = docsLanguages(product, pages)
|
|
100
103
|
const translations = translationReport(pages.map((p) => ({ path: p.path, lang: p.lang, hash: p.hash, front: p.front, updated: p.updated })), languages)
|
|
101
104
|
let changelog = []
|
|
102
105
|
try { changelog = itemsWithoutPictures(await readFile(changelogFile(root, product), 'utf8')) } catch {}
|
|
103
|
-
return { dir, pages, languages, broken, unknown, translations, changelog_without_pictures: changelog }
|
|
106
|
+
return { dir, pages, languages, broken, unknown, missing_details: missingDetails, translations, changelog_without_pictures: changelog }
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
/** The check with the service's half (stale sections, references without an accepted picture), when a client is given. */
|
|
107
|
-
export async function docsCheck ({ root, config, product, client = null, scenarios = null, version = 'latest' }) {
|
|
108
|
-
const local = await localCheck({ root, product, scenarios })
|
|
110
|
+
export async function docsCheck ({ root, config, product, client = null, scenarios = null, details = null, version = 'latest' }) {
|
|
111
|
+
const local = await localCheck({ root, product, scenarios, details })
|
|
109
112
|
let service = null; let serviceError = null
|
|
110
113
|
if (client && config.org) {
|
|
111
114
|
try { service = await client.request('GET', `/orgs/${config.org}/docs/check?${new URLSearchParams({ product: product.slug, version })}`) } catch (e) { serviceError = String(e.message || e).split('\n')[0].slice(0, 200) }
|
|
112
115
|
}
|
|
113
116
|
const errors = local.broken.length + local.unknown.length + (service ? service.stale.length + service.unresolved.length : 0)
|
|
114
|
-
const warnings = local.translations.missing.length + local.translations.outdated.length + local.changelog_without_pictures.length + (service?.no_recording?.length || 0)
|
|
117
|
+
const warnings = local.translations.missing.length + local.translations.outdated.length + local.changelog_without_pictures.length + local.missing_details.length + (service?.no_recording?.length || 0) + (service?.no_detail?.length || 0)
|
|
115
118
|
return {
|
|
116
119
|
product: product.slug, version, dir: relative(root, local.dir) || '.', pages: local.pages.length, languages: local.languages,
|
|
117
|
-
broken: local.broken, unknown: local.unknown, translations: local.translations, changelog_without_pictures: local.changelog_without_pictures,
|
|
118
|
-
service: service ? { published: service.published, published_at: service.published_at || null, stale: service.stale, unresolved: service.unresolved, no_recording: service.no_recording || [], stale_checked: service.stale_checked !== false, url: service.url } : null,
|
|
120
|
+
broken: local.broken, unknown: local.unknown, missing_details: local.missing_details, translations: local.translations, changelog_without_pictures: local.changelog_without_pictures,
|
|
121
|
+
service: service ? { published: service.published, published_at: service.published_at || null, stale: service.stale, unresolved: service.unresolved, no_recording: service.no_recording || [], no_detail: service.no_detail || [], stale_checked: service.stale_checked !== false, url: service.url } : null,
|
|
119
122
|
service_error: serviceError, errors, warnings, ok: errors === 0
|
|
120
123
|
}
|
|
121
124
|
}
|
|
@@ -130,9 +133,11 @@ export function formatCheck (r) {
|
|
|
130
133
|
list('✗ stale sections (the screen changed since the text was published)', r.service.stale, (x) => `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''} § ${x.title || 'intro'}: the screen changed${x.refs[0]?.changed_in ? ` in ${x.refs[0].changed_in}` : ''} — check the text (${x.refs.map((y) => y.ref).join(', ')})`)
|
|
131
134
|
list('✗ references without an accepted picture', r.service.unresolved, (x) => `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''}: ${x.ref}`)
|
|
132
135
|
list('! media=video, but the accepted run has no recording of the step (readers see the picture; walk it with --record)', r.service.no_recording || [], (x) => `${x.release ? `changelog ${x.release}` : `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''}`}: ${x.ref}${x.run ? ` (run ${x.run})` : ''}`)
|
|
136
|
+
list('! #detail that no accepted run has yet (readers see the whole screen; walk the step again)', r.service.no_detail || [], (x) => `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''}: ${x.ref}${x.run ? ` (run ${x.run})` : ''}`)
|
|
133
137
|
if (!r.service.published) lines.push(' · nothing published yet: kaloko docs publish')
|
|
134
138
|
else if (!r.service.stale_checked) lines.push(' · stale sections are checked on the Business plan')
|
|
135
139
|
} else lines.push(` · ${r.service_error ? `service: ${r.service_error}` : 'offline: stale sections and pictures need a token'}`)
|
|
140
|
+
list('! details the scenario does not declare', r.missing_details || [], (x) => `${x.file}: ${x.ref} — ${x.why}`)
|
|
136
141
|
list('! missing translations', r.translations.missing, (x) => `${x.path || 'index'} → ${x.lang}`)
|
|
137
142
|
list('! outdated translations (the original changed later)', r.translations.outdated, (x) => `${x.path || 'index'} (${x.lang})${x.why === 'source_hash' ? ': source_hash differs' : ''}`)
|
|
138
143
|
list('! translations without an original', r.translations.orphans, (x) => `${x.path || 'index'} (${x.lang})`)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Quality evidence (qawalk.evidence.v1, schemas/evidence.v1.json; help/explanation/quality-evidence.md): what a
|
|
2
2
|
// downstream generator needs from an accepted run, without scraping the canvas or the PDF. Shared by the service
|
|
3
3
|
// (worker/src/evidence.js) and the CLI (kaloko evidence, kaloko signoff --package / --verify): WebCrypto only, no node:
|
|
4
4
|
// imports, so it runs in both.
|
package/src/lib/git.mjs
CHANGED
|
@@ -47,7 +47,7 @@ export async function projectAndIssue ({ cwd, config, scenario, branch, title =
|
|
|
47
47
|
const project = scenario.project
|
|
48
48
|
? { slug: scenario.project, repo: repo?.slug || null, source: 'scenario' }
|
|
49
49
|
: config.project
|
|
50
|
-
? { slug: config.project, repo: repo?.slug || null, source: 'config' }
|
|
50
|
+
? { slug: config.project, ...(config.project_name ? { name: String(config.project_name) } : {}), repo: repo?.slug || null, source: 'config' }
|
|
51
51
|
: repo ? { slug: repo.slug.split('/').pop(), repo: repo.slug, source: 'git' } : { slug: cwd.split('/').pop(), repo: null, source: 'directory' }
|
|
52
52
|
let issue = null
|
|
53
53
|
const given = scenario.issue
|