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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Your AI agents build; Kaloko walks every screen, puts it on one canvas and records who approved what.",
5
5
  "keywords": [
6
6
  "qa",
@@ -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.",
@@ -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",
@@ -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 ![…](kaloko:<scenario>/<step>#<id>).",
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,
@@ -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 regulated documentation
30
- (requirement refs, kaloko evidence, the evidence package, the evidence.ready
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`). Write, translate and fix
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): [reference/first-run.md](reference/first-run.md)
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
- - Evidence for regulated work (GxP): requirement `refs:`, `kaloko evidence`,
173
- the evidence package and its check, the `evidence.ready` webhook, feeding a
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 and the canvas can draw the tab
101
- order on the capture. The walk only moves focus: nothing is clicked or
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
  ![Payment](kaloko:checkout/payment?version=prev) the release before the one this entry belongs to
15
15
  ![Payment](kaloko:checkout/payment?locale=cs&viewport=mobile&state=dark)
16
16
  ![Payment](kaloko:checkout/payment?media=video) the step's recording plays on the picture
17
+ ![Pay](kaloko:checkout/payment#pay-button) a named detail of the step (details: in the scenario)
17
18
  ![Payment](kaloko:payment) 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
- # Evidence for your own documentation (regulated work)
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 a quality system. Kaloko provides the evidence and a way to check it; the team's
5
- own templates and skills write the documents. Kaloko never formats anything for a particular QMS, and you do not put
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
- *Kaloko in regulated work (GxP)*. Evidence is a Business feature.
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>` (it prints the sample link
28
- and these steps). Fill `environments` in `kaloko.config.yml` with the URL
29
- the app runs on.
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>?…`, `product`, `version`, `locale`, `viewport`) 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) |
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
- const detected = args['no-detect'] ? null : await detectProject(cwd)
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
- if (!(await scenarioFiles(config, root)).length) {
1119
- const scenario = initScenario(detected)
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, `# First scenario drafted by kaloko init from ${detected.routes.length ? 'the route files' : 'the home page'}; sharpen purposes and criteria, add the flows that matter.\n${scenarioYaml(scenario)}`)
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. Create one in Settings → Projects or with POST /api/v1/orgs/<org>/projects.'); return }
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(() => [])) { try { const s = yaml.load(await readFile(f, 'utf8')) || {}; if (s.id) scenarios.set(String(s.id), new Set((s.nodes || []).map((n) => String(n.id)))) } 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 screenshots of the keyboard walk (#19): only those of the current captures, not of earlier walks
41
- const shots = new Set(caps.flatMap((c) => (c.cap.keyboard?.shots || []).map((s) => s.file)))
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
  }