kaloko 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/kaloko/reference/stacks.md +10 -2
- package/src/cli.mjs +4 -4
- package/src/lib/capture.mjs +1 -1
- package/src/lib/doctor.mjs +2 -2
- package/src/lib/jev.mjs +4 -2
- package/src/lib/stack.mjs +6 -5
package/package.json
CHANGED
|
@@ -54,14 +54,22 @@ Item sources:
|
|
|
54
54
|
- `urls: [/a, /b]` is a short fixed list.
|
|
55
55
|
- `file: qa/data/products.csv` is a list next to the scenario. Accepted
|
|
56
56
|
formats are text with one path per line, CSV (first column), or JSON:
|
|
57
|
-
`["/a"]` or `[{ path, title, key }]`, where `key` gives a stable id.
|
|
57
|
+
`["/a"]` or `[{ path, title, key, purpose }]`, where `key` gives a stable id.
|
|
58
58
|
- `sitemap: /sitemap.xml` also follows a sitemap index (up to 50 sitemaps).
|
|
59
59
|
- `crawl: { from: /clanky, links: "a.card", next: "a.next", pages: 20 }`
|
|
60
60
|
collects links from a listing and follows its pagination.
|
|
61
61
|
- `script: qa/steps/items.mjs` uses the default export
|
|
62
|
-
`({ page, env, locale, url }) => [paths | { path, title, key }]` for
|
|
62
|
+
`({ page, env, locale, url }) => [paths | { path, title, key, purpose }]` for
|
|
63
63
|
anything else, such as an API or a search.
|
|
64
64
|
|
|
65
|
+
An item's `purpose` (for example its title and meta description) is what the
|
|
66
|
+
semantic evaluator reads as `screen_purpose` for that page instead of the
|
|
67
|
+
step's generic purpose, so one question asked of forty different pages is
|
|
68
|
+
judged against each page's own intent. A script also decides which pages
|
|
69
|
+
belong to which locale: return only the pages of `locale` when the pages
|
|
70
|
+
are language-specific (`/cs/…` in cs, the rest in en), so no page is judged
|
|
71
|
+
in the wrong language.
|
|
72
|
+
|
|
65
73
|
Pages are deduplicated. External URLs are allowed (e.g. an old domain).
|
|
66
74
|
`{locale}` in any source string is replaced by the walked locale. This is
|
|
67
75
|
how one sitemap with every language is split:
|
package/src/cli.mjs
CHANGED
|
@@ -213,8 +213,8 @@ const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).f
|
|
|
213
213
|
const say = (...m) => console.log(...m)
|
|
214
214
|
|
|
215
215
|
async function context (args) {
|
|
216
|
-
const { config, root } = await loadConfig(process.cwd(), args.config)
|
|
217
|
-
return { config, root }
|
|
216
|
+
const { config, root, file } = await loadConfig(process.cwd(), args.config)
|
|
217
|
+
return { config, root, file }
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
async function runContext (args) {
|
|
@@ -1370,8 +1370,8 @@ const commands = {
|
|
|
1370
1370
|
},
|
|
1371
1371
|
|
|
1372
1372
|
async doctor (args) {
|
|
1373
|
-
const { config, root } = await context(args)
|
|
1374
|
-
const r = await doctor({ config, root, fix: !!args.fix })
|
|
1373
|
+
const { config, root, file } = await context(args)
|
|
1374
|
+
const r = await doctor({ config, root, file, fix: !!args.fix })
|
|
1375
1375
|
say(args.json ? JSON.stringify(r, null, 2) : formatDoctor(r))
|
|
1376
1376
|
if (!r.ok) process.exitCode = 1
|
|
1377
1377
|
},
|
package/src/lib/capture.mjs
CHANGED
|
@@ -189,7 +189,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
189
189
|
...extra,
|
|
190
190
|
http_status: extra.http_status ?? result.http_status ?? null,
|
|
191
191
|
capture: { format: String(image).split('.').pop(), scale: opts.scale, masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
|
|
192
|
-
...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}) } } : {}),
|
|
192
|
+
...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) } } : {}),
|
|
193
193
|
...(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])) : {}) } } : {}),
|
|
194
194
|
files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
|
|
195
195
|
}
|
package/src/lib/doctor.mjs
CHANGED
|
@@ -32,14 +32,14 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
|
|
|
32
32
|
* @param {Function} [o.launchChrome] async () => { version } — throws when Chrome is missing
|
|
33
33
|
* @param {object} [o.env] process.env
|
|
34
34
|
*/
|
|
35
|
-
export async function doctor ({ config, root, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false }) {
|
|
35
|
+
export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false }) {
|
|
36
36
|
const checks = []
|
|
37
37
|
const add = (name, ok, detail = '', hint = '') => checks.push({ name, ok, detail, hint })
|
|
38
38
|
|
|
39
39
|
const major = Number(process.versions.node.split('.')[0])
|
|
40
40
|
add('node', major >= 20, `Node ${process.versions.node}`, major >= 20 ? '' : 'Kaloko needs Node 20 or newer.')
|
|
41
41
|
|
|
42
|
-
add('config', true, `${root}/
|
|
42
|
+
add('config', true, `${file || `${root}/kaloko.config.yml`} · org ${config.org || '—'} · ${Object.keys(config.environments || {}).length} environments`)
|
|
43
43
|
if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the organization slug from kaloko.app).')
|
|
44
44
|
|
|
45
45
|
try { const c = await launchChrome(config); add('chrome', true, c.version ? `Chrome ${c.version}` : 'Chrome found') } catch (e) { add('chrome', false, e.message.split('\n')[0].slice(0, 160), config.browser?.channel ? 'Install Google Chrome, or remove browser.channel to use bundled Chromium (npx playwright install chromium).' : 'Run: npx playwright install chromium') }
|
package/src/lib/jev.mjs
CHANGED
|
@@ -10,11 +10,13 @@ export const DEFAULTS = { hosted: true, signals: true, concurrency: 6, model: 'j
|
|
|
10
10
|
* does not count them against the allowance).
|
|
11
11
|
*/
|
|
12
12
|
export const SIGNALS = {
|
|
13
|
-
_purpose: (node) => node
|
|
13
|
+
_purpose: (node, capture) => purposeOf(node, capture) ? 'Does `page` do what `screen_purpose` says this screen must achieve?' : null,
|
|
14
14
|
_language: (node, capture) => capture.locale ? `Is the visible text of \`page\` mainly in the language with code "${capture.locale}"?` : null,
|
|
15
15
|
_broken: () => 'Does `page` look broken: an error message, placeholder text (lorem ipsum, TODO, {{variables}}), missing content or a blank page?'
|
|
16
16
|
}
|
|
17
17
|
export const SIGNAL_NAMES = { _purpose: 'purpose', _language: 'language', _broken: 'broken' }
|
|
18
|
+
/** What the captured page must achieve: a stack page's own purpose (items: { purpose }) before the step's. */
|
|
19
|
+
export const purposeOf = (node, capture) => capture?.item?.purpose || node.purpose || ''
|
|
18
20
|
|
|
19
21
|
/** Builds the JEV request for one capture: one `noul` question per semantic criterion, plus the quick signals. */
|
|
20
22
|
export function buildRequest ({ node, capture, criteria, model, signals = false }) {
|
|
@@ -37,7 +39,7 @@ export function buildRequest ({ node, capture, criteria, model, signals = false
|
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
41
|
const state = {
|
|
40
|
-
screen_purpose: node
|
|
42
|
+
screen_purpose: purposeOf(node, capture),
|
|
41
43
|
url: capture.url,
|
|
42
44
|
locale: capture.locale,
|
|
43
45
|
viewport: capture.viewport,
|
package/src/lib/stack.mjs
CHANGED
|
@@ -34,18 +34,18 @@ export function sitemapLocs (xml) {
|
|
|
34
34
|
return { index: /<sitemapindex[\s>]/i.test(xml), locs }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/** Reads a list file: text (one per line), CSV (first column) or JSON ([path] or [{path|url, title, key}]). */
|
|
37
|
+
/** Reads a list file: text (one per line), CSV (first column) or JSON ([path] or [{path|url, title, key, purpose}]). */
|
|
38
38
|
export async function listFile (file) {
|
|
39
39
|
const text = await readFile(file, 'utf8').catch(() => { throw new UserError(`items.file ${file} cannot be read.`) })
|
|
40
40
|
if (extname(file) === '.json') {
|
|
41
41
|
const data = JSON.parse(text)
|
|
42
|
-
return (Array.isArray(data) ? data : data.items || []).map((x) => (typeof x === 'string' ? { path: x } : { path: x.path || x.url, title: x.title, key: x.key }))
|
|
42
|
+
return (Array.isArray(data) ? data : data.items || []).map((x) => (typeof x === 'string' ? { path: x } : { path: x.path || x.url, title: x.title, key: x.key, purpose: x.purpose }))
|
|
43
43
|
}
|
|
44
44
|
return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#')).map((l) => ({ path: extname(file) === '.csv' ? l.split(/[,;]/)[0].replace(/^"|"$/g, '') : l })).filter((x) => x.path && !/^(path|url)$/i.test(x.path))
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* The pages of a stack step, deduplicated, filtered (match/exclude), limited: [{ id, path, url, title? }].
|
|
48
|
+
* The pages of a stack step, deduplicated, filtered (match/exclude), limited: [{ id, path, url, title?, purpose? }].
|
|
49
49
|
* `base` is the environment's URL for the locale; `page` is used for crawl and script sources. `match` and
|
|
50
50
|
* `exclude` are regexes on the path with its query; `{locale}` in any source string is the walked locale.
|
|
51
51
|
*/
|
|
@@ -79,7 +79,7 @@ export async function resolveItems ({ node, base, page, scenarioDir, env, locale
|
|
|
79
79
|
const fn = mod.default || mod.items
|
|
80
80
|
if (typeof fn !== 'function') throw new UserError(`items.script ${src.script} must export a default function.`)
|
|
81
81
|
raw = (await fn({ page, env, locale, url: abs })) || []
|
|
82
|
-
raw = raw.map((x) => (typeof x === 'string' ? { path: x } : { path: x.path || x.url, title: x.title, key: x.key }))
|
|
82
|
+
raw = raw.map((x) => (typeof x === 'string' ? { path: x } : { path: x.path || x.url, title: x.title, key: x.key, purpose: x.purpose }))
|
|
83
83
|
}
|
|
84
84
|
const match = src.match ? new RegExp(src.match) : null
|
|
85
85
|
const exclude = src.exclude ? new RegExp(src.exclude) : null
|
|
@@ -93,7 +93,8 @@ export async function resolveItems ({ node, base, page, scenarioDir, env, locale
|
|
|
93
93
|
if (exclude && exclude.test(test)) continue
|
|
94
94
|
if (seen.has(path)) continue
|
|
95
95
|
seen.add(path)
|
|
96
|
-
|
|
96
|
+
// `purpose` of an item (what this one page must achieve) replaces the step's purpose for the evaluator
|
|
97
|
+
out.push({ id: r.key ? itemId(String(r.key), taken) : itemId(path, taken), path, url: path.startsWith('http') ? path : toUrl(path), ...(r.title ? { title: r.title } : {}), ...(r.purpose ? { purpose: String(r.purpose).slice(0, 500) } : {}) })
|
|
97
98
|
if (out.length >= max) break
|
|
98
99
|
}
|
|
99
100
|
return { items: out, total: seen.size, truncated: out.length >= max && raw.length > out.length }
|