kaloko 0.9.0 → 0.10.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 +2 -0
- package/schemas/revision.v1.json +11 -0
- package/schemas/run.v1.json +12 -3
- package/schemas/scenario.v1.json +14 -0
- package/skills/kaloko/reference/design.md +93 -1
- package/skills/kaloko/reference/execution.md +6 -0
- package/skills/kaloko/reference/portable-html.md +12 -3
- package/skills/kaloko/reference/stacks.md +6 -0
- package/src/cli.mjs +95 -30
- package/src/lib/build.mjs +9 -46
- package/src/lib/capture.mjs +1 -1
- package/src/lib/design-cli.mjs +121 -47
- package/src/lib/design-core.mjs +26 -4
- package/src/lib/design-export.mjs +26 -6
- package/src/lib/design-lint.mjs +45 -11
- package/src/lib/design-origins.mjs +86 -0
- package/src/lib/design.mjs +112 -8
- package/src/lib/dimensions.mjs +66 -12
- package/src/lib/drift.mjs +1 -1
- package/src/lib/evaluate.mjs +6 -2
- package/src/lib/manifest-core.mjs +90 -5
- package/src/lib/spec-cli.mjs +7 -4
- package/src/lib/stack.mjs +5 -8
- package/src/lib/tokens.mjs +16 -0
- package/src/lib/walk-dimensions.mjs +31 -6
- package/viewer/index.html +138 -35
package/package.json
CHANGED
package/schemas/config.v1.json
CHANGED
|
@@ -193,7 +193,9 @@
|
|
|
193
193
|
"kind": { "enum": ["app", "design"], "default": "app", "description": "app: the application under test (a web app at base_url, or a native app in the app block). design: a folder of live HTML steps served locally by the CLI (serve); its runs are published as design versions (#42)." },
|
|
194
194
|
"serve": { "type": "string", "description": "kind: design. Folder with one sub-folder per step (<step>/index.html, optional <step>/<locale>/index.html) and shared files in _shared/; relative to the config." },
|
|
195
195
|
"fixtures": { "type": "string", "description": "kind: design. Folder of JSON fixtures live steps may fetch instead of real APIs; relative to the config." },
|
|
196
|
+
"origins": { "type": "array", "maxItems": 20, "items": { "type": "string", "pattern": "^https://(\\*\\.)?[A-Za-z0-9.-]+(:[0-9]{1,5})?/?$" }, "description": "kind: design. Origins the design loads fonts, stylesheets and images from (https://fonts.googleapis.com, https://*.cdn.example.com). The organization allows them in Settings → Security; kaloko design lint warns about one it does not allow. Scripts never load from them." },
|
|
196
197
|
"tokens_mode": { "type": "string", "description": "kind: design. Token mode (e.g. light, dark) steps are rendered in; default: the token set's first mode." },
|
|
198
|
+
"token_modes": { "oneOf": [{ "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$" } }, { "const": false }], "description": "kind: design. Token modes of the token set to render every step in, one capture per mode (keys <viewport>.mode-<name>); tokens_mode is the default and has the plain key. Not set: every mode of the set when it has two or more, except a set of only light and dark (left to color_schemes). false: only tokens_mode." },
|
|
197
199
|
"html_view": { "type": "boolean", "default": false, "description": "Experimental (#42): keep a static HTML view of every capture (assets rehosted, scripts removed, secrets scrubbed). Refused on production unless the environment is readonly." },
|
|
198
200
|
"base_url": {
|
|
199
201
|
"oneOf": [
|
package/schemas/revision.v1.json
CHANGED
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
"state": { "type": "string", "default": "default", "description": "default, a design state (empty, error, …) or view:<viewport> for an HTML view." },
|
|
13
13
|
"kind": { "enum": ["design", "html_view"], "default": "design" },
|
|
14
14
|
"entry": { "type": "string" },
|
|
15
|
+
"items": {
|
|
16
|
+
"type": ["array", "null"],
|
|
17
|
+
"description": "Stack steps (#61): the variants of the step, each a folder of the step with its own page (<step>/<id>/index.html). The revision covers the whole step folder; its entry is the overview page or the first variant's.",
|
|
18
|
+
"maxItems": 200,
|
|
19
|
+
"items": { "type": "object", "required": ["id", "entry"], "properties": { "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$" }, "entry": { "type": "string" } } }
|
|
20
|
+
},
|
|
15
21
|
"files": { "$ref": "bundle.v1.json#/properties/files" },
|
|
16
22
|
"tokens": { "type": ["object", "null"], "properties": { "project": { "type": ["string", "null"] }, "version": { "type": ["integer", "null"] }, "hash": { "type": ["string", "null"] } } },
|
|
17
23
|
"based_on": { "type": ["string", "null"], "description": "The revision this one changed (a canvas edit, a sync on top of the draft)." },
|
|
@@ -31,6 +37,11 @@
|
|
|
31
37
|
"mode": { "type": "string" }
|
|
32
38
|
}
|
|
33
39
|
}
|
|
40
|
+
},
|
|
41
|
+
"item_captures": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"description": "Stack steps (#61): item id → viewport → rendered capture of that variant, shaped like captures.",
|
|
44
|
+
"additionalProperties": { "type": "object", "additionalProperties": { "$ref": "#/properties/captures/additionalProperties" } }
|
|
34
45
|
}
|
|
35
46
|
}
|
|
36
47
|
}
|
package/schemas/run.v1.json
CHANGED
|
@@ -156,11 +156,11 @@
|
|
|
156
156
|
},
|
|
157
157
|
"viewports": {
|
|
158
158
|
"type": "object",
|
|
159
|
-
"description": "Capture key -> [width, height]. A key is the viewport name plus the dimension values that differ from the default, joined by dots (mobile, mobile.webkit, iphone-15.webkit.dark.reduce)."
|
|
159
|
+
"description": "Capture key -> [width, height]. A key is the viewport name plus the dimension values that differ from the default, joined by dots (mobile, mobile.webkit, iphone-15.webkit.dark.reduce). Token modes of a design add mode-<name>, step states state-<name> as the last part (mobile.mode-brand-b, mobile.dark.state-empty)."
|
|
160
160
|
},
|
|
161
161
|
"variants": {
|
|
162
162
|
"type": "object",
|
|
163
|
-
"description": "Capture key -> { viewport, browser, color_scheme?, reduced_motion?, forced_colors?, display_mode?, device? }. Absent in runs without dimensions: every key is then a plain viewport in Chromium.",
|
|
163
|
+
"description": "Capture key -> { viewport, browser, color_scheme?, reduced_motion?, forced_colors?, display_mode?, token_mode?, step_state?, device? }. Absent in runs without dimensions: every key is then a plain viewport in Chromium.",
|
|
164
164
|
"additionalProperties": {
|
|
165
165
|
"type": "object",
|
|
166
166
|
"properties": {
|
|
@@ -170,13 +170,15 @@
|
|
|
170
170
|
"reduced_motion": { "enum": ["no-preference", "reduce"] },
|
|
171
171
|
"forced_colors": { "enum": ["none", "active"] },
|
|
172
172
|
"display_mode": { "enum": ["browser", "standalone"] },
|
|
173
|
+
"token_mode": { "type": "string", "description": "Token mode of the design's token set the key was rendered in." },
|
|
174
|
+
"step_state": { "type": "string", "description": "Step state (default, empty, error, loading…) of the key." },
|
|
173
175
|
"device": { "type": "string" }
|
|
174
176
|
}
|
|
175
177
|
}
|
|
176
178
|
},
|
|
177
179
|
"dimensions": {
|
|
178
180
|
"type": "object",
|
|
179
|
-
"description": "Values of each dimension the run walked (browsers, color_schemes, reduced_motion, forced_colors, display_modes) for the canvas switchers."
|
|
181
|
+
"description": "Values of each dimension the run walked (browsers, color_schemes, reduced_motion, forced_colors, display_modes, token_modes with the default mode first, states with default first) for the canvas switchers. A step that has no such state has no captures under its keys."
|
|
180
182
|
},
|
|
181
183
|
"devices": {
|
|
182
184
|
"type": "object",
|
|
@@ -463,6 +465,8 @@
|
|
|
463
465
|
"reduced_motion": { "type": "string" },
|
|
464
466
|
"forced_colors": { "type": "string" },
|
|
465
467
|
"display_mode": { "type": "string" },
|
|
468
|
+
"token_mode": { "type": "string", "description": "Token mode a design capture was rendered in." },
|
|
469
|
+
"step_state": { "type": "string", "description": "Step state of the capture (default, empty, error…)." },
|
|
466
470
|
"device_preset": { "type": "string", "description": "Viewport name of the device preset the capture emulated." },
|
|
467
471
|
"device": { "type": ["string", "null"] },
|
|
468
472
|
"tree": { "type": ["string", "null"], "description": "UI tree of an app capture (qawalk.tree.v1)." },
|
|
@@ -562,6 +566,11 @@
|
|
|
562
566
|
"type": "object",
|
|
563
567
|
"description": "Page metadata per locale (meta, links, JSON-LD, microdata, headings)."
|
|
564
568
|
},
|
|
569
|
+
"states": {
|
|
570
|
+
"type": "array",
|
|
571
|
+
"items": { "type": "string" },
|
|
572
|
+
"description": "Step states the step is captured in (default first), when its scenario step lists states."
|
|
573
|
+
},
|
|
565
574
|
"session": {
|
|
566
575
|
"type": [
|
|
567
576
|
"string",
|
package/schemas/scenario.v1.json
CHANGED
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"reduced_motion": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["no-preference", "reduce"] }, "description": "prefers-reduced-motion values (keys <viewport>.reduce)." },
|
|
69
69
|
"forced_colors": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["none", "active"] }, "description": "forced-colors (Windows high contrast) values (keys <viewport>.forced)." },
|
|
70
70
|
"display_modes": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["browser", "standalone"] }, "description": "PWA display modes: standalone captures the page as an installed app (display-mode media rules and matchMedia; the whole screen of a device preset)." },
|
|
71
|
+
"token_modes": { "oneOf": [{ "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$" } }, { "const": false }], "description": "Design environments: token modes of the token set to render every step in (keys <viewport>.mode-<name>). Not set: every mode of the set (see the environment's token_modes). false: only the environment's tokens_mode." },
|
|
71
72
|
"devices": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" }, "description": "App scenarios: devices to walk on, e.g. pixel-8-api-34, iphone-15-ios-18 (emulators, simulators or real devices named in the environment's app.<platform>.devices). One capture key per device." },
|
|
72
73
|
"environments": {
|
|
73
74
|
"type": "array",
|
|
@@ -311,6 +312,13 @@
|
|
|
311
312
|
],
|
|
312
313
|
"description": "For `kaloko walk --affected`: globs of repository files (relative to the config) that shape this step, next to the routes and imports Kaloko finds itself; `always` walks the step on every affected walk (sign-in, consent)."
|
|
313
314
|
},
|
|
315
|
+
"states": {
|
|
316
|
+
"type": "array",
|
|
317
|
+
"minItems": 1,
|
|
318
|
+
"uniqueItems": true,
|
|
319
|
+
"items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$", "not": { "enum": ["index"] } },
|
|
320
|
+
"description": "States this step is captured in besides the default one, e.g. [empty, error, loading]; any name works. Each state is one more capture per viewport (keys <viewport>.state-<name>) and a value of the State switcher on the canvas. Design steps render <step>/<state>.html (or <step>/<locale>/<state>.html), else <step>/index.html?state=<name>; walks of web and app steps run the step script once more per state with the argument state. `default` may be listed; it is always captured."
|
|
321
|
+
},
|
|
314
322
|
"page": {
|
|
315
323
|
"enum": ["landing", "work", "list", "settings", "form", "doc"],
|
|
316
324
|
"description": "What kind of page this step is (see the scenario's page); the usability pack asks per type, e.g. one primary call to action only on landing pages."
|
|
@@ -542,6 +550,12 @@
|
|
|
542
550
|
"minItems": 1,
|
|
543
551
|
"items": { "type": "string" },
|
|
544
552
|
"description": "Environments where this criterion applies, e.g. [production] for hreflang to the live domain, the live demo or the sitemap. On any other environment the walk reports it as not applicable (not_evaluated with na: environment) and it does not make the step partial."
|
|
553
|
+
},
|
|
554
|
+
"states": {
|
|
555
|
+
"type": "array",
|
|
556
|
+
"minItems": 1,
|
|
557
|
+
"items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$" },
|
|
558
|
+
"description": "Step states this criterion applies to, e.g. [empty] for \"says the cart is empty\". Captures of other states report it as not applicable (na: state). Not set: every state of the step."
|
|
545
559
|
}
|
|
546
560
|
}
|
|
547
561
|
},
|
|
@@ -37,6 +37,11 @@ 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
|
+
- Web fonts and images from a service (Google Fonts, Adobe Fonts, the company CDN) load only from origins an
|
|
41
|
+
organization admin allows in Settings → Security. List the ones the design uses on the environment
|
|
42
|
+
(`design: { kind: design, serve: design, origins: [https://fonts.googleapis.com, https://fonts.gstatic.com] }`);
|
|
43
|
+
`kaloko design lint` warns about each the organization does not allow yet, so tell the person to ask an admin.
|
|
44
|
+
Scripts never load from those origins.
|
|
40
45
|
- Links between steps are plain relative links to the page: `<a href="../payment/index.html">`. The canvas
|
|
41
46
|
follows them; `data-kaloko-go="payment"` (or `data-qawalk-go`) moves to that step whatever the `href` says.
|
|
42
47
|
- Reference token values as CSS custom properties named after the token path (`color.accent` →
|
|
@@ -48,6 +53,40 @@ design/
|
|
|
48
53
|
`kaloko design lint` checks the folder; `kaloko sync` runs it first and stops on errors (things that break on
|
|
49
54
|
the live step), warnings are printed.
|
|
50
55
|
|
|
56
|
+
## Variants of one template (stack steps)
|
|
57
|
+
|
|
58
|
+
A step that comes in variants of one template (an e-mail per reason support can pick, a landing per city, a card per
|
|
59
|
+
plan) is one stack step ([stacks.md](stacks.md)), not one step per variant in a lane of its own:
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
- id: credit
|
|
63
|
+
kind: stack
|
|
64
|
+
title: Credit from support
|
|
65
|
+
items: { urls: [refund, late, damaged] } # folders of the step; without folder names: every subfolder with a page
|
|
66
|
+
fields: [{ id: reason, label: Reason, from: .reason }]
|
|
67
|
+
criteria: [...] # every variant meets them
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
design/credit/index.html optional overview (the step's entry; links into the variants)
|
|
72
|
+
design/credit/refund/index.html one folder per variant: its page and the files next to it
|
|
73
|
+
design/credit/refund/cs/index.html a locale override of one variant
|
|
74
|
+
design/credit/late/index.html
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `items.urls` that name folders (`refund`, `./refund/`, `refund/index.html`) list the variants in that order. App
|
|
78
|
+
paths (`/credit?reason=refund`), other sources or no list: every subfolder with an `index.html` is a variant,
|
|
79
|
+
sorted by name. Locale folders, `dark/` and folders starting with `_` are never variants.
|
|
80
|
+
- One revision covers the whole step folder; changing a variant is a new revision of the step. The walk captures
|
|
81
|
+
every variant in every viewport (`items.viewports` narrows it) with the index fields and criteria, and reuses
|
|
82
|
+
captures per revision and variant.
|
|
83
|
+
- `kaloko sync` sends the variants' captures with the step; the published version shows the stack index with every
|
|
84
|
+
variant and its verdict, and each variant opens live in the lightbox.
|
|
85
|
+
- `kaloko design lint` reports a variant folder without its `index.html` as an error.
|
|
86
|
+
- `kaloko export --live` and `export --design` write the variant folders; the design export adds
|
|
87
|
+
`screenshots/<step>/<variant>/<locale>/<viewport>.webp`.
|
|
88
|
+
- Not for steps whose variants are different screens of a flow: those stay separate steps.
|
|
89
|
+
|
|
51
90
|
## Devices, dark mode and app screens
|
|
52
91
|
|
|
53
92
|
Design steps can target device presets and colour schemes like any run ([devices.md](devices.md)):
|
|
@@ -68,6 +107,30 @@ color_schemes: [light, dark]
|
|
|
68
107
|
step spec and the token check of a dark capture name the dark tokens, and drift compares dark with dark.
|
|
69
108
|
- Browsers: design steps render in Chromium. Check the implementation in WebKit and Firefox instead.
|
|
70
109
|
|
|
110
|
+
## Step states and token modes
|
|
111
|
+
|
|
112
|
+
A step lists its other states in the scenario (`states: [empty, error, loading]`; any name of letters, digits, `-`
|
|
113
|
+
and `_` works). Draw each one so it can be reviewed and built:
|
|
114
|
+
|
|
115
|
+
- A state is a page next to the step's page: `<step>/<state>.html`, per locale `<step>/<locale>/<state>.html`. It
|
|
116
|
+
travels with the step's revision like the dark page; give it its own fixtures (`<step>/empty.json`, or a literal
|
|
117
|
+
`fetch('./fixtures/cart-empty.json')`) so the closure finds them.
|
|
118
|
+
- Without that page the walk opens `<step>/index.html?state=<name>` and says so. Use it when one page switches its
|
|
119
|
+
states in JS (`new URLSearchParams(location.search).get('state')`); the fixture must still be next to the page or
|
|
120
|
+
fetched by a literal path.
|
|
121
|
+
- Captures of a state are keyed `<viewport>.state-<name>` (`mobile.dark.state-empty`). Criteria with
|
|
122
|
+
`states: [empty]` are judged on that state only; other criteria on every state.
|
|
123
|
+
- The canvas shows a State switcher next to the language; review mode and the grid follow it. A step without the
|
|
124
|
+
state shows its default capture with a note.
|
|
125
|
+
|
|
126
|
+
Token modes: when the token set has two or more modes (resolver contexts or mode groups), every step renders in each
|
|
127
|
+
mode. The walk sets the mode's tokens as custom properties on `:root` after the page's own styles and
|
|
128
|
+
`data-kaloko-mode="<mode>"` on `<html>`; a page that switches themes by class can read the attribute. Captures are
|
|
129
|
+
keyed `<viewport>.mode-<name>`; the environment's `tokens_mode` (else the first mode) is the default with the plain
|
|
130
|
+
key, and the canvas switches modes like colour schemes. A set with only `light`/`dark` modes follows the colour
|
|
131
|
+
scheme instead (`color_schemes: [light, dark]`), unless `token_modes` names them. `token_modes: [brand-a, brand-b]` (environment, scenario or `kaloko start
|
|
132
|
+
--token-modes`) picks modes; `false` turns it off. Live view shows the default mode; other modes are screenshots.
|
|
133
|
+
|
|
71
134
|
**Native app screens as portable HTML.** A mockup of an iOS or Android screen is a full-screen page:
|
|
72
135
|
|
|
73
136
|
- Size it to the screen, not to a document: `html, body { height: 100% }` and the main area `min-height: 100dvh`.
|
|
@@ -121,7 +184,36 @@ opens straight into the presentation of that step: share it with the person who
|
|
|
121
184
|
without it, a step never rendered live) the presentation shows the screenshots.
|
|
122
185
|
- From the local preview and an offline export, the steps open from the run's merged folder (`live/site/`), so plain
|
|
123
186
|
relative links click through there too.
|
|
124
|
-
- A link's query and hash travel to the next step (`../payment/index.html?plan=pro#card`):
|
|
187
|
+
- A link's query and hash travel to the next step (`../payment/index.html?plan=pro#card`): enough for one value.
|
|
188
|
+
For more, use the prototype state below.
|
|
189
|
+
|
|
190
|
+
### Prototype state
|
|
191
|
+
|
|
192
|
+
Steps of a prototype share a small store, so a cart filled on one step shows on the next. The frame helper of the
|
|
193
|
+
live step provides `window.kaloko.state`; the canvas keeps it for the viewer in their browser tab, per draft or
|
|
194
|
+
version, and never sends it to the service. *Start over* in the presentation clears it.
|
|
195
|
+
|
|
196
|
+
```html
|
|
197
|
+
<!-- payment/index.html: the seeds are what the step shows when nothing set it yet -->
|
|
198
|
+
<script type="application/json" id="seed" data-kaloko-state>{ "cart": [{ "name": "Tea", "price": 120 }] }</script>
|
|
199
|
+
<script>
|
|
200
|
+
const render = (s) => { /* draw s.cart */ }
|
|
201
|
+
// first the step's own seeds, so it looks right alone (from disk, in a capture, in an export)
|
|
202
|
+
render(JSON.parse(document.getElementById('seed').textContent))
|
|
203
|
+
// then what the prototype remembers: the helper loads after the page's scripts and the state comes a moment later
|
|
204
|
+
addEventListener('kaloko:state', (e) => render(e.detail.state))
|
|
205
|
+
</script>
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- `kaloko.state.get(key)`, `set(key, value)` (JSON, up to 64 kB; `null` removes the key), `update(key, fn)`,
|
|
209
|
+
`subscribe(fn)` (called with the whole state now and on every change; returns an unsubscribe function), `reset()`,
|
|
210
|
+
`ready` (a promise of the first state). The `kaloko:state` event on `window` carries the same state; scripts that
|
|
211
|
+
run before the helper use it.
|
|
212
|
+
- The seeds in `<script type="application/json" data-kaloko-state>` fill keys that nothing has set yet. Give every
|
|
213
|
+
step that reads state seeds that make it look right on its own: reviewers open steps directly, captures render
|
|
214
|
+
each step alone, and the local preview and exports have no helper, so `window.kaloko` is missing there.
|
|
215
|
+
- Write with `set` or `update` on the step where the person acts (`update('cart', (c) => [...(c || []), item])`),
|
|
216
|
+
read on the next. Keep it small: ids, counts and form values, not page markup.
|
|
125
217
|
|
|
126
218
|
## What changed in a version
|
|
127
219
|
|
|
@@ -34,6 +34,12 @@ call `kaloko session reset` themselves when the plan starts as a new visitor.
|
|
|
34
34
|
to the node's person (`session.key`, `title`, `mailbox`, `account`, and
|
|
35
35
|
`credentials` for that account in the run's environment; see
|
|
36
36
|
[environments.md](environments.md#accounts-and-sign-in)).
|
|
37
|
+
- Step states: a step with `states: [empty, error]` runs its script once
|
|
38
|
+
more per state with `state` (`'default'` first, then `'empty'`, …); bring
|
|
39
|
+
the page into that state (route a mocked API response with `page.route`,
|
|
40
|
+
empty the cart) before the capture. Each run captures that state's keys
|
|
41
|
+
(`mobile.state-empty`). For a step an agent walks:
|
|
42
|
+
`kaloko capture --step <id> --state empty`. App scripts get `state` too.
|
|
37
43
|
- `agent` — steps that need judgement or change often. Follow
|
|
38
44
|
`execution.instructions`, then `kaloko capture --step <id>`.
|
|
39
45
|
- `manual` — a person does it; you capture afterwards.
|
|
@@ -19,7 +19,14 @@ warnings, `--json` gives every finding with file, line, rule and fix.
|
|
|
19
19
|
`https://`, no protocol-relative `//cdn…`, no paths starting with `/` (on the live origin `/` is the origin root, from
|
|
20
20
|
disk it is the disk root). Errors: `external-url`, `root-relative`, `outside-folder`, `missing-file`.
|
|
21
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
|
|
22
|
+
the characters used, with `@font-face` in the shared CSS.
|
|
23
|
+
- **Font and asset origins** are the one exception, for stylesheets, fonts and images only: an organization admin
|
|
24
|
+
allows origins such as `https://fonts.googleapis.com`, `https://fonts.gstatic.com`, `https://use.typekit.net` or
|
|
25
|
+
the company CDN in Settings → Security, and live steps load them. Declare the ones the design uses on the design
|
|
26
|
+
environment (`origins: [https://fonts.googleapis.com, https://fonts.gstatic.com]`); `kaloko design lint` then
|
|
27
|
+
takes them instead of an `external-url` error and warns (`origin-not-allowed`) about each the organization does
|
|
28
|
+
not allow yet. Scripts, module imports, `fetch` and video never load from them. A bundled font still works from
|
|
29
|
+
disk and from an export; prefer it when you can.
|
|
23
30
|
- Only bundle file types (html, css, js, mjs, json, svg, woff2, woff, png, jpg, webp, avif, gif, mp4, webm, txt).
|
|
24
31
|
Convert TTF/OTF fonts and other formats (`file-type`).
|
|
25
32
|
- **Size budget:** a file over 1 MB or a step over 5 MB (with the shared files) warns (`large-file`); 10 MB per file
|
|
@@ -40,8 +47,10 @@ warnings, `--json` gives every finding with file, line, rule and fix.
|
|
|
40
47
|
|
|
41
48
|
## State and data
|
|
42
49
|
|
|
43
|
-
- **State lives in the URL**, not in storage: `localStorage`,
|
|
44
|
-
sandboxed frame and are empty for every other viewer
|
|
50
|
+
- **State between steps lives in `window.kaloko.state` or the URL**, not in storage: `localStorage`,
|
|
51
|
+
`sessionStorage`, IndexedDB and cookies throw in the sandboxed frame and are empty for every other viewer
|
|
52
|
+
(`storage`). For a cart, a chosen plan or details filled in on an earlier step use the prototype state
|
|
53
|
+
([design.md → Prototype state](design.md#prototype-state)); for a single value the query and hash do
|
|
45
54
|
(`?plan=pro#card`): a link's query and hash travel to the next step in the presentation, and work from disk.
|
|
46
55
|
Wrap any storage access you keep in `try/catch` with a fallback.
|
|
47
56
|
- **No network:** real APIs, `XMLHttpRequest`, `WebSocket`, `EventSource` do not connect (`websocket`). Put data in
|
|
@@ -126,5 +126,11 @@ two or three example paths), whether the limit cut the list, and that
|
|
|
126
126
|
semantic criteria covered only the sample. Put the full list in the CSV or
|
|
127
127
|
the MCP result rather than in the issue.
|
|
128
128
|
|
|
129
|
+
## Design environments
|
|
130
|
+
|
|
131
|
+
On a design environment a stack step is a template with its variants: each variant is a folder of the step,
|
|
132
|
+
`design/<step>/<variant>/index.html`, listed by `items.urls` or discovered from subfolders, and every variant is
|
|
133
|
+
captured in every viewport. See [design.md](design.md#variants-of-one-template-stack-steps).
|
|
134
|
+
|
|
129
135
|
Not yet supported for stacks: comparison with a baseline run per page and
|
|
130
136
|
the documentation layer (latest captures per step).
|
package/src/cli.mjs
CHANGED
|
@@ -50,6 +50,7 @@ import { walkDesign, syncDesign, publishVersion, previewNotes, historyCmd, refer
|
|
|
50
50
|
import { screensCmd } from './lib/screens-cli.mjs'
|
|
51
51
|
import { designDir, readBase, headKey } from './lib/design.mjs'
|
|
52
52
|
import { lintDesign, formatLint } from './lib/design-lint.mjs'
|
|
53
|
+
import { cleanAssetOrigins } from './lib/design-origins.mjs'
|
|
53
54
|
import { exportDesign } from './lib/design-export.mjs'
|
|
54
55
|
import { driverFor, isApp, pngSize, ocr as ocrImage } from './lib/apps/drivers.mjs'
|
|
55
56
|
import { captureApp } from './lib/apps/capture.mjs'
|
|
@@ -57,7 +58,7 @@ import { readTree, flatten } from './lib/apps/tree.mjs'
|
|
|
57
58
|
import { recordSettings, recordingSession, parseRecord, recordingFiles, traceFiles, stripRecordings, withoutRecordings } from './lib/recording.mjs'
|
|
58
59
|
import { nativeSession } from './lib/apps/recording-native.mjs'
|
|
59
60
|
import { videoCmd } from './lib/video-cli.mjs'
|
|
60
|
-
import { passesOf, enginesOf, contextOptionsOf, launchEngine, pickKeys, variantFields, variantOf, dimensionValues, BROWSERS } from './lib/dimensions.mjs'
|
|
61
|
+
import { passesOf, enginesOf, contextOptionsOf, launchEngine, pickKeys, variantFields, variantOf, dimensionValues, stateKeys, nodeStates, BROWSERS } from './lib/dimensions.mjs'
|
|
61
62
|
import { startScope, scopeKeys, dimensionLine, baseViewports, engineKeys, appDeviceGroups } from './lib/walk-dimensions.mjs'
|
|
62
63
|
import { deviceConfig, prepareAndroid, readyAndroid, finishAndroid, prepareSimulator, applyAppAppearance } from './lib/apps/devices.mjs'
|
|
63
64
|
import { attachAndroidWebView } from './lib/apps/webview.mjs'
|
|
@@ -113,6 +114,9 @@ Run
|
|
|
113
114
|
More dimensions: every step is captured in each combination (keys like
|
|
114
115
|
mobile.webkit.dark); default from the scenario, then walk: in the config
|
|
115
116
|
[--devices pixel-8-api-34,iphone-15-ios-18] App scenarios: the emulators, simulators or devices to walk on
|
|
117
|
+
[--states empty,error] Only these step states (states: on a step; keys like mobile.state-empty)
|
|
118
|
+
[--token-modes brand-a,brand-b | --token-modes none]
|
|
119
|
+
Design environments: the token modes to render (default every mode of the set)
|
|
116
120
|
browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
|
|
117
121
|
Shared Chromium with CDP (agents, scripts and people drive it)
|
|
118
122
|
browser install [--with-deps] [--browsers chromium,webkit,firefox]
|
|
@@ -121,8 +125,8 @@ Run
|
|
|
121
125
|
devices [--json] Device presets for viewports: (phones, foldables, tablets, laptop, desktop, TV)
|
|
122
126
|
open --step <id> [--locale l] Navigate the current page to the step URL
|
|
123
127
|
session reset New visitor: clear cookies and storage in the shared browser
|
|
124
|
-
capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b]
|
|
125
|
-
Capture the current page for a step in all viewports
|
|
128
|
+
capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b] [--state <name>]
|
|
129
|
+
Capture the current page for a step in all viewports (--state: as that step state)
|
|
126
130
|
capture --step <id> --crop <css> [--title <text>]
|
|
127
131
|
Attach an element screenshot to a step
|
|
128
132
|
capture --step <id> --image <shot.png> [--tree <dump.xml|json> | --ocr] [--platform android|ios|desktop]
|
|
@@ -307,9 +311,11 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
307
311
|
sync [--pull|--push] [--branch b] [--mine a,b|--theirs a,b] [--no-lint]
|
|
308
312
|
Exchange revisions with the shared draft; only changed steps and missing files travel
|
|
309
313
|
Lints the folder first: errors (things that break on the live step) stop the push
|
|
310
|
-
design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--json]
|
|
314
|
+
design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]
|
|
311
315
|
Check the folder against the portable HTML rules (relative URLs, no CDNs, no storage,
|
|
312
|
-
deterministic content, viewport, lang, links that also work from disk); --strict fails on warnings
|
|
316
|
+
deterministic content, viewport, lang, links that also work from disk); --strict fails on warnings.
|
|
317
|
+
Fonts, styles and images from origins of the environment are compared with the
|
|
318
|
+
organization's allowed origins (--offline skips that)
|
|
313
319
|
share [--name <text>] [--branch b] [--notes file|-] [--notes-preview]
|
|
314
320
|
Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged",
|
|
315
321
|
with "what changed" notes per step (screens per viewport, text, tokens); --notes-preview
|
|
@@ -321,7 +327,7 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
321
327
|
a folder exported from Figma works without a token. One way: no prototype links
|
|
322
328
|
history [--scenario id] [--step id] [--locale l] [--json]
|
|
323
329
|
Versions of the flow; revisions of a step with who (person or agent) and when
|
|
324
|
-
spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]
|
|
330
|
+
spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--state s] [--local] [--json]
|
|
325
331
|
What to build for a step as approved: elements with role, name and selector, styles
|
|
326
332
|
mapped to DTCG tokens (or the nearest one), :hover/:focus/:active/:disabled, contrast
|
|
327
333
|
drift [step] [--ref design|vN] [--remote <run id|latest>] [--json]
|
|
@@ -457,6 +463,21 @@ function stepOf (scenario, id) {
|
|
|
457
463
|
return node
|
|
458
464
|
}
|
|
459
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Font and asset origins for `kaloko design lint` (#46): `wanted` from `origins` of the design environment, and with
|
|
468
|
+
* `online` the organization's list from the service (GET /me design_origins; null when not signed in or unreachable).
|
|
469
|
+
*/
|
|
470
|
+
async function lintOrigins (config, env, { online = false } = {}) {
|
|
471
|
+
let wanted = []
|
|
472
|
+
try { wanted = cleanAssetOrigins(env?.origins || []) } catch (e) { throw new UserError(`Environment ${env.name}, origins: ${e.message}`) }
|
|
473
|
+
if (!online || !wanted.length) return { wanted, allowed: null }
|
|
474
|
+
let timer
|
|
475
|
+
try {
|
|
476
|
+
const me = await Promise.race([service(config).request('GET', '/me'), new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 8000) })])
|
|
477
|
+
return Array.isArray(me?.design_origins) ? { wanted, allowed: me.design_origins } : { wanted, allowed: null, unchecked: true }
|
|
478
|
+
} catch { return { wanted, allowed: null, unchecked: true } } finally { clearTimeout(timer) }
|
|
479
|
+
}
|
|
480
|
+
|
|
460
481
|
/** Scenario of --scenario (id or file) or of the current run. */
|
|
461
482
|
/**
|
|
462
483
|
* kaloko design import figma <file-url|key|folder>: Figma frames (or a folder exported from Figma) become the steps of
|
|
@@ -488,7 +509,7 @@ async function designImport (args) {
|
|
|
488
509
|
if (args.json) say(JSON.stringify(r, null, 2))
|
|
489
510
|
else say(formatImport(r, { dir: relative(process.cwd(), dir) || '.' }))
|
|
490
511
|
if (args['dry-run']) return r
|
|
491
|
-
const lint = await lintDesign({ dir, scenario })
|
|
512
|
+
const lint = await lintDesign({ dir, scenario, origins: await lintOrigins(config, env) })
|
|
492
513
|
if (lint.errors) say(`\n${formatLint(lint, { max: 10 })}`)
|
|
493
514
|
if (!args.publish) {
|
|
494
515
|
say(`\nNext: kaloko start --scenario ${relative(process.cwd(), scenarioFile)} --env ${env.name} && kaloko walk && kaloko share (or rerun with --publish).\nAccept the version on the canvas and set it as the design reference (kaloko reference --design --version <n>): implementation runs with packs: [design] are then checked against these frames.`)
|
|
@@ -521,6 +542,24 @@ function manualKeys (run, pick) {
|
|
|
521
542
|
return scopeKeys(scope, keys.length ? keys : engineKeys(scope, 'chromium'), null)
|
|
522
543
|
}
|
|
523
544
|
|
|
545
|
+
/** Modes of a design environment's token set (tokens.resolver.json or tokens.json); [] without one. */
|
|
546
|
+
async function designTokenModesOf (config, root, env) {
|
|
547
|
+
try {
|
|
548
|
+
const { loadTokens } = await import('./lib/drift.mjs')
|
|
549
|
+
const { modesOf } = await import('./lib/tokens.mjs')
|
|
550
|
+
const set = await loadTokens({ client: null, config, root, designDir: designDir(env, root) })
|
|
551
|
+
return set ? modesOf(set) : []
|
|
552
|
+
} catch { return [] }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** --state <name> (#46): the keys of that state of the step instead of the default ones. */
|
|
556
|
+
function inState (run, node, keys, state) {
|
|
557
|
+
if (!state || state === true || state === 'default') return keys
|
|
558
|
+
const have = nodeStates(run.meta.scope, node)
|
|
559
|
+
if (!have.includes(state)) throw new UserError(`Step ${node.id} has no state "${state}" in this run (${have.length > 1 ? `its states: ${have.join(', ')}` : 'add states: to the step and start a new run'}).`)
|
|
560
|
+
return Object.fromEntries(stateKeys(run.meta.scope, Object.keys(keys), state).map((k) => [k, run.meta.scope.viewports[k]]))
|
|
561
|
+
}
|
|
562
|
+
|
|
524
563
|
/** { key: [w, h] } a capture on a Chromium page takes: every Chromium key of the run, or those of `pick`. */
|
|
525
564
|
function scopeViewports (run, pick) {
|
|
526
565
|
return scopeKeys(run.meta.scope, engineKeys(run.meta.scope, 'chromium'), pick)
|
|
@@ -772,15 +811,17 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
|
|
|
772
811
|
if (!scripted && !(link && ['playwright', 'script'].includes(node.execution?.strategy))) { pending.push(`${node.id} (${locale}): ${node.execution?.strategy || 'agent'} — ${(node.execution?.instructions || '').split('\n')[0]}`); continue }
|
|
773
812
|
const streakKey = `${group.device || ''}|${locale}`
|
|
774
813
|
if (streak.stopped(streakKey)) { skipped.push(`${node.id} (${locale}${tag})`); continue }
|
|
775
|
-
|
|
814
|
+
let records = []
|
|
815
|
+
// step states (#46): the script (or the deep link) runs once per state; `state` tells the script which
|
|
816
|
+
let state = 'default'
|
|
776
817
|
try {
|
|
777
818
|
let captured = false
|
|
778
819
|
await rec?.beforeStep(node)
|
|
779
820
|
const capture = async (o = {}) => {
|
|
780
821
|
captured = true
|
|
781
|
-
await rec?.beforeCapture(node)
|
|
822
|
+
if (state === 'default') await rec?.beforeCapture(node)
|
|
782
823
|
// a named viewport: within this device, or any of the run when it has no devices (as before)
|
|
783
|
-
const keys = o.viewport ? pickKeys(scope, group.device ? group.keys : Object.keys(scope.viewports), [o.viewport]) : group.keys
|
|
824
|
+
const keys = stateKeys(scope, o.viewport ? pickKeys(scope, group.device ? group.keys : Object.keys(scope.viewports), [o.viewport]) : group.keys, state)
|
|
784
825
|
if (!keys.length) throw new UserError(`Unknown viewport "${o.viewport}" for ${group.device || platform}.`)
|
|
785
826
|
const out = []
|
|
786
827
|
for (const key of keys) {
|
|
@@ -803,14 +844,25 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
|
|
|
803
844
|
records.push(...out)
|
|
804
845
|
return out.length === 1 ? out[0] : out
|
|
805
846
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
847
|
+
const mod = scripted ? await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href) : null
|
|
848
|
+
const once = async () => {
|
|
849
|
+
captured = false
|
|
850
|
+
if (mod) await mod.default({ app: driver, page: driver.page || null, webview: driver.webview || null, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(scope, node) } : {}) }, device: group.device, env, run: run.meta, capture, link: (id = node.id) => linkFor(env, stepOf(scenario, id), locale), url: (id = node.id) => linkFor(env, stepOf(scenario, id), locale) })
|
|
851
|
+
else await driver.link(link)
|
|
852
|
+
if (!captured) await capture()
|
|
853
|
+
}
|
|
854
|
+
await once()
|
|
811
855
|
await rec?.afterStep(node, driver.web ? { records } : { record: records[0] })
|
|
812
856
|
say(`✓ ${node.id} ${locale}${tag}`)
|
|
813
857
|
streak.ok(streakKey)
|
|
858
|
+
for (const s of nodeStates(scope, node).slice(1)) {
|
|
859
|
+
state = s; records = []
|
|
860
|
+
try { await once(); say(`✓ ${node.id} ${locale}${tag} · ${s}`) } catch (error) {
|
|
861
|
+
failed.push(`${node.id} (${locale}${tag} · ${s}): ${error.message.split('\n')[0]}`)
|
|
862
|
+
say(`✗ ${node.id} ${locale}${tag} · ${s}: ${error.message.split('\n')[0]}`)
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
state = 'default'
|
|
814
866
|
} catch (error) {
|
|
815
867
|
await rec?.afterStep(node, driver.web ? { error, records } : { error, record: records[0] }).catch(() => {})
|
|
816
868
|
failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
|
|
@@ -1026,7 +1078,9 @@ const commands = {
|
|
|
1026
1078
|
if (scenario.environments && !scenario.environments.includes(env.name)) throw new UserError(`Scenario ${scenario.id} is not allowed on ${env.name} (allowed: ${scenario.environments.join(', ')}).`)
|
|
1027
1079
|
if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
|
|
1028
1080
|
const locales = list(args.locales) || scenario.locales || ['default']
|
|
1029
|
-
|
|
1081
|
+
// design environments render every mode of their token set (#46): the modes come from the folder's set
|
|
1082
|
+
const tokenModes = env.kind === 'design' ? await designTokenModesOf(config, root, env) : []
|
|
1083
|
+
const scope = { ...startScope({ args, scenario, config, env, tokenModes }), ...(args.note ? { note: args.note } : {}) }
|
|
1030
1084
|
scope.locales = locales
|
|
1031
1085
|
const vpNames = baseViewports(scope)
|
|
1032
1086
|
const git = await gitMeta(root)
|
|
@@ -1105,7 +1159,8 @@ const commands = {
|
|
|
1105
1159
|
}
|
|
1106
1160
|
if (isApp(scenario.platform)) {
|
|
1107
1161
|
// the app's current screen, through the environment's driver (no navigation); --viewport may name a device key
|
|
1108
|
-
const
|
|
1162
|
+
const key0 = typeof args.viewport === 'string' ? args.viewport : Object.keys(run.meta.scope.viewports || {})[0]
|
|
1163
|
+
const key = Object.keys(inState(run, node, { [key0]: run.meta.scope.viewports[key0] }, args.state))[0] || key0
|
|
1109
1164
|
const device = run.meta.scope.devices?.[variantOf(run.meta.scope, key).viewport]?.app ? variantOf(run.meta.scope, key).viewport : null
|
|
1110
1165
|
const cfg = deviceConfig(scenario.platform, env.app?.[scenario.platform] || {}, device)
|
|
1111
1166
|
const driver = driverFor(scenario.platform, { ...env, app: { ...(env.app || {}), [scenario.platform]: cfg } }, { root, scenarioDir: resolve(root, run.meta.scenario_file, '..'), locale })
|
|
@@ -1139,7 +1194,7 @@ const commands = {
|
|
|
1139
1194
|
return
|
|
1140
1195
|
}
|
|
1141
1196
|
const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
|
|
1142
|
-
const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: manualKeys(run, list(args.viewports)), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, stability: Number(args.stability) || 0 })
|
|
1197
|
+
const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: inState(run, node, manualKeys(run, list(args.viewports)), args.state), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, stability: Number(args.stability) || 0 })
|
|
1143
1198
|
for (const r of results) {
|
|
1144
1199
|
const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
|
|
1145
1200
|
say(`✓ ${node.id} ${locale} ${r.viewport} ${r.size.width}×${r.size.height}px ${failed.length ? `failing checks: ${failed.join(', ')}` : 'checks ok'}${r.blocked_requests.length ? ` blocked: ${r.blocked_requests.length}` : ''}`)
|
|
@@ -1197,8 +1252,8 @@ const commands = {
|
|
|
1197
1252
|
}
|
|
1198
1253
|
}
|
|
1199
1254
|
if (env.kind === 'design') {
|
|
1200
|
-
await walkDesign({ config, root, run, scenario, env, pick: list(args.steps), locales: list(args.locales) || run.meta.scope.locales, viewports:
|
|
1201
|
-
const lint = await lintDesign({ dir: designDir(env, root), scenario })
|
|
1255
|
+
await walkDesign({ config, root, run, scenario, env, pick: list(args.steps), locales: list(args.locales) || run.meta.scope.locales, viewports: scopeKeys(run.meta.scope, engineKeys(run.meta.scope, 'chromium', '*'), list(args.viewports)), say, noCache: !!args['no-cache'] })
|
|
1256
|
+
const lint = await lintDesign({ dir: designDir(env, root), scenario, origins: await lintOrigins(config, env) })
|
|
1202
1257
|
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)' : ''}.`)
|
|
1203
1258
|
return
|
|
1204
1259
|
}
|
|
@@ -1341,22 +1396,32 @@ const commands = {
|
|
|
1341
1396
|
const scriptFile = resolve(scenarioDir, node.execution.script)
|
|
1342
1397
|
// one invocation of the step script; the automatic capture runs unless the script captured the step itself:
|
|
1343
1398
|
// a crop is an attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
|
|
1344
|
-
const
|
|
1345
|
-
|
|
1399
|
+
const rec0 = await recorderFor(sPage)
|
|
1400
|
+
// step states (#46): the script runs once per state with `state`; each run captures that state's keys
|
|
1401
|
+
// (the recording follows the default state)
|
|
1402
|
+
const runStep = async (mod, state = 'default') => {
|
|
1346
1403
|
let captured = false
|
|
1404
|
+
const rec = state === 'default' ? rec0 : null
|
|
1347
1405
|
rec?.beforeStep(node)
|
|
1348
1406
|
const records = []
|
|
1349
|
-
const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeKeys(run.meta.scope, pass.keys, opts.viewports || null), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); records.push(...r); return r }) }
|
|
1407
|
+
const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeKeys(run.meta.scope, stateKeys(run.meta.scope, Object.keys(scopeKeys(run.meta.scope, pass.keys, opts.viewports || null)), state)), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); records.push(...r); return r }) }
|
|
1350
1408
|
try {
|
|
1351
|
-
await mod.default({ page: sPage, context: sCtx, locale, node: { id: node.id, title: localized(node.title, locale), kind: node.kind }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0 }), acceptDialogs: () => acceptDialogs(sPage) })
|
|
1409
|
+
await mod.default({ page: sPage, context: sCtx, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(run.meta.scope, node) } : {}) }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0 }), acceptDialogs: () => acceptDialogs(sPage) })
|
|
1352
1410
|
if (!captured) await capture()
|
|
1353
1411
|
} catch (error) { await rec?.afterStep(node, { error }); throw error }
|
|
1354
1412
|
await rec?.afterStep(node, { records })
|
|
1355
1413
|
}
|
|
1356
1414
|
try {
|
|
1357
|
-
|
|
1415
|
+
const mod = await import(pathToFileURL(scriptFile).href)
|
|
1416
|
+
await runStep(mod)
|
|
1358
1417
|
say(`✓ ${node.id} ${locale}${tag}`)
|
|
1359
1418
|
streak.ok(streakKey)
|
|
1419
|
+
for (const state of nodeStates(run.meta.scope, node).slice(1)) {
|
|
1420
|
+
try { await runStep(mod, state); say(`✓ ${node.id} ${locale}${tag} · ${state}`) } catch (error) {
|
|
1421
|
+
failed.push(`${node.id} (${locale}${tag} · ${state}): ${error.message.split('\n')[0]}`)
|
|
1422
|
+
say(`✗ ${node.id} ${locale}${tag} · ${state}: ${error.message.split('\n')[0]}`)
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1360
1425
|
} catch (error) {
|
|
1361
1426
|
// a selector the page no longer has: propose one from the page (#13); --heal applies it once the step passes
|
|
1362
1427
|
const healed = args['no-heal'] ? null : await healStep({ error, page: sPage, scriptFile, root, output: config.output, run, node, locale, apply: !!args.heal, rerun: runStep }).catch(() => null)
|
|
@@ -1796,7 +1861,7 @@ const commands = {
|
|
|
1796
1861
|
const client = service(config)
|
|
1797
1862
|
if (env.kind === 'design' && !args.pull && !args['no-lint']) {
|
|
1798
1863
|
// what renders locally may still break on the live step (CSP, sandbox): say so before anything is pushed
|
|
1799
|
-
const lint = await lintDesign({ dir: designDir(env, root), scenario })
|
|
1864
|
+
const lint = await lintDesign({ dir: designDir(env, root), scenario, origins: await lintOrigins(config, env, { online: true }) })
|
|
1800
1865
|
if (lint.errors || lint.warnings) say(formatLint(lint, { max: 15 }) + '\n')
|
|
1801
1866
|
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.`)
|
|
1802
1867
|
}
|
|
@@ -1805,19 +1870,19 @@ const commands = {
|
|
|
1805
1870
|
|
|
1806
1871
|
async design (args) {
|
|
1807
1872
|
if (args._[0] === 'import') return designImport(args)
|
|
1808
|
-
if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--json]\n kaloko design import figma <file-url|key|export folder> [--frames a,b] [--map frames.json] [--token-env FIGMA_TOKEN] [--publish]')
|
|
1873
|
+
if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]\n kaloko design import figma <file-url|key|export folder> [--frames a,b] [--map frames.json] [--token-env FIGMA_TOKEN] [--publish]')
|
|
1809
1874
|
const { config, root } = await context(args)
|
|
1810
1875
|
let run = null
|
|
1811
1876
|
try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
|
|
1812
1877
|
let dir = typeof args.dir === 'string' ? resolve(args.dir) : null
|
|
1878
|
+
const runEnv = run ? environment(config, run.meta.environment.name) : null
|
|
1879
|
+
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')
|
|
1813
1880
|
if (!dir) {
|
|
1814
|
-
const runEnv = run ? environment(config, run.meta.environment.name) : null
|
|
1815
|
-
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')
|
|
1816
1881
|
if (!env) throw new UserError('No design environment: pass --dir <folder> or --env <design env>.')
|
|
1817
1882
|
dir = designDir(env, root)
|
|
1818
1883
|
}
|
|
1819
1884
|
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
|
|
1820
|
-
const report = await lintDesign({ dir, scenario })
|
|
1885
|
+
const report = await lintDesign({ dir, scenario, origins: env?.kind === 'design' ? await lintOrigins(config, env, { online: !args.offline }) : null })
|
|
1821
1886
|
say(args.json ? JSON.stringify(report, null, 2) : formatLint(report, { all: !!args.all, max: args.all ? 500 : 60 }))
|
|
1822
1887
|
if (report.errors || (args.strict && report.warnings)) process.exitCode = 1
|
|
1823
1888
|
},
|