kaloko 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Kaloko
2
2
 
3
- Acceptance QA walkthroughs rendered as a shareable flow canvas.
3
+ See what your agent built, on one canvas, and sign it off.
4
4
 
5
- Your coding agent (Claude Code, Codex or any LLM with a shell) runs QA from
6
- your acceptance plan with the `kaloko` skill. Kaloko captures every screen and
7
- e-mail of the flow, checks the criteria and builds a canvas that shows the
8
- whole flow (screens, branches, locales, evidence) for review, sign-off and
9
- living documentation.
5
+ Your coding agent (Claude Code, Codex or any LLM with a shell) walks your
6
+ acceptance plan with the `kaloko` skill. Kaloko captures every screen and
7
+ e-mail of the flow, checks the criteria and lays the whole flow out on a
8
+ canvas: screens, branches, locales and evidence. People review and approve it
9
+ there, and the newest captures double as documentation.
10
10
 
11
11
  - Product and guides: <https://kaloko.app>
12
12
  - Service (organizations, shared canvases, reviews): <https://kaloko.app>
@@ -32,9 +32,9 @@ after `npm update kaloko` to refresh the skill.
32
32
 
33
33
  ## Then ask your agent
34
34
 
35
- With the skill installed you do not type the commands below; the agent does.
36
- It writes the acceptance plan and the scenario from the task, walks the
37
- screens, evaluates, shares the canvas and later processes reviewers' feedback.
35
+ With the skill installed, the agent types the commands below, not you. It
36
+ writes the acceptance plan and the scenario from the task, walks the screens,
37
+ evaluates, shares the canvas and later works through what reviewers said.
38
38
  For example:
39
39
 
40
40
  > Read the task in docs/tasks/TASK-123, write its acceptance plan and a Kaloko
@@ -66,8 +66,8 @@ token, environment URL, evaluator key), `npx kaloko doctor` says what to fix.
66
66
 
67
67
  ## Design (Kaloko Design)
68
68
 
69
- Design the screens of a flow as live HTML with your own agent and skills, in the same scenario development and
70
- QA use. A design environment (`kind: design`, a folder of steps) is walked and synced into a shared draft the
69
+ Design the screens of a flow as live HTML with your own agent and skills, in the same scenario that development
70
+ and QA use. A design environment (`kind: design`, a folder of steps) is walked and synced into a shared draft the
71
71
  team sees live; `kaloko share` there publishes a version of the flow (unchanged steps are stored once). An
72
72
  accepted version becomes the design reference, and `packs: [design]` checks every implementation run against
73
73
  it (pixels, structure, tokens, contrast). See `skills/kaloko/reference/design.md`, `docs/GLOSSARY.md` and
@@ -75,11 +75,29 @@ it (pixels, structure, tokens, contrast). See `skills/kaloko/reference/design.md
75
75
 
76
76
  ```bash
77
77
  npx kaloko start --scenario qa/flows/checkout.yml --env design
78
+ npx kaloko design lint # portable HTML: works on the canvas, from disk and in other tools
78
79
  npx kaloko walk && npx kaloko sync # only changed steps render and travel
79
80
  npx kaloko share --name "Checkout v2" # a version of the whole flow
80
81
  npx kaloko reference --design --version 2 # after the team accepted it
81
82
  ```
82
83
 
84
+ On the canvas, *Prototype* (or `P`) presents the steps full screen as live pages: click through links between
85
+ steps, switch the device, walk with the arrow keys, and share a link that opens straight into a step. The rules
86
+ for pages that work everywhere are in `skills/kaloko/reference/portable-html.md`.
87
+
88
+ ## Your data is yours: formats
89
+
90
+ Nothing in Kaloko is stored in a format only Kaloko can read. Every export opens from disk without it:
91
+
92
+ | Export | Command | Formats |
93
+ |---|---|---|
94
+ | Design version | `npx kaloko export --design [--version n] --out v2.zip` | HTML, CSS, JS, fonts and images in the design folder layout; `tokens.json` (W3C DTCG); `scenario.yml` (`qawalk.scenario.v1`); screenshots (WebP); `index.html` start page |
95
+ | Live files (handoff) | `npx kaloko export --live [--version n] --out design/` | the design folder as written |
96
+ | Shared run | `npx kaloko export --run <id>` | zip with `run.json` (`qawalk.run.v1`), screenshots and HTML snapshots, live HTML of design steps, comments and verdicts, and `index.html`: the canvas, offline |
97
+ | Documentation captures | `npx kaloko export --scenario <id>` | newest image and HTML of every step, `index.json` |
98
+
99
+ The schemas of these formats ship with the package (`schemas/`).
100
+
83
101
  ## Configuration and secrets
84
102
 
85
103
  Project configuration is `kaloko.config.yml` in the repository root:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
5
5
  "keywords": [
6
6
  "qa",
@@ -487,6 +487,8 @@
487
487
  "link_matches",
488
488
  "http_status",
489
489
  "link_home",
490
+ "links_ok",
491
+ "hreflang_pairs",
490
492
  "app_touch_targets",
491
493
  "app_no_truncation",
492
494
  "app_no_crash",
@@ -508,10 +510,12 @@
508
510
  ]
509
511
  },
510
512
  "selector": {
511
- "type": "string"
513
+ "type": "string",
514
+ "description": "CSS selector; links_ok: the links to check (default a[href])."
512
515
  },
513
516
  "pattern": {
514
- "type": "string"
517
+ "type": "string",
518
+ "description": "Regex; links_ok: link paths to skip."
515
519
  },
516
520
  "attr": {
517
521
  "type": "string"
@@ -526,17 +530,23 @@
526
530
  },
527
531
  "min": {
528
532
  "type": "integer",
529
- "minimum": 0
533
+ "minimum": 0,
534
+ "description": "count: at least; http_status: lowest status; hreflang_pairs: languages required (default 2)."
530
535
  },
531
536
  "max": {
532
537
  "type": "integer",
533
- "minimum": 0
538
+ "minimum": 0,
539
+ "description": "count: at most; http_status: highest status; perf_*: the limit; links_ok: links checked per page (default 100)."
534
540
  },
535
541
  "ratio": {
536
542
  "type": "number",
537
543
  "minimum": 0,
538
544
  "description": "design_pixels: maximum share of differing pixels (default 0.03)."
539
545
  },
546
+ "follow": {
547
+ "type": "boolean",
548
+ "description": "links_ok: follow links even when neither the environment nor the scenario is readonly (GET from an isolated copy of the session; links that look like actions are never followed)."
549
+ },
540
550
  "flags": {
541
551
  "type": "string",
542
552
  "pattern": "^[imsu]{0,4}$",
@@ -31,9 +31,10 @@ the canvas, storage and collaboration are the service.
31
31
  the work belongs to, and you read `kaloko feedback` and `kaloko inbox`
32
32
  before continuing on that task.
33
33
  - Changes to intent are written back to `ACCEPTANCE.md` (change log row).
34
- - On a design environment: the folder follows the bundle rules, `kaloko walk`
35
- and `kaloko sync` ran, `kaloko status` shows no conflict, and you read the
36
- draft's feedback; a version is published only when the person asked.
34
+ - On a design environment: the folder follows the bundle rules and
35
+ `kaloko design lint` shows no error, `kaloko walk` and `kaloko sync` ran,
36
+ `kaloko status` shows no conflict, and you read the draft's feedback; a
37
+ version is published only when the person asked.
37
38
 
38
39
  ## You may do without asking
39
40
 
@@ -84,7 +85,12 @@ they can do; never write secrets into the config, a scenario or a script.
84
85
  [reference/documentation.md](reference/documentation.md)
85
86
  - Designing in a design environment (bundle, sync and publish, comments,
86
87
  references, handoff, tokens, the design pack, canvas edits, branches, HTML
87
- view, bringing your own design skill): [reference/design.md](reference/design.md)
88
+ view, presenting the prototype, exports, bringing your own design skill):
89
+ [reference/design.md](reference/design.md)
90
+ - Writing design pages that work on the live step, from disk and in other
91
+ tools (relative URLs, no CDNs, state in the URL, deterministic content,
92
+ links between steps; what `kaloko design lint` checks):
93
+ [reference/portable-html.md](reference/portable-html.md)
88
94
  - Command reference: `kaloko help`; when something does not work (Chrome,
89
95
  token, environment URL, evaluator key), run `kaloko doctor` first and fix
90
96
  what it marks ✕.
@@ -187,6 +187,27 @@ button styled uppercase reads `VYTVOŘIT`. Add `flags: i` to ignore case
187
187
  white-label check for `vyvolej` also matches the Czech verb "Vyvolej si";
188
188
  anchor it (`vyvolej\.to`).
189
189
 
190
+ Two checks need the network and run next to the capture, once per page:
191
+
192
+ - `links_ok`: every same-origin link (`selector`, default `a[href]`) answers
193
+ below 400 after redirects. `max` caps the links per page (default 100),
194
+ `pattern` skips matching paths. Redirects are listed in the evidence.
195
+ Links are followed only on a readonly environment or scenario; elsewhere
196
+ the criterion is not evaluated unless the check sets `follow: true`. Links
197
+ that look like actions (log out, unsubscribe, delete, remove, tokens,
198
+ switching organization) are never requested.
199
+ - `hreflang_pairs`: at least `min` languages (default 2); the page's own
200
+ language points to the page itself; every other version answers 200
201
+ without a redirect and links back. This is what search engines require
202
+ of hreflang; a count of `link[hreflang]` does not prove it.
203
+
204
+ Both are GETs from a separate request context that starts with a copy of
205
+ the walk's cookies and the environment's gate and never writes back (a
206
+ `?lang=` link cannot switch the language of the next step). They are cached
207
+ per environment, locale and sign-in state, so a stack of hundreds of pages
208
+ fetches its shared navigation once. A recheck
209
+ from stored HTML keeps their last result; capture again to refresh them.
210
+
190
211
  Describe every relevant variant as nodes and `alt` edges, even when a given
191
212
  run covers only part of it. Decisions are `kind: decision` nodes.
192
213
 
@@ -1,7 +1,7 @@
1
1
  # Collaboration: share, feedback, inbox
2
2
 
3
- A run becomes useful when the people who decide can see it. The loop that
4
- works for teams using Kaloko:
3
+ A run is only worth something once the people who decide have seen it. The
4
+ loop teams use:
5
5
 
6
6
  1. **Share the run** (`kaloko share`, tester token, after asking). The
7
7
  command prints the canvas URL.
@@ -37,9 +37,14 @@ design/
37
37
  images, fonts, module imports, `fetch('./fixtures/x.json')`). Links to other steps are not files it needs.
38
38
  - Live steps run sandboxed on their own origin with `connect-src 'self'`: no real APIs. Use JSON fixtures in
39
39
  the bundle.
40
- - Prototype links: `<a data-qawalk-go="payment">` moves the canvas to that step, whatever the `href` says.
40
+ - Links between steps are plain relative links to the page: `<a href="../payment/index.html">`. The canvas
41
+ follows them; `data-kaloko-go="payment"` (or `data-qawalk-go`) moves to that step whatever the `href` says.
41
42
  - Reference token values as CSS custom properties (`var(--color-accent)`), so the inspector and the token
42
43
  check see where values come from.
44
+ - Write pages by the portable HTML rules ([portable-html.md](portable-html.md)): relative URLs only, bundled
45
+ fonts, no CDNs, state in the URL instead of storage, deterministic content, viewport meta and `lang`.
46
+ `kaloko design lint` checks the folder; `kaloko sync` runs it first and stops on errors (things that break on
47
+ the live step), warnings are printed.
43
48
 
44
49
  ## Sync and publish
45
50
 
@@ -62,6 +67,19 @@ design/
62
67
  4. Change what the comments ask, sync again, then answer in the comment thread (MCP `add_draft_comment`).
63
68
  5. When the person is happy, `kaloko share --name "…"` publishes a version.
64
69
 
70
+ ## Present the prototype
71
+
72
+ The canvas plays design steps full screen: *Prototype* in the top bar or the step detail, or `P`. Each step runs as
73
+ its live page in a device frame (phone for mobile viewports, a window for desktop; switchable), clicks on links and
74
+ `data-kaloko-go` move between steps, `←` `→` walk the flow, `Esc` leaves. The address (`#present=<step>&vp=mobile`)
75
+ opens straight into the presentation of that step: share it with the person who reviews.
76
+
77
+ - Live pages need the user-content origin of the service; where it is not available (a self-hosted instance
78
+ without it, a step never rendered live) the presentation shows the screenshots.
79
+ - From the local preview and an offline export, the steps open from the run's merged folder (`live/site/`), so plain
80
+ relative links click through there too.
81
+ - A link's query and hash travel to the next step (`../payment/index.html?plan=pro#card`): keep prototype state there.
82
+
65
83
  ## References
66
84
 
67
85
  - The design reference is an accepted version: `kaloko reference --design --version 4` (a person accepts the
@@ -78,6 +96,18 @@ Development builds from the design reference:
78
96
  page structure and open comments; `list_step_changes` names the steps that changed since the last reference.
79
97
  - Translate to the project's stack with the project's own skills; keep the step ids and the token names.
80
98
 
99
+ ## Your data is yours
100
+
101
+ Everything exports in open formats, readable without Kaloko:
102
+
103
+ | What | Command | Contents |
104
+ |---|---|---|
105
+ | a design version (or `--draft`, default the design reference) | `kaloko export --design [--version n] --out dir/` or `--out v4.zip` | the live files in the folder layout, `tokens.json` (DTCG), `scenario.yml` (`qawalk.scenario.v1`), `screenshots/<step>/<locale>/<viewport>.webp`, `kaloko.json` (what is where) and `index.html`, a start page that opens from disk |
106
+ | the live files only (the handoff) | `kaloko export --live [--version n] --out design/` | the design folder as the designer wrote it |
107
+ | a shared run | `kaloko export --run <id>` | `run.json`, every capture, the live HTML of design steps (per revision and merged in `live/site/`), comments and verdicts, and `index.html`: the canvas, offline from `file://` |
108
+
109
+ When someone asks for the design or the evidence outside Kaloko, export; do not screenshot the canvas.
110
+
81
111
  ## Tokens
82
112
 
83
113
  - The set is W3C DTCG. Both value styles are read: strings (`"#2E6BFF"`, `"16px"`) and the current format's
@@ -22,9 +22,8 @@ evaluating the same capture again costs nothing. `evaluators.jev.hosted: false`
22
22
  When neither is available, or the allowance is used up, semantic criteria stay `not_evaluated`; say so in the
23
23
  report with the reason `kaloko evaluate` gives.
24
24
 
25
- Results are cached by content hash, so re-running is cheap. The default
26
- evaluator is text-only and most accurate in English; it is weak at numbers,
27
- which is why those are deterministic.
25
+ The default evaluator is text-only and most accurate in English. It is weak
26
+ at numbers, which is why those are checked deterministically.
28
27
 
29
28
  When a criterion comes back `needs_review`, read the reduced state the
30
29
  evaluator saw (`steps/<node>/<locale>/<viewport>.capture.json`, key `state`)
@@ -0,0 +1,92 @@
1
+ # Portable interactive HTML
2
+
3
+ Design steps are live pages. The same files have to work in three places:
4
+
5
+ - **on the live origin** of the canvas (`<revision>.kaloko-usercontent.com`): a sandboxed frame with an opaque origin and
6
+ `Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-src 'none';
7
+ worker-src 'none'; form-action 'none'` (no popups, no modals, no service workers);
8
+ - **from disk** (`file://`): the local preview, `kaloko export --design`, and anyone who unzips an export;
9
+ - **in another tool**: the export is the handoff and the way out of Kaloko.
10
+
11
+ `kaloko design lint` checks a design folder against the rules below; `kaloko sync` runs it first and stops on errors
12
+ (`--no-lint` skips it, `kaloko walk` prints a summary). Errors break for certain on the live step; warnings make the
13
+ prototype fragile or the captures unstable. `kaloko design lint --all` also lists information, `--strict` fails on
14
+ warnings, `--json` gives every finding with file, line, rule and fix.
15
+
16
+ ## Files
17
+
18
+ - Everything a page needs is in the design folder, referenced with **relative paths** (`../_shared/app.css`). No
19
+ `https://`, no protocol-relative `//cdn…`, no paths starting with `/` (on the live origin `/` is the origin root, from
20
+ disk it is the disk root). Errors: `external-url`, `root-relative`, `outside-folder`, `missing-file`.
21
+ - **No CDNs.** Download libraries into `_shared/` and link them relatively. Fonts are bundled as **woff2**, subset to
22
+ the characters used, with `@font-face` in the shared CSS; Google Fonts links do not load.
23
+ - Only bundle file types (html, css, js, mjs, json, svg, woff2, woff, png, jpg, webp, avif, gif, mp4, webm, txt).
24
+ Convert TTF/OTF fonts and other formats (`file-type`).
25
+ - **Size budget:** a file over 1 MB or a step over 5 MB (with the shared files) warns (`large-file`); 10 MB per file
26
+ and 40 MB per step are the limits (`too-large`). Images as webp/avif at the size shown.
27
+
28
+ ## Links between steps
29
+
30
+ - Link steps with **plain relative links to the page itself**: `<a href="../payment/index.html">`, from a locale page
31
+ `../../payment/cs/index.html`. A link to a folder (`../payment/`) opens nothing from disk or a zip
32
+ (`directory-link`); a link to a page that does not exist warns (`broken-link`).
33
+ - The canvas follows such links in the presentation and the Live view, and `data-kaloko-go="payment"` (or the older
34
+ `data-qawalk-go`) moves to a step whatever the `href` says. Keep a real `href` next to it so the page also works
35
+ from disk.
36
+ - No `target="_blank"`, `window.open`, `top.location` (`new-window`, `top-navigation`): the frame may not open
37
+ windows or navigate the canvas.
38
+ - Forms do not submit (`form-action 'none'`): handle `submit` in a script (`event.preventDefault()`) and go on with a
39
+ link or `data-kaloko-go` (`form-action`).
40
+
41
+ ## State and data
42
+
43
+ - **State lives in the URL**, not in storage: `localStorage`, `sessionStorage`, IndexedDB and cookies throw in the
44
+ sandboxed frame and are empty for every other viewer (`storage`). Use `location.hash` or the query
45
+ (`?plan=pro#card`): a link's query and hash travel to the next step in the presentation, and work from disk.
46
+ Wrap any storage access you keep in `try/catch` with a fallback.
47
+ - **No network:** real APIs, `XMLHttpRequest`, `WebSocket`, `EventSource` do not connect (`websocket`). Put data in
48
+ JSON fixtures of the bundle and `fetch('./fixtures/cart.json')`.
49
+ - **No code from strings:** `eval`, `new Function`, `setTimeout('…')` are blocked (`eval`). No service workers
50
+ (`service-worker`), no iframes, `object` or `embed` (`embedded-frame`). `alert`, `confirm` and `prompt` are blocked
51
+ (`modal`): use an in-page `<dialog>`.
52
+
53
+ ## Deterministic content
54
+
55
+ Captures are cached by content and compared in pixels. A page that changes on every render makes unchanged steps look
56
+ changed (`nondeterministic`):
57
+
58
+ - fixed dates (`new Date('2026-01-15T10:00')`), no `Date.now()` or `new Date()` for visible text;
59
+ - fake data from a seeded generator, not `Math.random()` or `crypto.randomUUID()`;
60
+ - animations that end, or none at the moment of capture; content that does not depend on load timing.
61
+
62
+ ## Document basics
63
+
64
+ Every page starts like this (`doctype`, `charset`, `viewport-meta`, `lang`, `title`):
65
+
66
+ ```html
67
+ <!doctype html>
68
+ <html lang="en">
69
+ <head>
70
+ <meta charset="utf-8">
71
+ <meta name="viewport" content="width=device-width, initial-scale=1">
72
+ <title>Payment · Checkout</title>
73
+ <link rel="stylesheet" href="../_shared/app.css">
74
+ </head>
75
+ ```
76
+
77
+ - Images have `alt` (`alt=""` for decoration) (`img-alt`); real buttons, labels for fields, one `<h1>`, landmarks.
78
+ - Respect `prefers-reduced-motion` when anything animates (`reduced-motion`):
79
+ `@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important } }`
80
+ - The mobile viewports render at their real width: the viewport meta and fluid layout matter.
81
+
82
+ ## Tokens
83
+
84
+ - `tokens.json` (W3C DTCG) in the folder root; values as **CSS custom properties** in the shared CSS
85
+ (`--color-accent: #2e6bff`) and used with `var(--color-accent)`, so the inspector names them and `kaloko tokens check`
86
+ sees raw values (`tokens-unused`, `no-tokens`).
87
+ - Name custom properties after the token path with dots as dashes (`color.accent` → `--color-accent`).
88
+
89
+ ## Done means
90
+
91
+ `kaloko design lint` shows no error and no warning you cannot explain, the pages open from the folder by
92
+ double-click and click through, and `kaloko walk` renders the same in every viewport.
package/src/cli.mjs CHANGED
@@ -12,7 +12,8 @@ import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral
12
12
  import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
13
13
  import { observeConsole } from './lib/console.mjs'
14
14
  import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.mjs'
15
- import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher } from './lib/access.mjs'
15
+ import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher, gateForRequests } from './lib/access.mjs'
16
+ import { configureGate } from './lib/linkcheck.mjs'
16
17
  import { rememberEnvironment } from './lib/secrets.mjs'
17
18
  import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
18
19
  import { buildManifest, writePreview } from './lib/build.mjs'
@@ -32,6 +33,8 @@ import { htmlViewAllowed } from './lib/htmlview.mjs'
32
33
  import { service } from './lib/service.mjs'
33
34
  import { walkDesign, syncDesign, publishVersion, historyCmd, referenceCmd, tokensCmd, exportLive, localRevisions, branchCmd, uploadBlobs } from './lib/design-cli.mjs'
34
35
  import { designDir, readBase, headKey } from './lib/design.mjs'
36
+ import { lintDesign, formatLint } from './lib/design-lint.mjs'
37
+ import { exportDesign } from './lib/design-export.mjs'
35
38
  import { driverFor, isApp, pngSize, ocr as ocrImage } from './lib/apps/drivers.mjs'
36
39
  import { captureApp } from './lib/apps/capture.mjs'
37
40
  import { readTree, flatten } from './lib/apps/tree.mjs'
@@ -121,7 +124,8 @@ Results
121
124
  Semantic evaluator vs human labels: precision/recall per criterion at the
122
125
  configured thresholds and suggested thresholds (labels from a golden file or
123
126
  from reviewers' criterion verdicts in the service)
124
- export [--run id] [--out file.zip] Download a shared run (run.json + files) as a zip before it expires
127
+ export [--run id] [--out file.zip] Download a shared run as a zip before it expires: run.json, every file, the live
128
+ HTML of design steps and index.html, the canvas that opens offline from disk
125
129
  export --scenario <id> [--accepted] [--out dir] [--locales l] [--viewports v]
126
130
  Newest capture of every step as steps/<step>/<locale>/<viewport>.<ext> plus
127
131
  index.json (documentation layer, Business plan); unchanged files are skipped
@@ -134,8 +138,12 @@ Design (environments with kind: design; live HTML steps in a folder)
134
138
  start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
135
139
  walk [--steps a,b] [--no-cache] Render the steps; unchanged revisions reuse their captures
136
140
  status Per step: synced, changed here, changed in the draft, conflict; step status
137
- sync [--pull|--push] [--branch b] [--mine a,b|--theirs a,b]
141
+ sync [--pull|--push] [--branch b] [--mine a,b|--theirs a,b] [--no-lint]
138
142
  Exchange revisions with the shared draft; only changed steps and missing files travel
143
+ Lints the folder first: errors (things that break on the live step) stop the push
144
+ design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--json]
145
+ Check the folder against the portable HTML rules (relative URLs, no CDNs, no storage,
146
+ deterministic content, viewport, lang, links that also work from disk); --strict fails on warnings
139
147
  share [--name <text>] [--branch b] Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged"
140
148
  history [--scenario id] [--step id] [--locale l] [--json]
141
149
  Versions of the flow; revisions of a step with who (person or agent) and when
@@ -149,6 +157,9 @@ Design (environments with kind: design; live HTML steps in a folder)
149
157
  Live files of a version (default: the design reference) into a folder: the handoff
150
158
  export --live --views --run <id> [--viewport desktop] [--out design/]
151
159
  Fork into a draft: the HTML views of an app run become design steps (experimental)
160
+ export --design [--version n|--draft|--run id] [--out dir|file.zip]
161
+ A design version in open formats: HTML + assets, tokens.json (DTCG), scenario.yml,
162
+ screenshots and an index.html that opens from disk (default: the design reference)
152
163
 
153
164
  Common options: --run <id|dir> (or KALOKO_RUN) --config <file> KALOKO_AGENT=<name> labels writes by an agent
154
165
  `
@@ -654,6 +665,7 @@ const commands = {
654
665
  } finally { await enc?.close().catch(() => {}) }
655
666
  return
656
667
  }
668
+ configureGate(await gateForRequests(env, { root }))
657
669
  let conn = await connectBrowser({ root, config, env })
658
670
  let ephemeral = null
659
671
  let page
@@ -709,12 +721,15 @@ const commands = {
709
721
  const { config, root, run, scenario, env } = await runContext(args)
710
722
  if (env.kind === 'design') {
711
723
  await walkDesign({ config, root, run, scenario, env, pick: list(args.steps), locales: list(args.locales) || run.meta.scope.locales, viewports: scopeViewports(run, list(args.viewports)), say, noCache: !!args['no-cache'] })
724
+ const lint = await lintDesign({ dir: designDir(env, root), scenario })
725
+ if (lint.errors || lint.warnings) say(`\nPortable HTML: ${lint.errors} error(s), ${lint.warnings} warning(s); kaloko design lint lists them${lint.errors ? ' (errors stop kaloko sync: they break on the live step)' : ''}.`)
712
726
  return
713
727
  }
714
728
  if (isApp(scenario.platform)) return walkApp({ config, root, run, scenario, env, args })
715
729
  const pick = list(args.steps)
716
730
  const locales = list(args.locales) || run.meta.scope.locales
717
731
  // --ephemeral: an own browser (own cookies and tabs) even when the shared one runs, so runs can walk in parallel
732
+ configureGate(await gateForRequests(env, { root }))
718
733
  const conn = args.ephemeral ? null : await connectBrowser({ root, config, env })
719
734
  const releaseLock = conn ? await acquireWalkLock({ root, config, env, run: run.meta.id }).catch(async (e) => { await conn.browser.close(); throw e }) : null
720
735
  const ephemeral = conn ? null : await launchEphemeral(config)
@@ -1026,9 +1041,33 @@ const commands = {
1026
1041
  async sync (args) {
1027
1042
  const { config, root, run, scenario, env } = await runContext(args)
1028
1043
  const client = service(config)
1044
+ if (env.kind === 'design' && !args.pull && !args['no-lint']) {
1045
+ // what renders locally may still break on the live step (CSP, sandbox): say so before anything is pushed
1046
+ const lint = await lintDesign({ dir: designDir(env, root), scenario })
1047
+ if (lint.errors || lint.warnings) say(formatLint(lint, { max: 15 }) + '\n')
1048
+ if (lint.errors) throw new UserError(`${lint.errors} error(s) above break the design on the live step; fix them, or sync with --no-lint to push anyway.`)
1049
+ }
1029
1050
  await syncDesign({ config, root, run, scenario, env, client, args, say, push: !args.pull, pull: !args.push, render: (steps) => walkDesign({ config, root, run, scenario, env, pick: steps, locales: run.meta.scope.locales, viewports: run.meta.scope.viewports, say }).then(() => evaluateRun({ run, scenario, config, root })) })
1030
1051
  },
1031
1052
 
1053
+ async design (args) {
1054
+ if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--json]')
1055
+ const { config, root } = await context(args)
1056
+ let run = null
1057
+ try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
1058
+ let dir = typeof args.dir === 'string' ? resolve(args.dir) : null
1059
+ if (!dir) {
1060
+ const runEnv = run ? environment(config, run.meta.environment.name) : null
1061
+ const env = typeof args.env === 'string' ? environment(config, args.env) : runEnv?.kind === 'design' ? runEnv : Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design')
1062
+ if (!env) throw new UserError('No design environment: pass --dir <folder> or --env <design env>.')
1063
+ dir = designDir(env, root)
1064
+ }
1065
+ const scenario = typeof args.scenario === 'string' && /\.ya?ml$/.test(args.scenario) ? await loadScenario(resolve(args.scenario)) : run ? await loadScenario(resolve(root, run.meta.scenario_file)).catch(() => null) : null
1066
+ const report = await lintDesign({ dir, scenario })
1067
+ say(args.json ? JSON.stringify(report, null, 2) : formatLint(report, { all: !!args.all, max: args.all ? 500 : 60 }))
1068
+ if (report.errors || (args.strict && report.warnings)) process.exitCode = 1
1069
+ },
1070
+
1032
1071
  async history (args) {
1033
1072
  const { config, root } = await context(args)
1034
1073
  const scenarioId = await scenarioIdOf(args, config, root)
@@ -1234,7 +1273,7 @@ const commands = {
1234
1273
  const d = compareRuns(run, base)
1235
1274
  if (args.json) { say(JSON.stringify(d, null, 2)); return }
1236
1275
  say(formatComparison(d))
1237
- if (config.share?.url && run.org) say(`\nCanvas: ${config.share.url.replace(/\/$/, '')}/${run.org}/runs/${run.id}/#cmp=${base.id}`)
1276
+ if (config.share?.url && run.org) { const { serviceUrl } = await import('./lib/service.mjs'); say(`\nCanvas: ${serviceUrl(config)}/${run.org}/runs/${run.id}/#cmp=${base.id}`) }
1238
1277
  },
1239
1278
 
1240
1279
  async draft (args) {
@@ -1290,6 +1329,15 @@ const commands = {
1290
1329
  async export (args) {
1291
1330
  const { config, root } = await context(args)
1292
1331
  const client = service(config)
1332
+ if (args.design) {
1333
+ // open formats only: the version as a folder (or zip) that opens from disk without Kaloko
1334
+ const scenarioId = await scenarioIdOf(args, config, root)
1335
+ const v = typeof args.version === 'string' ? args.version : typeof args.design === 'string' ? args.design : null
1336
+ const ref = args.draft ? 'draft' : v ? `v${v.replace(/^v/, '')}` : typeof args.run === 'string' ? args.run : 'design'
1337
+ const out = typeof args.out === 'string' ? resolve(args.out) : resolve(root, config.output, 'design-export', `${scenarioId}-${ref}`)
1338
+ await exportDesign({ config, scenarioId, client, ref, branch: typeof args.branch === 'string' ? args.branch : 'main', out, say })
1339
+ return
1340
+ }
1293
1341
  if (args.live) {
1294
1342
  const scenarioId = await scenarioIdOf(args, config, root)
1295
1343
  let out = typeof args.out === 'string' ? resolve(args.out) : null
@@ -119,6 +119,13 @@ export async function contextOptions (env, { root, config, account } = {}) {
119
119
  return options
120
120
  }
121
121
 
122
+ /** The gate for requests made outside the browser (link checks): headers for the gate's origins only, Basic, mTLS. */
123
+ export async function gateForRequests (env, { root } = {}) {
124
+ const headers = gateHeaders(env)
125
+ const options = await contextOptions(env, { root })
126
+ return { headers, toGate: Object.keys(headers).length ? originMatcher(gateOrigins(env)) : () => false, httpCredentials: options.httpCredentials || null, clientCertificates: options.clientCertificates || [] }
127
+ }
128
+
122
129
  /** A new browser context with the environment's gate, policy and (optionally) an account's session. */
123
130
  export async function newContext (browser, env, opts = {}) {
124
131
  const context = await browser.newContext(await contextOptions(env, opts))
package/src/lib/build.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parsePlan, coverage, flaky } from './coverage.mjs'
2
2
  import { readFile, writeFile } from 'node:fs/promises'
3
+ import { existsSync } from 'node:fs'
3
4
  import { join, resolve } from 'node:path'
4
5
  import { readJson, writeJson } from './util.mjs'
5
6
  import { readCaptures, runPaths, listRuns } from './run.mjs'
@@ -140,7 +141,9 @@ export async function writePreview ({ run, manifest }) {
140
141
  for (const [l, vps] of Object.entries(n.captures)) { captures[l] = {}; for (const [vp, c] of Object.entries(vps)) captures[l][vp] = c?.tree ? { ...c, tree_inline: await readJson(join(run.dir, c.tree), null) } : c }
141
142
  return { ...n, captures }
142
143
  }
143
- const preview = { ...manifest, nodes: await Promise.all(manifest.nodes.map(async (n) => (n.stack ? { ...n, stack: { ...n.stack, items_inline: (await readJson(join(run.dir, n.stack.items), { items: [] })).items } } : withTrees(n)))) }
144
+ // design runs: live steps open from the merged tree, where links between steps resolve (design.mjs copyLive)
145
+ const site = existsSync(join(run.dir, 'live', 'site')) ? { live_site: 'live/site/' } : {}
146
+ const preview = { ...manifest, ...site, nodes: await Promise.all(manifest.nodes.map(async (n) => (n.stack ? { ...n, stack: { ...n.stack, items_inline: (await readJson(join(run.dir, n.stack.items), { items: [] })).items } } : withTrees(n)))) }
144
147
  const json = JSON.stringify(preview).replace(/</g, '\\u003c')
145
148
  const html = template.replace('/*__QAWALK_RUN__*/null', () => json)
146
149
  await writeJson(runPaths(run.dir).manifest, manifest)
@@ -55,6 +55,7 @@ import { criteriaHash } from './evaluate.mjs'
55
55
  import { consoleEntriesFor } from './console.mjs'
56
56
  import { redact, redactDeep } from './secrets.mjs'
57
57
  import { readFields } from './stack.mjs'
58
+ import { checkLinks, checkHreflang, requester, walkCache, NETWORK_ASSERTS, isolatedRequest, cacheScope } from './linkcheck.mjs'
58
59
 
59
60
  /** Probe arguments derived from the node's criteria. */
60
61
  export function probeArgs (node, maxChars, design = false) {
@@ -101,6 +102,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
101
102
  const paths = runPaths(run.dir)
102
103
  const original = page.viewportSize()
103
104
  const results = []
105
+ let netResults = null
104
106
  for (const [viewport, [width, height]] of Object.entries(viewports)) {
105
107
  const now = page.viewportSize()
106
108
  if (!now || now.width !== width || now.height !== height) await page.setViewportSize({ width, height })
@@ -153,6 +155,12 @@ export async function captureStep ({ page, node, locale, viewports, run, console
153
155
  const ownStatus = (e) => result.http_status >= 400 && /^Failed to load resource: the server responded with a status of \d+/.test(e.text) && e.src && sameDocument(e.src, url)
154
156
  const errors = consoleEntries.filter((e) => e.type !== 'blocked' && sameDocument(e.url, url) && !e.text.includes('ERR_BLOCKED_BY_CLIENT') && !ownStatus(e)).map((e) => e.text)
155
157
  const blocked = consoleEntries.filter((e) => e.type === 'blocked').map((e) => `${e.text}`)
158
+ // network checks (internal links, hreflang pairs) run once per page, outside the page, with a walk-wide cache
159
+ const net = (node.criteria || []).filter((c) => c.check.type === 'deterministic' && NETWORK_ASSERTS.has(c.check.assert))
160
+ if (net.length) {
161
+ netResults ||= await networkChecks(page, net, url, { run, locale })
162
+ Object.assign(result.checks, netResults)
163
+ }
156
164
  for (const c of node.criteria || []) {
157
165
  if (c.check.type === 'deterministic' && c.check.assert === 'no_console_errors') {
158
166
  result.checks[c.id] = collected === null
@@ -214,6 +222,50 @@ export async function captureCrop ({ page, node, locale, selector, title, run, p
214
222
  return entry
215
223
  }
216
224
 
225
+ /**
226
+ * links_ok and hreflang_pairs for the current page: { [criterion id]: { ok, evidence } }. The requests go through an
227
+ * isolated request context (linkcheck.mjs), so the walker's cookies, language and session stay as they were.
228
+ * links_ok follows links only on a readonly environment or scenario, or when the check says `follow: true`.
229
+ */
230
+ export async function networkChecks (page, criteria, url, { run = null, locale = '', factory = null } = {}) {
231
+ const out = {}
232
+ const readonly = !!(run?.meta?.environment?.readonly || run?.meta?.scenario?.readonly)
233
+ const follows = (c) => c.check.assert !== 'links_ok' || readonly || c.check.follow === true
234
+ const todo = criteria.filter((c) => {
235
+ if (follows(c)) return true
236
+ out[c.id] = { ok: null, evidence: 'links not followed: the environment and scenario are not readonly (set follow: true on the check to opt in)' }
237
+ return false
238
+ })
239
+ if (!todo.length) return out
240
+ let request
241
+ let get
242
+ try {
243
+ request = await isolatedRequest(page.context(), { factory })
244
+ const { cookies = [] } = await page.context().storageState().catch(() => ({}))
245
+ get = requester(request, walkCache, cacheScope({ environment: run?.meta?.environment?.name || '', locale, cookies }))
246
+ } catch (error) {
247
+ for (const c of todo) out[c.id] = { ok: null, evidence: `network checks unavailable: ${error.message}` }
248
+ await request?.dispose().catch(() => {})
249
+ return out
250
+ }
251
+ try {
252
+ const doc = await page.evaluate((sels) => ({
253
+ lang: document.documentElement.getAttribute('lang') || '',
254
+ alternates: [...document.querySelectorAll("link[rel~='alternate'][hreflang][href]")].map((l) => ({ hreflang: l.getAttribute('hreflang').toLowerCase(), href: l.href })),
255
+ links: Object.fromEntries(sels.map((sel) => [sel, (() => { try { return [...document.querySelectorAll(sel)].map((a) => a.href).filter(Boolean) } catch { return [] } })()]))
256
+ }), [...new Set(todo.filter((c) => c.check.assert === 'links_ok').map((c) => c.check.selector || 'a[href]'))]).catch(() => null)
257
+ for (const c of todo) {
258
+ if (!doc) { out[c.id] = { ok: null, evidence: 'page could not be read for network checks' }; continue }
259
+ try {
260
+ out[c.id] = c.check.assert === 'links_ok'
261
+ ? await checkLinks({ links: doc.links[c.check.selector || 'a[href]'] || [], pageUrl: url, get, max: c.check.max ?? undefined, exclude: c.check.pattern || null })
262
+ : await checkHreflang({ alternates: doc.alternates, pageUrl: url, lang: doc.lang, get, min: c.check.min ?? 2 })
263
+ } catch (error) { out[c.id] = { ok: false, evidence: `check error: ${error.message}` } }
264
+ }
265
+ } finally { await request.dispose().catch(() => {}) }
266
+ return out
267
+ }
268
+
217
269
  function sameDocument (a, b) {
218
270
  try {
219
271
  const x = new URL(a); const y = new URL(b)
@@ -9,9 +9,13 @@ export const CONFIG_FILE = 'kaloko.config.yml'
9
9
  /** The file name before the rename to Kaloko; still found, so existing projects keep working. */
10
10
  export const LEGACY_CONFIG_FILE = 'qawalk.config.yml'
11
11
 
12
- /** Environment variables were QAWALK_*; the old names still work and fill the KALOKO_* ones that are not set. */
12
+ /** Environment variables were QAWALK_*: either name fills the other when that one is not set, so new .env files work
13
+ * with configs that still name QAWALK_TOKEN (share.token_env) and old .env files work with the new defaults. */
13
14
  export function aliasLegacyEnv (vars = process.env) {
14
- for (const [k, v] of Object.entries(vars)) if (k.startsWith('QAWALK_') && vars[`KALOKO_${k.slice(7)}`] == null) vars[`KALOKO_${k.slice(7)}`] = v
15
+ for (const [k, v] of Object.entries(vars)) {
16
+ const m = k.match(/^(QAWALK|KALOKO)_(.+)$/)
17
+ if (m && vars[`${m[1] === 'QAWALK' ? 'KALOKO' : 'QAWALK'}_${m[2]}`] == null) vars[`${m[1] === 'QAWALK' ? 'KALOKO' : 'QAWALK'}_${m[2]}`] = v
18
+ }
15
19
  }
16
20
  aliasLegacyEnv()
17
21
 
@@ -113,5 +117,7 @@ export async function loadEnvFiles (files) {
113
117
  if (!m || process.env[m[1]] !== undefined) continue
114
118
  process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2')
115
119
  }
120
+ // per file, so a project .env with a former name still wins over a user-level file with the new one
121
+ aliasLegacyEnv()
116
122
  }
117
123
  }