kaloko 0.5.2 → 0.7.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.
@@ -0,0 +1,211 @@
1
+ // Batch H in the CLI: `kaloko issue create|list` (Jira and Linear, #21) and `kaloko schedule …` (hosted runs, #16):
2
+ // add a read-only scenario to Kaloko's own browsers, list, run, pause, resume and remove schedules, or write a GitHub
3
+ // Actions workflow that walks it on a schedule with the full CLI (`schedule export github`).
4
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
5
+ import { existsSync } from 'node:fs'
6
+ import { resolve, relative, dirname, join } from 'node:path'
7
+ import { UserError } from './util.mjs'
8
+ import { gateOf, accountFor } from './access.mjs'
9
+ import { secret } from './secrets.mjs'
10
+ import { runner } from './ci.mjs'
11
+
12
+ const RUN_ID = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]{3,120}$/
13
+ const WEEKDAYS = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 }
14
+
15
+ // ---------- kaloko issue ----------
16
+
17
+ const issueLine = (i) => `${i.key.padEnd(10)} ${String(i.state).padEnd(8)} step ${i.step}${i.criterion ? ` (${i.criterion})` : ''} · ${i.title}\n ${i.url}`
18
+
19
+ /**
20
+ * `kaloko issue create --step <id> --to jira|linear [--criterion AC2] [--title …] [--note …] [--run id] [--locale l]
21
+ * [--viewport v] [--target KEY] [--json]` and `kaloko issue list [--run id] [--scenario id] [--state open] [--json]`.
22
+ */
23
+ export async function issueCmd ({ args, config, client, runId, say }) {
24
+ const sub = args._[0]
25
+ const org = config.org
26
+ if (!org) throw new UserError('Set org: in the config first.')
27
+ if (sub === 'list') {
28
+ const q = new URLSearchParams(Object.entries({ run: typeof args.run === 'string' ? args.run : null, scenario: typeof args.scenario === 'string' ? args.scenario : null, state: typeof args.state === 'string' ? args.state : null }).filter(([, v]) => v))
29
+ const items = await client.request('GET', `/orgs/${org}/issues${q.size ? `?${q}` : ''}`)
30
+ if (args.json) { say(JSON.stringify(items, null, 2)); return items }
31
+ say(items.length ? items.map(issueLine).join('\n') : 'No issues created from steps yet.')
32
+ return items
33
+ }
34
+ if (sub !== 'create') throw new UserError('Usage: kaloko issue create --step <id> --to jira|linear [--criterion AC2] [--title "…"] [--note "…"] [--run id]\n kaloko issue list [--run id] [--scenario id] [--state open|done|canceled] [--json]')
35
+ const to = String(args.to || '')
36
+ if (!['jira', 'linear'].includes(to)) throw new UserError('--to jira or --to linear says where the issue goes.')
37
+ if (typeof args.step !== 'string' || !args.step) throw new UserError('--step <id> names the step the issue is about.')
38
+ const run = typeof args.run === 'string' ? args.run : runId
39
+ if (!run || !RUN_ID.test(run)) throw new UserError('No shared run: pass --run <id>, or share the current run first (kaloko share).')
40
+ const body = { run_id: run, step: args.step, to, ...Object.fromEntries(['criterion', 'title', 'note', 'locale', 'viewport', 'target'].filter((k) => typeof args[k] === 'string' && args[k]).map((k) => [k, args[k]])) }
41
+ const issue = await client.request('POST', `/orgs/${org}/issues`, body)
42
+ if (args.json) { say(JSON.stringify(issue, null, 2)); return issue }
43
+ say(`✓ ${issue.key} in ${to === 'jira' ? 'Jira' : 'Linear'}${issue.screenshot ? ', with the screenshot' : ''}: ${issue.url}\n The step got a comment that is resolved when the issue is done.`)
44
+ return issue
45
+ }
46
+
47
+ // ---------- kaloko schedule ----------
48
+
49
+ /** A step script that only opens its page (export default async ({ page, url }) => { await page.goto(url(…)) }). */
50
+ export function onlyNavigates (code) {
51
+ const s = String(code || '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1').trim()
52
+ return /^export\s+default\s+async\s*(?:function\s*\w*\s*)?\(\s*\{\s*page\s*,\s*url\s*\}\s*\)\s*(?:=>)?\s*\{\s*(?:await\s+page\.goto\(\s*url\(\s*\)\s*(?:,\s*\{[^{}]*\})?\s*\)\s*;?\s*)\}\s*;?$/.test(s)
53
+ }
54
+
55
+ /** The scenario as the service takes it: packs expanded, steps whose script only navigates marked hosted: open. */
56
+ export async function hostedScenario (scenario, scenarioFile) {
57
+ const out = JSON.parse(JSON.stringify(scenario))
58
+ for (const n of out.nodes || []) {
59
+ const script = n.execution?.script
60
+ if (!script || /^open\.(m?js|cjs)$/.test(String(script).split('/').pop())) continue
61
+ try { if (onlyNavigates(await readFile(resolve(dirname(scenarioFile), script), 'utf8'))) n.hosted = 'open' } catch {}
62
+ }
63
+ return out
64
+ }
65
+
66
+ /** How the hosted browser gets past the environment's gate: HTTP Basic from its variables, or an account's cookies. */
67
+ export async function hostedAuth ({ env, account = null, root, config, vars = process.env }) {
68
+ if (account) {
69
+ const file = accountFor(env, account, { root, config }).storage_state
70
+ let state
71
+ try { state = JSON.parse(await readFile(file, 'utf8')) } catch { throw new UserError(`No saved session for ${account} in ${env.name} (${relative(process.cwd(), file)}). Sign in once with kaloko auth save --env ${env.name} --account ${account}, then schedule again.`) }
72
+ return { cookies: (state.cookies || []).map(({ name, value, domain, path, expires, httpOnly, secure, sameSite }) => ({ name, value, domain, path, expires, httpOnly, secure, sameSite })) }
73
+ }
74
+ const gate = gateOf(env)
75
+ if (gate.basic) return { basic: { user: secret(gate.basic.user_env, `HTTP Basic user for ${env.name}`, vars), password: secret(gate.basic.password_env, `HTTP Basic password for ${env.name}`, vars) } }
76
+ if (gate.headers && Object.keys(gate.headers).length) throw new UserError(`${env.name} is behind header credentials (access.headers); hosted runs send HTTP Basic or a saved session only. Walk it in CI: kaloko schedule export github.`)
77
+ return null
78
+ }
79
+
80
+ const atHour = (v, d = 5) => { const n = Number(String(v ?? '').split(':')[0]); return Number.isInteger(n) && n >= 0 && n <= 23 ? n : d }
81
+ const weekdayOf = (v, d = 1) => WEEKDAYS[String(v || '').slice(0, 3).toLowerCase()] || (Number(v) >= 1 && Number(v) <= 7 ? Number(v) : d)
82
+ const when = (s) => s.every === 'manual' ? 'on demand' : `${s.every === 'week' ? `weekly on ${Object.keys(WEEKDAYS)[s.weekday - 1]}` : 'daily'} at ${String(s.at_hour).padStart(2, '0')}:00 UTC`
83
+ const scheduleLine = (s) => `${s.id} ${s.scenario_id} on ${s.environment} (${s.base_url}) · ${when(s)}${s.enabled ? '' : ' · paused'}${s.pages_per_run ? ` · ${s.pages_per_run} pages` : ''}\n next ${s.enabled && s.next_run_at ? s.next_run_at.slice(0, 16).replace('T', ' ') : '—'} · last ${s.last_run_at ? `${s.last_run_at.slice(0, 16).replace('T', ' ')} ${s.last_status}${s.last_run_id ? ` ${s.last_run_id}` : ''}` : '—'}${s.last_error ? `\n ! ${s.last_error}` : ''}`
84
+
85
+ /** The GitHub Actions workflow that walks a scenario on a schedule with the full CLI and shares the run. */
86
+ export function githubScheduleWorkflow ({ scenario, scenarioId, env, every = 'day', at_hour = 5, weekday = 1, pm = 'npm', dependency = true, version = 'latest', node = 22, browsers = ['chromium'], secrets = [] }) {
87
+ const { install, kaloko: k } = runner({ pm, dependency, version })
88
+ const cron = every === 'week' ? `0 ${at_hour} * * ${weekday % 7}` : `0 ${at_hour} * * *`
89
+ const engines = browsers.some((b) => b !== 'chromium') ? ` --browsers ${browsers.join(',')}` : ''
90
+ const names = [...new Set(secrets.filter((n) => /^[A-Z_][A-Z0-9_]*$/.test(n)))]
91
+ return `# Kaloko: walk ${scenarioId} on ${env} ${every === 'week' ? 'every week' : 'every day'} and share the run.
92
+ # Written by kaloko schedule export github. Secrets: KALOKO_TOKEN (tester role, upload scope; on Enterprise a service
93
+ # account's)${names.length ? `; ${names.join(', ')} (the environment's credentials, as in kaloko.config.yml)` : ''}.
94
+ name: Kaloko ${scenarioId} on ${env}
95
+ on:
96
+ schedule:
97
+ - cron: '${cron}' # UTC
98
+ workflow_dispatch:
99
+ concurrency:
100
+ group: kaloko-schedule-${scenarioId}-${env}
101
+ cancel-in-progress: false
102
+ permissions:
103
+ contents: read
104
+ jobs:
105
+ walk:
106
+ runs-on: ubuntu-latest
107
+ timeout-minutes: 45
108
+ env:
109
+ KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
110
+ TYPESAFE_API_KEY: \${{ secrets.TYPESAFE_API_KEY }}
111
+ KALOKO_AGENT: github-actions
112
+ ${names.map((n) => ` ${n}: \${{ secrets.${n} }}`).join('\n')}${names.length ? '\n' : ''} steps:
113
+ - uses: actions/checkout@v4
114
+ - uses: actions/setup-node@v4
115
+ with:
116
+ node-version: ${node}
117
+ ${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - run: corepack enable\n' : ''} - run: ${install}\n` : ''} - run: ${k} browser install --with-deps${engines}
118
+ - name: Walk, evaluate, share
119
+ run: |
120
+ ${k} start --scenario ${scenario} --env ${env}
121
+ ${k} walk
122
+ ${k} evaluate
123
+ ${k} share
124
+ `
125
+ }
126
+
127
+ /** Variable names the environment and the scenario's accounts read their secrets from. */
128
+ export function secretNames (env, scenario = {}) {
129
+ const out = []
130
+ const gate = gateOf(env)
131
+ if (gate.basic) out.push(gate.basic.user_env, gate.basic.password_env)
132
+ for (const spec of Object.values(gate.headers || {})) if (spec && typeof spec === 'object' && spec.env) out.push(spec.env)
133
+ const used = new Set(Object.values(scenario.sessions || {}).map((s) => s?.account).filter(Boolean))
134
+ for (const [key, a] of Object.entries(env.accounts || {})) if (used.has(key)) out.push(a.user_env, a.password_env, a.totp_env)
135
+ return out.filter(Boolean)
136
+ }
137
+
138
+ /**
139
+ * `kaloko schedule add --scenario <file> --env <name> [--every day|week|manual] [--at 5] [--weekday mon] [--account key]`,
140
+ * `list`, `run <id>`, `pause <id>`, `resume <id>`, `remove <id>`, `export github --scenario <file> --env <name>`.
141
+ * deps: { loadScenario, environment, baseUrl, projectAndIssue, packageRoot, service }.
142
+ */
143
+ export async function scheduleCmd ({ args, config, root, say, deps }) {
144
+ const [sub, id] = args._
145
+ const org = config.org
146
+ const client = () => deps.service(config)
147
+ if (sub === 'export') {
148
+ if (id !== 'github') throw new UserError('Usage: kaloko schedule export github --scenario <file> --env <name> [--every day|week] [--at 5] [--weekday mon] [--out file] [--print] [--force]')
149
+ const file = typeof args.scenario === 'string' ? resolve(root, args.scenario) : null
150
+ if (!file) throw new UserError('--scenario <file> names the scenario to walk.')
151
+ const envName = typeof args.env === 'string' ? args.env : null
152
+ if (!envName) throw new UserError('--env <name> names the environment (production, staging …).')
153
+ const scenario = await deps.loadScenario(file)
154
+ // only the names of its variables are needed here: the workflow reads the values from repository secrets
155
+ if (!config.environments?.[envName]) throw new UserError(`Unknown environment "${envName}". Known: ${Object.keys(config.environments || {}).join(', ')}`)
156
+ const env = { name: envName, ...config.environments[envName] }
157
+ if (scenario.environments && !scenario.environments.includes(envName)) throw new UserError(`${relative(root, file)} allows environments [${scenario.environments.join(', ')}], not ${envName}.`)
158
+ let pkg = null
159
+ try { pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) } catch {}
160
+ const pm = existsSync(join(root, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(root, 'yarn.lock')) ? 'yarn' : existsSync(join(root, 'bun.lockb')) || existsSync(join(root, 'bun.lock')) ? 'bun' : 'npm'
161
+ const version = JSON.parse(await readFile(join(deps.packageRoot, 'package.json'), 'utf8')).version
162
+ const every = args.every === 'week' ? 'week' : 'day'
163
+ const content = githubScheduleWorkflow({ scenario: relative(root, file), scenarioId: scenario.id, env: envName, every, at_hour: atHour(args.at), weekday: weekdayOf(args.weekday), pm, dependency: !!(pkg && ({ ...pkg.dependencies, ...pkg.devDependencies }).kaloko), version: `^${version}`, browsers: [...new Set(['chromium', ...(scenario.browsers || []), ...(config.walk?.browsers || [])])], secrets: secretNames(env, scenario) })
164
+ if (args.print) { say(content); return { content } }
165
+ const out = resolve(root, typeof args.out === 'string' ? args.out : `.github/workflows/kaloko-${scenario.id.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${envName}.yml`)
166
+ if (existsSync(out) && !args.force) throw new UserError(`${relative(process.cwd(), out)} exists; pass --force to replace it (or --print to compare).`)
167
+ await mkdir(dirname(out), { recursive: true })
168
+ await writeFile(out, content)
169
+ say(`Wrote ${relative(process.cwd(), out)}: ${scenario.id} on ${envName} ${every === 'week' ? 'every week' : 'every day'} at ${String(atHour(args.at)).padStart(2, '0')}:00 UTC with the full walk (clicks, forms, every criterion).\nNext: add the repository secret KALOKO_TOKEN${secretNames(env, scenario).length ? ` and ${secretNames(env, scenario).join(', ')}` : ''}, then commit the workflow.`)
170
+ return { file: out }
171
+ }
172
+ if (!org) throw new UserError('Set org: in the config first.')
173
+ if (sub === 'list' || !sub) {
174
+ const data = await client().request('GET', `/orgs/${org}/schedules`)
175
+ if (args.json) { say(JSON.stringify(data, null, 2)); return data }
176
+ if (!data.configured) { say('Hosted runs are not enabled on this service. Walk on a schedule in CI: kaloko schedule export github --scenario <file> --env <name>'); return data }
177
+ say(`${data.usage.pages} of ${data.usage.budget} hosted pages used in ${data.usage.month}${data.plan_allows ? '' : ' (hosted runs come with the Business plan)'}`)
178
+ say(data.schedules.length ? data.schedules.map(scheduleLine).join('\n') : 'No schedules yet: kaloko schedule add --scenario <file> --env <name> --every day')
179
+ return data
180
+ }
181
+ if (sub === 'add') {
182
+ const file = typeof args.scenario === 'string' ? resolve(root, args.scenario) : null
183
+ if (!file) throw new UserError('--scenario <file> names a read-only scenario.')
184
+ const envName = typeof args.env === 'string' ? args.env : null
185
+ if (!envName) throw new UserError('--env <name> names the environment (its base_url is what the hosted browser opens).')
186
+ const scenario = await deps.loadScenario(file)
187
+ const env = deps.environment(config, envName)
188
+ if (scenario.environments && !scenario.environments.includes(envName)) throw new UserError(`${relative(root, file)} allows environments [${scenario.environments.join(', ')}], not ${envName}.`)
189
+ if (env.kind === 'design' || env.base_url == null) throw new UserError(`${envName} has no web address; hosted runs open web pages.`)
190
+ if (typeof env.base_url !== 'string') throw new UserError(`${envName} has a base_url per locale; hosted runs take one address. Walk it in CI: kaloko schedule export github.`)
191
+ const every = ['day', 'week', 'manual'].includes(args.every) ? args.every : 'day'
192
+ const { project } = await deps.projectAndIssue({ cwd: root, config, scenario, branch: null })
193
+ const body = { scenario: await hostedScenario(scenario, file), environment: envName, base_url: deps.baseUrl(env), every, at_hour: atHour(args.at), weekday: weekdayOf(args.weekday), project: project?.slug || null, auth: await hostedAuth({ env, account: typeof args.account === 'string' ? args.account : null, root, config }) }
194
+ let s
195
+ try { s = await client().request('POST', `/orgs/${org}/schedules`, body) } catch (e) {
196
+ if (/HTTP 404/.test(e.message)) throw new UserError(`${e.message}\nWalk it on a schedule in CI instead: kaloko schedule export github --scenario ${relative(root, file)} --env ${envName}`)
197
+ throw e
198
+ }
199
+ if (args.json) { say(JSON.stringify(s, null, 2)); return s }
200
+ say(`✓ ${s.created ? 'Scheduled' : 'Updated'} ${s.scenario_id} on ${envName} ${when(s)}: ${s.pages_per_run} pages per run${s.auth !== 'none' ? ` (${s.auth === 'basic' ? 'HTTP Basic' : 'saved session'})` : ''}. Runs are shared as you and land in the inbox.`)
201
+ for (const k of s.skipped || []) say(` - ${k.id}: not walked (${k.reason})`)
202
+ if ((s.skipped || []).length) say(' Steps that click or type need the full walk: kaloko schedule export github')
203
+ return s
204
+ }
205
+ if (!id) throw new UserError(`Usage: kaloko schedule ${sub} <schedule id> (kaloko schedule list shows them)`)
206
+ const path = `/orgs/${org}/schedules/${encodeURIComponent(id)}`
207
+ if (sub === 'run') { await client().request('POST', `${path}/run`); say(`✓ ${id} starts within 15 minutes; the run lands in your inbox.`); return }
208
+ if (sub === 'pause' || sub === 'resume') { const s = await client().request('PATCH', path, { enabled: sub === 'resume' }); say(`✓ ${id} ${s.enabled ? `resumed: ${when(s)}` : 'paused'}`); return s }
209
+ if (sub === 'remove') { await client().request('DELETE', path); say(`✓ ${id} removed; its runs stay.`); return }
210
+ throw new UserError('Usage: kaloko schedule add|list|run|pause|resume|remove|export github (kaloko help)')
211
+ }
@@ -14,14 +14,23 @@ function describeFocus () {
14
14
  const path = (e) => { const p = []; for (; e && e.nodeType === 1; e = e.parentElement || e.getRootNode()?.host) { let i = 1; for (let s = e.previousElementSibling; s; s = s.previousElementSibling) i++; p.unshift(`${e.tagName.toLowerCase()}:${i}`) } return p.join('/') }
15
15
  const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim()
16
16
  const name = clean(el.getAttribute('aria-label') || (el.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean).map((i) => document.getElementById(i)?.innerText || '').join(' ') || el.innerText || el.getAttribute('alt') || el.getAttribute('title') || el.value || el.getAttribute('placeholder') || [...el.querySelectorAll('img[alt]')].map((i) => i.alt).join(' '))
17
- const r = el.getBoundingClientRect(); const cs = getComputedStyle(el)
17
+ // a visually hidden radio or checkbox shows itself and its focus through its label (segmented switches)
18
+ let shown = el
19
+ const own = getComputedStyle(el); const ob = el.getBoundingClientRect()
20
+ // a small radio or checkbox inside its label: the label is the control people see, and its focus style lives there
21
+ const wrap = el.labels && [...el.labels].find((l) => l.contains(el))
22
+ if (wrap && ob.width <= 16 && ob.height <= 16) { const b = wrap.getBoundingClientRect(); if (b.width > ob.width && b.height > 0) shown = wrap }
23
+ if (shown === el && el.labels && el.labels.length && (Number(own.opacity) <= 0.05 || ob.width < 2 || ob.height < 2)) shown = [...el.labels].find((l) => { const b = l.getBoundingClientRect(); return b.width > 0 && b.height > 0 && getComputedStyle(l).visibility !== 'hidden' }) || el
24
+ const r = shown.getBoundingClientRect(); const cs = getComputedStyle(shown)
18
25
  let fixed = false
19
26
  for (let e = el; e && e.nodeType === 1; e = e.parentElement) { const p = getComputedStyle(e).position; if (p === 'fixed' || p === 'sticky') { fixed = true; break } }
20
27
  const visible = r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && Number(cs.opacity) > 0.05 && r.right + scrollX > 0 && r.bottom + scrollY > 0
21
28
  const inView = r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth
22
29
  let obscured = false
23
30
  if (visible && inView) {
24
- const hit = document.elementFromPoint(Math.min(innerWidth - 1, Math.max(0, r.left + r.width / 2)), Math.min(innerHeight - 1, Math.max(0, r.top + r.height / 2)))
31
+ // an inline link wrapped over lines: test its first line box, not the middle of the bounding box (a neighbour may sit there)
32
+ const line = getComputedStyle(el).display === 'inline' && el.getClientRects().length > 1 ? el.getClientRects()[0] : r
33
+ const hit = document.elementFromPoint(Math.min(innerWidth - 1, Math.max(0, line.left + line.width / 2)), Math.min(innerHeight - 1, Math.max(0, line.top + line.height / 2)))
25
34
  obscured = !!hit && !(hit === el || el.contains(hit) || hit.contains(el) || (el.labels && [...el.labels].some((l) => l.contains(hit))))
26
35
  }
27
36
  const cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : ''
@@ -30,7 +39,10 @@ function describeFocus () {
30
39
  desc: `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${cls}`,
31
40
  x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height),
32
41
  visible, fixed, obscured, dialog: !!el.closest('dialog[open], [aria-modal=true]'), main: !!el.closest('main, [role=main]'),
33
- tabindex: el.getAttribute('tabindex')
42
+ tabindex: el.getAttribute('tabindex'),
43
+ // focus inside content the page cannot see into: an iframe, or an element that is not focusable itself (the host
44
+ // of a closed shadow tree, e.g. a third-party widget wrapping its own iframe)
45
+ embedded: el.tagName === 'IFRAME' || !el.matches('a[href], area[href], button, input, select, textarea, summary, iframe, [tabindex], [contenteditable]:not([contenteditable=false])')
34
46
  }
35
47
  }
36
48
 
@@ -43,9 +55,18 @@ function focusByPath (key) {
43
55
  el = kids[i - 1]
44
56
  if (!el) return null
45
57
  }
46
- el.scrollIntoView({ block: 'center', inline: 'nearest' }); el.focus({ preventScroll: true })
47
- const r = el.getBoundingClientRect()
48
- return { x: r.left, y: r.top, w: r.width, h: r.height, vw: innerWidth, vh: innerHeight }
58
+ // a visually hidden radio or checkbox: its label is what shows the focus (as in describeFocus)
59
+ const ob = el.getBoundingClientRect()
60
+ // a small radio or checkbox inside its label: the label (as in describeFocus)
61
+ const wrap = el.labels && [...el.labels].find((l) => l.contains(el))
62
+ const wb = wrap && ob.width <= 16 && ob.height <= 16 ? wrap.getBoundingClientRect() : null
63
+ const shown = wb && wb.width > ob.width && wb.height > 0 ? wrap : el.labels && el.labels.length && (Number(getComputedStyle(el).opacity) <= 0.05 || ob.width < 2 || ob.height < 2) ? [...el.labels].find((l) => { const b = l.getBoundingClientRect(); return b.width > 0 && b.height > 0 }) || el : el
64
+ shown.scrollIntoView({ block: 'center', inline: 'nearest' }); el.focus({ preventScroll: true })
65
+ const r = shown.getBoundingClientRect()
66
+ // an inline element wrapped over lines draws its ring around each line box: their outlines, not the bounding box
67
+ const rects = getComputedStyle(shown).display === 'inline' ? [...shown.getClientRects()].filter((q) => q.width > 0 && q.height > 0) : []
68
+ const lines = rects.length > 1 ? Math.round(rects.reduce((sum, q) => sum + 2 * (q.width + q.height), 0)) : null
69
+ return { x: r.left, y: r.top, w: r.width, h: r.height, vw: innerWidth, vh: innerHeight, lines }
49
70
  }
50
71
 
51
72
  // In the page: static facts for the order check (positive tabindex, controls taken out of the tab order, click-only).
@@ -53,7 +74,11 @@ function orderFacts () {
53
74
  const visible = (e) => { const r = e.getBoundingClientRect(); const cs = getComputedStyle(e); return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' }
54
75
  const d = (e) => `${e.tagName.toLowerCase()}${e.id ? '#' + e.id : ''}${typeof e.className === 'string' && e.className.trim() ? '.' + e.className.trim().split(/\s+/).slice(0, 2).join('.') : ''}`
55
76
  const pos = [...document.querySelectorAll('[tabindex]')].filter((e) => Number(e.getAttribute('tabindex')) > 0 && visible(e))
56
- const removed = [...document.querySelectorAll('a[href][tabindex^="-"], button[tabindex^="-"], input[tabindex^="-"]:not([type=hidden]), select[tabindex^="-"], textarea[tabindex^="-"]')].filter((e) => visible(e) && !e.closest('[aria-hidden=true], [inert]'))
77
+ // roving tabindex (WAI-ARIA composite widgets: tabs, radio groups, toolbars, menus, listboxes, grids, trees): the
78
+ // widget is one tab stop and arrow keys move inside it, so its other items rightly carry tabindex="-1"
79
+ const widget = '[role=tablist], [role=radiogroup], [role=toolbar], [role=menu], [role=menubar], [role=listbox], [role=grid], [role=treegrid], [role=tree]'
80
+ const roving = (e) => { const w = e.parentElement && e.parentElement.closest(widget); return !!w && [...w.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')].some((x) => x !== e && x.closest(widget) === w && !x.disabled && !/^-/.test(x.getAttribute('tabindex') || '') && visible(x)) }
81
+ const removed = [...document.querySelectorAll('a[href][tabindex^="-"], button[tabindex^="-"], input[tabindex^="-"]:not([type=hidden]), select[tabindex^="-"], textarea[tabindex^="-"]')].filter((e) => visible(e) && !e.closest('[aria-hidden=true], [inert]') && !roving(e))
57
82
  const clickOnly = [...document.querySelectorAll('[role=button], [role=link], [role=tab], [role=menuitem], [role=checkbox], [onclick]')].filter((e) => visible(e) && !e.matches('a[href], button, input, select, textarea, summary, [tabindex], [contenteditable=true]') && !e.closest('a[href], button'))
58
83
  const box = (e) => { const r = e.getBoundingClientRect(); return { x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height) } }
59
84
  return { positive: pos.slice(0, 5).map(d), positiveCount: pos.length, removed: removed.slice(0, 5).map(d), removedCount: removed.length, clickOnly: clickOnly.slice(0, 5).map(d), clickOnlyCount: clickOnly.length, boxes: [...removed, ...clickOnly].slice(0, 5).map(box), innerHeight }
@@ -97,8 +122,9 @@ export function focusDelta (focusedPng, plainPng) {
97
122
  }
98
123
 
99
124
  /** Verdict of one focus stop from its delta and size: ok, weak (below 3:1), none (no visible change). */
100
- export function focusVerdict (delta, w, h) {
101
- const perimeter = 2 * (Math.max(1, w) + Math.max(1, h))
125
+ export function focusVerdict (delta, w, h, edge = null) {
126
+ // edge: the length of the element's outline inside the viewport (a tall panel shows only its sides)
127
+ const perimeter = edge > 0 ? edge : 2 * (Math.max(1, w) + Math.max(1, h))
102
128
  if (delta.strong >= perimeter * 0.75) return 'ok'
103
129
  if (delta.changed >= perimeter * 0.5) return 'weak'
104
130
  return 'none'
@@ -136,7 +162,8 @@ export async function keyboardChecks (page, criteria, { maxStops = 60, focusChec
136
162
  const f = await page.evaluate(describeFocus).catch(() => null)
137
163
  if (!f) { keyboard.complete = true; break } // focus went back to the browser: the whole page was walked
138
164
  if (stops.length && f.key === stops[0].key) { keyboard.complete = true; break }
139
- if (f.tag === 'iframe') { if (stops.at(-1)?.key === f.key) { if (++iframeRun > 30) break; continue } } else iframeRun = 0
165
+ // Tab moves inside an iframe or a closed widget while the page still reports the same element: count it once
166
+ if (f.embedded) { if (stops.at(-1)?.key === f.key) { if (++iframeRun > 30) break; continue } } else iframeRun = 0
140
167
  if (seen.has(f.key)) { const from = seen.get(f.key); const cycle = stops.slice(from); trap = { at: from, size: cycle.length, dialog: cycle.every((s) => s.dialog), first: cycle[0] }; break }
141
168
  seen.set(f.key, stops.length)
142
169
  stops.push(f)
@@ -196,7 +223,7 @@ export async function keyboardChecks (page, criteria, { maxStops = 60, focusChec
196
223
  // visible focus: each stop focused and unfocused, the same area compared (keyboard modality stays on)
197
224
  const verdicts = new Map()
198
225
  if (want.has('a11y_focus_visible') || shots > 0) {
199
- const candidates = stops.map((s, i) => ({ s, n: i + 1 })).filter(({ s }) => s.visible && s.tag !== 'iframe').slice(0, Math.max(want.has('a11y_focus_visible') ? focusChecks : 0, shots))
226
+ const candidates = stops.map((s, i) => ({ s, n: i + 1 })).filter(({ s }) => s.visible && !s.embedded).slice(0, Math.max(want.has('a11y_focus_visible') ? focusChecks : 0, shots))
200
227
  for (const { s, n } of candidates) {
201
228
  const pos = await page.evaluate(focusByPath, s.key).catch(() => null)
202
229
  if (!pos || !pos.w || !pos.h) continue
@@ -212,7 +239,10 @@ export async function keyboardChecks (page, criteria, { maxStops = 60, focusChec
212
239
  await page.evaluate(() => { let a = document.activeElement; while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement; a?.blur?.() })
213
240
  const plain = await page.screenshot({ clip, scale: 'css', animations: 'disabled', caret: 'hide' })
214
241
  const delta = focusDelta(focused, plain)
215
- verdicts.set(n, { v: s.obscured ? 'obscured' : focusVerdict(delta, pos.w, pos.h), contrast: delta.contrast })
242
+ // only the edges of the element inside the viewport can show the indicator
243
+ const vw = Math.min(pos.vw, pos.x + pos.w) - Math.max(0, pos.x); const vh = Math.min(pos.vh, pos.y + pos.h) - Math.max(0, pos.y)
244
+ const edge = (pos.y >= 0 ? vw : 0) + (pos.y + pos.h <= pos.vh ? vw : 0) + (pos.x >= 0 ? vh : 0) + (pos.x + pos.w <= pos.vw ? vh : 0)
245
+ verdicts.set(n, { v: s.obscured ? 'obscured' : focusVerdict(delta, pos.w, pos.h, pos.lines || edge), contrast: delta.contrast })
216
246
  }
217
247
  if (want.has('a11y_focus_visible')) {
218
248
  const bad = [...verdicts].filter(([, x]) => x.v !== 'ok')
@@ -14,7 +14,7 @@ export const sampleUrl = (base, org) => `${String(base).replace(/\/$/, '')}/${or
14
14
  * Lines printed at the end of `kaloko init`. `devServer` ({ command, url }) when init found the app's dev server but it
15
15
  * is not answering yet; `scenario` the path of the first scenario init drafted, which then is the first real flow.
16
16
  */
17
- export function initNextSteps ({ base, org, loggedIn = false, devServer = null, scenario = null }) {
17
+ export function initNextSteps ({ base, org, loggedIn = false, devServer = null, scenario = null, products = false }) {
18
18
  const o = realOrg(org)
19
19
  const steps = [
20
20
  loggedIn ? 'Signed in already (KALOKO_TOKEN found).' : 'npx kaloko login (approve a token in the browser; or put KALOKO_TOKEN from Settings → API tokens into .env)'
@@ -23,6 +23,8 @@ export function initNextSteps ({ base, org, loggedIn = false, devServer = null,
23
23
  steps.push(o ? `Try a review on the sample run: ${sampleUrl(base, o)}` : 'Set org: in kaloko.config.yml, then open <service>/<org>/sample to try a review on the sample run.')
24
24
  if (scenario) steps.push(`Your first real flow: npx kaloko start --scenario ${scenario} --env local && npx kaloko walk`)
25
25
  else steps.push('Your first real flow: npx kaloko draft <your app URL> (public pages), or ask your agent:', ` "${FIRST_FLOW_PROMPT}"`)
26
+ // products: in the config show up in the app (Products) once they are on Kaloko, before any release
27
+ if (products) steps.push('Put the products of kaloko.config.yml on Kaloko: npx kaloko products sync (they show under Products with their changelog and docs)')
26
28
  const lines = ['Next:']
27
29
  let n = 0
28
30
  for (const s of steps) lines.push(s.startsWith(' "') ? ` ${s}` : ` ${++n}. ${s}`)
@@ -0,0 +1,36 @@
1
+ // `kaloko screens`: the screen library from the terminal (batch G): the newest capture of every step of the
2
+ // organization's flows, searched on the service (GET /orgs/<org>/screens), one line per screen with its canvas link.
3
+ import { UserError } from './util.mjs'
4
+
5
+ const FILTERS = ['project', 'product', 'module', 'scenario', 'locale', 'viewport', 'device', 'status', 'environment', 'limit', 'cursor']
6
+
7
+ /** The query string of a search from CLI arguments: words are the query, --designs adds design versions. */
8
+ export function screensQuery (args) {
9
+ const p = new URLSearchParams()
10
+ const q = (args._ || []).join(' ').trim() || (typeof args.q === 'string' ? args.q : '')
11
+ if (q) p.set('q', q)
12
+ for (const k of FILTERS) if (typeof args[k] === 'string' || typeof args[k] === 'number') p.set(k, String(args[k]))
13
+ if (args.designs || args.source === 'all') p.set('source', 'all')
14
+ else if (args.source === 'design') p.set('source', 'design')
15
+ if (args.all) p.set('group', 'none')
16
+ return p
17
+ }
18
+
19
+ export function formatScreens (r) {
20
+ if (!r.items.length) return 'No screen matches.'
21
+ const lines = r.items.map((it) => {
22
+ const where = [it.locale, it.viewport].filter(Boolean).join('/')
23
+ const st = it.status ? `${it.status}`.padEnd(8) : ''.padEnd(8)
24
+ const tag = it.source === 'design' ? ` [design${it.version ? ` v${it.version}` : ''}]` : ''
25
+ return `${st} ${it.scenario.id}/${it.step.id} ${where}${tag} ${it.step.title || ''}${it.url ? ` ${it.url}` : it.path ? ` ${it.path}` : ''}\n ${it.canvas}`
26
+ })
27
+ if (r.next) lines.push(`… more: kaloko screens ${r.filters?.q ? `"${r.filters.q}" ` : ''}--cursor ${r.next}`)
28
+ return lines.join('\n')
29
+ }
30
+
31
+ export async function screensCmd ({ config, client, args, say }) {
32
+ if (!config.org) throw new UserError('Set org: in kaloko.config.yml (or run kaloko login) to search the screen library.')
33
+ const r = await client.request('GET', `/orgs/${config.org}/screens?${screensQuery(args)}`)
34
+ say(args.json ? JSON.stringify(r, null, 2) : formatScreens(r))
35
+ return r
36
+ }
@@ -7,13 +7,26 @@ export function serviceUrl (config, vars = process.env) {
7
7
  return url.replace(/^https:\/\/app\.qawalk\.com$/, 'https://kaloko.app')
8
8
  }
9
9
 
10
+ /** Which CI runs this (github, gitlab, …), or null on a laptop: the service counts a team's first run from CI. */
11
+ export function ciName (vars = process.env) {
12
+ if (vars.GITHUB_ACTIONS === 'true') return 'github'
13
+ if (vars.GITLAB_CI) return 'gitlab'
14
+ if (vars.CIRCLECI) return 'circleci'
15
+ if (vars.BUILDKITE) return 'buildkite'
16
+ if (vars.JENKINS_URL) return 'jenkins'
17
+ if (vars.BITBUCKET_BUILD_NUMBER) return 'bitbucket'
18
+ if (vars.TF_BUILD) return 'azure'
19
+ return vars.CI && vars.CI !== 'false' ? 'ci' : null
20
+ }
21
+
10
22
  export function service (config) {
11
23
  const base = serviceUrl(config) + '/api/v1'
12
24
  const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
13
25
  const token = process.env[tokenEnv]
14
26
  if (!token) throw new UserError(`Set ${tokenEnv} (create a token in Kaloko → Settings).`)
15
27
  // an agent names itself (KALOKO_AGENT, e.g. "claude-code"); the service shows it in presence, history and audit (#42)
16
- const headers = { Authorization: `Bearer ${token}`, ...(process.env.KALOKO_AGENT ? { 'X-QAwalk-Agent': process.env.KALOKO_AGENT.slice(0, 60) } : {}) }
28
+ const ci = ciName()
29
+ const headers = { Authorization: `Bearer ${token}`, ...(process.env.KALOKO_AGENT ? { 'X-QAwalk-Agent': process.env.KALOKO_AGENT.slice(0, 60) } : {}), ...(ci ? { 'X-Kaloko-CI': ci } : {}) }
17
30
  const check = async (res, what) => {
18
31
  if (res.ok) return res.json()
19
32
  const body = await res.json().catch(() => ({}))
@@ -36,6 +49,12 @@ export function service (config) {
36
49
  const res = await limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }))
37
50
  return check(res, `${method} ${path}`)
38
51
  },
52
+ /** A request whose answer is not JSON (an export): the response, or an error with the service's message. */
53
+ async raw (method, path, body) {
54
+ const res = await limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }))
55
+ if (!res.ok) { const b = await res.json().catch(() => ({})); throw new UserError(`${method} ${path} → HTTP ${res.status}: ${b.error || res.statusText}`) }
56
+ return res
57
+ },
39
58
  async fetch (path) {
40
59
  const res = await fetch(base + path, { headers })
41
60
  if (!res.ok) { const body = await res.json().catch(() => ({})); throw new UserError(`GET ${path} → HTTP ${res.status}: ${body.error || res.statusText}`) }