kaloko 0.3.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/README.md +119 -0
- package/bin/kaloko.mjs +4 -0
- package/bin/qawalk.mjs +4 -0
- package/package.json +42 -0
- package/schemas/bundle.v1.json +19 -0
- package/schemas/config.v1.json +268 -0
- package/schemas/revision.v1.json +36 -0
- package/schemas/run.v1.json +549 -0
- package/schemas/scenario.v1.json +668 -0
- package/schemas/tokens.v1.json +102 -0
- package/skills/kaloko/SKILL.md +94 -0
- package/skills/kaloko/reference/acceptance.md +208 -0
- package/skills/kaloko/reference/apps.md +161 -0
- package/skills/kaloko/reference/collaboration.md +218 -0
- package/skills/kaloko/reference/design.md +143 -0
- package/skills/kaloko/reference/documentation.md +72 -0
- package/skills/kaloko/reference/environments.md +140 -0
- package/skills/kaloko/reference/evaluation.md +62 -0
- package/skills/kaloko/reference/execution.md +75 -0
- package/skills/kaloko/reference/mcp.md +69 -0
- package/skills/kaloko/reference/stacks.md +122 -0
- package/src/cli.mjs +1344 -0
- package/src/lib/access.mjs +169 -0
- package/src/lib/apps/capture.mjs +59 -0
- package/src/lib/apps/drivers.mjs +284 -0
- package/src/lib/apps/helper.swift +102 -0
- package/src/lib/apps/tree.mjs +222 -0
- package/src/lib/browser-daemon.mjs +41 -0
- package/src/lib/browser.mjs +161 -0
- package/src/lib/build.mjs +149 -0
- package/src/lib/calibrate.mjs +97 -0
- package/src/lib/capture.mjs +233 -0
- package/src/lib/compare.mjs +88 -0
- package/src/lib/config.mjs +117 -0
- package/src/lib/console.mjs +13 -0
- package/src/lib/coverage.mjs +75 -0
- package/src/lib/design-cli.mjs +440 -0
- package/src/lib/design-core.mjs +163 -0
- package/src/lib/design.mjs +164 -0
- package/src/lib/doctor.mjs +149 -0
- package/src/lib/draft.mjs +76 -0
- package/src/lib/drift.mjs +145 -0
- package/src/lib/evaluate.mjs +222 -0
- package/src/lib/git.mjs +70 -0
- package/src/lib/htmlview.mjs +130 -0
- package/src/lib/jev.mjs +95 -0
- package/src/lib/mail.mjs +182 -0
- package/src/lib/manifest-core.mjs +127 -0
- package/src/lib/pack-help.mjs +55 -0
- package/src/lib/packs.mjs +121 -0
- package/src/lib/pr.mjs +78 -0
- package/src/lib/probe.mjs +379 -0
- package/src/lib/recheck.mjs +70 -0
- package/src/lib/run.mjs +85 -0
- package/src/lib/scenario.mjs +98 -0
- package/src/lib/schema.mjs +25 -0
- package/src/lib/secrets.mjs +59 -0
- package/src/lib/service.mjs +52 -0
- package/src/lib/stack.mjs +130 -0
- package/src/lib/tokens.mjs +352 -0
- package/src/lib/util.mjs +97 -0
- package/templates/ACCEPTANCE.md +55 -0
- package/templates/cs/ACCEPTANCE.md +55 -0
- package/templates/kaloko.config.yml +76 -0
- package/templates/mail/letter-opener.mjs +47 -0
- package/templates/scenario.yml +119 -0
- package/templates/steps/consent.mjs +33 -0
- package/viewer/index.html +2772 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
import { writeFile, access, readFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { gzipSync } from 'node:zlib'
|
|
4
|
+
import { join, resolve, relative, dirname } from 'node:path'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import yaml from 'js-yaml'
|
|
7
|
+
import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FILE } from './lib/config.mjs'
|
|
8
|
+
import { loadScenario, validateScenario } from './lib/scenario.mjs'
|
|
9
|
+
import { createRun, resolveRun, readCaptures, listRuns } from './lib/run.mjs'
|
|
10
|
+
import { gitMeta, projectAndIssue, appVersion } from './lib/git.mjs'
|
|
11
|
+
import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
|
|
12
|
+
import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
|
|
13
|
+
import { observeConsole } from './lib/console.mjs'
|
|
14
|
+
import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.mjs'
|
|
15
|
+
import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher } from './lib/access.mjs'
|
|
16
|
+
import { rememberEnvironment } from './lib/secrets.mjs'
|
|
17
|
+
import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
|
|
18
|
+
import { buildManifest, writePreview } from './lib/build.mjs'
|
|
19
|
+
import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote } from './lib/util.mjs'
|
|
20
|
+
import { packageRoot } from './lib/schema.mjs'
|
|
21
|
+
import { compareRuns, formatComparison } from './lib/compare.mjs'
|
|
22
|
+
import { doctor, formatDoctor } from './lib/doctor.mjs'
|
|
23
|
+
import { parsePlan, coverage, formatCoverage } from './lib/coverage.mjs'
|
|
24
|
+
import { calibrate, formatCalibration, casesFromGolden, casesFromRun } from './lib/calibrate.mjs'
|
|
25
|
+
import { pageFacts, draftScenario, scenarioYaml } from './lib/draft.mjs'
|
|
26
|
+
import { recheckCaptures } from './lib/recheck.mjs'
|
|
27
|
+
import { resolveItems, fieldsOf, DEFAULT_LIMIT } from './lib/stack.mjs'
|
|
28
|
+
import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
|
|
29
|
+
import { expandPacks } from './lib/packs.mjs'
|
|
30
|
+
import { upsertPrComment } from './lib/pr.mjs'
|
|
31
|
+
import { htmlViewAllowed } from './lib/htmlview.mjs'
|
|
32
|
+
import { service } from './lib/service.mjs'
|
|
33
|
+
import { walkDesign, syncDesign, publishVersion, historyCmd, referenceCmd, tokensCmd, exportLive, localRevisions, branchCmd, uploadBlobs } from './lib/design-cli.mjs'
|
|
34
|
+
import { designDir, readBase, headKey } from './lib/design.mjs'
|
|
35
|
+
import { driverFor, isApp, pngSize, ocr as ocrImage } from './lib/apps/drivers.mjs'
|
|
36
|
+
import { captureApp } from './lib/apps/capture.mjs'
|
|
37
|
+
import { readTree, flatten } from './lib/apps/tree.mjs'
|
|
38
|
+
|
|
39
|
+
const fmtBytes = (n) => { n = Number(n) || 0; const u = ['B', 'kB', 'MB', 'GB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++ } return `${n.toFixed(i ? 1 : 0)} ${u[i]}` }
|
|
40
|
+
|
|
41
|
+
const HELP = `kaloko — walk, assess and approve what your agents built, on one flow canvas
|
|
42
|
+
|
|
43
|
+
Usage: kaloko <command> [options]
|
|
44
|
+
|
|
45
|
+
Project
|
|
46
|
+
draft <url> [url…] [--id SITE-X] [--title t] [--out qa/flows/site.yml] [--force]
|
|
47
|
+
First plan + read-only production scenario for public pages, drafted from the
|
|
48
|
+
pages (title, description, H1) with the seo, a11y, perf and console packs
|
|
49
|
+
init [--agent claude|codex|all] [--org <slug>]
|
|
50
|
+
Create ${CONFIG_FILE} and install the kaloko skill for your agent
|
|
51
|
+
doctor [--json] [--fix] Check Node, Chrome, config, environments, browser ports, token, evaluator key
|
|
52
|
+
and app tools; --fix asks macOS for the desktop capture permissions
|
|
53
|
+
validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]]
|
|
54
|
+
Validate scenarios (schema + references); --coverage compares criteria with the plan
|
|
55
|
+
With a token it also warns about scenarios the service still lists whose file is gone (--offline skips it)
|
|
56
|
+
scenarios List scenarios matched by the config
|
|
57
|
+
|
|
58
|
+
Run
|
|
59
|
+
start --scenario <file> --env <name> [--locales cs,en] [--viewports desktop,mobile] [--executor <name>]
|
|
60
|
+
[--app-version <v>] (default: the git tag on HEAD; documentation captures carry it)
|
|
61
|
+
Create a run directory and make it current
|
|
62
|
+
browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
|
|
63
|
+
Shared Chromium with CDP (agents, scripts and people drive it)
|
|
64
|
+
open --step <id> [--locale l] Navigate the current page to the step URL
|
|
65
|
+
session reset New visitor: clear cookies and storage in the shared browser
|
|
66
|
+
capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b]
|
|
67
|
+
Capture the current page for a step in all viewports
|
|
68
|
+
capture --step <id> --crop <css> [--title <text>]
|
|
69
|
+
Attach an element screenshot to a step
|
|
70
|
+
capture --step <id> --image <shot.png> [--tree <dump.xml|json> | --ocr] [--platform android|ios|desktop]
|
|
71
|
+
[--device <name>] [--density 2.625 | --scale 3] [--viewport <name>]
|
|
72
|
+
An app screen taken by any tool (Maestro, Appium, XCUITest, a person) with its
|
|
73
|
+
UI dump (uiautomator/Appium XML, idb or Maestro JSON); app scenarios without
|
|
74
|
+
--image capture the current screen of the environment's app
|
|
75
|
+
mail --step <id> [--locale l] [--to <addr>] [--subject <text>] [--timeout 60]
|
|
76
|
+
[--since <ISO date> | --any-time] [--id <mailbox message id>]
|
|
77
|
+
Only messages received since the run started, unless --since/--any-time; --id takes one exact message
|
|
78
|
+
Capture an e-mail from the environment's mailbox (mail.provider) as an email step
|
|
79
|
+
mail --step <id> --file <message.eml|.html|.json|.txt> [--subject <text>]
|
|
80
|
+
Capture an e-mail you obtained yourself (any stack, any mailbox)
|
|
81
|
+
auth save --env <e> --account <key> [--url <url>]
|
|
82
|
+
A person signs in once (SSO, MFA) in a visible browser; the session is saved
|
|
83
|
+
for that account and walk reuses it (never uploaded)
|
|
84
|
+
auth list Saved sessions per environment and account, with their age
|
|
85
|
+
walk [--steps a,b] [--locales l] [--keep-going] [--ephemeral]
|
|
86
|
+
Run playwright steps (execution.script) and capture them; a session stops
|
|
87
|
+
after 3 steps in a row fail with the same error unless --keep-going
|
|
88
|
+
One walk at a time per shared browser; --ephemeral walks in an own browser
|
|
89
|
+
Semantic questions start as each step is captured; the walk ends with the
|
|
90
|
+
verdicts and what failed (--no-evaluate skips that; kaloko evaluate redoes it)
|
|
91
|
+
[--items a,b] [--limit N] Stack steps (kind: stack, many pages of one template): only pages whose id or
|
|
92
|
+
path contains a, b; at most N pages (default items.limit, 500)
|
|
93
|
+
status Show what the current run has captured
|
|
94
|
+
Scenarios may declare "sessions" (people in the flow); walk keeps one browser context per session and locale
|
|
95
|
+
|
|
96
|
+
Results
|
|
97
|
+
evaluate Deterministic checks + semantic evaluator (default JEV) for all captures
|
|
98
|
+
evaluate --recheck [--steps a,b] After a scenario fix: re-run deterministic checks on the stored HTML (no walk);
|
|
99
|
+
console, overflow and performance keep their live result
|
|
100
|
+
build Write run.json and the local preview (index.html)
|
|
101
|
+
preview Build and print the preview path
|
|
102
|
+
share [--expires 30] [--pr] Upload the run to kaloko.app (KALOKO_TOKEN, tester role);
|
|
103
|
+
--pr adds or updates one comment on the branch's pull request via gh
|
|
104
|
+
feedback [--run id] [--scenario id] [--json]
|
|
105
|
+
Open threads from reviewers (steps, the whole run, the whole flow) with ids and replies
|
|
106
|
+
comment [--step id | --flow] --note "…" [--status rejected|approved] [--run id] [--scenario id]
|
|
107
|
+
comment --reply|--resolve|--reopen|--edit|--delete <id> [--note "…"] [--flow]
|
|
108
|
+
Comment, answer and close feedback from the CLI (resolve with "Fixed in <commit>")
|
|
109
|
+
projects [--json] Projects of the organization (use the slug as project: in a scenario or config)
|
|
110
|
+
runs [--scenario id] [--all] [--json]
|
|
111
|
+
Shared runs: the newest per scenario (with "+ N older"); --all or --scenario lists every run
|
|
112
|
+
trash <run-id>... | restore <run-id>
|
|
113
|
+
Move runs to the trash (uploader or admin) and back; restorable until purge
|
|
114
|
+
prune [--scenario id] [--environment e] [--keep 3] [--older-than 14d] [--yes]
|
|
115
|
+
Keep the newest runs per scenario and environment plus kept, accepted, baseline,
|
|
116
|
+
referenced and commented runs; lists what would go to the trash unless --yes
|
|
117
|
+
keep <run-id> [--off] A kept run never expires and is never pruned
|
|
118
|
+
archive <scenario-id> [--undo] Hide a retired scenario from the scenario list (admin); its runs stay reachable
|
|
119
|
+
inbox [--all] [--read] [--json] Your notifications on kaloko.app (mentions, returned runs, new runs, expiry); --read marks them read
|
|
120
|
+
calibrate [--golden labels.yml] [--scenario id [--limit 30]] [--target 0.95] [--json]
|
|
121
|
+
Semantic evaluator vs human labels: precision/recall per criterion at the
|
|
122
|
+
configured thresholds and suggested thresholds (labels from a golden file or
|
|
123
|
+
from reviewers' criterion verdicts in the service)
|
|
124
|
+
export [--run id] [--out file.zip] Download a shared run (run.json + files) as a zip before it expires
|
|
125
|
+
export --scenario <id> [--accepted] [--out dir] [--locales l] [--viewports v]
|
|
126
|
+
Newest capture of every step as steps/<step>/<locale>/<viewport>.<ext> plus
|
|
127
|
+
index.json (documentation layer, Business plan); unchanged files are skipped
|
|
128
|
+
public [--run id] [--off] Make a shared run readable without sign-in (admin; testers may refresh an already public scenario)
|
|
129
|
+
compare [--run id] [--baseline id | --design] [--json]
|
|
130
|
+
Text diff of two runs: step statuses, criterion verdicts, scores and evidence
|
|
131
|
+
(default: current run against the scenario baseline, else the previous run)
|
|
132
|
+
|
|
133
|
+
Design (environments with kind: design; live HTML steps in a folder)
|
|
134
|
+
start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
|
|
135
|
+
walk [--steps a,b] [--no-cache] Render the steps; unchanged revisions reuse their captures
|
|
136
|
+
status Per step: synced, changed here, changed in the draft, conflict; step status
|
|
137
|
+
sync [--pull|--push] [--branch b] [--mine a,b|--theirs a,b]
|
|
138
|
+
Exchange revisions with the shared draft; only changed steps and missing files travel
|
|
139
|
+
share [--name <text>] [--branch b] Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged"
|
|
140
|
+
history [--scenario id] [--step id] [--locale l] [--json]
|
|
141
|
+
Versions of the flow; revisions of a step with who (person or agent) and when
|
|
142
|
+
reference [--scenario id] (--design|--baseline) (--run id|--version n|--off) [--branch b]
|
|
143
|
+
Show or set the design reference (accepted version) and the baseline
|
|
144
|
+
tokens pull|push|check|validate [--project p] [--file tokens.json|tokens.resolver.json] [--mode m] [--json]
|
|
145
|
+
The project's token set (DTCG); check lists raw values outside it in the live folder
|
|
146
|
+
branch list | create <name> [--from vN] [--git <branch>] | merge <name> [--take-branch a,b] [--keep-main c]
|
|
147
|
+
Branches of the draft; a git-linked branch is what implementation runs of that git branch check against
|
|
148
|
+
export --live [--version n|--draft|--run id] [--out design/]
|
|
149
|
+
Live files of a version (default: the design reference) into a folder: the handoff
|
|
150
|
+
export --live --views --run <id> [--viewport desktop] [--out design/]
|
|
151
|
+
Fork into a draft: the HTML views of an app run become design steps (experimental)
|
|
152
|
+
|
|
153
|
+
Common options: --run <id|dir> (or KALOKO_RUN) --config <file> KALOKO_AGENT=<name> labels writes by an agent
|
|
154
|
+
`
|
|
155
|
+
|
|
156
|
+
/** validate: scenarios the service still lists as active whose scenario file no longer exists here (#58). */
|
|
157
|
+
async function warnRetired (args) {
|
|
158
|
+
if (args.offline) return
|
|
159
|
+
let config, client
|
|
160
|
+
try { ({ config } = await context(args)); client = service(config) } catch { return } // no project or no token: offline
|
|
161
|
+
if (!config?.org) return
|
|
162
|
+
let list
|
|
163
|
+
try { list = await client.request('GET', `/orgs/${config.org}/scenarios?runs=1`) } catch { return }
|
|
164
|
+
const gone = []
|
|
165
|
+
for (const s of list || []) {
|
|
166
|
+
if (s.archived || !s.file || (config.project && s.project && s.project !== config.project)) continue
|
|
167
|
+
try { await readFile(resolve(s.file)) } catch { gone.push(s) }
|
|
168
|
+
}
|
|
169
|
+
for (const s of gone) say(`! ${s.id}: the service lists it as active, but ${s.file} is gone here (split or renamed?). Archive it: kaloko archive ${s.id}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The end of a walk: evaluate (the semantic answers are mostly cached by now) and say what did not pass. */
|
|
173
|
+
async function walkVerdicts ({ prefetch, run, scenario, config, root }) {
|
|
174
|
+
const t0 = Date.now()
|
|
175
|
+
await prefetch.done()
|
|
176
|
+
const report = await evaluateRun({ run, scenario, config, root })
|
|
177
|
+
const fails = report.findings.filter((f) => f.verdict === 'fail'); const unsure = report.findings.filter((f) => f.verdict === 'needs_review')
|
|
178
|
+
say(`\nEvaluated ${report.evaluated} capture(s) in ${Math.round((Date.now() - t0) / 100) / 10} s: ${fails.length ? `${fails.length} failing` : 'nothing failing'}${unsure.length ? `, ${unsure.length} for a person to look at` : ''}.`)
|
|
179
|
+
const line = (f) => `${f.node}${f.item ? `/${f.item}` : ''} ${f.locale}/${f.viewport} ${f.id}${f.why ? `: ${String(f.why).slice(0, 140)}` : ''}`
|
|
180
|
+
// pack criteria say how to fix them; a fix repeated across steps and locales is printed once
|
|
181
|
+
const hinted = new Set()
|
|
182
|
+
const withFix = (f) => { const l = line(f); if (!f.fix || hinted.has(f.id)) return l; hinted.add(f.id); return `${l}\n fix: ${f.fix}` }
|
|
183
|
+
if (fails.length) say(` ✕ ${fails.slice(0, 15).map(withFix).join('\n ✕ ')}${fails.length > 15 ? `\n … ${fails.length - 15} more (kaloko build lists them all)` : ''}`)
|
|
184
|
+
if (unsure.length) say(` ? ${unsure.slice(0, 8).map(line).join('\n ? ')}${unsure.length > 8 ? `\n … ${unsure.length - 8} more` : ''}`)
|
|
185
|
+
if (report.skipped.length) say(` Semantic criteria not evaluated: ${[...new Set(report.skipped.map((x) => x.split(': ').slice(1).join(': ')))].slice(0, 2).join('; ')}`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parse (argv) {
|
|
189
|
+
const args = { _: [] }
|
|
190
|
+
for (let i = 0; i < argv.length; i++) {
|
|
191
|
+
const a = argv[i]
|
|
192
|
+
if (a.startsWith('--')) {
|
|
193
|
+
const [k, v] = a.slice(2).split('=')
|
|
194
|
+
if (v !== undefined) args[k] = v
|
|
195
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith('--')) args[k] = argv[++i]
|
|
196
|
+
else args[k] = true
|
|
197
|
+
} else args._.push(a)
|
|
198
|
+
}
|
|
199
|
+
return args
|
|
200
|
+
}
|
|
201
|
+
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : null)
|
|
202
|
+
const say = (...m) => console.log(...m)
|
|
203
|
+
|
|
204
|
+
async function context (args) {
|
|
205
|
+
const { config, root } = await loadConfig(process.cwd(), args.config)
|
|
206
|
+
return { config, root }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function runContext (args) {
|
|
210
|
+
const ctx = await context(args)
|
|
211
|
+
const run = await resolveRun({ root: ctx.root, output: ctx.config.output, run: args.run })
|
|
212
|
+
const scenario = await loadScenario(resolve(ctx.root, run.meta.scenario_file))
|
|
213
|
+
const env = environment(ctx.config, run.meta.environment.name)
|
|
214
|
+
// a design is not checked against itself: the design reference checks apply to implementation runs only (#42)
|
|
215
|
+
if (env.kind === 'design') for (const n of scenario.nodes) if (n.criteria) n.criteria = n.criteria.filter((c) => !['design_pixels', 'design_structure'].includes(c.check.assert))
|
|
216
|
+
rememberEnvironment(env)
|
|
217
|
+
return { ...ctx, run, scenario, env }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function stepOf (scenario, id) {
|
|
221
|
+
if (!id) throw new UserError('--step <id> is required.')
|
|
222
|
+
const node = scenario.nodes.find((n) => n.id === id)
|
|
223
|
+
if (!node) throw new UserError(`Unknown step "${id}". Steps: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}`)
|
|
224
|
+
return node
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Scenario of --scenario (id or file) or of the current run. */
|
|
228
|
+
async function scenarioIdOf (args, config, root) {
|
|
229
|
+
if (typeof args.scenario === 'string') {
|
|
230
|
+
if (/\.ya?ml$/.test(args.scenario)) return (await loadScenario(resolve(args.scenario))).id
|
|
231
|
+
return args.scenario
|
|
232
|
+
}
|
|
233
|
+
try { return (await resolveRun({ root, output: config.output, run: args.run })).meta.scenario.id } catch {}
|
|
234
|
+
throw new UserError('--scenario <id|file> is required when there is no current run.')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function scopeViewports (run, pick) {
|
|
238
|
+
const all = run.meta.scope.viewports
|
|
239
|
+
const names = pick || Object.keys(all)
|
|
240
|
+
return Object.fromEntries(names.map((n) => { if (!all[n]) throw new UserError(`Unknown viewport "${n}".`); return [n, all[n]] }))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function scenarioFiles (config, root) {
|
|
244
|
+
const { readdir } = await import('node:fs/promises')
|
|
245
|
+
const out = []
|
|
246
|
+
for (const pattern of config.scenarios || []) {
|
|
247
|
+
const parts = pattern.split('/')
|
|
248
|
+
const fixed = []
|
|
249
|
+
for (const p of parts) { if (/[*?]/.test(p)) break; fixed.push(p) }
|
|
250
|
+
const base = join(root, ...fixed)
|
|
251
|
+
const re = globToRegExp(pattern)
|
|
252
|
+
const walk = async (dir) => {
|
|
253
|
+
let entries = []
|
|
254
|
+
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
|
|
255
|
+
for (const e of entries) {
|
|
256
|
+
const full = join(dir, e.name)
|
|
257
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules' && !e.name.startsWith('.')) await walk(full) } else if (re.test(relative(root, full))) out.push(full)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
await walk(base)
|
|
261
|
+
}
|
|
262
|
+
return [...new Set(out)].sort()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function catalogFor (config, root) {
|
|
266
|
+
const runs = await listRuns(root, config.output)
|
|
267
|
+
const out = []
|
|
268
|
+
for (const file of await scenarioFiles(config, root)) {
|
|
269
|
+
try {
|
|
270
|
+
const s = yaml.load(await readFile(file, 'utf8'))
|
|
271
|
+
out.push({
|
|
272
|
+
id: s.id, title: s.title, kind: s.kind || 'task', file: relative(root, file),
|
|
273
|
+
runs: runs.filter((r) => r.scenario?.id === s.id).map((r) => ({ id: r.id, started_at: r.started_at, git: r.git, environment: r.environment, summary: r.summary })).sort((a, b) => b.started_at.localeCompare(a.started_at))
|
|
274
|
+
})
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
return out
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// `{run}` in an address stands for the short run id (same as mail.address in the config).
|
|
281
|
+
const expandRun = (value, short) => (value || '').replace('{run}', short)
|
|
282
|
+
|
|
283
|
+
// Finds the e-mail (or takes the one given) and captures it as the node's screens.
|
|
284
|
+
async function captureMail ({ config, root, run, env, node, locale, to, subject, timeoutMs = 60000, viewports, message = null, since, id = null }) {
|
|
285
|
+
if (!message) {
|
|
286
|
+
const client = mailClient(env.mail, root, env)
|
|
287
|
+
// only messages that arrived during this run unless told otherwise (#52): stale e-mails are not captured silently
|
|
288
|
+
message = await waitForMessage(client, { to, subject, since: since === undefined ? run.meta.started_at : since, id }, { timeoutMs })
|
|
289
|
+
}
|
|
290
|
+
const browser = await launchEphemeral(config)
|
|
291
|
+
try {
|
|
292
|
+
// The capture itself needs page JavaScript (webp encoding, settle), so the
|
|
293
|
+
// context keeps it on; the e-mail's own scripts are stripped and every
|
|
294
|
+
// non-image request is blocked instead. Images hosted behind the environment's
|
|
295
|
+
// gate get its headers, nobody else does.
|
|
296
|
+
const page = await (await browser.newContext()).newPage()
|
|
297
|
+
const gate = gateHeaders(env)
|
|
298
|
+
const toGate = Object.keys(gate).length ? originMatcher(gateOrigins(env)) : () => false
|
|
299
|
+
await page.route('**/*', (route) => {
|
|
300
|
+
const r = route.request()
|
|
301
|
+
if (r.url().startsWith('data:')) return route.continue()
|
|
302
|
+
if (r.resourceType() !== 'image') return route.abort()
|
|
303
|
+
return toGate(r.url()) ? route.continue({ headers: { ...r.headers(), ...gate } }) : route.continue()
|
|
304
|
+
})
|
|
305
|
+
const html = (message.html || `<pre>${(message.text || '').replace(/</g, '<')}</pre>`).replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
306
|
+
// Remote images are allowed for the screenshot but must not hold the capture hostage.
|
|
307
|
+
await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 20000 })
|
|
308
|
+
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {})
|
|
309
|
+
const mail = { id: message.id, subject: message.subject, from: message.from || '', to: (message.to || []).join(', '), date: message.date }
|
|
310
|
+
const results = await captureStep({ page, node, locale, viewports: scopeViewports(run, viewports), run, maxChars: config.evaluators?.jev?.max_state_chars, extra: { mail } })
|
|
311
|
+
return { subject: mail.subject, count: results.length }
|
|
312
|
+
} finally {
|
|
313
|
+
await browser.close()
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Documentation layer: newest (accepted) capture of every step into a folder, skipping files whose run did not change. */
|
|
318
|
+
async function exportScenario ({ args, config, root, client }) {
|
|
319
|
+
const { mkdir: mk, writeFile: wf, readFile: rf } = await import('node:fs/promises')
|
|
320
|
+
const id = args.scenario
|
|
321
|
+
const out = typeof args.out === 'string' ? resolve(args.out) : resolve(root, config.output, 'docs', id)
|
|
322
|
+
const q = args.accepted ? '?accepted=1' : ''
|
|
323
|
+
const index = await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(id)}/steps${q}`)
|
|
324
|
+
const want = (v, allowed) => !allowed || allowed.includes(v)
|
|
325
|
+
const locales = list(args.locales); const viewports = list(args.viewports)
|
|
326
|
+
let previous = null
|
|
327
|
+
try { previous = JSON.parse(await rf(join(out, 'index.json'), 'utf8')) } catch {}
|
|
328
|
+
const had = new Map((previous?.steps || []).flatMap((s) => (s.files || []).map((f) => [`${s.id}|${f.locale}|${f.viewport}`, f])))
|
|
329
|
+
const steps = []; let written = 0; let kept = 0
|
|
330
|
+
for (const s of index.steps) {
|
|
331
|
+
const files = []
|
|
332
|
+
for (const c of s.captures.filter((c) => want(c.locale, locales) && want(c.viewport, viewports))) {
|
|
333
|
+
const ext = c.image.format
|
|
334
|
+
const rel = `steps/${s.id}/${c.locale}/${c.viewport}`
|
|
335
|
+
const old = had.get(`${s.id}|${c.locale}|${c.viewport}`)
|
|
336
|
+
const entry = { locale: c.locale, viewport: c.viewport, image: `${rel}.${ext}`, html: `${rel}.html`, run: c.run.id, captured_at: c.captured_at, page_url: c.page_url, size: c.size, version: c.run.version }
|
|
337
|
+
if (old && old.run === c.run.id) { files.push(old); kept++; continue }
|
|
338
|
+
await mk(join(out, 'steps', s.id, c.locale), { recursive: true })
|
|
339
|
+
const img = await client.fetch(c.links.image.replace(/^https?:\/\/[^/]+\/api\/v1/, ''))
|
|
340
|
+
await wf(join(out, entry.image), Buffer.from(await img.arrayBuffer()))
|
|
341
|
+
const html = await client.fetch(c.links.html.replace(/^https?:\/\/[^/]+\/api\/v1/, ''))
|
|
342
|
+
await wf(join(out, entry.html), Buffer.from(await html.arrayBuffer()))
|
|
343
|
+
files.push(entry); written++
|
|
344
|
+
}
|
|
345
|
+
steps.push({ id: s.id, title: s.title, status: s.status, files })
|
|
346
|
+
}
|
|
347
|
+
await mk(out, { recursive: true })
|
|
348
|
+
await wf(join(out, 'index.json'), JSON.stringify({ schema: 'qawalk.docs-export.v1', scenario: index.scenario, run: index.run, accepted: !!args.accepted, exported_at: new Date().toISOString(), steps }, null, 2) + '\n')
|
|
349
|
+
say(`Exported ${index.steps.length} steps of ${id} from run ${index.run.id}${index.run.app_version ? ` (version ${index.run.app_version})` : ''} → ${relative(process.cwd(), out) || '.'}\n ${written} files downloaded, ${kept} unchanged`)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Walks a stack step: resolves its pages, then opens and captures each one with the step's criteria and index
|
|
354
|
+
* fields (HTTP status of the main document included). A step script, when given, runs per page with `item`.
|
|
355
|
+
*/
|
|
356
|
+
async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, args }) {
|
|
357
|
+
const { items, total, truncated } = await resolveItems({ node, base: baseUrl(env, locale), page, scenarioDir, env, locale, limit: Number(args.limit) || undefined })
|
|
358
|
+
const only = list(args.items)
|
|
359
|
+
const chosen = only ? items.filter((it) => only.some((q) => it.id === q || it.path.includes(q))) : items
|
|
360
|
+
const scope = run.meta.scope.viewports
|
|
361
|
+
const vpNames = (node.items.viewports || []).filter((v) => scope[v])
|
|
362
|
+
const viewports = Object.fromEntries((vpNames.length ? vpNames : [Object.keys(scope)[0]]).map((v) => [v, scope[v]]))
|
|
363
|
+
say(`▦ ${node.id} ${locale}: ${chosen.length} page(s)${truncated ? ` — limit ${Number(args.limit) || node.items.limit || DEFAULT_LIMIT} reached (${total}+ found; raise items.limit or pass --limit)` : ''}`)
|
|
364
|
+
const fields = fieldsOf(node)
|
|
365
|
+
const script = node.execution?.script ? await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href) : null
|
|
366
|
+
let status = null
|
|
367
|
+
const onResponse = (r) => { if (r.request().isNavigationRequest() && r.frame() === page.mainFrame()) status = r.status() }
|
|
368
|
+
page.on('response', onResponse)
|
|
369
|
+
const streak = failureStreak(5)
|
|
370
|
+
const failed = []; let ok = 0
|
|
371
|
+
try {
|
|
372
|
+
for (const [i, item] of chosen.entries()) {
|
|
373
|
+
if (streak.stopped('stack')) { failed.push(`${item.id}: not walked after repeated failures`); continue }
|
|
374
|
+
status = null
|
|
375
|
+
const since = Date.now()
|
|
376
|
+
let captured = false
|
|
377
|
+
const capture = (opts = {}) => { captured = true; return captureStep({ page, node, locale, viewports: opts.viewports ? Object.fromEntries(opts.viewports.map((v) => [v, scope[v]])) : viewports, run, consoleFile, since, item, fields, maxChars: config.evaluators?.jev?.max_state_chars, options: captureOptions(scenario, node), extra: { http_status: status } }) }
|
|
378
|
+
try {
|
|
379
|
+
if (script) await script.default({ page, item, url: () => item.url, locale, env, run: run.meta, capture, acceptDialogs: () => acceptDialogs(page) })
|
|
380
|
+
else await page.goto(item.url, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
|
381
|
+
if (!captured) await capture()
|
|
382
|
+
ok++; streak.ok('stack')
|
|
383
|
+
} catch (error) {
|
|
384
|
+
failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
|
|
385
|
+
if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}): the last 5 pages failed with the same error`)
|
|
386
|
+
}
|
|
387
|
+
if ((i + 1) % 25 === 0 && i + 1 < chosen.length) say(` ${node.id} ${locale}: ${i + 1}/${chosen.length}`)
|
|
388
|
+
}
|
|
389
|
+
} finally { page.off('response', onResponse) }
|
|
390
|
+
say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}: ${ok}/${chosen.length} page(s) captured`)
|
|
391
|
+
return failed
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Deep link of an app step: a URL scheme as written, a path under the environment's base URL (universal links). */
|
|
395
|
+
function linkFor (env, node, locale) {
|
|
396
|
+
const raw = typeof node.path === 'string' ? node.path : (node.path?.[locale] ?? node.path?.default)
|
|
397
|
+
if (!raw) return null
|
|
398
|
+
const p = raw.replaceAll('{random}', () => Math.random().toString(36).slice(2, 10))
|
|
399
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(p)) return p
|
|
400
|
+
return env.base_url ? urlFor(env, { path: p }, locale) : null
|
|
401
|
+
}
|
|
402
|
+
const appViewport = (run) => Object.keys(run.meta.scope.viewports || {})[0] || null
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Walks an app scenario (platform android, ios, electron, desktop) with the environment's driver: per locale the
|
|
406
|
+
* app starts fresh, each step runs its script (with `app`, the driver) or opens its deep link, then is captured.
|
|
407
|
+
*/
|
|
408
|
+
async function walkApp ({ config, root, run, scenario, env, args }) {
|
|
409
|
+
const platform = scenario.platform
|
|
410
|
+
const pick = list(args.steps)
|
|
411
|
+
const locales = list(args.locales) || run.meta.scope.locales
|
|
412
|
+
const scenarioDir = resolve(root, run.meta.scenario_file, '..')
|
|
413
|
+
const pending = []; const failed = []; const skipped = []
|
|
414
|
+
// WebP and thumbnails are encoded in a headless Chrome page (no native image dependency); PNG without Chrome
|
|
415
|
+
const enc = await launchEphemeral(config).catch(() => null)
|
|
416
|
+
const encoder = enc ? await enc.newPage().catch(() => null) : null
|
|
417
|
+
const streak = failureStreak(args['keep-going'] ? 0 : 3)
|
|
418
|
+
const maxChars = config.evaluators?.jev?.max_state_chars
|
|
419
|
+
try {
|
|
420
|
+
for (const locale of locales) {
|
|
421
|
+
const driver = driverFor(platform, env, { root, scenarioDir, locale })
|
|
422
|
+
try {
|
|
423
|
+
await driver.open({ locale, reset: true })
|
|
424
|
+
if (driver.web) observeConsole(driver.page)
|
|
425
|
+
say(`${platform} ${locale}: ${driver.web ? 'Electron window' : 'app'} ready`)
|
|
426
|
+
} catch (error) { failed.push(`${platform} (${locale}): ${error.message.split('\n')[0]}`); say(`✗ ${platform} ${locale}: ${error.message.split('\n')[0]}`); await driver.close().catch(() => {}); if (error instanceof UserError) break; continue }
|
|
427
|
+
try {
|
|
428
|
+
for (const node of scenario.nodes) {
|
|
429
|
+
if (node.kind === 'decision' || (pick && !pick.includes(node.id))) continue
|
|
430
|
+
if (node.kind === 'stack') { pending.push(`${node.id} (${locale}): stack steps list web pages; not walked on ${platform}`); continue }
|
|
431
|
+
if (node.kind === 'email' && node.mail?.to) {
|
|
432
|
+
try { const r = await captureMail({ config, root, run, env, node, locale, to: expandRun(node.mail.to, run.meta.short), subject: localized(node.mail?.subject, locale), timeoutMs: 45000 }); say(`✓ ${node.id} ${locale}: "${r.subject}"`) } catch (error) { failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`); say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`) }
|
|
433
|
+
continue
|
|
434
|
+
}
|
|
435
|
+
const scripted = ['playwright', 'script'].includes(node.execution?.strategy) && node.execution?.script
|
|
436
|
+
const link = linkFor(env, node, locale)
|
|
437
|
+
if (!scripted && !(link && ['playwright', 'script'].includes(node.execution?.strategy))) { pending.push(`${node.id} (${locale}): ${node.execution?.strategy || 'agent'} — ${(node.execution?.instructions || '').split('\n')[0]}`); continue }
|
|
438
|
+
if (streak.stopped(locale)) { skipped.push(`${node.id} (${locale})`); continue }
|
|
439
|
+
try {
|
|
440
|
+
let captured = false
|
|
441
|
+
const capture = async (o = {}) => {
|
|
442
|
+
captured = true
|
|
443
|
+
if (driver.web) {
|
|
444
|
+
const [w, h] = await driver.page.evaluate(() => [innerWidth, innerHeight])
|
|
445
|
+
return captureStep({ page: driver.page, node, locale, options: captureOptions(scenario, node), viewports: { [o.viewport || appViewport(run) || 'window']: [w, h] }, run, consoleFile: null, maxChars, extra: { platform, device: 'Electron' } })
|
|
446
|
+
}
|
|
447
|
+
return captureApp({ snapshot: await driver.snapshot(), node, locale, viewport: o.viewport || appViewport(run), run, platform, encoder, maxChars, extra: { url: link } })
|
|
448
|
+
}
|
|
449
|
+
if (scripted) {
|
|
450
|
+
const mod = await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href)
|
|
451
|
+
await mod.default({ app: driver, page: driver.page || null, locale, node: { id: node.id, title: localized(node.title, locale), kind: node.kind }, env, run: run.meta, capture, link: (id = node.id) => linkFor(env, stepOf(scenario, id), locale), url: (id = node.id) => linkFor(env, stepOf(scenario, id), locale) })
|
|
452
|
+
} else await driver.link(link)
|
|
453
|
+
if (!captured) await capture()
|
|
454
|
+
say(`✓ ${node.id} ${locale}`)
|
|
455
|
+
streak.ok(locale)
|
|
456
|
+
} catch (error) {
|
|
457
|
+
failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
|
|
458
|
+
say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
|
|
459
|
+
if (streak.record(locale, error.message)) say(` stopping ${locale}: the last 3 steps failed with the same error`)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
} finally { await driver.close().catch(() => {}) }
|
|
463
|
+
}
|
|
464
|
+
} finally { await enc?.close().catch(() => {}) }
|
|
465
|
+
if (pending.length) say(`\nSteps for an agent or a person (drive the app, then kaloko capture --step <id> --image shot.png [--tree dump.xml|json]):\n - ${[...new Set(pending)].join('\n - ')}`)
|
|
466
|
+
if (failed.length) { say(`\nSteps that failed (fix and rerun with --steps):\n - ${failed.join('\n - ')}`); process.exitCode = 1 }
|
|
467
|
+
if (skipped.length) { say(`\nSteps not walked after repeated failures:\n - ${skipped.join('\n - ')}`); process.exitCode = 1 }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* `kaloko capture --image`: a screen someone else took (a person, an agent, Maestro, Appium, XCUITest, Detox),
|
|
472
|
+
* with its UI dump when there is one (uiautomator/Appium XML, idb or Maestro JSON), or OCR on macOS (--ocr).
|
|
473
|
+
*/
|
|
474
|
+
async function captureImage ({ config, run, scenario, node, locale, args }) {
|
|
475
|
+
const png = await readFile(resolve(String(args.image))).catch(() => { throw new UserError(`--image ${args.image} cannot be read.`) })
|
|
476
|
+
const px = pngSize(png)
|
|
477
|
+
if (!px) throw new UserError('--image must be a PNG screenshot.')
|
|
478
|
+
const platform = typeof args.platform === 'string' ? args.platform : (scenario.platform && scenario.platform !== 'web' ? scenario.platform : 'desktop')
|
|
479
|
+
let scale = Number(args.scale) || null
|
|
480
|
+
let tree = null
|
|
481
|
+
if (typeof args.tree === 'string') {
|
|
482
|
+
const raw = await readFile(resolve(args.tree), 'utf8').catch(() => { throw new UserError(`--tree ${args.tree} cannot be read.`) })
|
|
483
|
+
const density = Number(args.density) || 1
|
|
484
|
+
tree = readTree(raw, { density })
|
|
485
|
+
// without --scale: the tree's extent against the image width (Android dumps in pixels stay 1 unless --density)
|
|
486
|
+
if (!scale && Number(args.density)) scale = density
|
|
487
|
+
if (!scale) { const right = Math.max(0, ...flatten(tree.root).map((n) => n.bounds ? n.bounds.x + n.bounds.w : 0)); scale = right > 0 ? Math.max(1, Math.min(4, Math.round(px[0] / right))) : 1 }
|
|
488
|
+
} else if (args.ocr) {
|
|
489
|
+
scale ||= platform === 'ios' ? 3 : 1
|
|
490
|
+
const res = await ocrImage(png, locale ? [locale] : undefined)
|
|
491
|
+
tree = readTree({ ocr: res.ocr.map((l) => ({ text: l.text, x: Math.round(l.x / scale), y: Math.round(l.y / scale), w: Math.round(l.w / scale), h: Math.round(l.h / scale) })) })
|
|
492
|
+
}
|
|
493
|
+
scale ||= 1
|
|
494
|
+
const enc = await launchEphemeral(config).catch(() => null)
|
|
495
|
+
try {
|
|
496
|
+
const encoder = enc ? await enc.newPage().catch(() => null) : null
|
|
497
|
+
return await captureApp({ snapshot: { png, tree, size: [Math.round(px[0] / scale), Math.round(px[1] / scale)], scale, device: typeof args.device === 'string' ? args.device : null, crash: null, logErrors: null }, node, locale, viewport: typeof args.viewport === 'string' ? args.viewport : appViewport(run), run, platform, encoder, maxChars: config.evaluators?.jev?.max_state_chars, extra: { source: 'import', url: typeof args.url === 'string' ? args.url : null } })
|
|
498
|
+
} finally { await enc?.close().catch(() => {}) }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const commands = {
|
|
502
|
+
async init (args) {
|
|
503
|
+
const cwd = process.cwd()
|
|
504
|
+
const file = join(cwd, CONFIG_FILE)
|
|
505
|
+
let exists = false
|
|
506
|
+
try { await access(file); exists = true } catch {}
|
|
507
|
+
// a project from before the rename keeps its qawalk.config.yml (it is still read)
|
|
508
|
+
try { await access(join(cwd, LEGACY_CONFIG_FILE)); exists = true; say(`${LEGACY_CONFIG_FILE} found; kept (still read; rename it to ${CONFIG_FILE} when convenient).`) } catch {}
|
|
509
|
+
if (!exists) {
|
|
510
|
+
let tpl = await readFile(join(packageRoot, 'templates', 'kaloko.config.yml'), 'utf8')
|
|
511
|
+
if (typeof args.org === 'string') {
|
|
512
|
+
if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(args.org)) throw new UserError('--org must be the organization slug from kaloko.app (a-z, 0-9, -).')
|
|
513
|
+
tpl = tpl.replace(/^org: .*$/m, `org: ${args.org}`)
|
|
514
|
+
}
|
|
515
|
+
await writeFile(file, tpl)
|
|
516
|
+
say(`Created ${CONFIG_FILE}. Edit org and environments.`)
|
|
517
|
+
} else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
|
|
518
|
+
const agent = typeof args.agent === 'string' ? args.agent : 'none'
|
|
519
|
+
if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UserError('--agent must be claude, codex, all or none.')
|
|
520
|
+
const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
|
|
521
|
+
const skill = join(packageRoot, 'skills', 'kaloko')
|
|
522
|
+
const targets = { claude: ['.claude/skills/kaloko'], codex: ['skills/kaloko'], all: ['.claude/skills/kaloko', 'skills/kaloko'], none: [] }[agent]
|
|
523
|
+
for (const t of targets) {
|
|
524
|
+
await mkdir(join(cwd, t, '..'), { recursive: true })
|
|
525
|
+
await cp(skill, join(cwd, t), { recursive: true, force: true })
|
|
526
|
+
say(`Installed skill → ${t}`)
|
|
527
|
+
// the skill was called qawalk before the rename: two copies would compete for the same tasks
|
|
528
|
+
const legacy = join(cwd, t.replace(/kaloko$/, 'qawalk'))
|
|
529
|
+
if (existsSync(join(legacy, 'SKILL.md')) && /^name: qawalk$/m.test(await readFile(join(legacy, 'SKILL.md'), 'utf8'))) { await rm(legacy, { recursive: true, force: true }); say(`Removed the former skill ${t.replace(/kaloko$/, 'qawalk')}`) }
|
|
530
|
+
}
|
|
531
|
+
if (agent === 'codex' || agent === 'all') {
|
|
532
|
+
const agents = join(cwd, 'AGENTS.md')
|
|
533
|
+
let text = ''
|
|
534
|
+
try { text = await readFile(agents, 'utf8') } catch {}
|
|
535
|
+
if (text.includes('skills/qawalk/SKILL.md')) {
|
|
536
|
+
await writeFile(agents, text.replace(/## QAwalk\n/g, '## Kaloko\n').replace(/the QAwalk canvas/g, 'the Kaloko canvas').replaceAll('skills/qawalk/SKILL.md', 'skills/kaloko/SKILL.md'))
|
|
537
|
+
say('Updated the pointer in AGENTS.md to skills/kaloko')
|
|
538
|
+
} else if (!text.includes('skills/kaloko')) {
|
|
539
|
+
await appendFile(agents, `${text && !text.endsWith('\n') ? '\n' : ''}\n## Kaloko\n\nFor acceptance plans, scenario runs and the Kaloko canvas use \`skills/kaloko/SKILL.md\`.\n`)
|
|
540
|
+
say('Added a Kaloko pointer to AGENTS.md')
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const consentStep = join(cwd, 'qa', 'flows', 'steps', 'consent.mjs')
|
|
544
|
+
try { await access(consentStep) } catch {
|
|
545
|
+
await mkdir(join(consentStep, '..'), { recursive: true })
|
|
546
|
+
await cp(join(packageRoot, 'templates', 'steps', 'consent.mjs'), consentStep)
|
|
547
|
+
say('Added qa/flows/steps/consent.mjs (accepts the consent dialog, if the site has one)')
|
|
548
|
+
}
|
|
549
|
+
say(`Next: create a tester token in the app (Settings → API tokens) and put KALOKO_TOKEN into .env.\nScenario template: ${join(packageRoot, 'templates', 'scenario.yml')}`)
|
|
550
|
+
},
|
|
551
|
+
|
|
552
|
+
async validate (args) {
|
|
553
|
+
if (!args._.length) throw new UserError('Usage: kaloko validate <scenario.yml>...')
|
|
554
|
+
let failed = 0
|
|
555
|
+
for (const file of args._) {
|
|
556
|
+
const scenario = yaml.load(await readFile(file, 'utf8'))
|
|
557
|
+
const problems = validateScenario(scenario)
|
|
558
|
+
if (!problems.length) expandPacks(scenario)
|
|
559
|
+
if (problems.length) { failed++; say(`✕ ${file}\n - ${problems.join('\n - ')}`) } else say(`✓ ${file}`)
|
|
560
|
+
if (args.coverage) {
|
|
561
|
+
// the plan is scenario.source (relative to the working directory or to the scenario file), or --source
|
|
562
|
+
const candidates = typeof args.source === 'string' ? [resolve(args.source)] : scenario.source ? [resolve(scenario.source), resolve(file, '..', scenario.source), resolve(file, '..', '..', scenario.source)] : []
|
|
563
|
+
let plan = null
|
|
564
|
+
for (const c of candidates) { try { plan = parsePlan(await readFile(c, 'utf8')); break } catch {} }
|
|
565
|
+
if (!plan) { say(` · no acceptance plan found (${scenario.source ? `source: ${scenario.source}` : 'scenario has no source'}); pass --source <ACCEPTANCE.md>`); continue }
|
|
566
|
+
const cov = coverage(scenario, plan)
|
|
567
|
+
say(formatCoverage(file, cov))
|
|
568
|
+
if (cov.missing.length) process.exitCode = 1
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (failed) process.exitCode = 1
|
|
572
|
+
await warnRetired(args)
|
|
573
|
+
},
|
|
574
|
+
|
|
575
|
+
async scenarios (args) {
|
|
576
|
+
const { config, root } = await context(args)
|
|
577
|
+
for (const s of await catalogFor(config, root)) say(`${s.id.padEnd(36)} ${s.kind.padEnd(5)} ${String(s.runs.length).padStart(3)} runs ${s.file}`)
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
async start (args) {
|
|
581
|
+
const { config, root } = await context(args)
|
|
582
|
+
if (!args.scenario || !args.env) throw new UserError('Usage: kaloko start --scenario <file> --env <name>')
|
|
583
|
+
const scenarioFile = resolve(args.scenario)
|
|
584
|
+
const scenario = await loadScenario(scenarioFile)
|
|
585
|
+
const env = environment(config, args.env)
|
|
586
|
+
if (scenario.environments && !scenario.environments.includes(env.name)) throw new UserError(`Scenario ${scenario.id} is not allowed on ${env.name} (allowed: ${scenario.environments.join(', ')}).`)
|
|
587
|
+
if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
|
|
588
|
+
const locales = list(args.locales) || scenario.locales || ['default']
|
|
589
|
+
const vpAll = scenario.viewports || { desktop: [1280, 800], mobile: [390, 844] }
|
|
590
|
+
const vpNames = list(args.viewports) || Object.keys(vpAll)
|
|
591
|
+
const scope = { locales, viewports: Object.fromEntries(vpNames.map((n) => [n, vpAll[n]])), ...(args.note ? { note: args.note } : {}) }
|
|
592
|
+
const git = await gitMeta(root)
|
|
593
|
+
const executor = { kind: args.executor ? 'agent' : 'cli', name: args.executor || 'kaloko-cli' }
|
|
594
|
+
const { project, issue } = await projectAndIssue({ cwd: root, config, scenario, branch: git?.branch })
|
|
595
|
+
const appVer = typeof args['app-version'] === 'string' ? args['app-version'].slice(0, 80) : await appVersion(root)
|
|
596
|
+
const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer })
|
|
597
|
+
say(`Run ${meta.id}\n dir: ${relative(process.cwd(), dir)}\n env: ${env.name}${env.readonly ? ' (readonly)' : ''}\n locales: ${locales.join(', ')} viewports: ${vpNames.join(', ')}\n project: ${project.slug}${issue ? ` issue: ${issue.repo ? issue.repo + '#' : '#'}${issue.number}` : ''}`)
|
|
598
|
+
const addr = recipient(env.mail, meta.short)
|
|
599
|
+
if (addr) say(` test e-mail: ${addr}`)
|
|
600
|
+
say(` other agents may start runs here too: pin this one with --run ${meta.id} or KALOKO_RUN=${meta.id}`)
|
|
601
|
+
},
|
|
602
|
+
|
|
603
|
+
async browser (args) {
|
|
604
|
+
const { config, root } = await context(args)
|
|
605
|
+
const action = args._[0] || 'status'
|
|
606
|
+
let env = null
|
|
607
|
+
if (typeof args.env === 'string') env = environment(config, args.env)
|
|
608
|
+
else { try { const run = await resolveRun({ root, output: config.output, run: args.run }); env = environment(config, run.meta.environment.name) } catch {} }
|
|
609
|
+
if (action === 'start') {
|
|
610
|
+
const state = await startBrowser({ root, config, env, headed: !!args.headed })
|
|
611
|
+
say(`${state.reused ? 'Browser already running' : 'Browser started'} for ${env.name}: ${state.cdp} (pid ${state.pid})${state.readonly ? ', readonly policy on' : ''}\nConnect Playwright with chromium.connectOverCDP('${state.cdp}') or Playwright MCP --cdp-endpoint ${state.cdp}`)
|
|
612
|
+
} else if (action === 'stop') {
|
|
613
|
+
say(await stopBrowser({ root, config, env }) ? `Browser stopped${env ? ` (${env.name})` : ' (all environments)'}.` : 'Browser was not running.')
|
|
614
|
+
} else {
|
|
615
|
+
const states = await allStates({ root, config })
|
|
616
|
+
say(states.length ? states.map((s) => `${s.environment}: ${s.cdp} (pid ${s.pid})${s.readonly ? ', readonly' : ''}`).join('\n') : 'No shared browser is running.')
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
async open (args) {
|
|
621
|
+
const { config, root, run, scenario, env } = await runContext(args)
|
|
622
|
+
const node = stepOf(scenario, args.step)
|
|
623
|
+
const locale = args.locale || run.meta.scope.locales[0]
|
|
624
|
+
const url = urlFor(env, node, locale)
|
|
625
|
+
if (!url) throw new UserError(`Step ${node.id} has no path.`)
|
|
626
|
+
const conn = await connectBrowser({ root, config, env })
|
|
627
|
+
if (!conn) throw new UserError('Start the browser first: kaloko browser start')
|
|
628
|
+
const page = await currentPage(conn.context, args.page)
|
|
629
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
|
630
|
+
say(`Opened ${url}`)
|
|
631
|
+
await conn.browser.close()
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
async capture (args) {
|
|
635
|
+
const { config, root, run, scenario, env } = await runContext(args)
|
|
636
|
+
const node = stepOf(scenario, args.step)
|
|
637
|
+
const locale = args.locale || run.meta.scope.locales[0]
|
|
638
|
+
if (!run.meta.scope.locales.includes(locale)) throw new UserError(`Locale ${locale} is not in the run scope (${run.meta.scope.locales.join(', ')}).`)
|
|
639
|
+
if (typeof args.image === 'string') {
|
|
640
|
+
const r = await captureImage({ config, run, scenario, node, locale, args })
|
|
641
|
+
const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
|
|
642
|
+
say(`✓ ${node.id} ${locale} ${r.viewport} ${r.platform}${r.device ? ` · ${r.device}` : ''} ${r.size.width}×${r.size.height} ${r.files.tree ? `UI tree (${r.capture.tree})` : 'no UI tree'} ${failed.length ? `failing checks: ${failed.join(', ')}` : 'checks ok'}`)
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
if (isApp(scenario.platform)) {
|
|
646
|
+
// the app's current screen, through the environment's driver (no navigation)
|
|
647
|
+
const driver = driverFor(scenario.platform, env, { root, scenarioDir: resolve(root, run.meta.scenario_file, '..'), locale })
|
|
648
|
+
if (driver.web) throw new UserError('For Electron, run kaloko walk --steps <id>, or pass --image.')
|
|
649
|
+
await driver.open({ locale, attach: true })
|
|
650
|
+
const enc = await launchEphemeral(config).catch(() => null)
|
|
651
|
+
try {
|
|
652
|
+
const r = await captureApp({ snapshot: await driver.snapshot(), node, locale, viewport: typeof args.viewport === 'string' ? args.viewport : Object.keys(run.meta.scope.viewports || {})[0], run, platform: scenario.platform, encoder: enc ? await enc.newPage() : null, maxChars: config.evaluators?.jev?.max_state_chars })
|
|
653
|
+
say(`✓ ${node.id} ${locale} ${r.viewport} ${r.device || scenario.platform} ${r.files.tree ? `UI tree (${r.capture.tree})` : 'no UI tree'}`)
|
|
654
|
+
} finally { await enc?.close().catch(() => {}) }
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
let conn = await connectBrowser({ root, config, env })
|
|
658
|
+
let ephemeral = null
|
|
659
|
+
let page
|
|
660
|
+
if (conn) page = await currentPage(conn.context, args.page)
|
|
661
|
+
else {
|
|
662
|
+
if (!args.url && !node.path) throw new UserError('No shared browser is running. Start it (kaloko browser start) or pass --url.')
|
|
663
|
+
ephemeral = await launchEphemeral(config)
|
|
664
|
+
page = await (await newContext(ephemeral, env, { root, config })).newPage()
|
|
665
|
+
observeConsole(page)
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const target = args.url === true || (!conn && !args.url) ? urlFor(env, node, locale) : args.url
|
|
669
|
+
if (target) await page.goto(target, { waitUntil: 'domcontentloaded' })
|
|
670
|
+
if (args.crop) {
|
|
671
|
+
const entry = await captureCrop({ page, node, locale, selector: args.crop, title: args.title, run })
|
|
672
|
+
say(`Attached ${entry.file} to ${node.id}`)
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
|
|
676
|
+
const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, list(args.viewports)), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null })
|
|
677
|
+
for (const r of results) {
|
|
678
|
+
const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
|
|
679
|
+
say(`✓ ${node.id} ${locale} ${r.viewport} ${r.size.width}×${r.size.height}px ${failed.length ? `failing checks: ${failed.join(', ')}` : 'checks ok'}${r.blocked_requests.length ? ` blocked: ${r.blocked_requests.length}` : ''}`)
|
|
680
|
+
}
|
|
681
|
+
} finally {
|
|
682
|
+
if (conn) await conn.browser.close()
|
|
683
|
+
if (ephemeral) await ephemeral.close()
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
|
|
687
|
+
async mail (args) {
|
|
688
|
+
const { config, root, run, scenario, env } = await runContext(args)
|
|
689
|
+
const node = stepOf(scenario, args.step)
|
|
690
|
+
if (node.kind !== 'email') throw new UserError(`Step ${node.id} is not an email step.`)
|
|
691
|
+
const locale = args.locale || run.meta.scope.locales[0]
|
|
692
|
+
// recipient: --to, the node's mail.to, the node's session mailbox, then the environment's address
|
|
693
|
+
const to = expandRun(args.to || node.mail?.to || (node.session && scenario.sessions?.[node.session]?.mailbox) || recipient(env.mail, run.meta.short), run.meta.short)
|
|
694
|
+
const subject = args.subject || localized(node.mail?.subject, locale)
|
|
695
|
+
let message = null
|
|
696
|
+
if (typeof args.file === 'string') {
|
|
697
|
+
message = await readMessageFile(resolve(args.file))
|
|
698
|
+
if (typeof args.subject === 'string') message.subject = args.subject
|
|
699
|
+
if (typeof args.to === 'string') message.to = [args.to]
|
|
700
|
+
}
|
|
701
|
+
let since
|
|
702
|
+
if (args['any-time']) since = null
|
|
703
|
+
else if (typeof args.since === 'string') { const t = Date.parse(args.since); if (Number.isNaN(t)) throw new UserError(`--since ${args.since} is not a date (ISO 8601, e.g. 2026-09-27T10:00).`); since = new Date(t).toISOString() }
|
|
704
|
+
const results = await captureMail({ config, root, run, env, node, locale, to, subject, timeoutMs: (Number(args.timeout) || 60) * 1000, viewports: list(args.viewports), message, since, id: typeof args.id === 'string' ? args.id : null })
|
|
705
|
+
say(`✓ ${node.id} ${locale}: "${results.subject}" → ${results.count} viewport(s)`)
|
|
706
|
+
},
|
|
707
|
+
|
|
708
|
+
async walk (args) {
|
|
709
|
+
const { config, root, run, scenario, env } = await runContext(args)
|
|
710
|
+
if (env.kind === 'design') {
|
|
711
|
+
await walkDesign({ config, root, run, scenario, env, pick: list(args.steps), locales: list(args.locales) || run.meta.scope.locales, viewports: scopeViewports(run, list(args.viewports)), say, noCache: !!args['no-cache'] })
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
if (isApp(scenario.platform)) return walkApp({ config, root, run, scenario, env, args })
|
|
715
|
+
const pick = list(args.steps)
|
|
716
|
+
const locales = list(args.locales) || run.meta.scope.locales
|
|
717
|
+
// --ephemeral: an own browser (own cookies and tabs) even when the shared one runs, so runs can walk in parallel
|
|
718
|
+
const conn = args.ephemeral ? null : await connectBrowser({ root, config, env })
|
|
719
|
+
const releaseLock = conn ? await acquireWalkLock({ root, config, env, run: run.meta.id }).catch(async (e) => { await conn.browser.close(); throw e }) : null
|
|
720
|
+
const ephemeral = conn ? null : await launchEphemeral(config)
|
|
721
|
+
const ctx = conn ? conn.context : await newContext(ephemeral, env, { root, config })
|
|
722
|
+
const scenarioDir = resolve(root, run.meta.scenario_file, '..')
|
|
723
|
+
// semantic questions of every new capture start right away; the walk ends with the verdicts (--no-evaluate: skip)
|
|
724
|
+
const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root })
|
|
725
|
+
const pending = []
|
|
726
|
+
const failed = []
|
|
727
|
+
const skipped = []
|
|
728
|
+
const walked = await readCaptures(run.dir)
|
|
729
|
+
const sessions = scenario.sessions || null
|
|
730
|
+
// named sessions: one isolated browser context per person and locale (cookies, storage, sign-in), created lazily
|
|
731
|
+
const sessionContexts = new Map()
|
|
732
|
+
// `account` stays the scenario's key; `credentials` resolves it in this environment (config accounts)
|
|
733
|
+
const sessionInfo = (key) => key && sessions?.[key] ? { key, title: localized(sessions[key].title, locales[0]), mailbox: sessions[key].mailbox ? expandRun(sessions[key].mailbox, run.meta.short) : null, account: sessions[key].account || null, credentials: accountFor(env, sessions[key].account, { root, config }) } : null
|
|
734
|
+
const contextFor = async (key, locale) => {
|
|
735
|
+
if (!key || !sessions?.[key]) return null
|
|
736
|
+
const id = `${key}|${locale}`
|
|
737
|
+
if (!sessionContexts.has(id)) {
|
|
738
|
+
// every context gets the environment's gate and readonly policy; an account's saved sign-in is loaded
|
|
739
|
+
const account = accountFor(env, sessions[key].account, { root, config })
|
|
740
|
+
const c = await newContext(conn ? conn.browser : ephemeral, env, { root, config, account })
|
|
741
|
+
const p = await c.newPage()
|
|
742
|
+
observeConsole(p)
|
|
743
|
+
sessionContexts.set(id, { context: c, page: p })
|
|
744
|
+
const saved = account && await access(account.storage_state).then(() => true, () => false)
|
|
745
|
+
say(`session ${key} (${locale}): new browser context${saved ? ' with the saved sign-in' : ''}`)
|
|
746
|
+
}
|
|
747
|
+
return sessionContexts.get(id)
|
|
748
|
+
}
|
|
749
|
+
// stop a session after the same failure repeats (shared precondition such as a failed sign-in);
|
|
750
|
+
// --keep-going walks every step regardless
|
|
751
|
+
const streak = failureStreak(args['keep-going'] ? 0 : 3)
|
|
752
|
+
try {
|
|
753
|
+
for (const locale of locales) {
|
|
754
|
+
// the walk owns its tab; agents and people keep theirs in the same shared browser
|
|
755
|
+
const page = await ctx.newPage()
|
|
756
|
+
if (!conn) observeConsole(page)
|
|
757
|
+
try {
|
|
758
|
+
// every locale starts as a new visitor on its first walk in the run
|
|
759
|
+
if (conn && !walked.some((c) => c.locale === locale)) { await resetSession(ctx, page, baseUrl(env, locale)); say(`session reset (new visitor, ${locale})`) }
|
|
760
|
+
for (const node of scenario.nodes) {
|
|
761
|
+
if (node.kind === 'decision' || (pick && !pick.includes(node.id))) continue
|
|
762
|
+
if (node.kind !== 'stack' && node.path && !urlFor(env, node, locale)) continue // step does not exist in this locale
|
|
763
|
+
if (node.session && sessions?.[node.session]?.blocked_on?.includes(env.name)) { pending.push(`${node.id} (${locale}): session ${node.session} is blocked on ${env.name}`); continue }
|
|
764
|
+
const sess = await contextFor(node.session, locale)
|
|
765
|
+
const sPage = sess ? sess.page : page
|
|
766
|
+
const sCtx = sess ? sess.context : ctx
|
|
767
|
+
if (node.kind === 'stack') {
|
|
768
|
+
try {
|
|
769
|
+
const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile: conn && !sess ? consoleLog(root, config.output, env.name) : null, args })
|
|
770
|
+
for (const b of bad) failed.push(`${node.id} (${locale}) ${b}`)
|
|
771
|
+
} catch (error) {
|
|
772
|
+
failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
|
|
773
|
+
say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
|
|
774
|
+
}
|
|
775
|
+
continue
|
|
776
|
+
}
|
|
777
|
+
if (node.kind === 'email' && (node.mail?.to || sessionInfo(node.session)?.mailbox)) {
|
|
778
|
+
// an e-mail node with a recipient is walked like a step: wait for the message and capture it
|
|
779
|
+
try {
|
|
780
|
+
const to = expandRun(node.mail?.to || sessionInfo(node.session).mailbox, run.meta.short)
|
|
781
|
+
const results = await captureMail({ config, root, run, env, node, locale, to, subject: localized(node.mail?.subject, locale), timeoutMs: 45000 })
|
|
782
|
+
say(`✓ ${node.id} ${locale}: "${results.subject}"`)
|
|
783
|
+
} catch (error) {
|
|
784
|
+
failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
|
|
785
|
+
say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
|
|
786
|
+
}
|
|
787
|
+
continue
|
|
788
|
+
}
|
|
789
|
+
if (!['playwright', 'script'].includes(node.execution.strategy) || !node.execution.script) { pending.push(`${node.id} (${locale}): ${node.execution.strategy} — ${node.execution.instructions.split('\n')[0]}`); continue }
|
|
790
|
+
const streakKey = `${node.session || ''}|${locale}`
|
|
791
|
+
if (streak.stopped(streakKey)) { skipped.push(`${node.id} (${locale})`); continue }
|
|
792
|
+
try {
|
|
793
|
+
const mod = await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href)
|
|
794
|
+
// the automatic capture runs unless this script invocation captured the step itself: a crop is an
|
|
795
|
+
// attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
|
|
796
|
+
let captured = false
|
|
797
|
+
const capture = (opts = {}) => { captured = true; return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeViewports(run, opts.viewports), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile: conn && !sess ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {} }).then((r) => { prefetch?.add(r, run.dir); return r }) }
|
|
798
|
+
await mod.default({ page: sPage, context: sCtx, locale, node: { id: node.id, title: localized(node.title, locale), kind: node.kind }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0 }), acceptDialogs: () => acceptDialogs(sPage) })
|
|
799
|
+
if (!captured) await capture()
|
|
800
|
+
say(`✓ ${node.id} ${locale}`)
|
|
801
|
+
streak.ok(streakKey)
|
|
802
|
+
} catch (error) {
|
|
803
|
+
failed.push(`${node.id} (${locale}): ${error.message.split('\n')[0]}`)
|
|
804
|
+
say(`✗ ${node.id} ${locale}: ${error.message.split('\n')[0]}`)
|
|
805
|
+
if (streak.record(streakKey, error.message)) say(` stopping ${node.session ? `session ${node.session}` : 'this walk'} (${locale}): the last 3 steps failed with the same error; fix the shared cause (sign-in, environment) and rerun, or pass --keep-going`)
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
} finally { await page.close().catch(() => {}) }
|
|
809
|
+
}
|
|
810
|
+
} finally {
|
|
811
|
+
for (const { context } of sessionContexts.values()) await context.close().catch(() => {})
|
|
812
|
+
if (conn) await conn.browser.close() // over CDP this only disconnects; the shared browser keeps running
|
|
813
|
+
if (ephemeral) await ephemeral.close()
|
|
814
|
+
await releaseLock?.()
|
|
815
|
+
}
|
|
816
|
+
if (pending.length) say(`\nSteps for an agent or a person (capture them with kaloko capture --step <id>):\n - ${[...new Set(pending)].join('\n - ')}`)
|
|
817
|
+
if (failed.length) { say(`\nSteps that failed (fix and rerun with --steps):\n - ${failed.join('\n - ')}`); process.exitCode = 1 }
|
|
818
|
+
if (skipped.length) { say(`\nSteps not walked after repeated failures (rerun with --steps once the cause is fixed):\n - ${skipped.join('\n - ')}`); process.exitCode = 1 }
|
|
819
|
+
if (prefetch) await walkVerdicts({ prefetch, run, scenario, config, root })
|
|
820
|
+
},
|
|
821
|
+
|
|
822
|
+
async auth (args) {
|
|
823
|
+
const { config, root } = await context(args)
|
|
824
|
+
const action = args._[0]
|
|
825
|
+
const { stat, mkdir, writeFile: write, chmod } = await import('node:fs/promises')
|
|
826
|
+
if (action === 'list') {
|
|
827
|
+
const rows = []
|
|
828
|
+
for (const name of Object.keys(config.environments)) {
|
|
829
|
+
let env
|
|
830
|
+
try { env = environment(config, name) } catch { continue }
|
|
831
|
+
for (const key of Object.keys(env.accounts || {})) {
|
|
832
|
+
const file = accountFor(env, key, { root, config }).storage_state
|
|
833
|
+
const st = await stat(file).catch(() => null)
|
|
834
|
+
rows.push(`${name.padEnd(12)} ${key.padEnd(16)} ${st ? `saved ${Math.round((Date.now() - st.mtimeMs) / 36e5)} h ago · ${relative(process.cwd(), file)}` : 'no saved session'}`)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
say(rows.length ? rows.join('\n') : 'No accounts in kaloko.config.yml (environments.<env>.accounts).')
|
|
838
|
+
return
|
|
839
|
+
}
|
|
840
|
+
if (action !== 'save' || typeof args.env !== 'string' || typeof args.account !== 'string') throw new UserError('Usage: kaloko auth save --env <name> --account <key> [--url <url>] | kaloko auth list')
|
|
841
|
+
const env = environment(config, args.env)
|
|
842
|
+
if (env.readonly) throw new UserError(`Environment ${env.name} is readonly; saved sign-ins are for accounts on non-production environments.`)
|
|
843
|
+
const account = accountFor(env, args.account, { root, config })
|
|
844
|
+
if (!account.configured) throw new UserError(`Add the account first: environments.${env.name}.accounts.${args.account} in ${CONFIG_FILE} (it may be empty: {}).`)
|
|
845
|
+
if (!process.stdin.isTTY) throw new UserError('kaloko auth save needs a person at the terminal: they sign in in the browser window and press Enter.')
|
|
846
|
+
const { chromium } = await import('playwright')
|
|
847
|
+
const browser = await chromium.launch({ headless: false, channel: config.browser.channel || undefined })
|
|
848
|
+
try {
|
|
849
|
+
const page = await (await newContext(browser, env, { root, config })).newPage()
|
|
850
|
+
await page.goto(typeof args.url === 'string' ? args.url : baseUrl(env), { waitUntil: 'domcontentloaded' })
|
|
851
|
+
say(`Sign in as "${args.account}" on ${env.name} in the browser window (SSO, MFA as usual), then press Enter here.`)
|
|
852
|
+
await new Promise((done) => { process.stdin.resume(); process.stdin.once('data', () => { process.stdin.pause(); done() }) })
|
|
853
|
+
const state = await page.context().storageState()
|
|
854
|
+
await mkdir(join(account.storage_state, '..'), { recursive: true })
|
|
855
|
+
// cookies are credentials: owner-only file, and a .gitignore next to it
|
|
856
|
+
await write(account.storage_state, JSON.stringify(state, null, 2), { mode: 0o600 })
|
|
857
|
+
await chmod(account.storage_state, 0o600)
|
|
858
|
+
await write(join(account.storage_state, '..', '.gitignore'), '*\n').catch(() => {})
|
|
859
|
+
say(`Saved ${state.cookies.length} cookies and ${state.origins.length} origin storages → ${relative(process.cwd(), account.storage_state)}\nwalk loads it for sessions with account: ${args.account}. Sessions expire; run this again when sign-in is asked for.`)
|
|
860
|
+
} finally { await browser.close() }
|
|
861
|
+
},
|
|
862
|
+
|
|
863
|
+
async session (args) {
|
|
864
|
+
if (args._[0] !== 'reset') throw new UserError('Usage: kaloko session reset')
|
|
865
|
+
const { config, root, env } = await runContext(args)
|
|
866
|
+
const conn = await connectBrowser({ root, config, env })
|
|
867
|
+
if (!conn) throw new UserError('Start the browser first: kaloko browser start')
|
|
868
|
+
try { await resetSession(conn.context, await currentPage(conn.context), env.base_url) } finally { await conn.browser.close() }
|
|
869
|
+
say('Session reset: cookies and storage cleared (new visitor)')
|
|
870
|
+
},
|
|
871
|
+
|
|
872
|
+
async status (args) {
|
|
873
|
+
const { config, root, run, scenario, env } = await runContext(args)
|
|
874
|
+
const caps = await readCaptures(run.dir)
|
|
875
|
+
say(`Run ${run.meta.id} · ${run.meta.environment.name} · ${run.meta.scope.locales.join(', ')}`)
|
|
876
|
+
if (env.kind === 'design') {
|
|
877
|
+
// per step: unchanged / changed here / changed in the draft, from the folder and the last sync (#42)
|
|
878
|
+
const dir = designDir(env, root)
|
|
879
|
+
const local = await localRevisions({ dir, scenario, locales: run.meta.scope.locales, allLocales: run.meta.scope.locales, project: run.meta.project?.slug })
|
|
880
|
+
const branch = typeof args.branch === 'string' ? args.branch : 'main'
|
|
881
|
+
const base = (await readBase(dir))?.[`${scenario.id}@${branch}`]
|
|
882
|
+
let remote = null
|
|
883
|
+
try { remote = Object.fromEntries(((await service(config).request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(scenario.id)}/draft?branch=${encodeURIComponent(branch)}`)).heads || []).map((h) => [headKey(h.node, h.locale, h.state), h])) } catch {}
|
|
884
|
+
say(`Design folder ${relative(process.cwd(), dir)} · draft ${remote ? `synced #${base?.seq ?? '—'}` : 'not reachable (offline or no token)'}`)
|
|
885
|
+
for (const node of scenario.nodes.filter((n) => n.kind !== 'decision' && n.kind !== 'external')) {
|
|
886
|
+
const cells = run.meta.scope.locales.map((l) => {
|
|
887
|
+
const k = headKey(node.id, l), mine = local[k]?.id, b = base?.heads?.[k], r = remote?.[k]?.revision
|
|
888
|
+
if (!mine) return `${l}:none`
|
|
889
|
+
if (r && mine === r) return `${l}:synced`
|
|
890
|
+
if (mine !== b && r && r !== b) return `${l}:CONFLICT`
|
|
891
|
+
if (mine !== b) return `${l}:changed here`
|
|
892
|
+
if (r && r !== b) return `${l}:changed in draft`
|
|
893
|
+
return `${l}:${remote ? 'new' : 'local'}`
|
|
894
|
+
})
|
|
895
|
+
const st = remote && Object.values(remote).find((h) => h.node === node.id)?.status
|
|
896
|
+
say(` ${node.id.padEnd(28)} ${cells.join(' ')}${st ? ` [${st.replace(/_/g, ' ')}]` : ''}`)
|
|
897
|
+
}
|
|
898
|
+
return
|
|
899
|
+
}
|
|
900
|
+
for (const node of scenario.nodes.filter((n) => n.kind !== 'decision')) {
|
|
901
|
+
const got = node.kind === 'stack'
|
|
902
|
+
? run.meta.scope.locales.map((l) => `${l}:${new Set(caps.filter((c) => c.type === 'capture' && c.node === node.id && c.locale === l && c.item).map((c) => c.item)).size}`)
|
|
903
|
+
: run.meta.scope.locales.map((l) => caps.some((c) => c.type === 'capture' && c.node === node.id && c.locale === l) ? l : '·'.repeat(l.length))
|
|
904
|
+
say(` ${node.id.padEnd(28)} ${node.kind.padEnd(8)} ${(node.execution?.strategy || (node.kind === 'stack' ? 'pages' : '')).padEnd(10)} ${got.join(' ')}`)
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
|
|
908
|
+
async evaluate (args) {
|
|
909
|
+
const { config, root, run, scenario, env } = await runContext(args)
|
|
910
|
+
if (args.recheck) {
|
|
911
|
+
const r = await recheckCaptures({ run, scenario, config, root, env, only: list(args.steps) })
|
|
912
|
+
say(`Rechecked deterministic criteria of ${r.rechecked} capture(s) from the stored HTML (console, overflow and performance keep their live result).`)
|
|
913
|
+
if (r.skipped.length) say(` skipped: ${r.skipped.join('; ')}`)
|
|
914
|
+
}
|
|
915
|
+
const report = await evaluateRun({ run, scenario, config, root })
|
|
916
|
+
const via = report.via.qawalk ? ` via Kaloko${report.hosted?.limit != null ? ` (${report.hosted.questions} of ${report.hosted.limit} questions this month)` : ''}` : report.via['own-key'] ? ' with your key' : ''
|
|
917
|
+
say(`Evaluated ${report.evaluated} capture(s); semantic evaluator ${report.model}${via}: ${report.semantic_calls} call(s), ${report.semantic_cached} cached.`)
|
|
918
|
+
if (report.skipped.length) {
|
|
919
|
+
// one line per reason: "12 captures: TYPESAFE_API_KEY is not set …"
|
|
920
|
+
const byReason = new Map()
|
|
921
|
+
for (const x of report.skipped) { const [where, ...why] = x.split(': '); const k = why.join(': '); byReason.set(k, [...(byReason.get(k) || []), where]) }
|
|
922
|
+
say(`Semantic criteria not evaluated:\n - ${[...byReason].slice(0, 8).map(([why, where]) => `${where.length > 1 ? `${where.length} captures (${where.slice(0, 3).join(', ')}${where.length > 3 ? ', …' : ''})` : where[0]}: ${why}`).join('\n - ')}`)
|
|
923
|
+
}
|
|
924
|
+
if (report.outdated.length) say(`Criteria changed after capture of ${report.outdated.join(', ')}: not evaluated. Run "kaloko evaluate --recheck --steps ${report.outdated.join(',')}" (deterministic, from the stored HTML) or walk those steps again.`)
|
|
925
|
+
},
|
|
926
|
+
|
|
927
|
+
async build (args) {
|
|
928
|
+
const { config, root, run, scenario } = await runContext(args)
|
|
929
|
+
const manifest = await buildManifest({ run, scenario, config, root, catalog: await catalogFor(config, root) })
|
|
930
|
+
const file = await writePreview({ run, manifest })
|
|
931
|
+
const s = manifest.summary
|
|
932
|
+
say(`${manifest.id}: ${s.pass} pass · ${s.partial} partial · ${s.fail} fail · ${s.skip} not checked\nPreview: ${file}`)
|
|
933
|
+
return file
|
|
934
|
+
},
|
|
935
|
+
|
|
936
|
+
async preview (args) {
|
|
937
|
+
const file = await commands.build(args)
|
|
938
|
+
say(pathToFileURL(file).href)
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
async share (args) {
|
|
942
|
+
const { config, root, run, scenario, env } = await runContext(args)
|
|
943
|
+
const client = service(config)
|
|
944
|
+
// on a design environment, share publishes a version of the flow from the shared draft (#42)
|
|
945
|
+
if (env.kind === 'design') {
|
|
946
|
+
const r = await syncDesign({ config, root, run, scenario, env, client, args: { ...args, strict: true }, say, pull: false, render: (steps) => walkDesign({ config, root, run, scenario, env, pick: steps, locales: run.meta.scope.locales, viewports: run.meta.scope.viewports, say }).then(() => evaluateRun({ run, scenario, config, root })) })
|
|
947
|
+
if (r.conflicts) return
|
|
948
|
+
const v = await publishVersion({ config, scenario, client, args, say })
|
|
949
|
+
const meta = await readJson(join(run.dir, 'meta.json'))
|
|
950
|
+
meta.shared = { url: v.url, at: new Date().toISOString(), version: v.version, run: v.id }
|
|
951
|
+
await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
|
|
952
|
+
return
|
|
953
|
+
}
|
|
954
|
+
const manifest = await buildManifest({ run, scenario, config, root, catalog: [] })
|
|
955
|
+
await writePreview({ run, manifest })
|
|
956
|
+
const files = new Set()
|
|
957
|
+
for (const n of manifest.nodes) {
|
|
958
|
+
for (const byVp of Object.values(n.captures || {})) for (const c of Object.values(byVp)) [c.image || c.png, c.thumb, c.html, c.eval, c.tree].filter(Boolean).forEach((p) => files.add(p))
|
|
959
|
+
for (const a of n.attachments || []) files.add(a.file)
|
|
960
|
+
// stack steps: the items index and every page's files
|
|
961
|
+
if (n.stack?.items) {
|
|
962
|
+
files.add(n.stack.items)
|
|
963
|
+
const idx = await readJson(join(run.dir, n.stack.items), { items: [] })
|
|
964
|
+
for (const it of idx.items) for (const byVp of Object.values(it.captures || {})) for (const c of Object.values(byVp)) [c.png, c.thumb, c.html, c.eval].filter(Boolean).forEach((p) => files.add(p))
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const days = Number(args.expires) || config.share?.expires_days || 30
|
|
968
|
+
const { stat } = await import('node:fs/promises')
|
|
969
|
+
let total = 0
|
|
970
|
+
for (const p of files) total += (await stat(join(run.dir, p))).size
|
|
971
|
+
try {
|
|
972
|
+
const u = await client.request('GET', `/orgs/${config.org}/usage`)
|
|
973
|
+
if (total > u.limits.max_run_bytes) throw new UserError(`This run is ${fmtBytes(total)}; the limit per run is ${fmtBytes(u.limits.max_run_bytes)}. Capture fewer viewports or locales, or ask an admin for a higher limit.`)
|
|
974
|
+
if (u.usage.bytes + total > u.limits.quota_bytes) throw new UserError(`Storage quota would be exceeded: ${fmtBytes(u.usage.bytes)} used + ${fmtBytes(total)} > ${fmtBytes(u.limits.quota_bytes)}. Delete old runs in Settings or ask for a higher quota.`)
|
|
975
|
+
say(`Uploading ${files.size} files, ${fmtBytes(total)} (organization uses ${fmtBytes(u.usage.bytes)} of ${fmtBytes(u.limits.quota_bytes)})`)
|
|
976
|
+
} catch (e) { if (e instanceof UserError && /limit|quota/.test(e.message)) throw e }
|
|
977
|
+
const created = await client.request('POST', `/orgs/${config.org}/runs?expires_days=${days}`, manifest)
|
|
978
|
+
// text files travel and rest gzip-compressed; the service serves them with Content-Encoding: gzip
|
|
979
|
+
const body = async (p) => { const raw = await readFile(join(run.dir, p)); const gz = /\.(html|json)$/.test(p) && raw.length > 512; return { data: gz ? gzipSync(raw, { level: 6 }) : raw, gz } }
|
|
980
|
+
// the file list goes first: the service checks limits once and each upload is then a plain write
|
|
981
|
+
// (an older service without the plan answers 404/405 and every upload checks itself, as before)
|
|
982
|
+
if (files.size > 20) {
|
|
983
|
+
const plan = []
|
|
984
|
+
for (const p of files) plan.push({ path: p, bytes: (await body(p)).data.length })
|
|
985
|
+
await client.request('POST', `/orgs/${config.org}/runs/${manifest.id}/files`, { files: plan }).catch((e) => { if (!/HTTP (404|405)/.test(e.message)) throw e })
|
|
986
|
+
}
|
|
987
|
+
const queue = [...files]
|
|
988
|
+
let done = 0
|
|
989
|
+
const worker = async () => {
|
|
990
|
+
while (queue.length) {
|
|
991
|
+
const p = queue.shift()
|
|
992
|
+
const { data, gz } = await body(p)
|
|
993
|
+
await client.upload(`/orgs/${config.org}/runs/${manifest.id}/files/${p.split('/').map(encodeURIComponent).join('/')}`, data, p, gz ? { 'Content-Encoding': 'gzip' } : {})
|
|
994
|
+
done++
|
|
995
|
+
if (done % 10 === 0 || done === files.size) process.stdout.write(`\r uploaded ${done}/${files.size}`)
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
await Promise.all(Array.from({ length: files.size > 200 ? 12 : 6 }, worker))
|
|
999
|
+
if (files.size) process.stdout.write('\n')
|
|
1000
|
+
// experimental HTML views (#42): their bundles travel as blobs and attach to the run as revisions
|
|
1001
|
+
const viewIds = new Set(manifest.nodes.flatMap((n) => Object.values(n.captures || {}).flatMap((vps) => Object.values(vps).map((c) => c.view?.revision).filter(Boolean))))
|
|
1002
|
+
if (viewIds.size) {
|
|
1003
|
+
const revs = []
|
|
1004
|
+
for (const id of viewIds) { const r = await readJson(join(run.dir, 'views', `${id}.json`), null); if (r) revs.push(r) }
|
|
1005
|
+
const blobs = new Map()
|
|
1006
|
+
for (const r of revs) for (const [p, h] of Object.entries(r.files)) blobs.set(h, { abs: join(run.dir, 'live', r.id, p), ext: p.split('.').pop() })
|
|
1007
|
+
try {
|
|
1008
|
+
await uploadBlobs({ client, org: config.org, files: blobs, say })
|
|
1009
|
+
await client.request('POST', `/orgs/${config.org}/runs/${manifest.id}/views`, { revisions: revs.map(({ sizes, ...r }) => r) })
|
|
1010
|
+
say(`Attached ${revs.length} HTML view(s).`)
|
|
1011
|
+
} catch (e) { say(`HTML views not attached: ${e.message.split('\n')[0]}`) }
|
|
1012
|
+
}
|
|
1013
|
+
const fin = await client.request('POST', `/orgs/${config.org}/runs/${manifest.id}/finalize`)
|
|
1014
|
+
const meta = await readJson(join(run.dir, 'meta.json'))
|
|
1015
|
+
meta.shared = { url: fin.url, at: new Date().toISOString(), expires_days: created.expires_days }
|
|
1016
|
+
await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
|
|
1017
|
+
say(`Shared ${manifest.id} (${files.size} files, expires in ${created.expires_days} days)\n${fin.url}`)
|
|
1018
|
+
const capped = expiryNote(days, created.expires_days)
|
|
1019
|
+
if (capped) say(`Note: ${capped}`)
|
|
1020
|
+
if (args.pr) {
|
|
1021
|
+
const r = await upsertPrComment({ manifest, url: fin.url, cwd: root })
|
|
1022
|
+
say(r.ok ? `PR comment ${r.action}: ${r.url}` : `PR comment skipped: ${r.reason}.`)
|
|
1023
|
+
}
|
|
1024
|
+
},
|
|
1025
|
+
|
|
1026
|
+
async sync (args) {
|
|
1027
|
+
const { config, root, run, scenario, env } = await runContext(args)
|
|
1028
|
+
const client = service(config)
|
|
1029
|
+
await syncDesign({ config, root, run, scenario, env, client, args, say, push: !args.pull, pull: !args.push, render: (steps) => walkDesign({ config, root, run, scenario, env, pick: steps, locales: run.meta.scope.locales, viewports: run.meta.scope.viewports, say }).then(() => evaluateRun({ run, scenario, config, root })) })
|
|
1030
|
+
},
|
|
1031
|
+
|
|
1032
|
+
async history (args) {
|
|
1033
|
+
const { config, root } = await context(args)
|
|
1034
|
+
const scenarioId = await scenarioIdOf(args, config, root)
|
|
1035
|
+
await historyCmd({ config, scenarioId, client: service(config), args, say })
|
|
1036
|
+
},
|
|
1037
|
+
|
|
1038
|
+
async reference (args) {
|
|
1039
|
+
const { config, root } = await context(args)
|
|
1040
|
+
const scenarioId = await scenarioIdOf(args, config, root)
|
|
1041
|
+
await referenceCmd({ config, scenarioId, client: service(config), args, say })
|
|
1042
|
+
},
|
|
1043
|
+
|
|
1044
|
+
async branch (args) {
|
|
1045
|
+
const { config, root } = await context(args)
|
|
1046
|
+
const scenarioId = await scenarioIdOf(args, config, root)
|
|
1047
|
+
await branchCmd({ config, scenarioId, client: service(config), args, say })
|
|
1048
|
+
},
|
|
1049
|
+
|
|
1050
|
+
async tokens (args) {
|
|
1051
|
+
const { config, root } = await context(args)
|
|
1052
|
+
let env = typeof args.env === 'string' ? environment(config, args.env) : null
|
|
1053
|
+
if (!env) { try { const r = await resolveRun({ root, output: config.output, run: args.run }); env = environment(config, r.meta.environment.name) } catch {} }
|
|
1054
|
+
if (!env) env = Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design') || null
|
|
1055
|
+
const needsService = args._[0] !== 'check'
|
|
1056
|
+
await tokensCmd({ config, root, env, client: needsService ? service(config) : null, args, say, project: typeof args.project === 'string' ? args.project : config.project })
|
|
1057
|
+
},
|
|
1058
|
+
|
|
1059
|
+
async feedback (args) {
|
|
1060
|
+
const { config, root } = await context(args)
|
|
1061
|
+
const client = service(config)
|
|
1062
|
+
// on a design environment the feedback is the draft's comments, pinned ones with their element (#42)
|
|
1063
|
+
let designRun = null
|
|
1064
|
+
try { const r = await resolveRun({ root, output: config.output, run: args.run }); if (r.meta.environment.kind === 'design') designRun = r } catch {}
|
|
1065
|
+
if (designRun && !args.scenario) {
|
|
1066
|
+
const branch = typeof args.branch === 'string' ? args.branch : 'main'
|
|
1067
|
+
const items = await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(designRun.meta.scenario.id)}/draft/reviews?branch=${encodeURIComponent(branch)}`)
|
|
1068
|
+
if (args.json) { say(JSON.stringify(items, null, 2)); return }
|
|
1069
|
+
const open = items.filter((r) => !r.resolved_at && r.status !== 'approved')
|
|
1070
|
+
say(`Draft feedback for ${designRun.meta.scenario.id}${branch !== 'main' ? `@${branch}` : ''}: ${open.length} open`)
|
|
1071
|
+
for (const r of open) say(` ${r.status === 'rejected' ? '✕' : '•'} ${r.node_id}${r.locale ? ` [${r.locale}]` : ''}${r.anchor?.selector ? ` on ${r.anchor.selector}` : ''} — ${r.email}${r.agent ? ` via ${r.agent}` : ''}: ${r.note}`)
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1074
|
+
let runId = args.run
|
|
1075
|
+
let scenarioId = args.scenario
|
|
1076
|
+
if (!runId && !scenarioId) {
|
|
1077
|
+
try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id; else scenarioId = r.meta.scenario.id } catch {}
|
|
1078
|
+
}
|
|
1079
|
+
const q = new URLSearchParams({ run: runId || 'latest', ...(scenarioId ? { scenario: scenarioId } : {}) })
|
|
1080
|
+
const data = await client.request('GET', `/orgs/${config.org}/feedback?${q}`)
|
|
1081
|
+
if (args.json) { say(JSON.stringify(data, null, 2)); return }
|
|
1082
|
+
if (!data.run) { say('No published run found.'); return }
|
|
1083
|
+
say(`Feedback for ${data.run.id} (${data.scenario}) — run verdict: ${data.verdict?.verdict || 'pending'}${data.verdict?.by ? ` by ${data.verdict.by}` : ''}${data.verdict?.note ? `: ${data.verdict.note}` : ''}${data.verdict?.awaiting?.length ? `\n awaiting approval from: ${data.verdict.awaiting.join(', ')}` : ''}${data.approved.length ? `\n approved: ${data.approved.join(', ')}` : ''}`)
|
|
1084
|
+
for (const c of data.criterion_verdicts || []) say(` ✕ ${c.node_id} ${c.criterion_id}${c.locale ? ` [${c.locale}]` : ''} — ${c.email}: ${c.note}`)
|
|
1085
|
+
const thread = (r, where) => {
|
|
1086
|
+
say(` ${r.status === 'rejected' ? '✕' : '•'} [${r.id}] ${where}${r.locale ? ` [${r.locale}]` : ''}${r.pin ? ` · pinned at ${Math.round(r.pin.x * 100)}%,${Math.round(r.pin.y * 100)}%${r.pin.viewport ? ` (${r.pin.viewport})` : ''}` : ''}${r.anchor?.selector ? ` · on ${r.anchor.selector}` : ''} — ${r.email}${r.agent ? ` via ${r.agent}` : ''}: ${r.note}`)
|
|
1087
|
+
for (const x of r.replies || []) say(` ↳ ${x.email}${x.agent ? ` via ${x.agent}` : ''}: ${x.deleted ? '(deleted)' : x.note}`)
|
|
1088
|
+
}
|
|
1089
|
+
const flow = data.flow_comments || []
|
|
1090
|
+
if (!data.items.length && !flow.length) { say(' No open rejections or comments.'); return }
|
|
1091
|
+
for (const r of data.items) thread(r, r.node_id ? `step ${r.node_id}` : 'whole run')
|
|
1092
|
+
for (const r of flow) thread(r, 'whole flow')
|
|
1093
|
+
say(`\nAnswer with: kaloko comment --reply <id> --note "…"; close with: kaloko comment --resolve <id> --note "Fixed in <commit>"${flow.length ? ' (add --flow for flow comments)' : ''}`)
|
|
1094
|
+
},
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Comments from the CLI: on a step (--step), the whole run (default) or the whole flow (--flow); reply, resolve
|
|
1098
|
+
* (with a closing note), reopen, edit and delete by id. Targets the shared run (--run, default the current one).
|
|
1099
|
+
*/
|
|
1100
|
+
async comment (args) {
|
|
1101
|
+
const { config, root } = await context(args)
|
|
1102
|
+
const client = service(config)
|
|
1103
|
+
let runId = typeof args.run === 'string' ? args.run : null
|
|
1104
|
+
let scenarioId = typeof args.scenario === 'string' ? args.scenario : null
|
|
1105
|
+
if (!runId || !scenarioId) { try { const r = await resolveRun({ root, output: config.output, run: runId || undefined }); if (!runId && r.meta.shared) runId = r.meta.id; scenarioId ||= r.meta.scenario.id } catch {} }
|
|
1106
|
+
const flow = !!args.flow
|
|
1107
|
+
if (flow && !scenarioId) throw new UserError('--flow needs a scenario: pass --scenario <id> or run it in a project with a current run.')
|
|
1108
|
+
if (!flow && !runId) throw new UserError('No shared run: pass --run <id>, share the current run first, or use --flow for the whole flow.')
|
|
1109
|
+
const base = flow ? `/orgs/${config.org}/scenarios/${encodeURIComponent(scenarioId)}/comments` : `/orgs/${config.org}/runs/${runId}/comments`
|
|
1110
|
+
const note = typeof args.note === 'string' ? args.note : ''
|
|
1111
|
+
const where = flow ? `flow ${scenarioId}` : `run ${runId}`
|
|
1112
|
+
if (typeof args.resolve === 'string') { await client.request('POST', `${base}/${encodeURIComponent(args.resolve)}/resolve`, { note }); say(`✓ resolved ${args.resolve} (${where})${note ? ` with: ${note}` : ''}`); return }
|
|
1113
|
+
if (typeof args.reopen === 'string') { await client.request('POST', `${base}/${encodeURIComponent(args.reopen)}/reopen`, {}); say(`✓ reopened ${args.reopen}`); return }
|
|
1114
|
+
if (typeof args.delete === 'string') { const r = await client.request('DELETE', `${base}/${encodeURIComponent(args.delete)}`); say(`✓ deleted ${args.delete}${r.kept ? ' (kept as "deleted" because others replied)' : ''}`); return }
|
|
1115
|
+
if (typeof args.edit === 'string') { await client.request('PATCH', `${base}/${encodeURIComponent(args.edit)}`, { note }); say(`✓ edited ${args.edit}`); return }
|
|
1116
|
+
if (!note.trim()) throw new UserError('Write the comment with --note "…".')
|
|
1117
|
+
const status = ['rejected', 'approved'].includes(args.status) ? args.status : 'comment'
|
|
1118
|
+
const body = { note, status, node_id: typeof args.step === 'string' ? args.step : '', ...(typeof args.reply === 'string' ? { parent_id: args.reply } : {}), ...(typeof args.locale === 'string' ? { locale: args.locale } : {}) }
|
|
1119
|
+
const c = await client.request('POST', base, body)
|
|
1120
|
+
say(`✓ ${args.reply ? `replied to ${args.reply}` : `commented on ${body.node_id ? `step ${body.node_id} of ` : 'the whole '}${where}`} [${c.id}]`)
|
|
1121
|
+
},
|
|
1122
|
+
|
|
1123
|
+
async runs (args) {
|
|
1124
|
+
const { config } = await context(args)
|
|
1125
|
+
const client = service(config)
|
|
1126
|
+
const all = !!args.all || typeof args.scenario === 'string'
|
|
1127
|
+
const q = new URLSearchParams({ ...(all ? {} : { latest: '1' }), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(typeof args.environment === 'string' ? { environment: args.environment } : {}), limit: '300' })
|
|
1128
|
+
const runs = await client.request('GET', `/orgs/${config.org}/runs?${q}`)
|
|
1129
|
+
if (args.json) { say(JSON.stringify(runs, null, 2)); return }
|
|
1130
|
+
if (!runs.length) { say('No shared runs match.'); return }
|
|
1131
|
+
for (const r of runs) {
|
|
1132
|
+
const s = r.summary || {}
|
|
1133
|
+
say(`${r.id} ${r.environment?.name || ''} ${r.verdict || 'pending'}${r.kept ? ' · kept' : ''} ${s.pass || 0}✓ ${s.partial || 0}◐ ${s.fail || 0}✕ ${r.author || ''}${r.older ? ` + ${r.older} older` : ''}`)
|
|
1134
|
+
}
|
|
1135
|
+
if (!all) say(`\nNewest run per scenario; --all or --scenario <id> lists every run.`)
|
|
1136
|
+
},
|
|
1137
|
+
|
|
1138
|
+
async trash (args) {
|
|
1139
|
+
const { config } = await context(args)
|
|
1140
|
+
if (!args._.length) throw new UserError('Usage: kaloko trash <run-id>...')
|
|
1141
|
+
const client = service(config)
|
|
1142
|
+
for (const id of args._) { await client.request('DELETE', `/orgs/${config.org}/runs/${encodeURIComponent(id)}`); say(`✓ ${id} moved to the trash (kaloko restore ${id} brings it back)`) }
|
|
1143
|
+
},
|
|
1144
|
+
|
|
1145
|
+
async restore (args) {
|
|
1146
|
+
const { config } = await context(args)
|
|
1147
|
+
if (args._.length !== 1) throw new UserError('Usage: kaloko restore <run-id>')
|
|
1148
|
+
await service(config).request('POST', `/orgs/${config.org}/runs/${encodeURIComponent(args._[0])}/restore`, {})
|
|
1149
|
+
say(`✓ ${args._[0]} restored`)
|
|
1150
|
+
},
|
|
1151
|
+
|
|
1152
|
+
async prune (args) {
|
|
1153
|
+
const { config } = await context(args)
|
|
1154
|
+
const days = typeof args['older-than'] === 'string' ? Number(args['older-than'].replace(/d$/, '')) : null
|
|
1155
|
+
if (days != null && !Number.isInteger(days)) throw new UserError('--older-than takes days, e.g. 14d.')
|
|
1156
|
+
const body = { dry_run: !args.yes, keep_latest: args.keep === undefined ? 3 : Number(args.keep), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(typeof args.environment === 'string' ? { environment: args.environment } : {}), ...(typeof args.project === 'string' ? { project: args.project } : {}), ...(days != null ? { older_than_days: days } : {}) }
|
|
1157
|
+
const r = await service(config).request('POST', `/orgs/${config.org}/runs/prune`, body)
|
|
1158
|
+
if (args.json) { say(JSON.stringify(r, null, 2)); return }
|
|
1159
|
+
if (!r.trash.length) { say(`Nothing to clean up (keeping the newest ${r.keep_latest} per scenario and environment).`); return }
|
|
1160
|
+
say(`${r.dry_run ? 'Would move' : 'Moved'} ${r.trash.length} run(s) to the trash:`)
|
|
1161
|
+
for (const x of r.trash) say(` ${x.id} ${x.environment} ${x.uploaded_by}`)
|
|
1162
|
+
const others = r.kept.filter((x) => x.reason === 'uploaded by someone else').length
|
|
1163
|
+
if (others) say(`${others} older run(s) of other people stay (only an admin prunes them).`)
|
|
1164
|
+
if (r.dry_run) say('Dry run: nothing changed. Add --yes to move them to the trash (restorable until purge).')
|
|
1165
|
+
},
|
|
1166
|
+
|
|
1167
|
+
async keep (args) {
|
|
1168
|
+
const { config } = await context(args)
|
|
1169
|
+
if (args._.length !== 1) throw new UserError('Usage: kaloko keep <run-id> [--off]')
|
|
1170
|
+
const r = await service(config).request('POST', `/orgs/${config.org}/runs/${encodeURIComponent(args._[0])}/keep`, { keep: !args.off })
|
|
1171
|
+
say(r.kept ? `✓ ${args._[0]} is kept: it never expires and is never pruned` : `✓ ${args._[0]} is no longer kept`)
|
|
1172
|
+
},
|
|
1173
|
+
|
|
1174
|
+
async archive (args) {
|
|
1175
|
+
const { config } = await context(args)
|
|
1176
|
+
if (args._.length !== 1) throw new UserError('Usage: kaloko archive <scenario-id> [--undo]')
|
|
1177
|
+
await service(config).request('POST', `/orgs/${config.org}/scenarios/${encodeURIComponent(args._[0])}/${args.undo ? 'unarchive' : 'archive'}`, {})
|
|
1178
|
+
say(args.undo ? `✓ ${args._[0]} is active again` : `✓ ${args._[0]} archived: hidden from the scenario list, its runs stay reachable (kaloko archive ${args._[0]} --undo)`)
|
|
1179
|
+
},
|
|
1180
|
+
|
|
1181
|
+
async inbox (args) {
|
|
1182
|
+
const { config } = await context(args)
|
|
1183
|
+
const client = service(config)
|
|
1184
|
+
const q = new URLSearchParams(args.all ? {} : { unread: '1' })
|
|
1185
|
+
const data = await client.request('GET', `/orgs/${config.org}/notifications?${q}`)
|
|
1186
|
+
if (args.json) say(JSON.stringify(data, null, 2))
|
|
1187
|
+
else {
|
|
1188
|
+
say(`Inbox for ${config.org}: ${data.unread} unread${args.all ? `, showing ${data.items.length}` : ''}`)
|
|
1189
|
+
const line = (n) => { const d = n.detail || {}; const who = n.actor ? `${n.actor}: ` : ''; const where = n.run_id ? ` (${n.run_id}${n.node_id ? `#${n.node_id}` : ''})` : ''; return `${n.read ? ' ' : '•'} ${n.created_at.slice(0, 16).replace('T', ' ')} ${n.type} ${who}${[d.scenario, d.node, d.note, d.email, d.domain, d.expires_at && `expires ${d.expires_at.slice(0, 10)}`].filter(Boolean).join(' · ')}${where}` }
|
|
1190
|
+
for (const n of data.items) say(line(n))
|
|
1191
|
+
}
|
|
1192
|
+
if (args.read && data.items.some((n) => !n.read)) {
|
|
1193
|
+
const r = await client.request('POST', `/orgs/${config.org}/notifications`, { read: data.items.filter((n) => !n.read).map((n) => n.id) })
|
|
1194
|
+
say(`Marked ${r.read} read.`)
|
|
1195
|
+
}
|
|
1196
|
+
},
|
|
1197
|
+
|
|
1198
|
+
async projects (args) {
|
|
1199
|
+
const { config } = await context(args)
|
|
1200
|
+
const data = await service(config).request('GET', `/orgs/${config.org}/projects`)
|
|
1201
|
+
if (args.json) { say(JSON.stringify(data, null, 2)); return }
|
|
1202
|
+
if (!data.length) { say('No projects yet. Create one in Settings → Projects or with POST /api/v1/orgs/<org>/projects.'); return }
|
|
1203
|
+
for (const p of data) say(`${p.slug.padEnd(24)} ${p.name}${p.adhoc ? ' (from runs only)' : ''} ${p.scenarios} scenario(s), ${p.runs} run(s)${config.project === p.slug ? ' ← this config' : ''}`)
|
|
1204
|
+
},
|
|
1205
|
+
|
|
1206
|
+
async compare (args) {
|
|
1207
|
+
const { config, root } = await context(args)
|
|
1208
|
+
const { readJson } = await import('./lib/util.mjs')
|
|
1209
|
+
let client = null
|
|
1210
|
+
const remote = async (id) => { client ||= service(config); return client.request('GET', `/orgs/${config.org}/runs/${id}`) }
|
|
1211
|
+
// a run is read locally when its directory has a manifest, otherwise from the service
|
|
1212
|
+
const load = async (id) => {
|
|
1213
|
+
if (!id) return null
|
|
1214
|
+
const local = await readJson(id.includes('/') ? resolve(id, 'run.json') : join(root, config.output, id, 'run.json'), null)
|
|
1215
|
+
return local || remote(id)
|
|
1216
|
+
}
|
|
1217
|
+
let runId = args.run
|
|
1218
|
+
if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
|
|
1219
|
+
if (!runId) throw new UserError('--run <id> is required when there is no current run.')
|
|
1220
|
+
const run = await load(runId)
|
|
1221
|
+
if (!run) throw new UserError(`Run ${runId} not found locally or on the service.`)
|
|
1222
|
+
let baseId = typeof args.baseline === 'string' ? args.baseline : null
|
|
1223
|
+
// --design: against the design reference of the scenario (#42)
|
|
1224
|
+
if (args.design && run.scenario?.id) { client ||= service(config); baseId = (await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(run.scenario.id)}/references`)).design; if (!baseId) throw new UserError('This scenario has no design reference yet (kaloko reference --design --version <n>).') }
|
|
1225
|
+
if (!baseId) {
|
|
1226
|
+
// the service knows the scenario baseline; locally fall back to the previous run of the scenario
|
|
1227
|
+
if (!run.baseline_id && run.scenario?.id) { try { run.baseline_id = (await remote(runId)).baseline_id } catch {} }
|
|
1228
|
+
baseId = run.baseline_id && run.baseline_id !== run.id ? run.baseline_id : (run.history || []).filter((h) => h.started_at < run.started_at).sort((a, b) => b.started_at.localeCompare(a.started_at))[0]?.id
|
|
1229
|
+
if (!baseId) { const others = (await listRuns(root, config.output)).filter((r) => r.scenario?.id === run.scenario?.id && r.id !== run.id && r.started_at < run.started_at).sort((a, b) => b.started_at.localeCompare(a.started_at)); baseId = others[0]?.id }
|
|
1230
|
+
}
|
|
1231
|
+
if (!baseId) throw new UserError('No baseline or earlier run of this scenario to compare with; pass --baseline <id>.')
|
|
1232
|
+
const base = await load(baseId)
|
|
1233
|
+
if (!base) throw new UserError(`Run ${baseId} not found locally or on the service.`)
|
|
1234
|
+
const d = compareRuns(run, base)
|
|
1235
|
+
if (args.json) { say(JSON.stringify(d, null, 2)); return }
|
|
1236
|
+
say(formatComparison(d))
|
|
1237
|
+
if (config.share?.url && run.org) say(`\nCanvas: ${config.share.url.replace(/\/$/, '')}/${run.org}/runs/${run.id}/#cmp=${base.id}`)
|
|
1238
|
+
},
|
|
1239
|
+
|
|
1240
|
+
async draft (args) {
|
|
1241
|
+
const { root } = await context(args).catch(() => ({ root: process.cwd() }))
|
|
1242
|
+
const urls = args._.filter((u) => /^https?:\/\//.test(u))
|
|
1243
|
+
if (!urls.length) throw new UserError('Usage: kaloko draft <url> [url…] (public pages of one site)')
|
|
1244
|
+
const pages = []
|
|
1245
|
+
for (const url of urls) {
|
|
1246
|
+
let facts = {}
|
|
1247
|
+
try { const res = await fetch(url, { headers: { 'User-Agent': 'Kaloko-draft/1 (+https://kaloko.app)' }, redirect: 'follow', signal: AbortSignal.timeout(10000) }); facts = pageFacts(await res.text()); say(` ${res.status} ${url}${facts.title ? ` — ${facts.title}` : ''}`) } catch (e) { say(` ✕ ${url}: ${e.message} (drafted from the URL only)`) }
|
|
1248
|
+
pages.push({ url, facts })
|
|
1249
|
+
}
|
|
1250
|
+
let draft
|
|
1251
|
+
try { draft = draftScenario(pages, { id: typeof args.id === 'string' ? args.id : undefined, title: typeof args.title === 'string' ? args.title : undefined }) } catch (e) { throw new UserError(e.message) }
|
|
1252
|
+
const file = resolve(root, typeof args.out === 'string' ? args.out : join('qa', 'flows', `${new URL(draft.origin).hostname.replace(/^www\./, '').replace(/[^a-z0-9]+/gi, '-')}.yml`))
|
|
1253
|
+
const planFile = file.replace(/\.ya?ml$/, '') + '.ACCEPTANCE.md'
|
|
1254
|
+
draft.scenario.source = relative(root, planFile)
|
|
1255
|
+
|
|
1256
|
+
for (const f of [file, planFile]) if (existsSync(f) && !args.force) throw new UserError(`${relative(process.cwd(), f)} exists; pass --force to overwrite.`)
|
|
1257
|
+
const problems = validateScenario(JSON.parse(JSON.stringify(draft.scenario)))
|
|
1258
|
+
if (problems.length) throw new UserError(`Draft did not validate:\n - ${problems.join('\n - ')}`)
|
|
1259
|
+
await mkdir(join(dirname(file), 'steps'), { recursive: true })
|
|
1260
|
+
await writeFile(file, scenarioYaml(draft.scenario))
|
|
1261
|
+
await writeFile(planFile, draft.plan)
|
|
1262
|
+
const consent = join(dirname(file), 'steps', 'consent.mjs'); const open = join(dirname(file), 'steps', 'open.mjs')
|
|
1263
|
+
if (!existsSync(consent)) await writeFile(consent, await readFile(join(packageRoot, 'templates', 'steps', 'consent.mjs'), 'utf8'))
|
|
1264
|
+
if (!existsSync(open)) await writeFile(open, "// Opens the step's page (kaloko draft).\nexport default async ({ page, url }) => { await page.goto(url(), { waitUntil: 'domcontentloaded' }) }\n")
|
|
1265
|
+
say(`Drafted ${draft.scenario.nodes.length} step(s) of ${draft.origin}\n scenario: ${relative(process.cwd(), file)}\n plan: ${relative(process.cwd(), planFile)}\nNext: sharpen the purposes and criteria, make sure environments.production.base_url is ${draft.origin} (readonly: true), then kaloko start --scenario ${relative(process.cwd(), file)} --env production`)
|
|
1266
|
+
},
|
|
1267
|
+
|
|
1268
|
+
async calibrate (args) {
|
|
1269
|
+
const { config } = await context(args)
|
|
1270
|
+
const thresholds = { ...JEV_DEFAULTS.thresholds, ...(config.evaluators?.jev?.thresholds || {}) }
|
|
1271
|
+
const cases = []
|
|
1272
|
+
if (typeof args.golden === 'string') cases.push(...await casesFromGolden(resolve(args.golden)))
|
|
1273
|
+
if (typeof args.scenario === 'string') {
|
|
1274
|
+
const client = service(config)
|
|
1275
|
+
const runs = await client.request('GET', `/orgs/${config.org}/runs?scenario=${encodeURIComponent(args.scenario)}&limit=${Number(args.limit) || 30}`)
|
|
1276
|
+
for (const r of runs) {
|
|
1277
|
+
const m = await client.request('GET', `/orgs/${config.org}/runs/${r.id}`)
|
|
1278
|
+
cases.push(...casesFromRun(m, m.criterion_verdicts))
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
if (!cases.length) {
|
|
1282
|
+
throw new UserError(typeof args.scenario === 'string'
|
|
1283
|
+
? `No labelled cases in the last ${Number(args.limit) || 30} runs of ${args.scenario}: reviewers have not set pass/fail on semantic criteria yet (lightbox → criterion → Pass/Fail), or pass --golden labels.yml.`
|
|
1284
|
+
: 'No labelled cases. Pass --golden labels.yml or --scenario <id> (reviewers set pass/fail on semantic criteria in the canvas).')
|
|
1285
|
+
}
|
|
1286
|
+
const report = calibrate(cases, thresholds, { target: Number(args.target) || 0.95 })
|
|
1287
|
+
say(args.json ? JSON.stringify(report, null, 2) : formatCalibration(report))
|
|
1288
|
+
},
|
|
1289
|
+
|
|
1290
|
+
async export (args) {
|
|
1291
|
+
const { config, root } = await context(args)
|
|
1292
|
+
const client = service(config)
|
|
1293
|
+
if (args.live) {
|
|
1294
|
+
const scenarioId = await scenarioIdOf(args, config, root)
|
|
1295
|
+
let out = typeof args.out === 'string' ? resolve(args.out) : null
|
|
1296
|
+
if (!out) { const d = Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design'); out = d ? designDir(d, root) : resolve(root, 'design') }
|
|
1297
|
+
await exportLive({ config, scenarioId, client, args, say, out })
|
|
1298
|
+
return
|
|
1299
|
+
}
|
|
1300
|
+
if (typeof args.scenario === 'string') return exportScenario({ args, config, root, client })
|
|
1301
|
+
let runId = args.run
|
|
1302
|
+
if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
|
|
1303
|
+
if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id> is required (a shared run id).')
|
|
1304
|
+
const out = typeof args.out === 'string' ? resolve(args.out) : resolve(root, config.output, `${runId}.zip`)
|
|
1305
|
+
const { createWriteStream } = await import('node:fs')
|
|
1306
|
+
const { Readable } = await import('node:stream')
|
|
1307
|
+
const { pipeline } = await import('node:stream/promises')
|
|
1308
|
+
const res = await client.fetch(`/orgs/${config.org}/runs/${runId}/export.zip`)
|
|
1309
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(out))
|
|
1310
|
+
const { size } = await (await import('node:fs/promises')).stat(out)
|
|
1311
|
+
say(`Exported ${runId} → ${out} (${fmtBytes(size)})`)
|
|
1312
|
+
},
|
|
1313
|
+
|
|
1314
|
+
async public (args) {
|
|
1315
|
+
const { config, root } = await context(args)
|
|
1316
|
+
const client = service(config)
|
|
1317
|
+
let runId = args.run
|
|
1318
|
+
if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
|
|
1319
|
+
if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id> is required (a shared run id).')
|
|
1320
|
+
const r = await client.request('POST', `/orgs/${config.org}/runs/${runId}/public`, { public: !args.off })
|
|
1321
|
+
say(r.public ? `Public: ${r.url} (readable without sign-in; only one public run per scenario)` : `Private again: ${runId}`)
|
|
1322
|
+
},
|
|
1323
|
+
|
|
1324
|
+
async doctor (args) {
|
|
1325
|
+
const { config, root } = await context(args)
|
|
1326
|
+
const r = await doctor({ config, root, fix: !!args.fix })
|
|
1327
|
+
say(args.json ? JSON.stringify(r, null, 2) : formatDoctor(r))
|
|
1328
|
+
if (!r.ok) process.exitCode = 1
|
|
1329
|
+
},
|
|
1330
|
+
|
|
1331
|
+
async help () { say(HELP) }
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
export async function main (argv) {
|
|
1335
|
+
const args = parse(argv)
|
|
1336
|
+
const name = args._.shift() || 'help'
|
|
1337
|
+
const cmd = commands[name] || (args.help ? commands.help : null)
|
|
1338
|
+
if (!cmd) { console.error(`Unknown command "${name}".\n`); say(HELP); process.exitCode = 2; return }
|
|
1339
|
+
try {
|
|
1340
|
+
await cmd(args)
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
if (error instanceof UserError) { console.error(error.message); process.exitCode = 1 } else throw error
|
|
1343
|
+
}
|
|
1344
|
+
}
|