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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
5
5
  "keywords": [
6
6
  "qa",
@@ -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
  },
@@ -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
  }
@@ -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}/qawalk.config.yml · org ${config.org || '—'} · ${Object.keys(config.environments || {}).length} environments`)
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.purpose ? 'Does `page` do what `screen_purpose` says this screen must achieve?' : null,
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.purpose || '',
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
- 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 } : {}) })
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 }