kaloko 0.15.0 → 0.16.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/package.json +1 -1
- package/schemas/config.v1.json +3 -1
- package/schemas/run.v1.json +4 -0
- package/schemas/scenario.v1.json +18 -0
- package/skills/kaloko/SKILL.md +17 -7
- package/skills/kaloko/reference/acceptance.md +4 -2
- package/skills/kaloko/reference/docs.md +24 -0
- package/skills/kaloko/reference/evidence.md +6 -4
- package/skills/kaloko/reference/first-run.md +37 -4
- package/skills/kaloko/reference/mcp.md +2 -1
- package/src/cli.mjs +104 -13
- package/src/lib/build.mjs +2 -3
- package/src/lib/capture.mjs +50 -4
- package/src/lib/changelog-core.mjs +12 -6
- package/src/lib/cli-args.mjs +6 -3
- package/src/lib/detail.mjs +102 -0
- package/src/lib/docs-export.mjs +2 -1
- package/src/lib/docs.mjs +14 -9
- package/src/lib/evidence-core.mjs +1 -1
- package/src/lib/git.mjs +1 -1
- package/src/lib/init-detect.mjs +336 -17
- package/src/lib/manifest-core.mjs +36 -3
- package/src/lib/projects-cli.mjs +88 -0
- package/src/lib/scenario.mjs +8 -0
- package/src/lib/util.mjs +6 -2
- package/templates/kaloko.config.yml +2 -0
- package/viewer/index.html +74 -27
package/package.json
CHANGED
package/schemas/config.v1.json
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
"properties": {
|
|
9
9
|
"schema": { "const": "qawalk.config.v1" },
|
|
10
10
|
"org": { "type": "string", "description": "Organization slug on kaloko.app." },
|
|
11
|
-
"project": { "type": "string" },
|
|
11
|
+
"project": { "type": "string", "description": "Slug of the Kaloko project runs of this repository belong to (a scenario's project: wins)." },
|
|
12
|
+
"project_name": { "type": "string", "maxLength": 80, "description": "The name people see for that project on Kaloko (ECO Podcasty). A share creates a missing project with it; kaloko projects sync renames an existing one." },
|
|
13
|
+
"product": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,79}$", "description": "Slug of a product on Kaloko the project is part of; kaloko projects sync adds the project to it." },
|
|
12
14
|
"products": {
|
|
13
15
|
"type": "object",
|
|
14
16
|
"description": "Optional: what is released and versioned as a whole and has its own visual changelog (docs/DOCS-CHANGELOG.md). Without it the project is the product. Keys are product slugs.",
|
package/schemas/run.v1.json
CHANGED
|
@@ -281,6 +281,10 @@
|
|
|
281
281
|
"slug": {
|
|
282
282
|
"type": "string"
|
|
283
283
|
},
|
|
284
|
+
"name": {
|
|
285
|
+
"type": "string",
|
|
286
|
+
"description": "The project's name from project_name: in kaloko.config.yml; a project that a share creates gets it."
|
|
287
|
+
},
|
|
284
288
|
"repo": {
|
|
285
289
|
"type": [
|
|
286
290
|
"string",
|
package/schemas/scenario.v1.json
CHANGED
|
@@ -373,6 +373,24 @@
|
|
|
373
373
|
"description": "Regions left out of pixel comparisons (clocks, ads, carousels): a selector, or a rectangle in page pixels of one viewport. kaloko stability suggests them.",
|
|
374
374
|
"items": { "$ref": "#/$defs/ignoreRegion" }
|
|
375
375
|
},
|
|
376
|
+
"details": {
|
|
377
|
+
"type": "array",
|
|
378
|
+
"maxItems": 20,
|
|
379
|
+
"description": "Named details for user guides and docs: one element in the part of the page around it (its section or card), outlined, captured in every locale and viewport. Docs show one with .",
|
|
380
|
+
"items": {
|
|
381
|
+
"type": "object",
|
|
382
|
+
"required": ["id", "name", "selector"],
|
|
383
|
+
"additionalProperties": false,
|
|
384
|
+
"properties": {
|
|
385
|
+
"id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,59}$", "description": "Stable id, used in docs references (#add-to-cart) and file names." },
|
|
386
|
+
"name": { "$ref": "#/$defs/localized", "description": "What the reader looks at, e.g. \"Add to cart button\"; one text or one per locale." },
|
|
387
|
+
"selector": { "type": "string", "minLength": 1, "description": "CSS selector of the element; the first match is outlined." },
|
|
388
|
+
"context": { "type": "string", "minLength": 1, "description": "How much of the page around it: section (the nearest section or landmark), card (the nearest card, list item or framed box), page (one screen around it) or a CSS selector of an ancestor. Default: the nearest section or card at least 480 × 240 px, at most one screen." },
|
|
389
|
+
"padding": { "type": "integer", "minimum": 0, "maximum": 200, "description": "Pixels of page around the context (default 16)." },
|
|
390
|
+
"states": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$" }, "description": "Step states to take it in (default: every state the step is captured in)." }
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
},
|
|
376
394
|
"items": {
|
|
377
395
|
"type": "object",
|
|
378
396
|
"additionalProperties": false,
|
package/skills/kaloko/SKILL.md
CHANGED
|
@@ -26,9 +26,9 @@ description: >
|
|
|
26
26
|
Clef triage findings (experimental) to read, mention or answer, when
|
|
27
27
|
a run's health (the advisory checks of packs: SEO, accessibility,
|
|
28
28
|
performance, console) should be checked, fixed or adopted into acceptance,
|
|
29
|
-
and when a team's own repository turns runs into
|
|
30
|
-
(requirement refs, kaloko evidence, the evidence package, the
|
|
31
|
-
webhook).
|
|
29
|
+
and when a team's own repository turns runs into quality evidence for its
|
|
30
|
+
QMS or GxP (requirement refs, kaloko evidence, the evidence package, the
|
|
31
|
+
evidence.ready webhook).
|
|
32
32
|
---
|
|
33
33
|
|
|
34
34
|
# Kaloko
|
|
@@ -89,7 +89,9 @@ verdict), and run `kaloko prune` without `--yes` to see what a clean-up would do
|
|
|
89
89
|
Draft and edit the Unreleased section of the changelog (`kaloko changelog draft
|
|
90
90
|
--apply`) and add step references to it and to guides. Put the products of the
|
|
91
91
|
config on Kaloko (`kaloko products sync`: it records no release) and list them
|
|
92
|
-
(`kaloko products list`).
|
|
92
|
+
(`kaloko products list`). Create the project the config names
|
|
93
|
+
(`kaloko projects sync` or `kaloko projects create` with the name the person
|
|
94
|
+
gave). Write, translate and fix
|
|
93
95
|
guides in the docs folder, run `kaloko docs check`, and export them for a docs
|
|
94
96
|
site (`kaloko export docs --format …`) into a local folder.
|
|
95
97
|
|
|
@@ -105,6 +107,9 @@ design reference or baseline, merging a design branch, keeping your version of
|
|
|
105
107
|
a step in a sync conflict (`--mine`), turning on `html_view`, turning on recordings for
|
|
106
108
|
a whole project (`capture.record` in the config: storage), a real clean-up
|
|
107
109
|
(`kaloko prune --yes`), trashing someone else's run, archiving a scenario,
|
|
110
|
+
renaming a project or restricting it (`kaloko projects rename`, a `kaloko
|
|
111
|
+
projects sync` that would rename), picking one site of a repository that
|
|
112
|
+
holds several (ask which one before `kaloko init` or a walk),
|
|
108
113
|
recording a release (`kaloko release`) or asking to publish a changelog,
|
|
109
114
|
publishing docs (`kaloko docs publish`, `--reviewed` above all: it says a
|
|
110
115
|
person checked the text), writing a release workflow (`kaloko ci github
|
|
@@ -125,7 +130,11 @@ only when a person says that value may be uploaded.
|
|
|
125
130
|
|
|
126
131
|
- A new project or an organization without runs of its own (install, sign in,
|
|
127
132
|
the sample run, the first flow from a URL or a task, share, point the person
|
|
128
|
-
to the canvas)
|
|
133
|
+
to the canvas), and naming the project on Kaloko (`project_name:`,
|
|
134
|
+
`kaloko projects create|rename|sync`): [reference/first-run.md](reference/first-run.md)
|
|
135
|
+
- A repository with several sites or apps (monorepo, Rails engines, Folio
|
|
136
|
+
sites): ask which one, then `kaloko init --site … --base-url …`:
|
|
137
|
+
[reference/first-run.md#several-sites-in-one-repository](reference/first-run.md#several-sites-in-one-repository)
|
|
129
138
|
- Writing or updating the plan and scenario: [reference/acceptance.md](reference/acceptance.md)
|
|
130
139
|
- Android, iOS (Simulator or a real iPhone), Electron, macOS and Windows apps,
|
|
131
140
|
several devices of one app, WebViews inside apps, or screens captured by
|
|
@@ -169,8 +178,9 @@ only when a person says that value may be uploaded.
|
|
|
169
178
|
[reference/collaboration.md](reference/collaboration.md)
|
|
170
179
|
- Reading runs, feedback and the inbox over MCP instead of the CLI:
|
|
171
180
|
[reference/mcp.md](reference/mcp.md)
|
|
172
|
-
-
|
|
173
|
-
|
|
181
|
+
- Quality evidence for a team's QMS (ISO 9001, ISO 13485, IEC 62304, ISO 27001;
|
|
182
|
+
GxP is the strictest case): requirement `refs:`, `kaloko evidence`, the
|
|
183
|
+
evidence package and its check, the `evidence.ready` webhook, feeding a
|
|
174
184
|
team's own document templates, signatures and what an agent must never do:
|
|
175
185
|
[reference/evidence.md](reference/evidence.md)
|
|
176
186
|
- Filing a returned step or a failing criterion in Jira or Linear, issue keys in
|
|
@@ -97,8 +97,10 @@ English words; one plain word such as a brand name is left alone).
|
|
|
97
97
|
reader announces). `HC313` landmarks (one `main`, labelled repeats) and
|
|
98
98
|
`HC314` ARIA (unknown roles and attributes, references to missing ids,
|
|
99
99
|
`aria-hidden` over focusable content) are read from the page. The first
|
|
100
|
-
five focus stops are kept as screenshots
|
|
101
|
-
|
|
100
|
+
five focus stops are kept as screenshots (step attachments of kind
|
|
101
|
+
`focus`, "Keyboard focus · HC309" on the canvas, linked to the check; the
|
|
102
|
+
Health tab shows them under `HC309` and in the tab order) and the canvas
|
|
103
|
+
can draw the tab order on the capture. The walk only moves focus: nothing is clicked or
|
|
102
104
|
typed, and the page is left unfocused at the top. Stack steps walk the
|
|
103
105
|
keyboard on their first five pages. Still a subset of WCAG 2.2 AA; it
|
|
104
106
|
does not replace an audit with a screen reader.
|
|
@@ -14,6 +14,7 @@ A picture in Markdown names a step instead of a PNG file:
|
|
|
14
14
|
 the release before the one this entry belongs to
|
|
15
15
|

|
|
16
16
|
 the step's recording plays on the picture
|
|
17
|
+
 a named detail of the step (details: in the scenario)
|
|
17
18
|
 a step of the product's default_scenario
|
|
18
19
|
```
|
|
19
20
|
|
|
@@ -28,6 +29,29 @@ a dialog, a language switch), not for every step. Check a reference with MCP `re
|
|
|
28
29
|
`GET /api/v1/orgs/<org>/steps/resolve?ref=…`: it answers the signed image URL (and `video` with `mp4`, `webm` and `duration_ms` when it plays), the run and how the version was found
|
|
29
30
|
(`release`, `app_version`, `reference_env`, `accepted`; `exact: false` means a fallback).
|
|
30
31
|
|
|
32
|
+
## Details for guides
|
|
33
|
+
|
|
34
|
+
When a guide talks about one control, show that control in its part of the page instead of the whole screen. Declare
|
|
35
|
+
it on the step in the scenario and refer to it with `#<id>`:
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
details:
|
|
39
|
+
- id: pay-button # letters, digits, - and _; used in references and file names
|
|
40
|
+
name: { en: Pay button, cs: Tlačítko Zaplatit } # one text or one per locale
|
|
41
|
+
selector: "#pay" # the first match is outlined
|
|
42
|
+
context: card # optional: section | card | page | a CSS selector of an ancestor
|
|
43
|
+
padding: 24 # optional, px around the context (default 16)
|
|
44
|
+
states: [error] # optional: only in these states of the step
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Without `context` the picture is the nearest section, landmark or card around the element that is at least
|
|
48
|
+
480 × 240 px (90 % of the width on a phone), at most one screen around the element, with a ring drawn around the
|
|
49
|
+
element. Every walk takes each detail in every locale and viewport, masked like the screenshot, as an attachment of
|
|
50
|
+
kind `detail` (canvas: Page tab → Step attachments → Details for guides, with the reference). Only screen steps take
|
|
51
|
+
details. A reference to a detail no accepted run has yet shows the whole screen; `kaloko docs check` warns under
|
|
52
|
+
`missing_details` (the scenario does not declare it) and `no_detail` (no accepted run has it: walk and accept).
|
|
53
|
+
Prefer a detail over a crop when the text names one button, field or message; keep the whole screen for orientation.
|
|
54
|
+
|
|
31
55
|
Never invent a step id or a version. Use ids from the scenario files and versions from `CHANGELOG.md` or git tags.
|
|
32
56
|
|
|
33
57
|
## After a user-visible change
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Quality evidence for your own documentation
|
|
2
2
|
|
|
3
3
|
Use this when a team's own repository turns Kaloko runs into its documents: test protocols, reports, trace
|
|
4
|
-
matrices, release summaries for
|
|
5
|
-
|
|
4
|
+
matrices, release summaries for its quality management system (ISO 9001, ISO 13485, IEC 62304, ISO 27001, or GxP
|
|
5
|
+
under 21 CFR Part 11 and EU GMP Annex 11, the strictest case). Kaloko provides the evidence and a way to check it;
|
|
6
|
+
the team's own templates and skills write the documents. Kaloko never formats anything for a particular QMS, and you do not put
|
|
6
7
|
the team's SOP structure into Kaloko.
|
|
7
8
|
|
|
8
9
|
The background for people (what a record proves, the two signing paths, known gaps) is in Kaloko's help, page
|
|
9
|
-
*
|
|
10
|
+
*Quality evidence for your QMS* (it maps the frameworks and has a GxP section). Evidence is a Business feature.
|
|
11
|
+
Kaloko provides evidence; it never makes a team compliant or certified, so never write that it does.
|
|
10
12
|
|
|
11
13
|
## The contract
|
|
12
14
|
|
|
@@ -24,11 +24,17 @@ with a person who knows where to approve it.
|
|
|
24
24
|
## From zero to a first shared run
|
|
25
25
|
|
|
26
26
|
1. `npm install --save-dev kaloko`, then
|
|
27
|
-
`npx kaloko init --agent claude --org <org
|
|
28
|
-
and these steps).
|
|
29
|
-
the
|
|
27
|
+
`npx kaloko init --agent claude --org <org> --project <slug> --project-name "<name>"`
|
|
28
|
+
(it prints the sample link and these steps). Ask the person how the project
|
|
29
|
+
should be called on Kaloko when you do not know; the slug is lowercase
|
|
30
|
+
(`eco-podcasty`), the name is what people see (`ECO Podcasty`). Fill
|
|
31
|
+
`environments` in `kaloko.config.yml` with the URL the app runs on. When
|
|
32
|
+
the repository holds several sites, see [Several sites in one
|
|
33
|
+
repository](#several-sites-in-one-repository) first.
|
|
30
34
|
2. `npx kaloko login`: the person approves a token in the browser. Ask them
|
|
31
|
-
to; never create or paste tokens yourself.
|
|
35
|
+
to; never create or paste tokens yourself. Then `npx kaloko projects sync`
|
|
36
|
+
puts the project of the config on Kaloko with its name (a first
|
|
37
|
+
`kaloko share` would create it too).
|
|
32
38
|
3. `npx kaloko doctor` and fix what it marks ✕.
|
|
33
39
|
4. Draft the first scenario:
|
|
34
40
|
- public pages: `npx kaloko draft <url> [url…]` writes a read-only
|
|
@@ -49,3 +55,30 @@ with a person who knows where to approve it.
|
|
|
49
55
|
Done means: the scenario validates, the run is shared, its URL and summary
|
|
50
56
|
line are in your reply (and in the issue or PR the work belongs to), and the
|
|
51
57
|
person knows whether the sample or this run is what they should open.
|
|
58
|
+
|
|
59
|
+
## Several sites in one repository
|
|
60
|
+
|
|
61
|
+
A monorepo (`apps/*` with their own dev server), a Rails app with engines or
|
|
62
|
+
several Folio sites, or routes split by host: `kaloko init` lists the sites
|
|
63
|
+
it found and drafts no steps, because routes of the whole repository mix the
|
|
64
|
+
sites (favicons, ad endpoints and API routes of another site end up as
|
|
65
|
+
"pages").
|
|
66
|
+
|
|
67
|
+
1. Ask the person which site the work is about before `init` or a walk. Do
|
|
68
|
+
not pick one yourself.
|
|
69
|
+
2. Run init for that site, best against the running site so the pages come
|
|
70
|
+
from its sitemap or its navigation:
|
|
71
|
+
- `npx kaloko init --site eco-podcasty --base-url http://eco-podcasty.localhost:3000`
|
|
72
|
+
- `npx kaloko init --site apps/web` (a workspace app: its own routes and dev
|
|
73
|
+
server; `--base-url` still reads the running site when it is up)
|
|
74
|
+
Only the host of `--base-url` is asked, with short time limits; offline the
|
|
75
|
+
code is read.
|
|
76
|
+
3. Read the comment at the top of `qa/flows/main-pages.yml`: it says where the
|
|
77
|
+
pages came from and which paths were skipped and why. Sharpen the steps;
|
|
78
|
+
add pages the person names.
|
|
79
|
+
4. When the site is not running and has no routes of its own (a Folio site),
|
|
80
|
+
`npx kaloko init --site <name> --skeleton` writes a scenario with a TODO;
|
|
81
|
+
fill it with the pages the person names.
|
|
82
|
+
|
|
83
|
+
One config can serve one site. For a second site of the same repository use
|
|
84
|
+
another config (`--config kaloko.<site>.config.yml`) with its own `project:`.
|
|
@@ -32,6 +32,7 @@ is null. Projects and products come complete, without pages.
|
|
|
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
|
+
| `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 |
|
|
35
36
|
| `get_criteria_verdicts` | reviewer verdicts on manual or overridden criteria of a run |
|
|
36
37
|
| `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 |
|
|
37
38
|
| `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 |
|
|
@@ -42,7 +43,7 @@ is null. Projects and products come complete, without pages.
|
|
|
42
43
|
| `list_products` | the products the caller reads: name, modules, the projects it maps that the caller sees, the newest release, published docs (versions, languages), who reads it (and the public link), whether the caller manages it, links to its page, changelog and docs |
|
|
43
44
|
| `get_changelog` | the visual changelog of a product (`product`, `version`, `resolve: true` for the pictures, `limit`): releases newest first with the entry as written and its groups; see [docs.md](docs.md) |
|
|
44
45
|
| `draft_changelog_entry` | what accepted runs since the last release (or `since`) show differently, as a Keep a Changelog draft with before/after step references (`product`, `since`) |
|
|
45
|
-
| `resolve_step_image` | one step reference (`ref` = `kaloko:<scenario>/<step
|
|
46
|
+
| `resolve_step_image` | one step reference (`ref` = `kaloko:<scenario>/<step>?…[#<detail>]`, `product`, `version`, `locale`, `viewport`, `detail`) as a signed image URL with its run, how the version was found and canvas links; `video` (mp4, webm, duration_ms) when the step's recording plays (`media=video` / `media=image` in the ref); with a detail id the detail's picture and `detail` { id, name, box, size }, or the whole screen with `detail_missing` |
|
|
46
47
|
| `get_docs` | a product's published docs (`product`; without `path` the versions, languages and sidebar; with `path` one page with its Markdown, sections, stale sections and, with `resolve: true`, its pictures) |
|
|
47
48
|
| `docs_check` | what needs attention in the published docs of a version: broken step references, references without an accepted picture, stale sections, missing or outdated translations, release items without a picture |
|
|
48
49
|
| `list_step_changes` | documentation layer: steps whose capture or status changed since the previous accepted run or `since` (`scenario`, `since`, `accepted`); see [documentation.md](documentation.md) |
|
package/src/cli.mjs
CHANGED
|
@@ -28,7 +28,8 @@ import { doctor, formatDoctor } from './lib/doctor.mjs'
|
|
|
28
28
|
import { parsePlan, coverage, formatCoverage } from './lib/coverage.mjs'
|
|
29
29
|
import { calibrate, formatCalibration, casesFromGolden, casesFromRun } from './lib/calibrate.mjs'
|
|
30
30
|
import { pageFacts, draftScenario, scenarioYaml } from './lib/draft.mjs'
|
|
31
|
-
import { detectProject, initScenario, formatDetection } from './lib/init-detect.mjs'
|
|
31
|
+
import { detectProject, initScenario, skeletonScenario, scenarioHeader, formatDetection } from './lib/init-detect.mjs'
|
|
32
|
+
import { checkSlug, checkName, setConfigKeys, createProject, renameProject, syncProject, shareProjectNote } from './lib/projects-cli.mjs'
|
|
32
33
|
import { recheckCaptures } from './lib/recheck.mjs'
|
|
33
34
|
import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft } from './lib/stack.mjs'
|
|
34
35
|
import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
|
|
@@ -102,6 +103,14 @@ Project
|
|
|
102
103
|
(qa/flows/main-pages.yml) when the project has none; asks "Keep a visual changelog?"
|
|
103
104
|
(--docs: changelog adds CHANGELOG.md and the config block, full also docs/ folders
|
|
104
105
|
with Diátaxis templates: tutorials, how-to, reference, explanation)
|
|
106
|
+
[--project <slug>] [--project-name "ECO Podcasty"] [--product <slug>]
|
|
107
|
+
Write project:, project_name: and product: into the config (also into an existing
|
|
108
|
+
one); signed in, the project is created or renamed on Kaloko to match
|
|
109
|
+
[--site <name|dir>] [--base-url <url>] [--skeleton]
|
|
110
|
+
A repository with several sites or apps: --site picks one (the others are
|
|
111
|
+
listed); without it no routes are guessed, and --skeleton writes a scenario with
|
|
112
|
+
a TODO instead. --base-url reads the pages from the running site (sitemap.xml,
|
|
113
|
+
then the links of its home page) instead of the code; only that host is asked
|
|
105
114
|
ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected]
|
|
106
115
|
[--out file] [--print] [--force]
|
|
107
116
|
Write a workflow that walks what each pull request affects on its preview
|
|
@@ -250,6 +259,14 @@ Results
|
|
|
250
259
|
comment --reply|--resolve|--reopen|--edit|--delete <id> [--note "…"] [--flow]
|
|
251
260
|
Comment, answer and close feedback from the CLI (resolve with "Fixed in <commit>")
|
|
252
261
|
projects [--json] Projects of the organization (use the slug as project: in a scenario or config)
|
|
262
|
+
projects create <slug> [--name "ECO Podcasty"] [--visibility open|restricted] [--product <slug>] [--json]
|
|
263
|
+
Create a project on Kaloko (Developer / Tester role; you become its admin);
|
|
264
|
+
an existing project is refused, not renamed. restricted needs Enterprise
|
|
265
|
+
projects rename <slug> --name "ECO Podcasty" [--json]
|
|
266
|
+
Change the name people see (the project's admin)
|
|
267
|
+
projects sync [--json] Make Kaloko match project:, project_name: and product: of the config: create
|
|
268
|
+
the project when it is missing, rename it when the name differs, add it to the
|
|
269
|
+
product. A share never renames a project; it says when the names differ
|
|
253
270
|
runs [--scenario id] [--env e] [--all] [--limit N] [--cursor c] [--json]
|
|
254
271
|
Shared runs: the newest per scenario (with "+ N older"); --all or --scenario lists every
|
|
255
272
|
run: --all to the last one page by page, otherwise the newest 300; --limit N lists at
|
|
@@ -1022,6 +1039,29 @@ async function affectedSelection (args, { config, root, scenario, scenarioFile }
|
|
|
1022
1039
|
return affectedFor({ root, config, scenario, scenarioFile, base: typeof args.base === 'string' ? args.base : null, files: list(args.files), client, upstream: !args['no-upstream'] })
|
|
1023
1040
|
}
|
|
1024
1041
|
|
|
1042
|
+
/** kaloko projects create|rename|sync: the service follows the command or the config (src/lib/projects-cli.mjs). */
|
|
1043
|
+
async function projectsWrite (sub, args) {
|
|
1044
|
+
const usage = 'Usage: kaloko projects [--json]\n kaloko projects create <slug> [--name "…"] [--visibility open|restricted] [--product <slug>] [--json]\n kaloko projects rename <slug> --name "…" [--json]\n kaloko projects sync [--json]'
|
|
1045
|
+
if (!['create', 'rename', 'sync'].includes(sub)) throw new UsageError(`kaloko projects ${sub}: there is no such sub-command.\n${usage}`)
|
|
1046
|
+
if (sub !== 'sync' && args._.length !== 2) throw new UsageError(`kaloko projects ${sub} needs one project slug.\n${usage}`)
|
|
1047
|
+
if (sub === 'sync' && args._.length > 1) throw new UsageError(`kaloko projects sync takes no slug: it reads project: from the config.\n${usage}`)
|
|
1048
|
+
// a project that came from a run label may keep a slug the rules of today refuse (Acme-Web): rename takes it as it is
|
|
1049
|
+
const slug = sub === 'sync' ? null : sub === 'rename' && /^[^\s/?#]{1,80}$/.test(String(args._[1])) ? String(args._[1]) : checkSlug(args._[1])
|
|
1050
|
+
if (sub === 'rename' && args.name === undefined) throw new UsageError(`kaloko projects rename needs --name "…": the name people see.\n${usage}`)
|
|
1051
|
+
const name = args.name !== undefined ? checkName(args.name) : null
|
|
1052
|
+
const { config } = await context(args)
|
|
1053
|
+
if (!config.org) throw new UserError(NO_ORG)
|
|
1054
|
+
const client = service(config)
|
|
1055
|
+
let r
|
|
1056
|
+
if (sub === 'create') r = await createProject({ client, org: config.org, slug, name: name || (slug === config.project && config.project_name ? checkName(String(config.project_name), 'project_name:') : slug), visibility: typeof args.visibility === 'string' ? args.visibility : args.visibility === true ? '' : null, product: typeof args.product === 'string' ? args.product : args.product === true ? '' : null })
|
|
1057
|
+
else if (sub === 'rename') r = await renameProject({ client, org: config.org, slug, name })
|
|
1058
|
+
else r = await syncProject({ client, config })
|
|
1059
|
+
const { line, ...data } = r
|
|
1060
|
+
if (args.json) { say(JSON.stringify(data, null, 2)); return }
|
|
1061
|
+
say(line)
|
|
1062
|
+
if (sub !== 'sync' && slug === config.project && name && config.project_name && name !== config.project_name) say(`Note: kaloko.config.yml still says project_name: ${config.project_name}; change it there too, or kaloko projects sync would rename the project back.`)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1025
1065
|
const commands = {
|
|
1026
1066
|
async init (args) {
|
|
1027
1067
|
const cwd = process.cwd()
|
|
@@ -1030,8 +1070,20 @@ const commands = {
|
|
|
1030
1070
|
try { await access(file); exists = true } catch {}
|
|
1031
1071
|
// a project from before the rename keeps its qawalk.config.yml (it is still read)
|
|
1032
1072
|
try { await access(join(cwd, LEGACY_CONFIG_FILE)); exists = true; say(`${LEGACY_CONFIG_FILE} found; kept (still read; rename it to ${CONFIG_FILE} when convenient).`) } catch {}
|
|
1073
|
+
// the project on Kaloko (project:, project_name:, product:): checked before anything is written
|
|
1074
|
+
const projectKeys = {
|
|
1075
|
+
project: args.project !== undefined ? checkSlug(args.project === true ? '' : String(args.project), '--project slug') : null,
|
|
1076
|
+
project_name: args['project-name'] !== undefined ? checkName(args['project-name'], '--project-name') : null,
|
|
1077
|
+
product: args.product !== undefined ? checkSlug(args.product === true ? '' : String(args.product), '--product slug') : null
|
|
1078
|
+
}
|
|
1079
|
+
const projectAsked = Object.values(projectKeys).some((v) => v != null)
|
|
1033
1080
|
// what the project is (#26): framework, dev server, locales, main routes; --no-detect keeps the plain template
|
|
1034
|
-
|
|
1081
|
+
// several sites or apps (--site picks one) and pages read from the running site (--base-url, or the dev URL)
|
|
1082
|
+
let baseUrl = null
|
|
1083
|
+
if (args['base-url'] !== undefined) {
|
|
1084
|
+
try { baseUrl = new URL(String(args['base-url'])); if (!/^https?:$/.test(baseUrl.protocol)) throw new Error() } catch { throw new UsageError('--base-url must be the http(s) address the site runs on, e.g. http://localhost:3000.') }
|
|
1085
|
+
}
|
|
1086
|
+
const detected = args['no-detect'] ? null : await detectProject(cwd, { site: args.site ?? null, baseUrl: baseUrl?.href || null, probe: true })
|
|
1035
1087
|
if (detected) say(formatDetection(detected))
|
|
1036
1088
|
if (!exists) {
|
|
1037
1089
|
let tpl = await readFile(join(packageRoot, 'templates', 'kaloko.config.yml'), 'utf8')
|
|
@@ -1040,13 +1092,29 @@ const commands = {
|
|
|
1040
1092
|
tpl = tpl.replace(/^org: .*$/m, `org: ${args.org}`)
|
|
1041
1093
|
}
|
|
1042
1094
|
if (detected) {
|
|
1043
|
-
const project = (detected.name || cwd.split('/').pop() || '').toLowerCase().replace(/^@[^/]+\//, '').replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
1044
|
-
if (project) tpl = tpl.replace(/^project: .*$/m, `project: ${project}`)
|
|
1095
|
+
const project = (detected.site?.name || detected.name || cwd.split('/').pop() || '').toLowerCase().replace(/^@[^/]+\//, '').replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
1096
|
+
if (project && !projectKeys.project) tpl = tpl.replace(/^project: .*$/m, `project: ${project}`)
|
|
1045
1097
|
tpl = tpl.replace(' local:\n base_url:\n default: http://localhost:3000', ` local:\n # dev server: ${detected.command || 'start your app'}${detected.framework ? ` (${detected.framework.name}, found by kaloko init)` : ''}\n base_url:\n default: ${detected.url}`)
|
|
1046
1098
|
}
|
|
1099
|
+
if (projectAsked) tpl = setConfigKeys(tpl, projectKeys).replace(/^# The name people see on Kaloko.*\n# project_name: .*\n/m, projectKeys.project_name ? '' : '$&')
|
|
1047
1100
|
await writeFile(file, tpl)
|
|
1048
1101
|
say(`Created ${CONFIG_FILE}${detected ? ` (local: ${detected.url})` : ''}. Edit org and the staging and production environments.`)
|
|
1102
|
+
} else if (projectAsked) {
|
|
1103
|
+
// an existing config keeps everything else, comments included
|
|
1104
|
+
const cfgFile = existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE)
|
|
1105
|
+
const before = await readFile(cfgFile, 'utf8')
|
|
1106
|
+
const after = setConfigKeys(before, projectKeys)
|
|
1107
|
+
if (after !== before) { await writeFile(cfgFile, after); say(`Set ${Object.entries(projectKeys).filter(([, v]) => v != null).map(([k, v]) => `${k}: ${v}`).join(', ')} in ${relative(cwd, cfgFile)}; the rest is kept.`) } else say(`${relative(cwd, cfgFile)} already says so; kept.`)
|
|
1049
1108
|
} else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
|
|
1109
|
+
// signed in: the project on Kaloko follows the config now (created or renamed, added to the product)
|
|
1110
|
+
if (projectAsked) {
|
|
1111
|
+
try {
|
|
1112
|
+
const { config } = await loadConfig(cwd)
|
|
1113
|
+
if (!config.org || config.org === 'my-org') say('The project goes to Kaloko once org: names your organization (kaloko init --org <slug>, or kaloko login): then kaloko projects sync.')
|
|
1114
|
+
else if (!process.env[config.share?.token_env || 'KALOKO_TOKEN']) say('Not signed in, so the project is only in the config: kaloko login, then kaloko projects sync (a first kaloko share also creates it with this name).')
|
|
1115
|
+
else say((await syncProject({ client: service(config), config })).line)
|
|
1116
|
+
} catch (e) { say(`! The project is in the config but not on Kaloko yet: ${String(e.message).split('\n')[0]} kaloko projects sync tries again.`) }
|
|
1117
|
+
}
|
|
1050
1118
|
// the visual changelog (docs/DOCS-CHANGELOG.md): asked once; --docs=off|changelog|full for scripts and agents
|
|
1051
1119
|
let docsMode = typeof args.docs === 'string' ? args.docs : null
|
|
1052
1120
|
if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new UsageError('--docs must be off, changelog or full.')
|
|
@@ -1115,16 +1183,21 @@ const commands = {
|
|
|
1115
1183
|
if (detected && !args['no-scenario']) {
|
|
1116
1184
|
try {
|
|
1117
1185
|
const { config, root } = await loadConfig(cwd)
|
|
1118
|
-
|
|
1119
|
-
|
|
1186
|
+
const skeleton = !!args.skeleton
|
|
1187
|
+
if ((await scenarioFiles(config, root)).length) {
|
|
1188
|
+
// there are scenarios already
|
|
1189
|
+
} else if ((detected.ambiguous || detected.needsUrl) && !skeleton) {
|
|
1190
|
+
say(`No scenario drafted: ${detected.ambiguous ? 'say which site with --site' : `add --base-url for ${detected.site.name}`}, or write the steps yourself (--skeleton gives a file with a TODO to start from).`)
|
|
1191
|
+
} else {
|
|
1192
|
+
const scenario = skeleton ? skeletonScenario(detected) : initScenario(detected, { project: detected.site?.name || null })
|
|
1120
1193
|
const problems = validateScenario(JSON.parse(JSON.stringify(scenario)))
|
|
1121
1194
|
if (problems.length) throw new Error(problems.join('; '))
|
|
1122
1195
|
first = join(cwd, 'qa', 'flows', 'main-pages.yml')
|
|
1123
1196
|
await mkdir(join(cwd, 'qa', 'flows', 'steps'), { recursive: true })
|
|
1124
|
-
await writeFile(first,
|
|
1197
|
+
await writeFile(first, `${scenarioHeader(detected, { skeleton })}${scenarioYaml(scenario)}`)
|
|
1125
1198
|
const open = join(cwd, 'qa', 'flows', 'steps', 'open.mjs')
|
|
1126
1199
|
if (!existsSync(open)) await writeFile(open, "// Opens the step's page (kaloko init).\nexport default async ({ page, url }) => { await page.goto(url(), { waitUntil: 'domcontentloaded' }) }\n")
|
|
1127
|
-
say(`Drafted ${relative(cwd, first)}: ${scenario.nodes.length} page(s) in ${scenario.locales.join(', ')}`)
|
|
1200
|
+
say(skeleton ? `Drafted ${relative(cwd, first)}: a skeleton with a TODO; add the pages that matter.` : `Drafted ${relative(cwd, first)}: ${scenario.nodes.length} page(s) in ${scenario.locales.join(', ')}${detected.skipped?.length ? `; ${detected.skipped.length} path(s) that are not pages left out (listed at the top of the file)` : ''}`)
|
|
1128
1201
|
}
|
|
1129
1202
|
} catch (e) { say(`No scenario drafted: ${e.message.split('\n')[0]}`) }
|
|
1130
1203
|
}
|
|
@@ -1928,6 +2001,8 @@ const commands = {
|
|
|
1928
2001
|
return
|
|
1929
2002
|
}
|
|
1930
2003
|
let manifest = await buildManifest({ run, scenario, config, root, catalog: [] })
|
|
2004
|
+
// a project the share creates gets the name of the config (project_name:), also for runs started before it was set
|
|
2005
|
+
if (manifest.project?.slug && manifest.project.slug === config.project && config.project_name) manifest.project.name = String(config.project_name).replace(/\s+/g, ' ').trim()
|
|
1931
2006
|
await writePreview({ run, manifest })
|
|
1932
2007
|
// the plan and storage before anything is sent; recordings travel on plans with the recordings feature only
|
|
1933
2008
|
const u = await client.request('GET', `/orgs/${config.org}/usage`).catch(() => null)
|
|
@@ -1945,6 +2020,8 @@ const commands = {
|
|
|
1945
2020
|
files.add(n.stack.items)
|
|
1946
2021
|
const idx = await readJson(join(run.dir, n.stack.items), { items: [] })
|
|
1947
2022
|
for (const it of idx.items) for (const byVp of Object.values(it.captures || {})) for (const c of Object.values(byVp)) [c.png, c.thumb, c.html, c.eval].filter(Boolean).forEach((p) => files.add(p))
|
|
2023
|
+
// a page's own attachments (the focus shots of the keyboard walk on the first pages)
|
|
2024
|
+
for (const it of idx.items) for (const a of it.attachments || []) files.add(a.file)
|
|
1948
2025
|
}
|
|
1949
2026
|
}
|
|
1950
2027
|
// personal data (#64): nothing goes up with an unmasked e-mail, phone number, birth number or IBAN in it
|
|
@@ -2003,6 +2080,9 @@ const commands = {
|
|
|
2003
2080
|
meta.shared = { url: fin.url, at: new Date().toISOString(), expires_days: created.expires_days }
|
|
2004
2081
|
await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
|
|
2005
2082
|
say(`Shared ${manifest.id} (${files.size} files, expires in ${created.expires_days} days)\n${fin.url}`)
|
|
2083
|
+
// the project as Kaloko has it: created now with project_name:, or named differently (a share never renames)
|
|
2084
|
+
const projectNote = shareProjectNote(created.project, config)
|
|
2085
|
+
if (projectNote) say(projectNote)
|
|
2006
2086
|
// what waits for a person, with the review-mode link to give them (approvals of unchanged steps carry over)
|
|
2007
2087
|
const review = shareReviewLine(fin.review)
|
|
2008
2088
|
if (review) say(review)
|
|
@@ -2015,7 +2095,7 @@ const commands = {
|
|
|
2015
2095
|
say(r.ok ? `PR comment ${r.action}: ${r.url}` : `PR comment skipped: ${r.reason}.`)
|
|
2016
2096
|
}
|
|
2017
2097
|
// --json: what a script needs next, the run id and its canvas URL (the lines above went to stderr)
|
|
2018
|
-
if (args.json) say(JSON.stringify({ run: manifest.id, url: fin.url, scenario: scenario.id, files: files.size, expires_days: created.expires_days, review: fin.review || null, ...(args.pr ? { pr_comment: prComment } : {}) }, null, 2))
|
|
2098
|
+
if (args.json) say(JSON.stringify({ run: manifest.id, url: fin.url, scenario: scenario.id, files: files.size, expires_days: created.expires_days, review: fin.review || null, ...(created.project ? { project: created.project } : {}), ...(args.pr ? { pr_comment: prComment } : {}) }, null, 2))
|
|
2019
2099
|
},
|
|
2020
2100
|
|
|
2021
2101
|
async sync (args) {
|
|
@@ -2359,11 +2439,15 @@ const commands = {
|
|
|
2359
2439
|
},
|
|
2360
2440
|
|
|
2361
2441
|
async projects (args) {
|
|
2442
|
+
const sub = args._[0]
|
|
2443
|
+
if (sub !== undefined && sub !== 'list') return projectsWrite(sub, args)
|
|
2362
2444
|
const { config } = await context(args)
|
|
2363
2445
|
const data = await service(config).request('GET', `/orgs/${config.org}/projects`)
|
|
2364
2446
|
if (args.json) { say(JSON.stringify(data, null, 2)); return }
|
|
2365
|
-
if (!data.length) { say('No projects yet.
|
|
2447
|
+
if (!data.length) { say('No projects yet. kaloko projects create <slug> --name "…" creates one (or Settings → Projects in the app).'); return }
|
|
2366
2448
|
for (const p of data) say(`${p.slug.padEnd(24)} ${p.name}${p.adhoc ? ' (from runs only)' : ''} ${p.scenarios} scenario(s), ${p.runs} run(s)${config.project === p.slug ? ' ← this config' : ''}`)
|
|
2449
|
+
const mine = data.find((p) => p.slug === config.project)
|
|
2450
|
+
if (mine && config.project_name && mine.name !== config.project_name) say(`Note: ${mine.slug} is named "${mine.name}" here and "${config.project_name}" in the config; kaloko projects sync renames it.`)
|
|
2367
2451
|
},
|
|
2368
2452
|
|
|
2369
2453
|
async compare (args) {
|
|
@@ -2571,11 +2655,18 @@ const commands = {
|
|
|
2571
2655
|
return
|
|
2572
2656
|
}
|
|
2573
2657
|
// check: the scenario files say which steps exist
|
|
2574
|
-
const scenarios = new Map()
|
|
2575
|
-
for (const f of await scenarioFiles(config, root).catch(() => [])) {
|
|
2658
|
+
const scenarios = new Map(); const details = new Map()
|
|
2659
|
+
for (const f of await scenarioFiles(config, root).catch(() => [])) {
|
|
2660
|
+
try {
|
|
2661
|
+
const s = yaml.load(await readFile(f, 'utf8')) || {}
|
|
2662
|
+
if (!s.id) continue
|
|
2663
|
+
scenarios.set(String(s.id), new Set((s.nodes || []).map((n) => String(n.id))))
|
|
2664
|
+
for (const n of s.nodes || []) details.set(`${s.id}/${n.id}`, new Set((n.details || []).map((d) => String(d?.id))))
|
|
2665
|
+
} catch {}
|
|
2666
|
+
}
|
|
2576
2667
|
let client = null
|
|
2577
2668
|
if (!args.offline) { try { client = service(config) } catch {} }
|
|
2578
|
-
const r = await docs.docsCheck({ root, config, product, client, scenarios: scenarios.size ? scenarios : null, version })
|
|
2669
|
+
const r = await docs.docsCheck({ root, config, product, client, scenarios: scenarios.size ? scenarios : null, details: scenarios.size ? details : null, version })
|
|
2579
2670
|
say(args.json ? JSON.stringify(r, null, 2) : docs.formatCheck(r))
|
|
2580
2671
|
if (!r.ok || (args.strict && r.warnings)) process.exitCode = 1
|
|
2581
2672
|
},
|
package/src/lib/build.mjs
CHANGED
|
@@ -37,9 +37,8 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
|
|
|
37
37
|
if (spec) await writeJson(join(run.dir, spec), cap.spec)
|
|
38
38
|
caps.push({ locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html || null, tree: cap.files.tree || null, eval: `steps/${c.node}/${c.locale}/${c.viewport}.eval.json`, spec }, ...(cap.live ? { live: cap.live } : {}) })
|
|
39
39
|
}
|
|
40
|
-
// focus
|
|
41
|
-
const
|
|
42
|
-
const out = assembleNode({ node, pos: pos.get(node.id), locales, viewportNames: node.kind === 'email' ? mailKeys : viewportNames, captures: caps, attachments: attachments.filter((a) => a.node === node.id && (a.kind !== 'focus' || shots.has(a.file))) })
|
|
40
|
+
// focus shots and details count only when a current capture names them (manifest-core.mjs stepAttachments)
|
|
41
|
+
const out = assembleNode({ node, pos: pos.get(node.id), locales, viewportNames: node.kind === 'email' ? mailKeys : viewportNames, captures: caps, attachments })
|
|
43
42
|
await attachMedia(out, { run, entries: media, captures: caps })
|
|
44
43
|
nodes.push(out)
|
|
45
44
|
}
|