kaloko 0.17.0 → 0.18.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 CHANGED
@@ -162,6 +162,13 @@ jobs:
162
162
  Uploads go by content hash: captures that did not change since an earlier run
163
163
  are not sent again, and an interrupted `share` picks up where it stopped.
164
164
 
165
+ With the Kaloko GitHub App (Team and up), each pull request also gets one
166
+ `Kaloko` check on its newest commit. It turns green when the runs shared for
167
+ that commit pass, whether they came from this workflow, a coding agent in the
168
+ cloud or a laptop. Branch protection can require it, and labels and
169
+ `/kaloko skip <reason>` control a single pull request. Details:
170
+ `skills/kaloko/reference/ci.md`.
171
+
165
172
  ## When a screenshot is not enough
166
173
 
167
174
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Your AI agents build; Kaloko walks every screen, puts it on one canvas and records who approved what.",
5
5
  "keywords": [
6
6
  "qa",
@@ -136,6 +136,21 @@
136
136
  "site_contacts": { "type": "boolean", "default": true, "description": "kaloko share takes the site's own contacts for not personal data: e-mails on the environment's domain, e-mails and phone numbers in the page footer, and those of an Organization or ContactPoint in JSON-LD. false checks them like any other value." }
137
137
  }
138
138
  },
139
+ "github": {
140
+ "type": "object",
141
+ "additionalProperties": false,
142
+ "description": "The Kaloko check on pull requests (#141). The service reads it from the base branch; it may only make the check stricter than Settings on Kaloko.",
143
+ "properties": {
144
+ "checks": { "enum": ["off", "manual", "auto", "strict"], "description": "manual: only on request (label kaloko:run, /kaloko walk); auto: every pull request, the scenarios its change reaches; strict: every pull request, all scenarios, review required." },
145
+ "scope": { "enum": ["affected", "all"] },
146
+ "require_review": { "type": "boolean", "description": "Also post Kaloko review, which turns green when the runs are accepted." },
147
+ "drafts": { "enum": ["wait", "run"], "description": "wait: no check on a draft pull request until it is ready for review." },
148
+ "skip_authors": { "type": "array", "items": { "type": "string" }, "description": "Only in Settings: skipping authors would loosen the check, so the file's list is ignored." },
149
+ "skip_by": { "enum": ["write", "maintain", "admin"], "description": "The access a person needs to skip a pull request." },
150
+ "author_may_skip": { "type": "boolean" },
151
+ "wait_hours": { "type": "integer", "minimum": 1, "maximum": 336, "description": "strict: hours to wait for a run before the check times out." }
152
+ }
153
+ },
139
154
  "share": {
140
155
  "type": "object",
141
156
  "additionalProperties": false,
@@ -116,6 +116,23 @@
116
116
  "sha": {
117
117
  "type": "string",
118
118
  "description": "Full commit hash (GitHub check runs need it)."
119
+ },
120
+ "repo": {
121
+ "type": "string",
122
+ "description": "owner/name of the origin remote (kaloko 0.18+): the service finds the pull request by commit when pr is missing."
123
+ },
124
+ "host": {
125
+ "type": "string",
126
+ "enum": ["github", "gitlab", "other"],
127
+ "description": "Where the origin remote lives."
128
+ },
129
+ "prefix": {
130
+ "type": "string",
131
+ "description": "The project's folder inside the repository (monorepos), without a trailing slash."
132
+ },
133
+ "dirty": {
134
+ "type": "boolean",
135
+ "description": "Tracked files differed from the commit when the run started: the run does not count for the commit's GitHub check."
119
136
  }
120
137
  }
121
138
  },
@@ -168,6 +168,10 @@ only when a person says that value may be uploaded.
168
168
  [reference/recording.md](reference/recording.md)
169
169
  - CI: a workflow that walks each pull request's preview deployment and
170
170
  comments on it (`kaloko ci github|gitlab`): [reference/ci.md](reference/ci.md)
171
+ - The `Kaloko` check on a pull request, what makes it pass, `kaloko ci plan`,
172
+ labels and `/kaloko` commands, and walking from a coding agent in the cloud
173
+ (commit first, a dirty tree does not count; never skip the check yourself):
174
+ [reference/ci.md#the-kaloko-check-on-pull-requests](reference/ci.md#the-kaloko-check-on-pull-requests)
171
175
  - Environments, access (gates, accounts, sign-in), e-mails, readonly and
172
176
  production rules, and what is yours to arrange for the project's stack:
173
177
  [reference/environments.md](reference/environments.md)
@@ -30,6 +30,10 @@ the deployment):
30
30
  `walk --ephemeral --affected` and `share --pr` (one comment per scenario,
31
31
  updated on later pushes).
32
32
 
33
+ Before the loop, the workflow runs `kaloko ci plan` (see
34
+ [The Kaloko check on pull requests](#the-kaloko-check-on-pull-requests)), so
35
+ the check on the pull request knows which scenarios to wait for.
36
+
33
37
  `kaloko ci` also adds the `preview` environment to `kaloko.config.yml` with
34
38
  `base_url: ${KALOKO_PREVIEW_URL}`. Scenarios that list `environments:` must
35
39
  include `preview`, or `start` refuses them; the command says which.
@@ -67,6 +71,85 @@ in the newest shared run of the scenario count too, so a fresh CI checkout
67
71
  maps routes as well as a laptop with earlier runs. See
68
72
  [execution.md](execution.md#only-what-changed).
69
73
 
74
+ ## The Kaloko check on pull requests
75
+
76
+ With the Kaloko GitHub App installed (an organization admin: Settings →
77
+ Integrations → GitHub), every pull request in a connected repository gets one
78
+ check named `Kaloko` on its newest commit. It does not walk anything itself:
79
+ it waits for the runs shared for that commit, from a coding agent in the
80
+ cloud, a person's machine or this workflow, and decides from them.
81
+
82
+ | The check says | When |
83
+ |---|---|
84
+ | success | every scenario the change reaches has a run on this commit without failing steps, or nothing reaches a scenario |
85
+ | failure | a run on this commit has a failing step (in strict mode a partial one too) |
86
+ | in progress | a reached scenario has no run on this commit yet; the summary says how to get one |
87
+ | neutral | the repository has no scenario yet, or only code Kaloko cannot map without the repository changed (auto mode) |
88
+ | skipped | someone with the right access skipped it; the summary names them and their reason |
89
+
90
+ Runs of a dirty working tree (tracked files differ from the commit) are
91
+ listed but do not count: commit first, then walk. A new push supersedes the
92
+ old commit's check. The optional `Kaloko review` check follows the people's
93
+ decision (accepted, returned) on those runs.
94
+
95
+ Which scenarios a change reaches: the service maps the pull request's files
96
+ onto the steps' paths and captured URLs, like `kaloko affected` without the
97
+ repository. `kaloko ci plan` sends what `kaloko affected` found (it follows
98
+ imports and `affects:` globs), which wins:
99
+
100
+ ```bash
101
+ kaloko ci plan # every scenario of the config, for HEAD
102
+ kaloko ci plan --scenario qa/flows/checkout.yml --none # this commit reaches nothing there
103
+ kaloko ci plan --dry-run --json # show it, send nothing
104
+ ```
105
+
106
+ Modes (Settings, per repository): `off`, `manual` (only after the label
107
+ `kaloko:run` or the comment `/kaloko walk`), `auto` (the default) and
108
+ `strict` (all scenarios, review required, unknown impact waits, the check
109
+ times out after `wait_hours`). The `github:` section of `kaloko.config.yml`
110
+ on the base branch may make it stricter, never looser:
111
+
112
+ ```yaml
113
+ github:
114
+ checks: strict # off | manual | auto | strict
115
+ scope: all # affected | all
116
+ require_review: true
117
+ drafts: run # wait (default) | run
118
+ skip_by: maintain # write | maintain | admin
119
+ author_may_skip: false
120
+ wait_hours: 12
121
+ ```
122
+
123
+ One pull request: the labels `kaloko:skip` (whole pull request), `kaloko:all`,
124
+ `kaloko:review`, `kaloko:run`; the comments `/kaloko skip <reason>` (this
125
+ commit only), `/kaloko walk`, `/kaloko all`; the buttons on the check. Only
126
+ people with the access set in Settings skip, never apps or bots, and every
127
+ skip is on the check and in the audit log. Do not skip a check yourself as an
128
+ agent; walk, or tell the person why it fails.
129
+
130
+ To make it block merging, the repository's branch protection (or ruleset)
131
+ requires the status check `Kaloko`. Kaloko never changes branch protection.
132
+
133
+ ### Coding agents in the cloud
134
+
135
+ An agent in a sandbox (Claude Code on the web, Codex, Cursor background
136
+ agents, Copilot) runs the same commands as on a laptop:
137
+
138
+ ```bash
139
+ git commit -am "…" # walk what is committed
140
+ npx kaloko start --scenario qa/flows/checkout.yml --env preview
141
+ npx kaloko walk --affected
142
+ npx kaloko share # --pr also comments, if gh is signed in
143
+ ```
144
+
145
+ - `KALOKO_TOKEN` goes into the sandbox's secrets or environment variables
146
+ (tester role, upload scope; a token per agent shows who shared what).
147
+ - `npx kaloko browser install --with-deps` belongs in the environment's setup
148
+ script. When the sandbox cannot run a browser or reach the preview, let the
149
+ workflow above walk instead.
150
+ - `gh` is not needed: the run carries the repository and the commit, and
151
+ Kaloko finds the pull request.
152
+
70
153
  ## Exit codes
71
154
 
72
155
  | Code | Meaning |
package/src/cli.mjs CHANGED
@@ -127,6 +127,10 @@ Project
127
127
  In CI: the preview URL (KALOKO_PREVIEW_URL, the deployment event, provider
128
128
  variables or the commit's GitHub deployments), the pull request and its base;
129
129
  --wait polls that long for the deployment (seconds, or 10m)
130
+ ci plan [--scenario a.yml,b.yml] [--base ref] [--none] [--repo owner/name] [--sha commit] [--dry-run] [--json]
131
+ Tell the GitHub check which scenarios this commit reaches (kaloko affected, which
132
+ follows imports): it then waits for runs of those and passes for the rest;
133
+ --none marks the scenarios as not reached
130
134
  login [--org <slug>] [--url <service>] [--print] [--no-open]
131
135
  Sign this machine in through the browser: approve the code on kaloko.app, choose
132
136
  the organization and what the token may do; the token (at most 30 days) goes to
@@ -1776,7 +1780,31 @@ const commands = {
1776
1780
  console.log(preview.url)
1777
1781
  return
1778
1782
  }
1779
- if (!['github', 'gitlab'].includes(action)) throw new UsageError('Usage: kaloko ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected] [--out file] [--print] [--force]\n kaloko ci github --release [--snippet] [--print] [--force]\n kaloko ci preview-url [--wait 600] [--github-env] [--dotenv file]')
1783
+ if (action === 'plan') {
1784
+ // #141: what kaloko affected finds for this commit (it follows imports and affects: globs) decides the GitHub
1785
+ // check over the service's guess from the file list; a scenario nothing reaches lets the check pass
1786
+ const { config, root } = await context(args)
1787
+ const g = await gitMeta(root)
1788
+ const repo = typeof args.repo === 'string' ? args.repo : (g?.host === 'github' ? g.repo : null) || process.env.GITHUB_REPOSITORY || null
1789
+ const sha = typeof args.sha === 'string' ? args.sha.toLowerCase() : g?.sha
1790
+ if (!repo || !/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new UserError('kaloko ci plan needs the GitHub repository: run it in a clone whose origin is on GitHub, or pass --repo owner/name.')
1791
+ if (!sha || !/^[0-9a-f]{40}$/.test(sha)) throw new UserError('kaloko ci plan needs the full commit: run it inside the repository, or pass --sha.')
1792
+ const targets = list(args.scenario)?.map((f) => resolve(f)) || await scenarioFiles(config, root)
1793
+ if (!targets.length) throw new UserError('No scenario: pass --scenario <file> or list scenarios in the config.')
1794
+ const scenarios = []
1795
+ for (const file of targets) {
1796
+ const scenario = await loadScenario(file)
1797
+ if (args.none) { scenarios.push({ id: scenario.id, affected: false }); continue }
1798
+ const r = await affectedSelection(args, { config, root, scenario, scenarioFile: file })
1799
+ scenarios.push({ id: scenario.id, affected: !r.sel.none, steps: r.sel.none ? [] : selectedIds(scenario, r.sel) })
1800
+ }
1801
+ if (!args['dry-run']) await service(config).request('POST', `/orgs/${config.org}/github/plan`, { repo, sha, scenarios })
1802
+ if (args.json) { say(JSON.stringify({ repo, sha, scenarios, sent: !args['dry-run'] }, null, 2)); return }
1803
+ for (const x of scenarios) say(`${x.affected ? '→' : '·'} ${x.id}: ${x.affected ? `walk ${x.steps.length} step(s)` : 'nothing this change reaches'}`)
1804
+ say(args['dry-run'] ? 'Not sent (--dry-run).' : `Sent to Kaloko for ${repo}@${sha.slice(0, 7)}: the GitHub check expects runs of the scenarios marked →.`)
1805
+ return
1806
+ }
1807
+ if (!['github', 'gitlab'].includes(action)) throw new UsageError('Usage: kaloko ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected] [--out file] [--print] [--force]\n kaloko ci github --release [--snippet] [--print] [--force]\n kaloko ci preview-url [--wait 600] [--github-env] [--dotenv file]\n kaloko ci plan [--scenario a.yml,b.yml] [--base ref] [--none] [--repo owner/name] [--sha commit] [--dry-run] [--json]')
1780
1808
  const { config, root, file } = await context(args)
1781
1809
  if (args.release) {
1782
1810
  // release-tool integrations: record each published release on Kaloko (docs/DOCS-CHANGELOG.md)
@@ -60,11 +60,29 @@ export function formatStepRef ({ scenario = null, step, locale = null, viewport
60
60
  export function findStepRefs (markdown) {
61
61
  const out = []
62
62
  const re = /!\[([^\]]*)\]\((kaloko:[^)\s]+)\)/g
63
+ const text = withoutCode(String(markdown || ''))
63
64
  let m
64
- while ((m = re.exec(String(markdown || '')))) out.push({ alt: m[1], ref: m[2], parsed: parseStepRef(m[2]), index: m.index })
65
+ while ((m = re.exec(text))) out.push({ alt: m[1], ref: m[2], parsed: parseStepRef(m[2]), index: m.index })
65
66
  return out
66
67
  }
67
68
 
69
+ /** The Markdown with fenced code blocks and inline code blanked out (same length, newlines kept, so indexes still
70
+ * point into the original): a reference shown as an example in a guide is text, not a picture to resolve. */
71
+ export function withoutCode (md) {
72
+ const blank = (s) => s.replace(/[^\n]/g, ' ')
73
+ const lines = md.split('\n')
74
+ let fence = null
75
+ for (let i = 0; i < lines.length; i++) {
76
+ const f = lines[i].match(/^ {0,3}(?:[-*+]\s+|\d+[.)]\s+|>\s*)*(`{3,}|~{3,})/) || lines[i].match(/^\s*(`{3,}|~{3,})/)
77
+ if (fence) {
78
+ if (f && f[1][0] === fence[0] && f[1].length >= fence.length && !lines[i].trim().slice(f[1].length).trim()) fence = null
79
+ lines[i] = blank(lines[i])
80
+ } else if (f) { fence = f[1]; lines[i] = blank(lines[i]) }
81
+ }
82
+ // a code span stays inside its paragraph: a stray backtick never pairs across a blank line
83
+ return lines.join('\n').replace(/(?<!`)(`+)(?!`)(?:(?!\n[ \t]*\n)[\s\S])+?(?<!`)\1(?!`)/g, blank)
84
+ }
85
+
68
86
  // ---------- versions ----------
69
87
 
70
88
  /** "v2.4.0" → "2.4.0"; anything else stays as it is (trimmed). */
package/src/lib/ci.mjs CHANGED
@@ -99,9 +99,11 @@ export function runner ({ pm = 'npm', dependency = true, version = 'latest' }) {
99
99
  }
100
100
 
101
101
  /** The loop over scenarios both workflows run: walk what the change reaches, share, comment on the PR. */
102
- function walkScript ({ scenarios, env, affected, k }) {
102
+ function walkScript ({ scenarios, env, affected, k, plan = false }) {
103
103
  return [
104
104
  'status=0',
105
+ // the Kaloko check on the pull request (GitHub App): which scenarios this commit reaches, followed through imports
106
+ ...(plan && affected ? [`${k} ci plan --scenario ${scenarios.map(q).join(',')} || echo "kaloko ci plan did not reach Kaloko; the GitHub check guesses from the changed files."`] : []),
105
107
  `for s in ${scenarios.map(q).join(' ')}; do`,
106
108
  ...(affected ? [` if ! ${k} affected --scenario "$s" --fail-if-none; then echo "Nothing in $s is affected; skipped."; continue; fi`] : []),
107
109
  ` ${k} start --scenario "$s" --env ${env}`,
@@ -159,7 +161,7 @@ ${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - run: corepack enable\n'
159
161
  run: ${k} ci preview-url --wait 600 --github-env
160
162
  - name: Walk, share, comment
161
163
  run: |
162
- ${indent(walkScript({ scenarios, env, affected, k }), 10)}
164
+ ${indent(walkScript({ scenarios, env, affected, k, plan: true }), 10)}
163
165
  `
164
166
  }
165
167
 
package/src/lib/git.mjs CHANGED
@@ -90,5 +90,18 @@ export async function gitMeta (cwd) {
90
90
  pr = { number: p.number, url: p.url, state: String(p.state).toLowerCase(), ...(p.title ? { title: String(p.title).slice(0, 300) } : {}) }
91
91
  } catch {}
92
92
  }
93
- return { branch: branch === 'HEAD' && ciBranch ? ciBranch : branch, commit, sha, message, author, pr }
93
+ // #141: the repository from the origin remote (a sandbox without gh has no PR; the service finds it by commit), the
94
+ // project's folder inside it (monorepos) and whether tracked files differ from the commit (such a walk does not
95
+ // count for the commit's GitHub check)
96
+ const [origin, prefix, changes] = await Promise.all([
97
+ repoSlug(cwd),
98
+ out('git', ['rev-parse', '--show-prefix'], cwd),
99
+ out('git', ['status', '--porcelain', '--untracked-files=no'], cwd)
100
+ ])
101
+ return {
102
+ branch: branch === 'HEAD' && ciBranch ? ciBranch : branch, commit, sha, message, author, pr,
103
+ ...(origin ? { repo: origin.slug, host: origin.host } : {}),
104
+ ...(prefix ? { prefix: prefix.replace(/\/$/, '') } : {}),
105
+ dirty: changes === null ? false : changes.length > 0
106
+ }
94
107
  }
package/viewer/index.html CHANGED
@@ -3973,7 +3973,13 @@ function applyHash () {
3973
3973
  }
3974
3974
 
3975
3975
  /* ---------- replay: guided walkthrough with branch choice ---------- */
3976
- const outEdges = (id) => RUN.edges.filter((e) => e.from === id && byId(e.to)).sort((a, b) => (a.kind === 'alt') - (b.kind === 'alt'));
3976
+ // the ways on from a step in the order of the map: the main path before alternatives, forward before back, the same
3977
+ // row before another, then the nearest column and row, so → in replay follows the diagram, not the order of the file
3978
+ const outEdges = (id) => {
3979
+ const from = byId(id); const c = (n) => n?.col || 0, l = (n) => n?.lane || 0;
3980
+ const k = (e) => { const t = byId(e.to); return [e.kind === 'alt' ? 1 : 0, c(t) > c(from) ? 0 : 1, l(t) === l(from) ? 0 : 1, Math.abs(c(t) - c(from)), Math.abs(l(t) - l(from))]; };
3981
+ return RUN.edges.filter((e) => e.from === id && byId(e.to)).map((e, i) => [e, k(e), i]).sort(([, a, i], [, b, j]) => { for (let x = 0; x < a.length; x++) if (a[x] !== b[x]) return a[x] - b[x]; return i - j; }).map(([e]) => e);
3982
+ };
3977
3983
  function startNode () { const order = RUN.nodes.slice().sort((a, b) => a.col - b.col || a.lane - b.lane); return order.find((n) => !RUN.edges.some((e) => e.to === n.id)) || order[0]; }
3978
3984
  function rpOpen (path) {
3979
3985
  const ids = (path || []).filter((id) => byId(id));
@@ -4035,7 +4041,8 @@ function renderReplay () {
4035
4041
  $('rpClose').onclick = rpClose; $('rpPrev').onclick = rpBack;
4036
4042
  $('rp').querySelectorAll('[data-go]').forEach((b) => { b.onclick = () => rpGo(b.dataset.go); });
4037
4043
  $('rp').querySelectorAll('[data-jump]').forEach((b) => { b.onclick = () => rpJump(Number(b.dataset.jump)); });
4038
- $('rp').querySelectorAll('[data-stack-open]').forEach((b) => { b.onclick = () => openStack(b.dataset.stackOpen); });
4044
+ // the stack's index opens over the canvas, not under the replay (which would hide it and take its keys)
4045
+ $('rp').querySelectorAll('[data-stack-open]').forEach((b) => { b.onclick = () => { const id = b.dataset.stackOpen; rpClose(); openStack(id); }; });
4039
4046
  $('rp').querySelector('[data-restart]')?.addEventListener('click', () => rpOpen(null));
4040
4047
  if ($('rpVid')) $('rpVid').onclick = () => { S.rpVideo = !S.rpVideo; renderReplay(); };
4041
4048
  if (S.rpVideo) {