kaloko 0.16.1 → 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 +14 -0
- package/package.json +1 -1
- package/schemas/config.v1.json +15 -0
- package/schemas/evidence.v1.json +27 -4
- package/schemas/run.v1.json +36 -0
- package/skills/kaloko/SKILL.md +4 -0
- package/skills/kaloko/reference/ci.md +83 -0
- package/skills/kaloko/reference/collaboration.md +7 -1
- package/skills/kaloko/reference/evidence.md +44 -3
- package/skills/kaloko/reference/execution.md +5 -0
- package/skills/kaloko/reference/mcp.md +2 -1
- package/src/cli.mjs +51 -10
- package/src/lib/build.mjs +2 -0
- package/src/lib/changelog-core.mjs +19 -1
- package/src/lib/ci.mjs +4 -2
- package/src/lib/cli-args.mjs +11 -1
- package/src/lib/docs-core.mjs +3 -1
- package/src/lib/evidence-core.mjs +21 -10
- package/src/lib/git.mjs +14 -1
- package/src/lib/lanes-core.mjs +61 -0
- package/src/lib/run-refs.mjs +55 -0
- package/src/lib/run.mjs +4 -1
- package/src/lib/scenario.mjs +2 -1
- package/src/lib/signoff.mjs +4 -0
- package/viewer/index.html +54 -11
package/README.md
CHANGED
|
@@ -66,6 +66,13 @@ npx kaloko preview # local canvas
|
|
|
66
66
|
npx kaloko share --pr # upload to kaloko.app, comment on the PR
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
When a run belongs to a record outside Kaloko, say a change request in your QMS,
|
|
70
|
+
name it at the start: `--ref CC-2026-001` (repeat it for more, or set
|
|
71
|
+
`KALOKO_REFS` in CI) and, if you like, `--purpose dry_run`, `formal` or `smoke`
|
|
72
|
+
(`KALOKO_PURPOSE`). The canvas shows both, and `npx kaloko runs --ref CC-2026-001`
|
|
73
|
+
or `GET /api/v1/orgs/<org>/runs?ref=CC-2026-001` lists every run of that record
|
|
74
|
+
with its verdict, environment and purpose.
|
|
75
|
+
|
|
69
76
|
After a fix, walk only what the change reaches: `npx kaloko affected` explains
|
|
70
77
|
which steps the changed files touch (routes, imports, `affects:` globs in the
|
|
71
78
|
scenario), and `npx kaloko walk --affected` walks them. Several locales walk at
|
|
@@ -155,6 +162,13 @@ jobs:
|
|
|
155
162
|
Uploads go by content hash: captures that did not change since an earlier run
|
|
156
163
|
are not sent again, and an interrupted `share` picks up where it stopped.
|
|
157
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
|
+
|
|
158
172
|
## When a screenshot is not enough
|
|
159
173
|
|
|
160
174
|
```bash
|
package/package.json
CHANGED
package/schemas/config.v1.json
CHANGED
|
@@ -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,
|
package/schemas/evidence.v1.json
CHANGED
|
@@ -19,6 +19,18 @@
|
|
|
19
19
|
"time": { "type": ["string", "null"], "description": "ISO 8601 time in UTC (…Z), from the service's clock." },
|
|
20
20
|
"sha256": { "type": ["string", "null"], "pattern": "^[0-9a-f]{64}$", "description": "SHA-256, lowercase hex." },
|
|
21
21
|
"refs": { "type": "array", "items": { "type": "string" }, "description": "Requirement references as written in the scenario (refs:), free text, never interpreted." },
|
|
22
|
+
"session": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"description": "A person in the flow (a role): the key of the scenario's sessions: and its title.",
|
|
25
|
+
"required": ["key", "title"],
|
|
26
|
+
"properties": { "key": { "type": "string" }, "title": { "type": "string", "description": "The session's title (a localized title in its default or first language); the key when it has none." } }
|
|
27
|
+
},
|
|
28
|
+
"lane": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"description": "A lane of the canvas (a sub-process of the flow).",
|
|
31
|
+
"required": ["index", "label"],
|
|
32
|
+
"properties": { "index": { "type": "integer", "minimum": 0, "description": "The lane's number, from 0 (the canvas link #lane=<index>)." }, "label": { "type": ["string", "null"], "description": "The scenario's lanes: label, or the session's title when the sessions draw the lanes; null for an unnamed lane." } }
|
|
33
|
+
},
|
|
22
34
|
"run": {
|
|
23
35
|
"type": "object",
|
|
24
36
|
"required": ["generated_at", "acceptance", "signing", "record", "run", "scenario", "steps", "signatures", "trace"],
|
|
@@ -57,7 +69,9 @@
|
|
|
57
69
|
"started_at": { "$ref": "#/$defs/time", "description": "When the walk started (the walking machine's clock)." },
|
|
58
70
|
"finished_at": { "$ref": "#/$defs/time", "description": "When the manifest was built (the walking machine's clock)." },
|
|
59
71
|
"version": { "type": ["string", "null"], "description": "Design version, for runs of design environments." },
|
|
60
|
-
"app_version": { "type": ["string", "null"], "description": "Version of the application under test, when the run names one." }
|
|
72
|
+
"app_version": { "type": ["string", "null"], "description": "Version of the application under test, when the run names one." },
|
|
73
|
+
"refs": { "type": "array", "items": { "type": "string" }, "description": "External records the run serves, e.g. a change-control record (kaloko start --ref). Empty when the run names none, and for records made before runs carried them." },
|
|
74
|
+
"purpose": { "enum": ["dry_run", "formal", "smoke", null], "description": "Why the run was walked (kaloko start --purpose): a label, null when the run does not say." }
|
|
61
75
|
}
|
|
62
76
|
},
|
|
63
77
|
"scenario": {
|
|
@@ -68,7 +82,9 @@
|
|
|
68
82
|
"version": { "type": ["integer", "string", "null"], "description": "The scenario's own version: field, when set." },
|
|
69
83
|
"file": { "type": ["string", "null"], "description": "Path of the scenario file in the repository." },
|
|
70
84
|
"sha256": { "$ref": "#/$defs/sha256", "description": "SHA-256 of the scenario file as walked (CLI 0.15+; null before)." },
|
|
71
|
-
"refs": { "$ref": "#/$defs/refs" }
|
|
85
|
+
"refs": { "$ref": "#/$defs/refs" },
|
|
86
|
+
"lanes": { "type": ["array", "null"], "items": { "$ref": "#/$defs/lane" }, "description": "The flow's lanes (its sub-processes), in order: every named lane and every lane a step sits in. Named by the scenario's lanes:, or, without them, one per session. Null when the run's manifest could not be read." },
|
|
87
|
+
"sessions": { "type": ["array", "null"], "items": { "$ref": "#/$defs/session" }, "description": "The people in the flow (roles), in the scenario's order. Empty for a flow without sessions; null when the run's manifest could not be read." }
|
|
72
88
|
}
|
|
73
89
|
},
|
|
74
90
|
"environment": { "type": ["object", "null"], "description": "{ name, url }: the environment and its base URL." },
|
|
@@ -101,6 +117,8 @@
|
|
|
101
117
|
"title": { "type": ["string", "null"] },
|
|
102
118
|
"refs": { "$ref": "#/$defs/refs" },
|
|
103
119
|
"status": { "enum": ["pass", "partial", "fail", "skip"] },
|
|
120
|
+
"session": { "oneOf": [{ "$ref": "#/$defs/session" }, { "type": "null" }], "description": "The role that performs the step (the step's session:), or null." },
|
|
121
|
+
"lane": { "oneOf": [{ "$ref": "#/$defs/lane" }, { "type": "null" }], "description": "The lane the step sits in on the canvas (its sub-process); #lane=<index> on the canvas URL opens it." },
|
|
104
122
|
"decision": { "type": ["object", "null"], "description": "The step's current decision: { id, state: approved | returned, by, at, via, agent?, counts_as_person, note?, attachments?: [{ id, name, type, bytes, sha256, by, at, path: decisions/<step>/<file> in the evidence package }], pin?, shape? }." },
|
|
105
123
|
"decisions": { "type": "array", "description": "Every decision on the step, oldest first, shaped like decision; superseded ones too (resolved_at, deleted: true without content)." },
|
|
106
124
|
"carried": { "type": ["object", "null"], "description": "An approval carried over from an earlier accepted run: { from_run, compared_run, kind, approved_by, approved_at, approved_via }." },
|
|
@@ -184,7 +202,7 @@
|
|
|
184
202
|
},
|
|
185
203
|
"traceRow": {
|
|
186
204
|
"type": "object",
|
|
187
|
-
"description": "One row of the trace matrix: a requirement, a criterion, a step in one locale and viewport, its screenshot, the result and the decisions. A criterion's requirements are its own refs, its step's and its scenario's (requirement_level); a criterion without any gets one row with requirement null. The CSV has these columns in this order.",
|
|
205
|
+
"description": "One row of the trace matrix: a requirement, a criterion, a step in one locale and viewport with its role (session) and lane, its screenshot, the result and the decisions. A criterion's requirements are its own refs, its step's and its scenario's (requirement_level); a criterion without any gets one row with requirement null. The CSV has these columns in this order.",
|
|
188
206
|
"properties": {
|
|
189
207
|
"requirement": { "type": ["string", "null"] },
|
|
190
208
|
"requirement_level": { "enum": ["criterion", "step", "scenario", null] },
|
|
@@ -220,7 +238,11 @@
|
|
|
220
238
|
"run_signature_meaning": { "type": ["string", "null"] },
|
|
221
239
|
"run_signer_name": { "type": ["string", "null"] },
|
|
222
240
|
"run_signed_at": { "$ref": "#/$defs/time" },
|
|
223
|
-
"run_signer_time_zone": { "type": ["string", "null"] }
|
|
241
|
+
"run_signer_time_zone": { "type": ["string", "null"] },
|
|
242
|
+
"session": { "type": ["string", "null"], "description": "Key of the role that performs the step (the step's session:)." },
|
|
243
|
+
"session_title": { "type": ["string", "null"] },
|
|
244
|
+
"lane": { "type": ["integer", "null"], "description": "The step's lane (sub-process), from 0." },
|
|
245
|
+
"lane_label": { "type": ["string", "null"] }
|
|
224
246
|
}
|
|
225
247
|
},
|
|
226
248
|
"release": {
|
|
@@ -231,6 +253,7 @@
|
|
|
231
253
|
"release": { "type": "object", "description": "{ product, version, released_on, tag, commit, runs: [run id] }." },
|
|
232
254
|
"runs": { "type": "array", "items": { "$ref": "#/$defs/run" }, "description": "The evidence of each run of the release the caller can read." },
|
|
233
255
|
"missing": { "type": "array", "items": { "type": "object", "properties": { "run": { "type": "string" }, "reason": { "type": "string" } } }, "description": "Runs of the release without evidence here, with the reason." },
|
|
256
|
+
"filter": { "type": "object", "properties": { "ref": { "type": "string" }, "purpose": { "type": "string" } }, "description": "Present when the request narrowed the release to the runs of one record (?ref=) or purpose (?purpose=); runs then holds only those; missing still lists the runs that could not be read, since their references are unknown here." },
|
|
234
257
|
"trace": { "type": "array", "items": { "$ref": "#/$defs/traceRow" } }
|
|
235
258
|
}
|
|
236
259
|
},
|
package/schemas/run.v1.json
CHANGED
|
@@ -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
|
},
|
|
@@ -332,6 +349,25 @@
|
|
|
332
349
|
}
|
|
333
350
|
}
|
|
334
351
|
},
|
|
352
|
+
"refs": {
|
|
353
|
+
"type": "array",
|
|
354
|
+
"maxItems": 20,
|
|
355
|
+
"uniqueItems": true,
|
|
356
|
+
"items": {
|
|
357
|
+
"type": "string",
|
|
358
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/#+-]{0,79}$"
|
|
359
|
+
},
|
|
360
|
+
"description": "External records the run serves, e.g. a change-control record (kaloko start --ref, KALOKO_REFS). The service filters runs by them (GET …/runs?ref=)."
|
|
361
|
+
},
|
|
362
|
+
"purpose": {
|
|
363
|
+
"enum": [
|
|
364
|
+
"dry_run",
|
|
365
|
+
"formal",
|
|
366
|
+
"smoke",
|
|
367
|
+
null
|
|
368
|
+
],
|
|
369
|
+
"description": "Why the run was walked (kaloko start --purpose, KALOKO_PURPOSE): a dry run, a formal run or a smoke run. A label; it changes nothing about how the run is walked or decided."
|
|
370
|
+
},
|
|
335
371
|
"sessions": {
|
|
336
372
|
"type": [
|
|
337
373
|
"object",
|
package/skills/kaloko/SKILL.md
CHANGED
|
@@ -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 |
|
|
@@ -134,6 +134,12 @@ choice of branch at every decision node. `#replay=1&step=<id>&path=a,b,c`
|
|
|
134
134
|
deep-links a specific path, so an issue comment can point at "the sign-in
|
|
135
135
|
branch, step 3". Replay reads the manifest only; nothing is uploaded.
|
|
136
136
|
|
|
137
|
+
A link can also open the map on one part of the flow: `#lane=<index>` fits
|
|
138
|
+
and highlights that lane (lanes count from 0, in the order of the scenario's
|
|
139
|
+
`lanes:`, or of its `sessions:` when it has no lanes), and `#session=<key>`
|
|
140
|
+
highlights one person's steps across all lanes. `get_run_summary` lists the
|
|
141
|
+
run's `lanes` and `sessions`, and every step names its `lane` and `session`.
|
|
142
|
+
|
|
137
143
|
For people who work through a list rather than a flow (QA leads, content
|
|
138
144
|
and SEO teams, big runs), switch the canvas to **Index**. Use *Map | Index*
|
|
139
145
|
in the top bar, the key `I`, or `#mode=index` in the URL.
|
|
@@ -277,7 +283,7 @@ Everything above is available over the API with an organization token:
|
|
|
277
283
|
`…/signoff.html`: the signed acceptance record of an accepted run.
|
|
278
284
|
- `GET /api/v1/orgs/<org>/notifications[?unread=1]`, `POST` with
|
|
279
285
|
`{"read": "all"}` or `{"read": [ids]}`
|
|
280
|
-
- `GET /api/v1/orgs/<org>/runs[?project=&scenario=&kind=&verdict=&author=&environment=&branch=&q=][&latest=1]`
|
|
286
|
+
- `GET /api/v1/orgs/<org>/runs[?project=&scenario=&kind=&verdict=&author=&environment=&branch=&ref=&purpose=&q=][&latest=1]`
|
|
281
287
|
(`latest=1`: the newest run per scenario with `older`), `GET /api/v1/orgs/<org>/runs/<id>`
|
|
282
288
|
- Paging: runs, `comments`, `notifications`, `tokens` and `audit` take
|
|
283
289
|
`?limit=` and `?cursor=`. The body keeps its shape (an array stays an
|
|
@@ -21,8 +21,12 @@ described). Within v1 fields are only added; read the fields you need and ignore
|
|
|
21
21
|
is signed elsewhere; your document says so ("executed in Kaloko; approval recorded in <your eQMS>").
|
|
22
22
|
- `record.sha256` and `record.kind` (`acceptance` or `execution`): the signed record the evidence came from. Put the
|
|
23
23
|
hash into your document so a reader can tie it to the package.
|
|
24
|
-
- `scenario` (`version`, `file`, `sha256`, `refs`), `environment`, `git`, `tools` (CLI,
|
|
25
|
-
service version).
|
|
24
|
+
- `scenario` (`version`, `file`, `sha256`, `refs`, `lanes`, `sessions`), `environment`, `git`, `tools` (CLI,
|
|
25
|
+
Node.js, OS, browsers, service version). `scenario.lanes` lists the flow's lanes (`{ index, label }`, the
|
|
26
|
+
sub-processes) and `scenario.sessions` its roles (`{ key, title }`); without `lanes:` in the scenario, each session
|
|
27
|
+
is a lane.
|
|
28
|
+
- `steps[].session` (`{ key, title }`, the role that performs the step, or null) and `steps[].lane`
|
|
29
|
+
(`{ index, label }`; `label` is null for an unnamed lane).
|
|
26
30
|
- `steps[].criteria[]`: the criterion text as walked, `refs`, and `results[]` per locale and viewport with
|
|
27
31
|
`assessed_by` (`person`, `deterministic`, `ai`, `none`) and `assessor` (an e-mail or a model id). `advisory: true`
|
|
28
32
|
means an AI result no person decided: never present it as a person's verdict.
|
|
@@ -31,7 +35,8 @@ described). Within v1 fields are only added; read the fields you need and ignore
|
|
|
31
35
|
- `signatures[]` (name, e-mail, `signed_at`, `time_zone`, `local_time`, `meaning`, `auth`, `withdrawn`) and
|
|
32
36
|
`decision_changes[]` with reasons.
|
|
33
37
|
- `audit[]`: the run's audit trail; `trace[]`: the trace matrix, one row per requirement, criterion, step, locale and
|
|
34
|
-
viewport (columns in `TRACE_COLUMNS` order, the same as `trace.csv`).
|
|
38
|
+
viewport (columns in `TRACE_COLUMNS` order, the same as `trace.csv`). Each row names the step's role and lane in
|
|
39
|
+
`session`, `session_title`, `lane` and `lane_label` (the last four columns of the CSV).
|
|
35
40
|
- A release (`kind: release`) has `runs[]` (each a run evidence), `missing[]` with reasons and one `trace[]`.
|
|
36
41
|
|
|
37
42
|
## Getting it
|
|
@@ -48,6 +53,42 @@ Over the API with a read token: `GET /api/v1/orgs/<org>/runs/<id>/evidence`, `
|
|
|
48
53
|
`…/audit.csv`, and `GET /api/v1/orgs/<org>/evidence?product=<p>&version=<v>`. Over MCP: `get_evidence` with `run_id`, or
|
|
49
54
|
`product` and `version`.
|
|
50
55
|
|
|
56
|
+
## A screen next to the test step
|
|
57
|
+
|
|
58
|
+
A documentation portal that shows a step's screenshot beside the test step fetches it with a read token limited to
|
|
59
|
+
the project (send it as `Authorization: Bearer …` from the portal's server; never put the token in a page):
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
GET /api/v1/orgs/<org>/runs/<run>/steps/<step>/screen?locale=en&viewport=desktop&size=thumb&sha256=<hex>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- `locale` and `viewport` default to the run's first ones that the step has. A value the run does not have, a value
|
|
66
|
+
given twice, or `size` other than `thumb` or `full` answers 400. A screenshot the step does not have answers 404.
|
|
67
|
+
Errors after the run check carry `canvas`, the link to the step.
|
|
68
|
+
- The answer is the image with `ETag` (the SHA-256 of the file served) and `X-Kaloko-SHA256` (the SHA-256 of the
|
|
69
|
+
step's screenshot, the one `steps[].captures[].sha256` and the trace matrix list, for `thumb` too). `If-None-Match`
|
|
70
|
+
answers 304. `X-Kaloko-Locale`, `X-Kaloko-Viewport` and `X-Kaloko-Size` say what was served; a capture without a
|
|
71
|
+
thumbnail answers the full screenshot with `X-Kaloko-Size: full`. `Link` holds the step on the canvas.
|
|
72
|
+
- Store run, step, locale, viewport and that SHA-256, and send it back as `sha256`. When the run holds a different
|
|
73
|
+
file the answer is 409 with the run's `sha256`, so the portal never shows a picture nobody checked.
|
|
74
|
+
- Access is the run's: guests and project-limited tokens get 404 outside their projects, and nothing is served without
|
|
75
|
+
a token or a session (public links included). The image cannot be framed by other sites; link the reader to the
|
|
76
|
+
step on the canvas, where they sign in with their own account.
|
|
77
|
+
|
|
78
|
+
Over MCP: `get_step_screen` with `run_id`, `step` and optionally `locale`, `viewport`, `size`, `sha256` answers the
|
|
79
|
+
hashes, the canvas link and a `url` pinned to that file (no image bytes).
|
|
80
|
+
|
|
81
|
+
## Runs of an external record
|
|
82
|
+
|
|
83
|
+
When the run serves a record in the client's QMS (a change request, a validation protocol), start it with
|
|
84
|
+
`--ref CC-2026-001` (repeatable; `KALOKO_REFS` in CI, comma or space separated) and, when known,
|
|
85
|
+
`--purpose dry_run|formal|smoke` (`KALOKO_PURPOSE`). Up to 20 references of up to 80 characters: letters, digits and
|
|
86
|
+
`. _ - / : # +`. They are stored in the run (`refs`, `purpose` next to `issue`), shown in the canvas header, and the
|
|
87
|
+
evidence carries them as `run.refs` and `run.purpose`. Find the runs of a record with
|
|
88
|
+
`GET /api/v1/orgs/<org>/runs?ref=CC-2026-001[&purpose=formal]`, `kaloko runs --ref CC-2026-001` or MCP `list_runs`
|
|
89
|
+
with `ref`; the release evidence takes `&ref=` and `&purpose=` too (`kaloko evidence --release <v> --ref …`). Never
|
|
90
|
+
invent a reference: ask which record the run is for, or leave it out.
|
|
91
|
+
|
|
51
92
|
## When it is ready
|
|
52
93
|
|
|
53
94
|
An organization admin adds a webhook with the event `evidence.ready` (Settings → Integrations → Webhooks). It fires
|
|
@@ -24,6 +24,11 @@ call `kaloko session reset` themselves when the plan starts as a new visitor.
|
|
|
24
24
|
`start` prints a unique test e-mail address for the run; use it in forms so
|
|
25
25
|
`mail` can find the message.
|
|
26
26
|
|
|
27
|
+
`start --ref CC-2026-001 [--ref …] [--purpose dry_run|formal|smoke]` links the
|
|
28
|
+
run to records outside Kaloko (a change request, a protocol) and says why it
|
|
29
|
+
was walked; `KALOKO_REFS` and `KALOKO_PURPOSE` do the same in CI. See
|
|
30
|
+
`evidence.md` → Runs of an external record.
|
|
31
|
+
|
|
27
32
|
## Choosing a strategy per step
|
|
28
33
|
|
|
29
34
|
- `playwright` — stable, repeatable steps. The script exports
|
|
@@ -27,12 +27,13 @@ is null. Projects and products come complete, without pages.
|
|
|
27
27
|
|
|
28
28
|
| Tool | Use it for |
|
|
29
29
|
|---|---|
|
|
30
|
-
| `list_runs` | published runs, newest first; filters `scenario`, `project`, `verdict`, `author`, `environment`, `branch`, `limit`; `cursor` for the next page |
|
|
30
|
+
| `list_runs` | published runs, newest first; filters `scenario`, `project`, `verdict`, `author`, `environment`, `branch`, `ref` (a record the run serves), `purpose` (`dry_run`, `formal`, `smoke`), `limit`; `cursor` for the next page |
|
|
31
31
|
| `get_run_summary` | one run: statuses per step and locale, criteria that need attention with evidence (semantic ones with score and the evaluator's one-sentence `reason` per locale and viewport), regions ignored in pixel diffs and regions seen moving (`ignored_regions`, `unstable_regions`), screenshot URLs, verdict, baseline, canvas URL, open feedback |
|
|
32
32
|
| `get_feedback` | run verdict with note, approved steps, unresolved rejections and comments, failed manual criteria (`run_id`, or `scenario` for its latest run) |
|
|
33
33
|
| `list_notifications` | the token owner's inbox (`unread_only`, `limit`, `cursor`) |
|
|
34
34
|
| `list_projects` | projects of the organization with counts; the slug is the `project` filter of `list_runs` and the value for `project:` in a scenario |
|
|
35
35
|
| `upsert_project` | creates a project (`slug`, `name`, `visibility`, `product`; `create: true` refuses an existing one) or renames it; the same rules as `kaloko projects create\|rename\|sync` (a full token; renaming is for the project's admin). Ask before renaming |
|
|
36
|
+
| `get_step_screen` | one step's screenshot of a run, pinned (`run_id`, `step`, `locale`, `viewport`, `size` thumb or full, `sha256`): the SHA-256 of the screenshot and of the file, a `url` that fetches exactly that file with the same token, the canvas link; see [evidence.md](evidence.md) |
|
|
36
37
|
| `get_criteria_verdicts` | reviewer verdicts on manual or overridden criteria of a run |
|
|
37
38
|
| `get_stack_items` | the pages of a stack step (`run_id`, `step`; filters `status`, `criterion`, text): path, URL, title, status and failing criteria with evidence, screenshot URLs |
|
|
38
39
|
| `get_step_recording` | a step's recording and trace (`run_id`, `step`, `locale`, `viewport`, `trace: true` for the full trace): video URLs, duration, its chapter of a flow recording, failed attempts, the trace summary (failed requests, console errors, slowest request); see [recording.md](recording.md). `get_run_summary` lists trace summaries per step too |
|
package/src/cli.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import yaml from 'js-yaml'
|
|
|
8
8
|
import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FILE } from './lib/config.mjs'
|
|
9
9
|
import { loadScenario, parseScenario, validateScenario, scenarioWarnings } from './lib/scenario.mjs'
|
|
10
10
|
import { createRun, resolveRun, readCaptures, listRuns } from './lib/run.mjs'
|
|
11
|
+
import { startRefs, normalizePurpose, RUN_PURPOSES } from './lib/run-refs.mjs'
|
|
11
12
|
import { gitMeta, projectAndIssue, appVersion } from './lib/git.mjs'
|
|
12
13
|
import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, launchWithRetry, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
|
|
13
14
|
import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
|
|
@@ -126,6 +127,10 @@ Project
|
|
|
126
127
|
In CI: the preview URL (KALOKO_PREVIEW_URL, the deployment event, provider
|
|
127
128
|
variables or the commit's GitHub deployments), the pull request and its base;
|
|
128
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
|
|
129
134
|
login [--org <slug>] [--url <service>] [--print] [--no-open]
|
|
130
135
|
Sign this machine in through the browser: approve the code on kaloko.app, choose
|
|
131
136
|
the organization and what the token may do; the token (at most 30 days) goes to
|
|
@@ -143,6 +148,9 @@ Run
|
|
|
143
148
|
start --scenario <file> --env <name> [--locales cs,en] [--viewports desktop,mobile] [--executor <name>]
|
|
144
149
|
[--app-version <v>] (default: the git tag on HEAD; documentation captures carry it)
|
|
145
150
|
Create a run directory and make it current (--json: the run id and its folder)
|
|
151
|
+
[--ref CC-2026-001] [--ref …] Records the run serves (change control, protocol); repeat it or separate
|
|
152
|
+
with commas; KALOKO_REFS in CI. kaloko runs --ref lists the runs of a record
|
|
153
|
+
[--purpose dry_run|formal|smoke] Why the run was walked, shown as a label; KALOKO_PURPOSE in CI
|
|
146
154
|
[--viewports iphone-15,ipad-landscape,desktop] Viewport names of the scenario or device presets
|
|
147
155
|
(kaloko devices lists them; <preset>-landscape turns one sideways)
|
|
148
156
|
[--browsers chromium,webkit,firefox] [--color-schemes light,dark] [--reduced-motion no-preference,reduce]
|
|
@@ -269,10 +277,11 @@ Results
|
|
|
269
277
|
projects sync [--json] Make Kaloko match project:, project_name: and product: of the config: create
|
|
270
278
|
the project when it is missing, rename it when the name differs, add it to the
|
|
271
279
|
product. A share never renames a project; it says when the names differ
|
|
272
|
-
runs [--scenario id] [--env e] [--all] [--limit N] [--cursor c] [--json]
|
|
280
|
+
runs [--scenario id] [--env e] [--ref r] [--purpose p] [--all] [--limit N] [--cursor c] [--json]
|
|
273
281
|
Shared runs: the newest per scenario (with "+ N older"); --all or --scenario lists every
|
|
274
282
|
run: --all to the last one page by page, otherwise the newest 300; --limit N lists at
|
|
275
|
-
most N, --cursor c goes on where a list stopped (it prints the cursor)
|
|
283
|
+
most N, --cursor c goes on where a list stopped (it prints the cursor); --ref r lists
|
|
284
|
+
every run of a record, --purpose p only dry runs, formal runs or smoke runs
|
|
276
285
|
trash <run-id>... | restore <run-id>
|
|
277
286
|
Move runs to the trash (uploader or admin) and back; restorable until purge
|
|
278
287
|
prune [--scenario id] [--env e] [--keep 3] [--older-than 14d|48h] [--dry-run | --yes] [--json]
|
|
@@ -312,7 +321,7 @@ Results
|
|
|
312
321
|
Check a record or an evidence package someone handed over (no token needed); exit 1
|
|
313
322
|
when it does not verify
|
|
314
323
|
evidence <run-id> [--csv] [--out file] [--json]
|
|
315
|
-
evidence --release <version> [--product p] [--csv] [--out file] [--json]
|
|
324
|
+
evidence --release <version> [--product p] [--ref r] [--purpose p] [--csv] [--out file] [--json]
|
|
316
325
|
The evidence of a run or a release (Business, schema qawalk.evidence.v1): acceptance
|
|
317
326
|
and signing state, criteria as walked with requirement refs, results, screenshots
|
|
318
327
|
with SHA-256, decisions, signatures, audit trail; --csv prints the trace matrix
|
|
@@ -1257,6 +1266,9 @@ const commands = {
|
|
|
1257
1266
|
async start (args) {
|
|
1258
1267
|
const { config, root } = await context(args)
|
|
1259
1268
|
if (!args.scenario || !args.env) throw new UsageError('Usage: kaloko start --scenario <file> --env <name>')
|
|
1269
|
+
// the records the run serves and why it was walked (#126): checked before anything is created
|
|
1270
|
+
const { refs, purpose, problems: refIssues } = startRefs(args, process.env)
|
|
1271
|
+
if (refIssues.length) throw new UserError(refIssues.join('\n'))
|
|
1260
1272
|
const scenarioFile = resolve(args.scenario)
|
|
1261
1273
|
const scenario = await loadScenario(scenarioFile)
|
|
1262
1274
|
const env = environment(config, args.env)
|
|
@@ -1276,11 +1288,12 @@ const commands = {
|
|
|
1276
1288
|
const appVer = typeof args['app-version'] === 'string' ? args['app-version'].slice(0, 80) : await appVersion(root)
|
|
1277
1289
|
// the product the flow documents (visual changelog): its tag, or the only product of the config
|
|
1278
1290
|
const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
|
|
1279
|
-
const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product, pii })
|
|
1291
|
+
const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, refs, purpose, appVersion: appVer, product, pii })
|
|
1280
1292
|
const addr = recipient(env.mail, meta.short)
|
|
1281
1293
|
// --json: what a script needs to pin the run (KALOKO_RUN) and find its files
|
|
1282
|
-
if (args.json) { say(JSON.stringify({ run: meta.id, dir, env: env.name, readonly: !!env.readonly, locales, viewports: vpNames, project: project.slug, issue: issue || null, test_email: addr || null, pii: pii?.mode || null }, null, 2)); return }
|
|
1294
|
+
if (args.json) { say(JSON.stringify({ run: meta.id, dir, env: env.name, readonly: !!env.readonly, locales, viewports: vpNames, project: project.slug, issue: issue || null, refs, purpose, test_email: addr || null, pii: pii?.mode || null }, null, 2)); return }
|
|
1283
1295
|
say(`Run ${meta.id}\n dir: ${relative(process.cwd(), dir)}\n env: ${env.name}${env.readonly ? ' (readonly)' : ''}\n locales: ${locales.join(', ')} viewports: ${vpNames.length > 12 ? `${vpNames.slice(0, 12).join(', ')} … (${vpNames.length})` : vpNames.join(', ')}${dimensionLine(scope)}\n project: ${project.slug}${issue ? ` issue: ${issue.repo ? issue.repo + '#' : '#'}${issue.number}` : ''}`)
|
|
1296
|
+
if (refs.length || purpose) say(` ${[refs.length && `refs: ${refs.join(', ')}`, purpose && `purpose: ${purpose}`].filter(Boolean).join(' ')}`)
|
|
1284
1297
|
if (pii) say(` personal data: ${pii.mode === 'pseudonymize' ? 'replaced with fake values' : 'hidden'} in every capture`)
|
|
1285
1298
|
if (addr) say(` test e-mail: ${addr}`)
|
|
1286
1299
|
say(` other agents may start runs here too: pin this one with --run ${meta.id} or KALOKO_RUN=${meta.id}`)
|
|
@@ -1767,7 +1780,31 @@ const commands = {
|
|
|
1767
1780
|
console.log(preview.url)
|
|
1768
1781
|
return
|
|
1769
1782
|
}
|
|
1770
|
-
if (
|
|
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]')
|
|
1771
1808
|
const { config, root, file } = await context(args)
|
|
1772
1809
|
if (args.release) {
|
|
1773
1810
|
// release-tool integrations: record each published release on Kaloko (docs/DOCS-CHANGELOG.md)
|
|
@@ -2330,7 +2367,11 @@ const commands = {
|
|
|
2330
2367
|
async runs (args) {
|
|
2331
2368
|
const { config } = await context(args)
|
|
2332
2369
|
const client = service(config)
|
|
2333
|
-
|
|
2370
|
+
// --ref: every run of a record (#126), not only the newest per scenario
|
|
2371
|
+
const ref = typeof args.ref === 'string' ? args.ref : null
|
|
2372
|
+
const purpose = args.purpose === undefined ? null : normalizePurpose(args.purpose === true ? '' : args.purpose)
|
|
2373
|
+
if (args.purpose !== undefined && !purpose) throw new UsageError(`--purpose takes one of ${RUN_PURPOSES.join(', ')}.`)
|
|
2374
|
+
const all = !!args.all || typeof args.scenario === 'string' || !!ref
|
|
2334
2375
|
const envName = envOption(args)
|
|
2335
2376
|
// --limit N: at most N runs; --all follows the service's cursors to the last run, otherwise the newest RUNS_CAP;
|
|
2336
2377
|
// --cursor continues where an earlier list stopped (the cursor it printed)
|
|
@@ -2341,18 +2382,18 @@ const commands = {
|
|
|
2341
2382
|
let next = null
|
|
2342
2383
|
do {
|
|
2343
2384
|
const page = Math.min(1000, want - runs.length)
|
|
2344
|
-
const q = new URLSearchParams({ ...(all ? {} : { latest: '1' }), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(envName ? { environment: envName } : {}), limit: String(page), ...(cursor ? { cursor } : {}) })
|
|
2385
|
+
const q = new URLSearchParams({ ...(all ? {} : { latest: '1' }), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(envName ? { environment: envName } : {}), ...(ref ? { ref } : {}), ...(purpose ? { purpose } : {}), limit: String(page), ...(cursor ? { cursor } : {}) })
|
|
2345
2386
|
const got = await client.page(`/orgs/${config.org}/runs?${q}`)
|
|
2346
2387
|
runs.push(...got.data)
|
|
2347
2388
|
next = cursor = got.next
|
|
2348
2389
|
} while (next && runs.length < want)
|
|
2349
2390
|
// more runs than were listed: say how to go on (on stderr, so --json stays one JSON array)
|
|
2350
|
-
if (next) console.error(`Showing ${runs.length}; there are more. kaloko runs${args.all ? ' --all' : ''}${typeof args.scenario === 'string' ? ` --scenario ${args.scenario}` : ''}${envName ? ` --env ${envName}` : ''} --cursor ${next} lists the next ones${all && !args.all ? ', --all every one' : ''}.`)
|
|
2391
|
+
if (next) console.error(`Showing ${runs.length}; there are more. kaloko runs${args.all ? ' --all' : ''}${typeof args.scenario === 'string' ? ` --scenario ${args.scenario}` : ''}${envName ? ` --env ${envName}` : ''}${ref ? ` --ref ${ref}` : ''}${purpose ? ` --purpose ${purpose}` : ''} --cursor ${next} lists the next ones${all && !args.all ? ', --all every one' : ''}.`)
|
|
2351
2392
|
if (args.json) { say(JSON.stringify(runs, null, 2)); return }
|
|
2352
2393
|
if (!runs.length) { say('No shared runs match.'); return }
|
|
2353
2394
|
for (const r of runs) {
|
|
2354
2395
|
const s = r.summary || {}
|
|
2355
|
-
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` : ''}`)
|
|
2396
|
+
say(`${r.id} ${r.environment?.name || ''}${r.purpose ? ` (${r.purpose})` : ''} ${r.verdict || 'pending'}${r.kept ? ' · kept' : ''} ${s.pass || 0}✓ ${s.partial || 0}◐ ${s.fail || 0}✕ ${r.author || ''}${r.older ? ` + ${r.older} older` : ''}`)
|
|
2356
2397
|
}
|
|
2357
2398
|
if (!all) say(`\nNewest run per scenario; --all or --scenario <id> lists every run.`)
|
|
2358
2399
|
},
|
package/src/lib/build.mjs
CHANGED
|
@@ -69,6 +69,8 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
|
|
|
69
69
|
git: meta.git,
|
|
70
70
|
project: meta.project || null,
|
|
71
71
|
issue: meta.issue || null,
|
|
72
|
+
...(meta.refs?.length ? { refs: meta.refs } : {}),
|
|
73
|
+
...(meta.purpose ? { purpose: meta.purpose } : {}),
|
|
72
74
|
// version of the application under test; `version` is the number of a design version (#42)
|
|
73
75
|
app_version: meta.app_version ?? meta.version ?? null,
|
|
74
76
|
environment: meta.environment,
|
|
@@ -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(
|
|
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/cli-args.mjs
CHANGED
|
@@ -50,13 +50,21 @@ export const HIDDEN = {
|
|
|
50
50
|
*/
|
|
51
51
|
export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment', 'projects create', 'projects rename', 'projects sync'])
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Options a command takes more than once (kaloko start --ref A --ref B): their value is the list of every value given.
|
|
55
|
+
* Elsewhere the same name keeps the last value (kaloko spec --ref design).
|
|
56
|
+
*/
|
|
57
|
+
export const REPEATABLE = { start: ['ref'] }
|
|
58
|
+
|
|
53
59
|
/**
|
|
54
60
|
* argv → { _: positional, <option>: value | true }. `--key=value` splits at the first `=` only; `--key value` takes
|
|
55
|
-
* the next word unless it is another option; `-h` and `-v` are --help and --version.
|
|
61
|
+
* the next word unless it is another option; `-h` and `-v` are --help and --version. An option REPEATABLE for the
|
|
62
|
+
* command (the first positional) collects its values in a list.
|
|
56
63
|
*/
|
|
57
64
|
export function parseArgs (argv) {
|
|
58
65
|
const args = { _: [] }
|
|
59
66
|
const short = { '-h': 'help', '-v': 'version' }
|
|
67
|
+
const seen = {}
|
|
60
68
|
for (let i = 0; i < argv.length; i++) {
|
|
61
69
|
const a = argv[i]
|
|
62
70
|
if (a.startsWith('--')) {
|
|
@@ -66,9 +74,11 @@ export function parseArgs (argv) {
|
|
|
66
74
|
if (eq >= 0) args[k] = a.slice(eq + 1)
|
|
67
75
|
else if (next && !next.startsWith('--') && !short[next]) args[k] = argv[++i]
|
|
68
76
|
else args[k] = true
|
|
77
|
+
;(seen[k] ||= []).push(args[k])
|
|
69
78
|
} else if (short[a]) args[short[a]] = true
|
|
70
79
|
else args._.push(a)
|
|
71
80
|
}
|
|
81
|
+
for (const k of REPEATABLE[args._[0]] || []) if (seen[k]) args[k] = seen[k]
|
|
72
82
|
return args
|
|
73
83
|
}
|
|
74
84
|
|
package/src/lib/docs-core.mjs
CHANGED
|
@@ -419,12 +419,14 @@ function blocks (lines, o) {
|
|
|
419
419
|
|
|
420
420
|
/** A list item: its first paragraph inline (tight lists), the rest as blocks (nested lists, pictures, code). */
|
|
421
421
|
function listItem (lines, o) {
|
|
422
|
+
// lists nest at most 16 deep, like quotes; deeper items are text (1 000 levels of "- - -" would overflow the stack)
|
|
423
|
+
if ((o.listDepth || 0) >= 16) return inlineHtml(lines.join(' ').trim(), o)
|
|
422
424
|
const first = []
|
|
423
425
|
let i = 0
|
|
424
426
|
while (i < lines.length && lines[i].trim() && !LIST.test(lines[i]) && !/^\s{0,3}(```|~~~)/.test(lines[i])) first.push(lines[i++])
|
|
425
427
|
const { imgs, words } = picturesOf(first.join('\n'))
|
|
426
428
|
const rest = lines.slice(i)
|
|
427
|
-
return `${words ? inlineHtml(words.replace(/\n/g, ' '), o) : ''}${imgs.length ? pics(imgs, o) : ''}${rest.some((l) => l.trim()) ? blocks(rest, { ...o, notice: null }) : ''}`
|
|
429
|
+
return `${words ? inlineHtml(words.replace(/\n/g, ' '), o) : ''}${imgs.length ? pics(imgs, o) : ''}${rest.some((l) => l.trim()) ? blocks(rest, { ...o, notice: null, listDepth: (o.listDepth || 0) + 1 }) : ''}`
|
|
428
430
|
}
|
|
429
431
|
|
|
430
432
|
function pics (imgs, o) {
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
// evidenceOf(record, extras) the evidence document of one run, derived from its signed acceptance record
|
|
7
7
|
// releaseEvidenceOf(...) the evidence document of a release: its runs and one trace matrix over all of them
|
|
8
8
|
// traceRows(evidence) the trace matrix: requirement → criterion → step/locale/viewport → evidence → result
|
|
9
|
-
// → human decision
|
|
9
|
+
// → human decision, with the step's session (role) and lane; TRACE_COLUMNS is the
|
|
10
|
+
// column order of the CSV
|
|
10
11
|
// signatureSubject(...) what an electronic signature is bound to; verifySignatures(record) recomputes it
|
|
11
12
|
// packageManifest / verifyPackageFiles the MANIFEST.json of an evidence package and its check
|
|
12
13
|
//
|
|
13
14
|
// The JSON is the contract; CSV is a convenience for people (cells that start like a formula get a leading ').
|
|
14
15
|
import { canonicalJson, sha256Hex, recordSha256, verifyRecord } from './signoff-core.mjs'
|
|
16
|
+
import { stepPlaces, flowStructure } from './lanes-core.mjs'
|
|
15
17
|
|
|
16
18
|
export const EVIDENCE_SCHEMA = 'qawalk.evidence.v1'
|
|
17
19
|
export const MANIFEST_FILE = 'MANIFEST.json'
|
|
@@ -26,7 +28,9 @@ export const TRACE_COLUMNS = [
|
|
|
26
28
|
'criterion_id', 'criterion_text', 'criterion_type', 'locale', 'viewport', 'evidence_file', 'evidence_sha256',
|
|
27
29
|
'result', 'assessed_by', 'assessor', 'advisory',
|
|
28
30
|
'step_decision', 'step_decided_by', 'step_decided_at', 'step_decided_via', 'step_signature_meaning', 'step_signer_name', 'step_signed_at', 'step_signer_time_zone', 'step_comment',
|
|
29
|
-
'run_verdict', 'run_decided_by', 'run_decided_at', 'run_decided_via', 'run_signature_meaning', 'run_signer_name', 'run_signed_at', 'run_signer_time_zone'
|
|
31
|
+
'run_verdict', 'run_decided_by', 'run_decided_at', 'run_decided_via', 'run_signature_meaning', 'run_signer_name', 'run_signed_at', 'run_signer_time_zone',
|
|
32
|
+
// added later, so they come last: a reader that takes columns by position keeps working
|
|
33
|
+
'session', 'session_title', 'lane', 'lane_label'
|
|
30
34
|
]
|
|
31
35
|
export const AUDIT_COLUMNS = ['at', 'actor', 'action', 'target', 'via', 'agent', 'country', 'source', 'detail']
|
|
32
36
|
|
|
@@ -87,11 +91,15 @@ const sigCols = (s, prefix) => ({ [`${prefix}_signature_meaning`]: s?.meaning ??
|
|
|
87
91
|
/**
|
|
88
92
|
* The evidence document of a run (qawalk.evidence.v1, kind run) from its acceptance record. extras: { record_sha256,
|
|
89
93
|
* signed (the record carries the service's signature), audit (rows of the run's audit trail), generated_at,
|
|
90
|
-
* public_keys (URL of the well-known keys)
|
|
94
|
+
* public_keys (URL of the well-known keys), manifest (the run's run.json: the record names its SHA-256; it gives each
|
|
95
|
+
* step its session and lane, and the scenario its lanes and sessions) }.
|
|
91
96
|
*/
|
|
92
|
-
export function evidenceOf (record, { record_sha256: recordSha = null, signed = !!record?.signature, audit = [], generated_at: at = new Date().toISOString(), public_keys: keysUrl = null } = {}) {
|
|
97
|
+
export function evidenceOf (record, { record_sha256: recordSha = null, signed = !!record?.signature, audit = [], generated_at: at = new Date().toISOString(), public_keys: keysUrl = null, manifest = null } = {}) {
|
|
93
98
|
const r = record || {}
|
|
94
99
|
const run = r.run || {}
|
|
100
|
+
const places = manifest ? stepPlaces(manifest) : null
|
|
101
|
+
const flow = manifest ? flowStructure(manifest) : null
|
|
102
|
+
const placed = (s) => ({ session: places?.get(s.id)?.session ?? s.session ?? null, lane: places?.get(s.id)?.lane ?? s.lane ?? null })
|
|
95
103
|
const ev = {
|
|
96
104
|
schema: EVIDENCE_SCHEMA,
|
|
97
105
|
kind: 'run',
|
|
@@ -102,8 +110,8 @@ export function evidenceOf (record, { record_sha256: recordSha = null, signed =
|
|
|
102
110
|
acceptance: r.acceptance ?? (r.verdict?.verdict === 'accepted' ? 'accepted' : r.verdict?.verdict === 'returned' ? 'returned' : 'none'),
|
|
103
111
|
signing: r.signing ?? ((r.signatures || []).some((x) => !x.withdrawn) ? 'in_kaloko' : 'none'),
|
|
104
112
|
record: { schema: r.schema ?? null, kind: r.kind ?? (r.verdict?.verdict === 'accepted' ? 'acceptance' : 'execution'), sha256: recordSha, signed: !!signed, kid: r.signature?.kid ?? null, issued_at: r.issued_at ?? null, exported_by: r.exported_by ?? null },
|
|
105
|
-
run: { id: run.id ?? null, url: run.url ?? null, project: run.project ?? null, started_at: run.started_at ?? null, finished_at: run.finished_at ?? null, version: run.version ?? null, app_version: run.app_version ?? null },
|
|
106
|
-
scenario: { id: run.scenario?.id ?? null, title: run.scenario?.title ?? null, version: run.scenario?.version ?? null, file: run.scenario?.file ?? null, sha256: run.scenario?.sha256 ?? null, refs: cleanRefs(run.scenario?.refs) },
|
|
113
|
+
run: { id: run.id ?? null, url: run.url ?? null, project: run.project ?? null, started_at: run.started_at ?? null, finished_at: run.finished_at ?? null, version: run.version ?? null, app_version: run.app_version ?? null, refs: Array.isArray(run.refs) ? run.refs.filter((x) => typeof x === 'string') : [], purpose: run.purpose ?? null },
|
|
114
|
+
scenario: { id: run.scenario?.id ?? null, title: run.scenario?.title ?? null, version: run.scenario?.version ?? null, file: run.scenario?.file ?? null, sha256: run.scenario?.sha256 ?? null, refs: cleanRefs(run.scenario?.refs), lanes: flow?.lanes ?? null, sessions: flow?.sessions ?? null },
|
|
107
115
|
environment: run.environment || null,
|
|
108
116
|
git: run.git || null,
|
|
109
117
|
tools: r.tools || null,
|
|
@@ -113,7 +121,7 @@ export function evidenceOf (record, { record_sha256: recordSha = null, signed =
|
|
|
113
121
|
summary: r.summary || null,
|
|
114
122
|
ai: r.ai || null,
|
|
115
123
|
advisory: r.advisory ?? null,
|
|
116
|
-
steps: (r.steps || []).map((s) => ({ ...s, refs: cleanRefs(s.refs), criteria: (s.criteria || []).map((c) => ({ ...c, refs: cleanRefs(c.refs) })) })),
|
|
124
|
+
steps: (r.steps || []).map((s) => ({ ...s, ...placed(s), refs: cleanRefs(s.refs), criteria: (s.criteria || []).map((c) => ({ ...c, refs: cleanRefs(c.refs) })) })),
|
|
117
125
|
signatures: r.signatures || [],
|
|
118
126
|
decision_changes: r.decision_changes || [],
|
|
119
127
|
audit: (audit || []).map(auditEntry)
|
|
@@ -130,7 +138,7 @@ export function auditEntry (a) {
|
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
/** The evidence document of a release (kind release): its runs' evidence and one trace matrix over all of them. */
|
|
133
|
-
export function releaseEvidenceOf ({ release, runs, missing = [], org = null, issuer = null, generated_at: at = new Date().toISOString() }) {
|
|
141
|
+
export function releaseEvidenceOf ({ release, runs, missing = [], org = null, issuer = null, generated_at: at = new Date().toISOString(), filter = null }) {
|
|
134
142
|
return {
|
|
135
143
|
schema: EVIDENCE_SCHEMA,
|
|
136
144
|
kind: 'release',
|
|
@@ -141,6 +149,8 @@ export function releaseEvidenceOf ({ release, runs, missing = [], org = null, is
|
|
|
141
149
|
runs,
|
|
142
150
|
// runs of the release with no evidence: not accepted, deleted or out of reach, each with the reason
|
|
143
151
|
missing,
|
|
152
|
+
// ?ref= / ?purpose= narrowed the release to the runs of one record or purpose (#126)
|
|
153
|
+
...(filter && Object.keys(filter).length ? { filter } : {}),
|
|
144
154
|
trace: runs.flatMap((r) => r.trace || [])
|
|
145
155
|
}
|
|
146
156
|
}
|
|
@@ -161,6 +171,7 @@ export function traceRows (ev) {
|
|
|
161
171
|
const d = s.decision || (s.carried ? { state: 'approved', by: s.carried.approved_by, at: s.carried.approved_at, via: 'carried' } : null)
|
|
162
172
|
const stepCols = { step_decision: d?.state ?? null, step_decided_by: d?.by ?? null, step_decided_at: d?.at ?? null, step_decided_via: d?.via ?? null, ...sigCols(stepSig, 'step'), step_comment: d?.note || null }
|
|
163
173
|
const stepRefs = cleanRefs(s.refs)
|
|
174
|
+
const placeCols = { session: s.session?.key ?? null, session_title: s.session?.title ?? null, lane: s.lane?.index ?? null, lane_label: s.lane?.label ?? null }
|
|
164
175
|
const caps = new Map((s.captures || []).map((c) => [`${c.locale}|${c.viewport}`, c]))
|
|
165
176
|
for (const c of s.criteria || []) {
|
|
166
177
|
const refs = [...cleanRefs(c.refs).map((x) => [x, 'criterion']), ...stepRefs.map((x) => [x, 'step']), ...scenarioRefs.map((x) => [x, 'scenario'])]
|
|
@@ -175,7 +186,7 @@ export function traceRows (ev) {
|
|
|
175
186
|
step_id: s.id, step_title: s.title ?? null, criterion_id: c.id, criterion_text: c.text ?? null, criterion_type: c.type ?? null,
|
|
176
187
|
locale: cell.locale ?? null, viewport: cell.viewport ?? null, evidence_file: cap?.path ?? null, evidence_sha256: cap?.sha256 ?? null,
|
|
177
188
|
result: cell.verdict ?? null, assessed_by: cell.assessed_by ?? null, assessor: cell.assessor ?? null, advisory: !!cell.advisory,
|
|
178
|
-
...stepCols, ...runCols
|
|
189
|
+
...stepCols, ...runCols, ...placeCols
|
|
179
190
|
})
|
|
180
191
|
}
|
|
181
192
|
}
|
|
@@ -287,7 +298,7 @@ export function packageReadme ({ run, service, recordSha }) {
|
|
|
287
298
|
'record/kaloko-signoff.json the acceptance record, signed by Kaloko (Ed25519)',
|
|
288
299
|
'record/kaloko-signoff.pdf the same record for people, with the JSON attached',
|
|
289
300
|
'evidence.json everything below in one machine-readable file (schema qawalk.evidence.v1)',
|
|
290
|
-
'trace.json, trace.csv requirement → criterion → step, locale, viewport → screenshot (SHA-256) → result → decision',
|
|
301
|
+
'trace.json, trace.csv requirement → criterion → step (role, lane), locale, viewport → screenshot (SHA-256) → result → decision',
|
|
291
302
|
'audit.csv the audit trail of the run',
|
|
292
303
|
'captures/ the screenshots the record names',
|
|
293
304
|
'decisions/ files attached to step decisions (approve / return), as the record names them',
|
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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Lanes (sub-processes) and sessions (roles) of a flow: which lane and which person a step belongs to, with the names
|
|
2
|
+
// the canvas shows. Pure, no Node APIs: shared by the scenario layout (scenario.mjs), the quality evidence
|
|
3
|
+
// (evidence-core.mjs, CLI and service) and the MCP run summary. The canvas (viewer/index.html) keeps a byte-identical
|
|
4
|
+
// copy of laneLabels; test/lanes.test.mjs checks that the two stay the same.
|
|
5
|
+
//
|
|
6
|
+
// A scenario names its lanes in `lanes:`. Without them, its sessions draw the lanes: one per session, in declaration
|
|
7
|
+
// order. The lane index of a step is resolved once, by the layout when the run is built (run.json nodes[].lane), and
|
|
8
|
+
// every reader takes it from there.
|
|
9
|
+
|
|
10
|
+
/** Lane index of each session when the sessions draw the lanes (no `lanes` declared); null when `lanes` are declared. */
|
|
11
|
+
export function sessionLanes (lanes, sessions) {
|
|
12
|
+
if (!sessions || (Array.isArray(lanes) && lanes.length)) return null
|
|
13
|
+
return new Map(Object.keys(sessions).map((k, i) => [k, i]))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Names of the lanes, index = lane: the scenario's `lanes`, or (none declared) the titles of its sessions in
|
|
18
|
+
* declaration order. A localized title reads in `locale`, else its default, else its first language. Self-contained:
|
|
19
|
+
* the canvas carries the same function.
|
|
20
|
+
*/
|
|
21
|
+
export function laneLabels (lanes, sessions, locale) {
|
|
22
|
+
const text = (v) => v == null ? '' : typeof v === 'string' ? v : (v[locale] ?? v.default ?? Object.values(v)[0] ?? '')
|
|
23
|
+
return Array.isArray(lanes) && lanes.length ? lanes.map((l) => text(l)) : Object.entries(sessions || {}).map(([k, s]) => text(s?.title) || k)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A plain or localized title as text (the same reading as laneLabels). */
|
|
27
|
+
export const textOf = (v, locale = null) => v == null ? '' : typeof v === 'string' ? v : (v[locale] ?? v.default ?? Object.values(v)[0] ?? '')
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Where each step of a run manifest (qawalk.run.v1) sits: Map step id → { session: { key, title } | null,
|
|
31
|
+
* lane: { index, label } | null }. label is null for a lane without a name (a flow without lanes or sessions, or an
|
|
32
|
+
* extra row the layout opened for an alternative branch).
|
|
33
|
+
*/
|
|
34
|
+
export function stepPlaces (manifest, locale = null) {
|
|
35
|
+
const m = manifest || {}
|
|
36
|
+
const labels = laneLabels(m.lanes, m.sessions, locale)
|
|
37
|
+
const out = new Map()
|
|
38
|
+
for (const n of m.nodes || []) {
|
|
39
|
+
const key = typeof n.session === 'string' && n.session ? n.session : null
|
|
40
|
+
out.set(n.id, {
|
|
41
|
+
session: key ? { key, title: textOf(m.sessions?.[key]?.title, locale) || key } : null,
|
|
42
|
+
lane: Number.isInteger(n.lane) ? { index: n.lane, label: labels[n.lane] || null } : null
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The lanes and sessions of a run manifest as a portal lists them: lanes [{ index, label }] (every named lane and
|
|
50
|
+
* every lane a step sits in) and sessions [{ key, title }] in declaration order.
|
|
51
|
+
*/
|
|
52
|
+
export function flowStructure (manifest, locale = null) {
|
|
53
|
+
const m = manifest || {}
|
|
54
|
+
const labels = laneLabels(m.lanes, m.sessions, locale)
|
|
55
|
+
const used = new Set(labels.map((_, i) => i))
|
|
56
|
+
for (const n of m.nodes || []) if (Number.isInteger(n.lane)) used.add(n.lane)
|
|
57
|
+
return {
|
|
58
|
+
lanes: [...used].sort((a, b) => a - b).map((index) => ({ index, label: labels[index] || null })),
|
|
59
|
+
sessions: Object.entries(m.sessions || {}).map(([key, s]) => ({ key, title: textOf(s?.title, locale) || key }))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Run references and purpose (#126): the external records a run serves (a change-control record CC-2026-001, a
|
|
2
|
+
// validation protocol) and why it was walked (dry_run, formal, smoke). Set with kaloko start --ref / --purpose or
|
|
3
|
+
// KALOKO_REFS / KALOKO_PURPOSE, kept in the run manifest (refs, purpose) and checked again by the service
|
|
4
|
+
// (worker/src/manifest.js), which stores them so GET …/runs?ref= and ?purpose= can filter. No node: imports: the
|
|
5
|
+
// Worker uses it too.
|
|
6
|
+
|
|
7
|
+
/** A reference as people write record ids: letters, digits and . _ - / : # +, starting with a letter or digit. */
|
|
8
|
+
export const RUN_REF = /^[A-Za-z0-9][A-Za-z0-9._:/#+-]{0,79}$/
|
|
9
|
+
export const MAX_RUN_REFS = 20
|
|
10
|
+
export const RUN_PURPOSES = ['dry_run', 'formal', 'smoke']
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* References from options or the environment: an array of values (--ref repeated) or one string, each split at
|
|
14
|
+
* commas and white space. Trimmed, empty pieces dropped, duplicates removed in order. Not validated: see refProblems.
|
|
15
|
+
*/
|
|
16
|
+
export function splitRunRefs (value) {
|
|
17
|
+
const list = Array.isArray(value) ? value : value == null || value === true || value === false ? [] : [value]
|
|
18
|
+
const out = []
|
|
19
|
+
for (const v of list) for (const piece of String(v).split(/[\s,]+/)) if (piece && !out.includes(piece)) out.push(piece)
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What is wrong with a list of references ([] when it is fine). */
|
|
24
|
+
export function refProblems (refs) {
|
|
25
|
+
if (!Array.isArray(refs)) return ['refs must be a list of references.']
|
|
26
|
+
const problems = []
|
|
27
|
+
if (refs.length > MAX_RUN_REFS) problems.push(`A run takes at most ${MAX_RUN_REFS} references; this one has ${refs.length}.`)
|
|
28
|
+
for (const r of refs) if (typeof r !== 'string' || !RUN_REF.test(r)) problems.push(`"${String(r).slice(0, 100)}" is not a reference Kaloko keeps: up to 80 letters, digits and . _ - / : # +, starting with a letter or digit.`)
|
|
29
|
+
if (new Set(refs).size !== refs.length) problems.push('refs names a reference twice.')
|
|
30
|
+
return problems
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The references and purpose of kaloko start: --ref (repeated or comma separated) or else KALOKO_REFS, --purpose or
|
|
35
|
+
* else KALOKO_PURPOSE. Returns { refs, purpose, problems }; problems name the option or variable they came from.
|
|
36
|
+
*/
|
|
37
|
+
export function startRefs (args = {}, env = {}) {
|
|
38
|
+
const fromFlag = args.ref !== undefined && args.ref !== true && !(Array.isArray(args.ref) && args.ref.every((v) => v === true))
|
|
39
|
+
const refs = splitRunRefs(fromFlag ? [].concat(args.ref).filter((v) => v !== true) : env.KALOKO_REFS)
|
|
40
|
+
const problems = []
|
|
41
|
+
if (args.ref === true || (Array.isArray(args.ref) && args.ref.includes(true))) problems.push('--ref takes a reference, e.g. --ref CC-2026-001.')
|
|
42
|
+
for (const p of refProblems(refs)) problems.push(`${fromFlag ? '--ref' : 'KALOKO_REFS'}: ${p}`)
|
|
43
|
+
const given = typeof args.purpose === 'string' ? args.purpose : args.purpose === true ? '' : env.KALOKO_PURPOSE
|
|
44
|
+
const purpose = normalizePurpose(given)
|
|
45
|
+
if (args.purpose === true) problems.push(`--purpose takes one of ${RUN_PURPOSES.join(', ')}.`)
|
|
46
|
+
else if (purpose === undefined) problems.push(`${typeof args.purpose === 'string' ? '--purpose' : 'KALOKO_PURPOSE'} is "${String(given).slice(0, 40)}"; use ${RUN_PURPOSES.join(', ')}.`)
|
|
47
|
+
return { refs, purpose: purpose || null, problems }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** dry_run | formal | smoke from what people type (dry-run, Formal); null when empty; undefined when not a purpose. */
|
|
51
|
+
export function normalizePurpose (value) {
|
|
52
|
+
if (value == null || value === '') return null
|
|
53
|
+
const p = String(value).trim().toLowerCase().replace(/-/g, '_')
|
|
54
|
+
return RUN_PURPOSES.includes(p) ? p : undefined
|
|
55
|
+
}
|
package/src/lib/run.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function newRunId (scenario, date = new Date()) {
|
|
|
37
37
|
return { id: `${today(date)}-${slugify(scenario.id)}-${short}`, short }
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export async function createRun ({ output, scenario, scenarioFile, env, scope, git, executor, root, project = null, issue = null, appVersion = null, product = null, pii = null }) {
|
|
40
|
+
export async function createRun ({ output, scenario, scenarioFile, env, scope, git, executor, root, project = null, issue = null, refs = [], purpose = null, appVersion = null, product = null, pii = null }) {
|
|
41
41
|
const { id, short } = newRunId(scenario)
|
|
42
42
|
const dir = resolve(root, output, id)
|
|
43
43
|
await mkdir(dir, { recursive: true })
|
|
@@ -54,6 +54,9 @@ export async function createRun ({ output, scenario, scenarioFile, env, scope, g
|
|
|
54
54
|
executor,
|
|
55
55
|
project,
|
|
56
56
|
issue,
|
|
57
|
+
// the records the run serves (change control, protocol) and why it was walked (#126; src/lib/run-refs.mjs)
|
|
58
|
+
...(refs?.length ? { refs } : {}),
|
|
59
|
+
...(purpose ? { purpose } : {}),
|
|
57
60
|
app_version: appVersion,
|
|
58
61
|
// personal data hidden in every capture (#64): { mode: redact | pseudonymize, allow }
|
|
59
62
|
...(pii ? { pii } : {}),
|
package/src/lib/scenario.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { schemaErrors } from './schema.mjs'
|
|
|
4
4
|
import { UserError } from './util.mjs'
|
|
5
5
|
import { expandPacks } from './packs.mjs'
|
|
6
6
|
import { jsGuard } from './assertions.mjs'
|
|
7
|
+
import { sessionLanes } from './lanes-core.mjs'
|
|
7
8
|
|
|
8
9
|
// a scenario holds thousands of values, not millions: aliases (&a, *a) can expand a few kilobytes of YAML into
|
|
9
10
|
// billions that validation and its JSON copy would walk ("billion laughs"), so the expanded size has a ceiling
|
|
@@ -156,7 +157,7 @@ export function layout (scenario) {
|
|
|
156
157
|
const taken = new Set()
|
|
157
158
|
const claim = (c, l) => { while (taken.has(`${c}:${l}`)) l++; taken.add(`${c}:${l}`); return l }
|
|
158
159
|
// sessions without explicit lanes: one swimlane per person, in declaration order
|
|
159
|
-
const sessionLane =
|
|
160
|
+
const sessionLane = sessionLanes(scenario.lanes, scenario.sessions)
|
|
160
161
|
for (const n of [...nodes].sort((a, b) => col.get(a.id) - col.get(b.id) || order.get(a.id) - order.get(b.id))) {
|
|
161
162
|
let wanted = n.lane
|
|
162
163
|
if (wanted == null && sessionLane && n.session != null && sessionLane.has(n.session)) wanted = sessionLane.get(n.session)
|
package/src/lib/signoff.mjs
CHANGED
|
@@ -143,6 +143,10 @@ export async function evidenceCmd ({ config, args, say, client, runId = null })
|
|
|
143
143
|
const product = typeof args.product === 'string' ? args.product : Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : (config.project || null)
|
|
144
144
|
if (!product) throw new UsageError('Name the product of the release: --product <slug>.')
|
|
145
145
|
path = `/orgs/${config.org}/evidence?product=${encodeURIComponent(product)}&version=${encodeURIComponent(release)}`
|
|
146
|
+
// only the release's runs of one record or purpose (#126)
|
|
147
|
+
const ref = typeof args.ref === 'string' ? args.ref : null
|
|
148
|
+
if (ref) path += `&ref=${encodeURIComponent(ref)}`
|
|
149
|
+
if (typeof args.purpose === 'string') path += `&purpose=${encodeURIComponent(args.purpose)}`
|
|
146
150
|
} else path = `/orgs/${config.org}/runs/${encodeURIComponent(runId)}/evidence`
|
|
147
151
|
const ev = await client.request('GET', path)
|
|
148
152
|
const text = args.csv ? traceCsv(ev.trace || []) : JSON.stringify(ev, null, 2)
|
package/viewer/index.html
CHANGED
|
@@ -165,6 +165,8 @@ a.pr:hover{text-decoration-color:var(--ink)}
|
|
|
165
165
|
.run__env--staging{background:var(--partial-bg);color:var(--partial-ink)}
|
|
166
166
|
.run__env--production{background:var(--fail-bg);color:var(--fail-ink)}
|
|
167
167
|
.run__env--review{background:var(--pass-bg);color:var(--pass-ink)}
|
|
168
|
+
.run__purpose{font-size:11px;font-weight:600;padding:1px 6px;border-radius:4px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
|
|
169
|
+
.run__purpose--formal{border-color:currentColor;color:var(--ink)}
|
|
168
170
|
.bar5{display:flex;height:5px;border-radius:3px;overflow:hidden;background:var(--skip-bg);min-width:60px}
|
|
169
171
|
.bar5 i{display:block}
|
|
170
172
|
.steps{list-style:none;margin:0;padding:0 8px 16px}
|
|
@@ -355,6 +357,8 @@ body.embed .helpwrap{display:none}
|
|
|
355
357
|
.viewport{position:relative;overflow:hidden;cursor:grab;touch-action:none;
|
|
356
358
|
background-color:var(--ground);background-image:radial-gradient(var(--dots) 1px,transparent 1.2px);background-size:22px 22px}
|
|
357
359
|
.viewport.dragging{cursor:grabbing}
|
|
360
|
+
/* a pan never selects the lane headings, titles and thumbnails it crosses; the class lives only for the drag */
|
|
361
|
+
html.is-panning,html.is-panning *{user-select:none;-webkit-user-select:none}
|
|
358
362
|
.world{position:absolute;left:0;top:0;transform-origin:0 0}
|
|
359
363
|
.edges{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}
|
|
360
364
|
.edges path{fill:none;stroke:var(--edge);stroke-width:1.6}
|
|
@@ -363,6 +367,8 @@ body.embed .helpwrap{display:none}
|
|
|
363
367
|
.edges.hl path{opacity:.12}.edges.hl path.on{opacity:1;stroke:var(--ink);stroke-width:2.6}
|
|
364
368
|
.lane-band{position:absolute;border-radius:14px;background:color-mix(in srgb,var(--panel) 38%,transparent);border:1px solid color-mix(in srgb,var(--line) 70%,transparent);pointer-events:none}
|
|
365
369
|
.lane-band:nth-child(even){background:color-mix(in srgb,var(--panel-2,var(--panel)) 55%,transparent)}
|
|
370
|
+
/* #lane=<index>: the lane a link points to */
|
|
371
|
+
.lane-band.is-on{border:2px solid var(--line-strong);box-shadow:0 0 0 4px var(--accent)}
|
|
366
372
|
.lane-head{position:absolute;left:16px;top:-30px;font-size:14px;font-weight:700;color:var(--ink);white-space:nowrap;display:flex;gap:10px;align-items:baseline}
|
|
367
373
|
.lane-head small{font-size:12px;font-weight:500;color:var(--muted)}
|
|
368
374
|
.world.far .jev,.world.far .node__foot{visibility:hidden}
|
|
@@ -2298,7 +2304,13 @@ function drawEdges () {
|
|
|
2298
2304
|
BOXES = new Map(); for (const el of $('nodes').querySelectorAll('.node[data-id]')) BOXES.set(el.dataset.id, { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
|
|
2299
2305
|
try { return drawEdgesNow(); } finally { BOXES = null; }
|
|
2300
2306
|
}
|
|
2301
|
-
|
|
2307
|
+
/** Names of the lanes, index = lane: the scenario's lanes, or one per session. The same function as laneLabels in
|
|
2308
|
+
src/lib/lanes-core.mjs (the evidence names lanes with it; test/lanes.test.mjs keeps the two identical). */
|
|
2309
|
+
function laneLabels (lanes, sessions, locale) {
|
|
2310
|
+
const text = (v) => v == null ? '' : typeof v === 'string' ? v : (v[locale] ?? v.default ?? Object.values(v)[0] ?? '')
|
|
2311
|
+
return Array.isArray(lanes) && lanes.length ? lanes.map((l) => text(l)) : Object.entries(sessions || {}).map(([k, s]) => text(s?.title) || k)
|
|
2312
|
+
}
|
|
2313
|
+
const laneNamesOf = () => laneLabels(RUN.lanes, RUN.sessions, S.loc);
|
|
2302
2314
|
/** Box of every lane from its rendered cards; bands and edge corridors use it. */
|
|
2303
2315
|
function laneBoxes () {
|
|
2304
2316
|
const out = new Map();
|
|
@@ -2351,7 +2363,7 @@ function drawEdgesNow () {
|
|
|
2351
2363
|
$('edges').setAttribute('width', maxX + 100); $('edges').setAttribute('height', maxY + 200);
|
|
2352
2364
|
// lanes: a band with a header (title, steps, passed) behind the cards of each lane
|
|
2353
2365
|
const names = laneNamesOf();
|
|
2354
|
-
const bands = names.length > 1 ? [...lanesBox.entries()].sort(([a], [b]) => a - b).map(([i, L]) => `<div class="lane-band" data-lane="${i}" style="left:${L.x1 - 24}px;top:${L.y1 - 20}px;width:${L.x2 - L.x1 + 48}px;height:${L.y2 - L.y1 + 40}px"><span class="lane-head">${esc(names[i] || '')}<small>${L.n} ${T('stepsWord')} · ${L.pass}/${L.n} ${T('passedWord')}</small></span></div>`) : [];
|
|
2366
|
+
const bands = names.length > 1 ? [...lanesBox.entries()].sort(([a], [b]) => a - b).map(([i, L]) => `<div class="lane-band${S.laneHl === i ? ' is-on' : ''}" data-lane="${i}" style="left:${L.x1 - 24}px;top:${L.y1 - 20}px;width:${L.x2 - L.x1 + 48}px;height:${L.y2 - L.y1 + 40}px"><span class="lane-head">${esc(names[i] || '')}<small>${L.n} ${T('stepsWord')} · ${L.pass}/${L.n} ${T('passedWord')}</small></span></div>`) : [];
|
|
2355
2367
|
$('bands').innerHTML = bands.join('');
|
|
2356
2368
|
const lanes = names.length > 1 ? [] : names.map((l, i) => { const first = RUN.nodes.filter((n) => n.lane === i).sort((a, b) => a.col - b.col)[0]; return l && first ? `<span class="lane-label" style="left:${nodePos(first).x}px;top:${Y0 + i * LANEH() - 34}px">${esc(l)}</span>` : ''; });
|
|
2357
2369
|
$('labels').innerHTML = labels.join('') + lanes.join('');
|
|
@@ -2361,6 +2373,16 @@ function hlEdges (id) {
|
|
|
2361
2373
|
const svg = $('edges'); svg.classList.toggle('hl', !!id);
|
|
2362
2374
|
svg.querySelectorAll('path[data-from]').forEach((p) => p.classList.toggle('on', !!id && (p.dataset.from === id || p.dataset.to === id)));
|
|
2363
2375
|
}
|
|
2376
|
+
/** A lane from the steps panel or a #lane= link: fitted on the map, its group in view in the index. */
|
|
2377
|
+
function showLane (i) {
|
|
2378
|
+
if (S.mode === 'index') { const k = (S.ri?.entries || []).findIndex((x) => x.type === 'group' && x.label === laneNamesOf()[i]); if (k >= 0) $('riList').scrollTop = S.ri.tops[k]; return; }
|
|
2379
|
+
fitLane(i);
|
|
2380
|
+
}
|
|
2381
|
+
/** #lane=<index> highlights that lane's band (null: none). */
|
|
2382
|
+
function markLane (i) {
|
|
2383
|
+
S.laneHl = i;
|
|
2384
|
+
$('bands').querySelectorAll('.lane-band').forEach((b) => b.classList.toggle('is-on', Number(b.dataset.lane) === i));
|
|
2385
|
+
}
|
|
2364
2386
|
function fitLane (i) {
|
|
2365
2387
|
const L = laneBoxes().get(i), r = $('vp').getBoundingClientRect(); if (!L || !r.width) return;
|
|
2366
2388
|
// the whole lane when it fits at a readable zoom, else its start at the readable zoom
|
|
@@ -2954,12 +2976,12 @@ function focusNode (id) { const el = document.querySelector(`.node[data-id="${CS
|
|
|
2954
2976
|
|
|
2955
2977
|
function wireCanvas () {
|
|
2956
2978
|
const vp = $('vp'); let drag = null;
|
|
2957
|
-
vp.addEventListener('pointerdown', (e) => { if (e.target.closest('.node,.hud') || pinch) return; drag = { id: e.pointerId, x: e.clientX, y: e.clientY, vx: S.view.x, vy: S.view.y }; vp.setPointerCapture(e.pointerId); vp.classList.add('dragging'); });
|
|
2979
|
+
vp.addEventListener('pointerdown', (e) => { if (e.target.closest('.node,.hud') || pinch) return; drag = { id: e.pointerId, x: e.clientX, y: e.clientY, vx: S.view.x, vy: S.view.y }; vp.setPointerCapture(e.pointerId); vp.classList.add('dragging'); document.documentElement.classList.add('is-panning'); getSelection()?.removeAllRanges(); });
|
|
2958
2980
|
vp.addEventListener('pointermove', (e) => { if (!drag || e.pointerId !== drag.id) return; S.view.x = drag.vx + e.clientX - drag.x; S.view.y = drag.vy + e.clientY - drag.y; applyView(); });
|
|
2959
2981
|
// two fingers pinch-zoom the canvas around their midpoint and pan with it (the page itself never zooms)
|
|
2960
2982
|
let pinch = null;
|
|
2961
2983
|
const two = (e) => { const [a, b] = e.touches, r = vp.getBoundingClientRect(); return { d: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1, x: (a.clientX + b.clientX) / 2 - r.left, y: (a.clientY + b.clientY) / 2 - r.top }; };
|
|
2962
|
-
vp.addEventListener('touchstart', (e) => { if (e.touches.length !== 2) return; drag = null; vp.classList.remove('dragging'); const g = two(e); pinch = { ...g, k: S.view.k }; }, { passive: true });
|
|
2984
|
+
vp.addEventListener('touchstart', (e) => { if (e.touches.length !== 2) return; drag = null; vp.classList.remove('dragging'); document.documentElement.classList.remove('is-panning'); const g = two(e); pinch = { ...g, k: S.view.k }; }, { passive: true });
|
|
2963
2985
|
vp.addEventListener('touchmove', (e) => {
|
|
2964
2986
|
if (!pinch || e.touches.length !== 2) return; e.preventDefault();
|
|
2965
2987
|
const g = two(e); S.view.x += g.x - pinch.x; S.view.y += g.y - pinch.y; pinch.x = g.x; pinch.y = g.y;
|
|
@@ -2967,7 +2989,7 @@ function wireCanvas () {
|
|
|
2967
2989
|
}, { passive: false });
|
|
2968
2990
|
vp.addEventListener('touchend', (e) => { if (e.touches.length < 2) pinch = null; }, { passive: true });
|
|
2969
2991
|
vp.addEventListener('touchcancel', () => { pinch = null; }, { passive: true });
|
|
2970
|
-
const end = () => { drag = null; vp.classList.remove('dragging'); };
|
|
2992
|
+
const end = () => { drag = null; vp.classList.remove('dragging'); document.documentElement.classList.remove('is-panning'); };
|
|
2971
2993
|
vp.addEventListener('pointerup', end); vp.addEventListener('pointercancel', end);
|
|
2972
2994
|
// presence: my cursor to the others; dragging or zooming myself ends following someone
|
|
2973
2995
|
vp.addEventListener('pointermove', presenceCursor);
|
|
@@ -3053,15 +3075,15 @@ function renderChrome () {
|
|
|
3053
3075
|
// a live draft says so in the bar on a phone too, where the meta line under the title is hidden
|
|
3054
3076
|
$('liveTag').hidden = !(isDesign() && isDraft()); $('liveTag').textContent = T('liveShared');
|
|
3055
3077
|
if (isDesign()) $('runMeta').textContent = [isDraft() ? `${T('draft')} #${RUN.draft.seq}${RUN.draft.branch !== 'main' ? ` @${RUN.draft.branch}` : ''} · ${T('liveShared')}` : `${T('version')} v${RUN.version}${RUN.version_name ? ` · ${RUN.version_name}` : ''}`, fmtDate(RUN.started_at), RUN.verdict && RUN.verdict.verdict !== 'pending' && T(RUN.verdict.verdict), (RUN.references?.design || RUN.design_reference_id) === RUN.id && T('designRef')].filter(Boolean).join(' · ');
|
|
3056
|
-
else $('runMeta').textContent = [fmtDate(RUN.started_at), RUN.environment.name, g.branch
|
|
3078
|
+
else $('runMeta').textContent = [fmtDate(RUN.started_at), RUN.environment.name, purposeLabel(RUN.purpose), runRefs().join(', '), g.branch &&`${g.branch} @ ${g.commit}`, g.author, g.pr && `PR #${g.pr.number}`, RUN.verdict && RUN.verdict.verdict !== 'pending' && `${T(RUN.verdict.verdict)}${RUN.verdict.baseline ? ' · ' + T('baseline') : ''}`].filter(Boolean).join(' · ');
|
|
3057
3079
|
document.title = `${RUN.scenario.id} · Kaloko`;
|
|
3058
3080
|
const hist = [...(RUN.history || []), { id: RUN.id, started_at: RUN.started_at, current: true }].sort((a, b) => b.started_at.localeCompare(a.started_at));
|
|
3059
3081
|
const i = hist.findIndex((h) => h.current);
|
|
3060
3082
|
const c = { pass: RUN.summary.pass, partial: RUN.summary.partial, fail: RUN.summary.fail, skip: RUN.summary.skip };
|
|
3061
|
-
$('runcard').innerHTML = `<div class="runcard__top"><span class="runcard__when">${fmtDate(RUN.started_at)}</span><span class="run__env run__env--${esc(RUN.environment.name)}">${esc(RUN.environment.name)}</span>${RUN.pii?.hidden ? `<span class="run__pii" title="${esc(T('piiRun')(RUN.pii.mode))}">${esc(RUN.pii.mode === 'pseudonymize' ? T('piiTagFake') : T('piiTag'))}</span>` : ''}
|
|
3083
|
+
$('runcard').innerHTML = `<div class="runcard__top"><span class="runcard__when">${fmtDate(RUN.started_at)}</span><span class="run__env run__env--${esc(RUN.environment.name)}">${esc(RUN.environment.name)}</span>${purposeLabel(RUN.purpose) ? `<span class="run__purpose run__purpose--${esc(RUN.purpose)}" title="${esc(T('purposeWord'))}">${esc(purposeLabel(RUN.purpose))}</span>` : ''}${RUN.pii?.hidden ? `<span class="run__pii" title="${esc(T('piiRun')(RUN.pii.mode))}">${esc(RUN.pii.mode === 'pseudonymize' ? T('piiTagFake') : T('piiTag'))}</span>` : ''}
|
|
3062
3084
|
<span class="runcard__nav"><a class="icon-btn" aria-label="${T('older')}" ${hist[i + 1] ? `href="${runHref(hist[i + 1].id)}"` : 'aria-disabled="true" style="opacity:.35"'}>${ICON.prev}</a><a class="icon-btn" aria-label="${T('newer')}" ${hist[i - 1] ? `href="${runHref(hist[i - 1].id)}"` : 'aria-disabled="true" style="opacity:.35"'}>${ICON.next}</a></span></div>
|
|
3063
3085
|
${bar5(c)}
|
|
3064
|
-
<dl>${g.branch ? `<dt>${T('branch')}</dt><dd class="mono">${esc(g.branch)}</dd><dt>${T('commit')}</dt><dd><span class="mono">${esc(g.commit)}</span> ${esc(g.message)}</dd><dt>${T('author')}</dt><dd><span class="who"><span class="av">${esc(initials(g.author))}</span>${esc(g.author)}</span></dd><dt>${T('pr')}</dt><dd>${prLink(g.pr)}</dd>` : ''}${RUN.uploaded_by ? `<dt>${g.branch ? T('uploadedBy') : T('author')}</dt><dd><span class="who"><span class="av">${esc(initials(RUN.uploaded_by))}</span>${esc(RUN.uploaded_by)}</span></dd>` : ''}${RUN.issue ? `<dt>${T('issue')}</dt><dd>${issueRef(RUN.issue)}</dd>` : ''}
|
|
3086
|
+
<dl>${g.branch ? `<dt>${T('branch')}</dt><dd class="mono">${esc(g.branch)}</dd><dt>${T('commit')}</dt><dd><span class="mono">${esc(g.commit)}</span> ${esc(g.message)}</dd><dt>${T('author')}</dt><dd><span class="who"><span class="av">${esc(initials(g.author))}</span>${esc(g.author)}</span></dd><dt>${T('pr')}</dt><dd>${prLink(g.pr)}</dd>` : ''}${RUN.uploaded_by ? `<dt>${g.branch ? T('uploadedBy') : T('author')}</dt><dd><span class="who"><span class="av">${esc(initials(RUN.uploaded_by))}</span>${esc(RUN.uploaded_by)}</span></dd>` : ''}${RUN.issue ? `<dt>${T('issue')}</dt><dd>${issueRef(RUN.issue)}</dd>` : ''}${runRefs().length ? `<dt>${T('runRefsLabel')}</dt><dd>${runRefs().map((r) => `<span class="ref">${esc(r)}</span>`).join('')}</dd>` : ''}
|
|
3065
3087
|
${RUN.scenario.platform ? `<dt>${T('platformWord')}</dt><dd>${esc(platformLabel(RUN.scenario.platform))}${(() => { const d = screens().flatMap((n) => Object.values(n.captures?.[S.loc] || {})).map((c) => c?.device).find(Boolean); return d ? ` · ${esc(d)}` : ''; })()}</dd>` : ''}
|
|
3066
3088
|
<dt>${T('env')}</dt><dd class="mono">${RUN.environment.kind === 'design' ? `${esc(RUN.environment.name)}${RUN.environment.name !== 'design' ? ` · ${T('designEnv')}` : ''}` : esc(RUN.environment.base_url == null ? '—' : typeof RUN.environment.base_url === 'string' ? RUN.environment.base_url : loc(RUN.environment.base_url))}${RUN.environment.readonly ? ' · readonly' : ''}</dd>
|
|
3067
3089
|
${RUN.pii?.hidden ? `<dt>${T('piiWord')}</dt><dd>${esc(T('piiRun')(RUN.pii.mode))}</dd>` : ''}
|
|
@@ -3074,7 +3096,7 @@ function renderChrome () {
|
|
|
3074
3096
|
: laneNamesOf().length > 1
|
|
3075
3097
|
? laneNamesOf().map((name, i) => { const mine = order.filter((n) => n.lane === i); if (!mine.length) return ''; const c = { pass: mine.filter((n) => nodeSt(n) === 'pass').length }; return `<h3 data-lane="${i}" title="${esc(T('fitLane'))}">${esc(name)} <small class="muted">${c.pass}/${mine.length}</small></h3>${mine.map(stepLi).join('')}`; }).join('')
|
|
3076
3098
|
: order.map(stepLi).join('');
|
|
3077
|
-
$('steps').onclick = (e) => { const ln = e.target.closest('[data-lane]'); if (ln) {
|
|
3099
|
+
$('steps').onclick = (e) => { const ln = e.target.closest('[data-lane]'); if (ln) { showLane(Number(ln.dataset.lane)); return; } const h = e.target.closest('[data-sess]'); if (h) { S.sess = S.sess === h.dataset.sess ? null : h.dataset.sess; renderAll(); return; } const b = e.target.closest('[data-step]'); if (!b) return; if (S.mode === 'index') riFocus(b.dataset.step); else if (byId(b.dataset.step)?.kind === 'stack') { focusNode(b.dataset.step); openStack(b.dataset.step); } else focusNode(b.dataset.step); if (innerWidth <= 1100) { S.left = false; setPanels(); } };
|
|
3078
3100
|
$('scFoot').innerHTML = `${issFootHtml()}${RUN.project ? `<span class="mono">${esc(RUN.project.slug)}</span> · ` : ''}${RUN.issue?.url ? `<a class="pr" href="${esc(webHref(RUN.issue.url))}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a> · ` : ''}<code>${esc(RUN.scenario.file || '')}</code>${RUN.scenario.version ? ` · v${esc(RUN.scenario.version)}` : ''}<br><span class="mono">${esc(RUN.id)}</span>`;
|
|
3079
3101
|
}
|
|
3080
3102
|
// the summary panel before the cards: the cards measure themselves, and that one layout then covers the panel too
|
|
@@ -3935,6 +3957,12 @@ function applyHash () {
|
|
|
3935
3957
|
if (pres && byId(pres)) { if (p.get('loc') && RUN.scope.locales.includes(p.get('loc')) && p.get('loc') !== S.loc) { S.loc = p.get('loc'); renderAll(); } ptOpen(pres, p.get('vp')); return; }
|
|
3936
3958
|
if (!$('pt').hidden) { $('pt').hidden = true; $('pt').innerHTML = ''; }
|
|
3937
3959
|
const step = p.get('step');
|
|
3960
|
+
// #session=<key>: that person's steps stand out across the lanes (as picking them under People does)
|
|
3961
|
+
if (p.has('session')) { const k = sessions().includes(p.get('session')) ? p.get('session') : null; if (k !== S.sess) { S.sess = k; renderAll(); } }
|
|
3962
|
+
// #lane=<index>: that lane's band highlighted and fitted (a sub-process of the flow)
|
|
3963
|
+
const lane = /^\d+$/.test(p.get('lane') || '') && RUN.nodes.some((n) => (n.lane || 0) === Number(p.get('lane'))) ? Number(p.get('lane')) : null;
|
|
3964
|
+
if (lane !== (S.laneHl ?? null)) markLane(lane);
|
|
3965
|
+
if (lane !== null && !step) showLane(lane);
|
|
3938
3966
|
if (step && step.includes('~') && !byId(step) && byId(step.split('~')[0])?.kind === 'stack') { loadStack(byId(step.split('~')[0])).then((d) => { if (d) { S.itemList = d.items.map((it) => ensureItemNode(byId(step.split('~')[0]), d, it).id); applyHash(); } }); return; }
|
|
3939
3967
|
if (!step || !byId(step)) return;
|
|
3940
3968
|
if (p.get('loc') && RUN.scope.locales.includes(p.get('loc')) && p.get('loc') !== S.loc) { S.loc = p.get('loc'); renderAll(); }
|
|
@@ -3945,7 +3973,13 @@ function applyHash () {
|
|
|
3945
3973
|
}
|
|
3946
3974
|
|
|
3947
3975
|
/* ---------- replay: guided walkthrough with branch choice ---------- */
|
|
3948
|
-
|
|
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
|
+
};
|
|
3949
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]; }
|
|
3950
3984
|
function rpOpen (path) {
|
|
3951
3985
|
const ids = (path || []).filter((id) => byId(id));
|
|
@@ -4007,7 +4041,8 @@ function renderReplay () {
|
|
|
4007
4041
|
$('rpClose').onclick = rpClose; $('rpPrev').onclick = rpBack;
|
|
4008
4042
|
$('rp').querySelectorAll('[data-go]').forEach((b) => { b.onclick = () => rpGo(b.dataset.go); });
|
|
4009
4043
|
$('rp').querySelectorAll('[data-jump]').forEach((b) => { b.onclick = () => rpJump(Number(b.dataset.jump)); });
|
|
4010
|
-
|
|
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); }; });
|
|
4011
4046
|
$('rp').querySelector('[data-restart]')?.addEventListener('click', () => rpOpen(null));
|
|
4012
4047
|
if ($('rpVid')) $('rpVid').onclick = () => { S.rpVideo = !S.rpVideo; renderReplay(); };
|
|
4013
4048
|
if (S.rpVideo) {
|
|
@@ -5554,6 +5589,14 @@ document.addEventListener('click', (e) => {
|
|
|
5554
5589
|
if (t) { t.focus(); t.scrollIntoView({ block: 'center' }); }
|
|
5555
5590
|
});
|
|
5556
5591
|
|
|
5592
|
+
/* ---------- Run references and purpose (#126): the records a run serves (kaloko start --ref) and why it was walked ---------- */
|
|
5593
|
+
Object.assign(I18N.cs, { runRefsLabel: 'Navázané záznamy', purposeWord: 'Účel', purpose_dry_run: 'Zkušební běh', purpose_formal: 'Formální běh', purpose_smoke: 'Smoke test' });
|
|
5594
|
+
Object.assign(I18N.en, { runRefsLabel: 'Linked records', purposeWord: 'Purpose', purpose_dry_run: 'Dry run', purpose_formal: 'Formal run', purpose_smoke: 'Smoke test' });
|
|
5595
|
+
/** The run's references as strings (none for runs made before #126). */
|
|
5596
|
+
const runRefs = () => (Array.isArray(RUN.refs) ? RUN.refs : []).filter((x) => typeof x === 'string' && x);
|
|
5597
|
+
/** dry_run | formal | smoke in the reader's language; '' when the run names no purpose. */
|
|
5598
|
+
const purposeLabel = (p) => (['dry_run', 'formal', 'smoke'].includes(p) ? T(`purpose_${p}`) : '');
|
|
5599
|
+
|
|
5557
5600
|
/* ---------- Personal data (#64): the run's "personal data hidden" flag and the masked places of a capture ---------- */
|
|
5558
5601
|
Object.assign(I18N.cs, {
|
|
5559
5602
|
piiTag: 'Osobní údaje skryty', piiTagFake: 'Osobní údaje nahrazeny', piiWord: 'Osobní údaje', piiBox: 'Skryté osobní údaje', cc: 'Kopie',
|