kaloko 0.8.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.
Files changed (43) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +2 -0
  4. package/schemas/revision.v1.json +11 -0
  5. package/schemas/run.v1.json +12 -3
  6. package/schemas/scenario.v1.json +14 -0
  7. package/skills/kaloko/reference/design.md +93 -1
  8. package/skills/kaloko/reference/execution.md +6 -0
  9. package/skills/kaloko/reference/portable-html.md +12 -3
  10. package/skills/kaloko/reference/stacks.md +6 -0
  11. package/src/cli.mjs +119 -47
  12. package/src/lib/access.mjs +1 -1
  13. package/src/lib/apps/drivers.mjs +1 -1
  14. package/src/lib/apps/webview.mjs +3 -3
  15. package/src/lib/build.mjs +9 -46
  16. package/src/lib/capture.mjs +1 -1
  17. package/src/lib/changelog.mjs +2 -2
  18. package/src/lib/design-cli.mjs +121 -47
  19. package/src/lib/design-core.mjs +26 -4
  20. package/src/lib/design-export.mjs +28 -8
  21. package/src/lib/design-lint.mjs +45 -11
  22. package/src/lib/design-origins.mjs +86 -0
  23. package/src/lib/design.mjs +112 -8
  24. package/src/lib/dimensions.mjs +66 -12
  25. package/src/lib/doctor.mjs +72 -10
  26. package/src/lib/drift.mjs +1 -1
  27. package/src/lib/evaluate.mjs +7 -3
  28. package/src/lib/figma.mjs +1 -1
  29. package/src/lib/integrations-cli.mjs +3 -3
  30. package/src/lib/login.mjs +1 -1
  31. package/src/lib/mail.mjs +1 -1
  32. package/src/lib/manifest-core.mjs +90 -5
  33. package/src/lib/run.mjs +1 -1
  34. package/src/lib/service.mjs +39 -9
  35. package/src/lib/spec-cli.mjs +7 -4
  36. package/src/lib/stability.mjs +1 -1
  37. package/src/lib/stack.mjs +16 -9
  38. package/src/lib/tokens.mjs +16 -0
  39. package/src/lib/util.mjs +6 -0
  40. package/src/lib/video.mjs +11 -2
  41. package/src/lib/vision.mjs +1 -1
  42. package/src/lib/walk-dimensions.mjs +31 -6
  43. package/viewer/index.html +249 -48
@@ -0,0 +1,86 @@
1
+ // Font and asset origins on live design steps (#46, item 3). An organization admin lists the origins its designs
2
+ // load fonts, stylesheets and images from (Google Fonts, Adobe Fonts, a company CDN); the live origin's CSP adds them
3
+ // to style-src, font-src and img-src, never to script-src, connect-src or anything else. Shared by the service
4
+ // (worker/src/live.js, worker/src/design-origins.js) and the CLI (`kaloko design lint`), so both read an entry alike.
5
+ //
6
+ // cleanAssetOrigin(s, { reserved }) → 'https://fonts.gstatic.com' or 'https://*.cdn.example.com'; throws on anything else
7
+ // cleanAssetOrigins(list, opts) → the cleaned, de-duplicated list (at most MAX_ASSET_ORIGINS); throws on a bad entry
8
+ // originAllowed(list, url) → is the URL's origin covered by an entry (wildcards cover subdomains, not the apex)
9
+ // designCsp(appOrigin, origins) → the Content-Security-Policy of a live design step
10
+
11
+ export const MAX_ASSET_ORIGINS = 20
12
+ /** Hosts of Kaloko itself: never a font or asset origin of a design (the app, the files host, the live origin). */
13
+ export const RESERVED_HOSTS = ['kaloko.app', 'kaloko-usercontent.com']
14
+
15
+ const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'
16
+ const DOMAIN = new RegExp(`^(?=.{3,253}$)(?:${LABEL}\\.)+(?:[a-z][a-z0-9-]{0,61}[a-z0-9]|xn--[a-z0-9-]{1,59})$`)
17
+ const under = (host, base) => host === base || host.endsWith(`.${base}`)
18
+
19
+ /** One origin as typed (" HTTPS://Fonts.GoogleAPIs.com/ ") in its stored form; throws an Error that names the entry. */
20
+ export function cleanAssetOrigin (input, { reserved = [] } = {}) {
21
+ const raw = String(input ?? '').trim()
22
+ const shown = raw.slice(0, 80)
23
+ const s = raw.toLowerCase().replace(/\/$/, '')
24
+ const m = s.match(/^https:\/\/(\*\.)?([^/:?#@\s*]+)(?::(\d{1,5}))?$/)
25
+ if (!m) {
26
+ if (/^http:\/\//.test(s)) throw new Error(`Only https origins can be added: ${shown}`)
27
+ if (s.includes('*')) throw new Error(`A wildcard is allowed only as the first label (https://*.example.com): ${shown}`)
28
+ throw new Error(`Not an origin (https://host, without a path): ${shown}`)
29
+ }
30
+ const [, wild, host, port] = m
31
+ if (!DOMAIN.test(host)) throw new Error(`Not an origin (https://host, without a path): ${shown}`)
32
+ // a wildcard needs a registrable base at least: *.example.com, never *.com
33
+ if (wild && host.split('.').length < 2) throw new Error(`A wildcard needs a domain under it (https://*.example.com): ${shown}`)
34
+ if (port !== undefined && (Number(port) < 1 || Number(port) > 65535)) throw new Error(`Not an origin (https://host, without a path): ${shown}`)
35
+ for (const r of [...RESERVED_HOSTS, ...reserved].map((x) => String(x || '').toLowerCase().replace(/:\d+$/, '')).filter(Boolean)) {
36
+ if (under(host, r) || (wild && under(r, host))) throw new Error(`Kaloko's own hosts cannot be added: ${shown}`)
37
+ }
38
+ return `https://${wild || ''}${host}${port && port !== '443' ? `:${port}` : ''}`
39
+ }
40
+
41
+ /** A list (array, or text with one origin per line, commas or spaces) cleaned; throws on the first bad entry. */
42
+ export function cleanAssetOrigins (list, opts = {}) {
43
+ const items = (Array.isArray(list) ? list : String(list ?? '').split(/[\s,;]+/)).filter((x) => String(x ?? '').trim())
44
+ const out = []
45
+ for (const x of items) { const c = cleanAssetOrigin(x, opts); if (!out.includes(c)) out.push(c) }
46
+ if (out.length > MAX_ASSET_ORIGINS) throw new Error(`At most ${MAX_ASSET_ORIGINS} font and asset origins.`)
47
+ return out
48
+ }
49
+
50
+ /** Does an entry of `list` cover the origin of `url` (a URL or an origin)? Wildcards cover subdomains at any depth. */
51
+ export function originAllowed (list, url) {
52
+ let u
53
+ try { u = new URL(String(url).startsWith('//') ? `https:${url}` : String(url)) } catch { return false }
54
+ if (u.protocol !== 'https:') return false
55
+ return (list || []).some((entry) => {
56
+ const m = String(entry).match(/^https:\/\/(\*\.)?([^/:]+)(?::(\d+))?$/)
57
+ if (!m || (m[3] || '') !== u.port) return false
58
+ return m[1] ? u.hostname.endsWith(`.${m[2]}`) : u.hostname === m[2]
59
+ })
60
+ }
61
+
62
+ /**
63
+ * The CSP of a live design step. `origins` must already be cleaned (cleanAssetOrigins) and checked against the hosts
64
+ * of the instance: they are added to style-src, font-src and img-src only. Scripts, connections, frames, workers and
65
+ * forms stay on the step's own origin whatever the list says.
66
+ */
67
+ export function designCsp (appOrigin, origins = []) {
68
+ const extra = origins.length ? ` ${origins.join(' ')}` : ''
69
+ return [
70
+ "default-src 'self'",
71
+ "script-src 'self' 'unsafe-inline'",
72
+ `style-src 'self' 'unsafe-inline'${extra}`,
73
+ `img-src 'self' data: blob:${extra}`,
74
+ `font-src 'self' data:${extra}`,
75
+ "media-src 'self' blob:",
76
+ "connect-src 'self'",
77
+ "frame-src 'none'",
78
+ "worker-src 'none'",
79
+ "object-src 'none'",
80
+ "base-uri 'self'",
81
+ "form-action 'none'",
82
+ `frame-ancestors ${appOrigin}`,
83
+ // opaque origin even when opened directly: no cookies, storage or service workers
84
+ 'sandbox allow-scripts allow-forms'
85
+ ].join('; ')
86
+ }
@@ -4,7 +4,7 @@ import { createServer } from 'node:http'
4
4
  import { readFile, readdir, stat, mkdir, writeFile, copyFile, rm } from 'node:fs/promises'
5
5
  import { join, resolve, dirname, relative, sep } from 'node:path'
6
6
  import { UserError } from './util.mjs'
7
- import { bundleProblems, closure, contentTypeOf, revisionId, sha256Hex, TOKENS_FILE, SHARED_DIR, extOf } from './design-core.mjs'
7
+ import { bundleProblems, closure, contentTypeOf, revisionId, sha256Hex, TOKENS_FILE, SHARED_DIR, extOf, ITEM_ID, MAX_ITEMS, itemProblems } from './design-core.mjs'
8
8
 
9
9
  export const isDesign = (env) => env?.kind === 'design'
10
10
 
@@ -31,12 +31,25 @@ async function listFiles (dir, base = dir) {
31
31
  return out
32
32
  }
33
33
 
34
+ /**
35
+ * The page of a step state (#46): `<step>/<locale>/<state>.html` or `<step>/<state>.html` next to the entry, or null
36
+ * (the entry then renders with `?state=<name>`). `files` are the revision's paths (or a list of them).
37
+ */
38
+ export function statePage (entry, state, files) {
39
+ if (!state || state === 'default') return entry
40
+ const has = (p) => (Array.isArray(files) ? files.includes(p) : !!files?.[p])
41
+ const dir = entry.slice(0, entry.lastIndexOf('/') + 1)
42
+ const node = entry.split('/')[0]
43
+ for (const p of [`${dir}${state}.html`, `${node}/${state}.html`]) if (has(p)) return p
44
+ return null
45
+ }
46
+
34
47
  /**
35
48
  * The files of one step in one locale: `<step>/<locale>/…` overrides `<step>/…` (other locales' folders
36
49
  * are left out), plus every bundle file those reference. Returns { entry, files: [{ path, bytes, hash }] }
37
- * or null when the step has no folder.
50
+ * or null when the step has no folder. `states` (#46): the pages of the step's states travel with it.
38
51
  */
39
- export async function scanStep ({ dir, node, locale, locales }) {
52
+ export async function scanStep ({ dir, node, locale, locales, states = [] }) {
40
53
  const stepDir = join(dir, node)
41
54
  if (!await exists(stepDir)) return null
42
55
  const own = await listFiles(stepDir)
@@ -51,7 +64,9 @@ export async function scanStep ({ dir, node, locale, locales }) {
51
64
  const beside = (entryDir === `${node}/` ? common : mine.map((p) => p.slice(locale.length + 1))).filter((p) => !p.includes('/') && extOf(p) !== 'html')
52
65
  // a page of its own for the dark colour scheme (<step>/dark/index.html, or per locale): part of the revision
53
66
  const dark = own.includes(`${entryDir.slice(node.length + 1)}dark/index.html`) ? [`${entryDir}dark/index.html`] : []
54
- const roots = [entry, ...dark, ...beside.map((p) => entryDir + p)]
67
+ // pages of the step's states (<step>/<state>.html, or per locale): part of the revision like the dark page
68
+ const stateRoots = (states || []).filter((s) => s !== 'default').map((s) => statePage(entry, s, own.map((p) => `${node}/${p}`))).filter(Boolean)
69
+ const roots = [entry, ...dark, ...stateRoots, ...beside.map((p) => entryDir + p)]
55
70
  const paths = await closure({ roots, read: (p) => readFile(join(dir, p), 'utf8'), exists: (p) => isFile(join(dir, p)) })
56
71
  const files = []
57
72
  for (const path of paths) {
@@ -63,6 +78,94 @@ export async function scanStep ({ dir, node, locale, locales }) {
63
78
  return { entry, files }
64
79
  }
65
80
 
81
+ /**
82
+ * Items of a stack step as `items.urls` names them, when they name folders of the step (`refund`, `./refund/`,
83
+ * `refund/index.html`): a list of folder names. Null when the step lists app URLs (`/credit?reason=x`), uses another
84
+ * source (sitemap, file, crawl, script) or lists nothing: then the item folders are discovered.
85
+ */
86
+ export function listedItems (node) {
87
+ const urls = node?.items?.urls
88
+ if (!Array.isArray(urls) || !urls.length) return null
89
+ const ids = urls.map((u) => String(u).trim().replace(/^\.\//, '').replace(/\/index\.html$/, '').replace(/\/+$/, ''))
90
+ return ids.every((id) => ITEM_ID.test(id) && id !== '.' && id !== '..') ? [...new Set(ids)] : null
91
+ }
92
+
93
+ /** Folder names inside a step folder that are not items: locale overrides, the dark page and _private folders. */
94
+ const notItem = (name, locales) => locales.includes(name) || name === 'dark' || name.startsWith('_') || name.startsWith('.')
95
+
96
+ /**
97
+ * The item folders of a stack step (#61): `<step>/<item>/index.html` (or `<step>/<item>/<locale>/index.html`), in the
98
+ * order `items.urls` lists them, else every subfolder with a page, sorted by name. Returns [{ id, listed }]; a listed
99
+ * folder without a page is still returned, so the walk and the lint can name it.
100
+ */
101
+ export async function stackItems ({ dir, node, locales }) {
102
+ const stepDir = join(dir, node.id)
103
+ const listed = listedItems(node)
104
+ if (listed) return listed.map((id) => ({ id, listed: true }))
105
+ let entries = []
106
+ try { entries = await readdir(stepDir, { withFileTypes: true }) } catch { return [] }
107
+ const out = []
108
+ for (const e of entries.filter((x) => x.isDirectory() && !notItem(x.name, locales) && ITEM_ID.test(x.name)).sort((a, b) => a.name.localeCompare(b.name))) {
109
+ const has = await isFile(join(stepDir, e.name, 'index.html')) || (await Promise.all(locales.map((l) => isFile(join(stepDir, e.name, l, 'index.html'))))).some(Boolean)
110
+ if (has) out.push({ id: e.name, listed: false })
111
+ }
112
+ return out
113
+ }
114
+
115
+ /** Entry of a folder (`<base>/<locale>/index.html` over `<base>/index.html`) and the roots of its revision. */
116
+ async function folderRoots ({ dir, base, locale, locales, states = [] }) {
117
+ const own = await listFiles(join(dir, base))
118
+ const localeDirs = new Set(locales)
119
+ const common = own.filter((p) => !localeDirs.has(p.split('/')[0]))
120
+ const mine = own.filter((p) => p.split('/')[0] === locale)
121
+ const entry = mine.includes(`${locale}/index.html`) ? `${base}/${locale}/index.html` : common.includes('index.html') ? `${base}/index.html` : null
122
+ if (!entry) return null
123
+ const entryDir = entry.slice(0, entry.lastIndexOf('/') + 1)
124
+ const beside = (entryDir === `${base}/` ? common : mine.map((p) => p.slice(locale.length + 1))).filter((p) => !p.includes('/') && extOf(p) !== 'html')
125
+ const dark = own.includes(`${entryDir.slice(base.length + 1)}dark/index.html`) ? [`${entryDir}dark/index.html`] : []
126
+ // step states (#46) of a variant: <step>/<variant>/<state>.html (or per locale) next to its page
127
+ const ownPaths = own.map((p) => `${base}/${p}`)
128
+ const stateRoots = (states || []).filter((s) => s !== 'default').map((s) => [`${entryDir}${s}.html`, `${base}/${s}.html`].find((p) => ownPaths.includes(p))).filter(Boolean)
129
+ return { entry, roots: [entry, ...dark, ...stateRoots, ...beside.map((p) => entryDir + p)] }
130
+ }
131
+
132
+ /**
133
+ * The files of a stack step in one locale (#61): every item's page with what it references, and the optional overview
134
+ * (`<step>/index.html`). The revision covers the whole step folder; its entry is the overview, else the first item.
135
+ * Returns { entry, items: [{ id, entry }], files } or null when the step has no folder.
136
+ */
137
+ export async function scanStack ({ dir, node, locale, locales }) {
138
+ if (!await exists(join(dir, node.id))) return null
139
+ const found = await stackItems({ dir, node, locales })
140
+ if (!found.length) throw new UserError(`Design step ${node.id} is a stack: add a folder per variant with a page, ${node.id}/<variant>/index.html.`)
141
+ if (found.length > MAX_ITEMS) throw new UserError(`Design step ${node.id} has ${found.length} variants; a stack step holds at most ${MAX_ITEMS}.`)
142
+ const items = []
143
+ const roots = []
144
+ for (const it of found) {
145
+ const r = await folderRoots({ dir, base: `${node.id}/${it.id}`, locale, locales, states: node.states })
146
+ if (!r) throw new UserError(`Design step ${node.id}: variant ${it.id} has no page; add ${node.id}/${it.id}/index.html.`)
147
+ items.push({ id: it.id, entry: r.entry })
148
+ roots.push(...r.roots)
149
+ }
150
+ // the overview: only the step folder's own page and the files beside it, never the item folders (they come above)
151
+ const own = await listFiles(join(dir, node.id))
152
+ const overviewEntry = own.includes(`${locale}/index.html`) ? `${node.id}/${locale}/index.html` : own.includes('index.html') ? `${node.id}/index.html` : null
153
+ if (overviewEntry) {
154
+ const at = overviewEntry.slice(0, overviewEntry.lastIndexOf('/') + 1)
155
+ roots.unshift(overviewEntry, ...own.map((p) => `${node.id}/${p}`).filter((p) => p.startsWith(at) && !p.slice(at.length).includes('/') && extOf(p) !== 'html'))
156
+ }
157
+ const paths = await closure({ roots, read: (p) => readFile(join(dir, p), 'utf8'), exists: (p) => isFile(join(dir, p)) })
158
+ const files = []
159
+ for (const path of paths) {
160
+ const buf = await readFile(join(dir, path))
161
+ files.push({ path, bytes: buf.length, hash: await sha256Hex(buf) })
162
+ }
163
+ const entry = overviewEntry || items[0].entry
164
+ const problems = [...bundleProblems(files, entry), ...itemProblems(items, Object.fromEntries(files.map((f) => [f.path, f.hash])), node.id)]
165
+ if (problems.length) throw new UserError(`Design step ${node.id} (${locale}) breaks the bundle rules:\n - ${problems.join('\n - ')}`)
166
+ return { entry, items, files }
167
+ }
168
+
66
169
  /** Token set reference of the design folder (tokens.json), or null. */
67
170
  export async function tokensRef (dir, project) {
68
171
  const file = join(dir, TOKENS_FILE)
@@ -73,8 +176,9 @@ export async function tokensRef (dir, project) {
73
176
  }
74
177
 
75
178
  /** A revision of one step and locale, with its id. */
76
- export async function revisionOf ({ dir, scenario, node, locale, locales, tokens }) {
77
- const scan = await scanStep({ dir, node, locale, locales })
179
+ export async function revisionOf ({ dir, scenario, node, locale, locales, tokens, states = [], stack = null }) {
180
+ // `stack`: the scenario node of a stack step (#61); its revision covers every item folder; `states` (#46): their pages
181
+ const scan = stack ? await scanStack({ dir, node: stack, locale, locales }) : await scanStep({ dir, node, locale, locales, states })
78
182
  if (!scan) return null
79
183
  // the token set travels with every revision: the canvas inspector names values from it on the live origin
80
184
  if (tokens && !scan.files.some((f) => f.path === TOKENS_FILE)) {
@@ -82,8 +186,8 @@ export async function revisionOf ({ dir, scenario, node, locale, locales, tokens
82
186
  scan.files.push({ path: TOKENS_FILE, bytes: buf.length, hash: await sha256Hex(buf) })
83
187
  }
84
188
  const files = Object.fromEntries(scan.files.map((f) => [f.path, f.hash]))
85
- const id = await revisionId({ scenario, node, locale, entry: scan.entry, files, tokens })
86
- return { id, scenario, node, locale, state: 'default', kind: 'design', entry: scan.entry, files, sizes: Object.fromEntries(scan.files.map((f) => [f.path, f.bytes])), tokens }
189
+ const id = await revisionId({ scenario, node, locale, entry: scan.entry, files, tokens, items: scan.items || null })
190
+ return { id, scenario, node, locale, state: 'default', kind: 'design', entry: scan.entry, ...(scan.items ? { items: scan.items } : {}), files, sizes: Object.fromEntries(scan.files.map((f) => [f.path, f.bytes])), tokens }
87
191
  }
88
192
 
89
193
  /**
@@ -9,6 +9,7 @@
9
9
  import { UserError } from './util.mjs'
10
10
  import { devicePreset, emulationOf, presetSummary, presetNames } from './devices.mjs'
11
11
  import { launchWithRetry } from './browser.mjs'
12
+ import { STATE_PREFIX, stateOfKey, statesOf } from './manifest-core.mjs'
12
13
 
13
14
  export const BROWSERS = ['chromium', 'webkit', 'firefox']
14
15
  export const COLOR_SCHEMES = ['light', 'dark']
@@ -22,8 +23,15 @@ export const DIMENSIONS = [
22
23
  { name: 'color_scheme', list: 'color_schemes', values: COLOR_SCHEMES, default: 'light', suffix: (v) => v },
23
24
  { name: 'reduced_motion', list: 'reduced_motion', values: REDUCED_MOTION, default: 'no-preference', suffix: () => 'reduce' },
24
25
  { name: 'forced_colors', list: 'forced_colors', values: FORCED_COLORS, default: 'none', suffix: () => 'forced' },
25
- { name: 'display_mode', list: 'display_modes', values: DISPLAY_MODES, default: 'browser', suffix: (v) => v }
26
+ { name: 'display_mode', list: 'display_modes', values: DISPLAY_MODES, default: 'browser', suffix: (v) => v },
27
+ // token modes of a design's token set (#46): brand-a, high-contrast…; the default mode (the environment's
28
+ // tokens_mode, else the first) is the first of the list and is left out of keys
29
+ { name: 'token_mode', list: 'token_modes', values: null, default: null, suffix: (v) => `mode-${v}` },
30
+ // step states (#46): default, empty, error, loading or any name a step lists in `states:`; always the last part
31
+ { name: 'step_state', list: 'states', values: null, default: 'default', suffix: (v) => `${STATE_PREFIX}${v}` }
26
32
  ]
33
+ /** Names of token modes and states: they are parts of capture keys and file names. */
34
+ export const FREE_VALUE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/
27
35
  const ALIASES = { chrome: 'chromium', safari: 'webkit', gecko: 'firefox', 'no-motion': 'reduce', reduced: 'reduce', 'no_preference': 'no-preference', high_contrast: 'active', 'high-contrast': 'active', forced: 'active', pwa: 'standalone', installed: 'standalone' }
28
36
 
29
37
  const asList = (v) => v == null ? null : Array.isArray(v) ? v.map(String) : String(v).split(',').map((s) => s.trim()).filter(Boolean)
@@ -35,6 +43,11 @@ export function dimensionValues (dim, raw) {
35
43
  if (!list?.length) return null
36
44
  const out = []
37
45
  for (const v0 of list) {
46
+ if (!d.values) {
47
+ if (!FREE_VALUE.test(v0)) throw new UserError(`${d.list.replace(/_/g, ' ')}: "${v0}" is not a valid name (letters, digits, - and _; at most 40).`)
48
+ if (!out.includes(v0)) out.push(v0)
49
+ continue
50
+ }
38
51
  const v = ALIASES[v0.toLowerCase()] || v0.toLowerCase()
39
52
  if (!d.values.includes(v)) throw new UserError(`Unknown ${d.list.replace(/_/g, ' ')} value "${v0}". Use: ${d.values.join(', ')}.`)
40
53
  if (!out.includes(v)) out.push(v)
@@ -52,12 +65,28 @@ export function dimensionsFrom ({ args = {}, scenario = {}, config = {} } = {})
52
65
  const out = {}
53
66
  for (const d of DIMENSIONS) {
54
67
  const flag = d.list.replace(/_/g, '-')
68
+ if (d.name === 'step_state') { out.states = scenarioStates(scenario, args.states); continue }
69
+ // token modes come from the design's token set (startScope); here only an explicit choice
55
70
  const raw = args[flag] ?? args[d.list] ?? scenario?.[d.list] ?? walk[d.list]
56
- out[d.list] = dimensionValues(d.name, raw)
71
+ out[d.list] = raw === false || raw === 'false' || raw === 'none' ? (d.name === 'token_mode' ? false : null) : dimensionValues(d.name, raw)
57
72
  }
58
73
  return out
59
74
  }
60
75
 
76
+ /**
77
+ * The states of a run (#46): `default` and every state a step lists in `states:`, in scenario order; null when no
78
+ * step has states. `pick` (kaloko start --states) narrows them; default always stays.
79
+ */
80
+ export function scenarioStates (scenario, pick = null) {
81
+ const all = []
82
+ for (const n of scenario?.nodes || []) for (const s of statesOf(n)) if (!all.includes(s)) all.push(s)
83
+ if (all.length < 2) return null
84
+ const want = pick ? dimensionValues('step_state', pick) : null
85
+ if (want) for (const s of want) if (!all.includes(s)) throw new UserError(`No step of this scenario has the state "${s}" (states: ${all.slice(1).join(', ')}).`)
86
+ const out = want ? all.filter((s) => s === 'default' || want.includes(s)) : all
87
+ return out.length > 1 ? out : null
88
+ }
89
+
61
90
  /**
62
91
  * Viewports of a scenario: { name: [w, h] } as before, or names of device presets — as an array
63
92
  * (`[iphone-15, ipad-landscape, desktop]`) or as values (`phone: iphone-15`, `tablet: { device: ipad-portrait }`,
@@ -100,12 +129,14 @@ export function appDevices (ids, platform) {
100
129
  return { viewports, devices }
101
130
  }
102
131
 
103
- /** The capture key of a viewport and dimension values: defaults are left out. */
104
- export function variantKey (viewport, dims = {}) {
132
+ /** The capture key of a viewport and dimension values: defaults are left out (`defaults`: the run's own, token mode). */
133
+ export function variantKey (viewport, dims = {}, defaults = {}) {
105
134
  const parts = [viewport]
106
- for (const d of DIMENSIONS) { const v = dims[d.name]; if (v && v !== d.default) parts.push(d.suffix(v)) }
135
+ for (const d of DIMENSIONS) { const v = dims[d.name]; if (v && v !== (defaults[d.name] ?? d.default)) parts.push(d.suffix(v)) }
107
136
  return parts.join('.')
108
137
  }
138
+ /** Defaults a scope sets itself: the token mode left out of keys is the first of its modes. */
139
+ export const scopeDefaults = (scope) => (scope?.dimensions?.token_modes?.length ? { token_mode: scope.dimensions.token_modes[0] } : {})
109
140
 
110
141
  /**
111
142
  * Scope of a run: every combination of viewport and dimension values as a capture key with its size, and the
@@ -119,10 +150,11 @@ export function expandScope ({ locales, viewports, devices = {}, dims = {} }) {
119
150
  const out = {}; const variants = {}
120
151
  let combos = [{}]
121
152
  for (const [d, l] of used) combos = combos.flatMap((c) => l.map((v) => ({ ...c, [d.name]: v })))
153
+ const defaults = dims.token_modes?.length ? { token_mode: dims.token_modes[0] } : {}
122
154
  for (const [name, size] of Object.entries(viewports)) {
123
155
  const device = devices[name] || null
124
156
  for (const c of combos) {
125
- const key = variantKey(name, c)
157
+ const key = variantKey(name, c, defaults)
126
158
  let s = size
127
159
  // installed PWA: the whole screen of a preset without the browser's bars (the status bar stays)
128
160
  if (c.display_mode === 'standalone' && device?.screen) s = [size[0], Math.max(size[1], device.screen[1] - (device.safe?.top || 0))]
@@ -130,7 +162,7 @@ export function expandScope ({ locales, viewports, devices = {}, dims = {} }) {
130
162
  variants[key] = { viewport: name, browser: c.browser || 'chromium', ...c, ...(device ? { device: name } : {}) }
131
163
  }
132
164
  }
133
- if (Object.keys(out).length > 400) throw new UserError(`This scope has ${Object.keys(out).length} combinations of viewport, browser, colour scheme and motion; keep it under 400 (pick fewer viewports or values).`)
165
+ if (Object.keys(out).length > 400) throw new UserError(`This scope has ${Object.keys(out).length} combinations of viewport, browser, colour scheme, motion, token mode and state; keep it under 400 (pick fewer viewports or values).`)
134
166
  const dimensions = Object.fromEntries(used.map(([d, l]) => [d.list, l]))
135
167
  return { ...loc, viewports: out, variants, ...(used.length ? { dimensions } : {}), ...(Object.keys(devices).length ? { devices } : {}) }
136
168
  }
@@ -147,10 +179,27 @@ export function variantOf (scope, key) {
147
179
  else if (part === 'reduce') out.reduced_motion = 'reduce'
148
180
  else if (part === 'forced') out.forced_colors = 'active'
149
181
  else if (part === 'standalone') out.display_mode = 'standalone'
182
+ else if (part.startsWith('mode-')) out.token_mode = part.slice(5)
183
+ else if (part.startsWith(STATE_PREFIX)) out.step_state = part.slice(STATE_PREFIX.length)
150
184
  }
151
185
  return out
152
186
  }
153
187
 
188
+ /** The step state of a capture key ('default' when the key has none). */
189
+ export const stateOf = (key) => stateOfKey(key)
190
+
191
+ /**
192
+ * Keys of `state` for keys of the default state (a walk pass, a device): `mobile` → `mobile.state-empty`, only those
193
+ * the scope has. The default state gives the keys back.
194
+ */
195
+ export function stateKeys (scope, keys, state = 'default') {
196
+ if (!state || state === 'default') return keys.filter((k) => stateOfKey(k) === 'default')
197
+ return keys.filter((k) => stateOfKey(k) === 'default').map((k) => `${k}.${STATE_PREFIX}${state}`).filter((k) => scope?.viewports?.[k])
198
+ }
199
+
200
+ /** The states a walk captures a step in: its `states:` that the run has, default first. */
201
+ export const nodeStates = (scope, node) => statesOf(node).filter((s) => s === 'default' || scope?.dimensions?.states?.includes(s))
202
+
154
203
  /** The fields a capture record and a manifest capture carry for its dimensions (only those that were set). */
155
204
  export function variantFields (scope, key) {
156
205
  const v = scope?.variants?.[key]
@@ -167,7 +216,8 @@ export function variantFields (scope, key) {
167
216
  * `only` limits the engines (walk --browsers).
168
217
  */
169
218
  export function passesOf (scope, { only = null } = {}) {
170
- const keys = Object.keys(scope?.viewports || {})
219
+ // keys of other step states are captured by the same passes, one step script invocation per state
220
+ const keys = Object.keys(scope?.viewports || {}).filter((k) => stateOfKey(k) === 'default')
171
221
  const passes = new Map()
172
222
  for (const key of keys) {
173
223
  const v = variantOf(scope, key)
@@ -300,15 +350,19 @@ export function referenceKey (key, available, scope = null) {
300
350
  const have = new Set(available || [])
301
351
  if (have.has(key)) return key
302
352
  const v = variantOf(scope, key)
303
- const chromium = variantKey(v.viewport, { ...v, browser: 'chromium' })
353
+ const defaults = scopeDefaults(scope)
354
+ const chromium = variantKey(v.viewport, { ...v, browser: 'chromium' }, defaults)
304
355
  if (have.has(chromium)) return chromium
305
356
  // a reference without a colour scheme dimension (light by default) answers for light captures
306
- if (!v.color_scheme || v.color_scheme === 'light') { const plain = variantKey(v.viewport, { ...v, browser: 'chromium', color_scheme: null }); if (have.has(plain)) return plain }
357
+ if (!v.color_scheme || v.color_scheme === 'light') { const plain = variantKey(v.viewport, { ...v, browser: 'chromium', color_scheme: null }, defaults); if (have.has(plain)) return plain }
307
358
  return null
308
359
  }
309
360
 
310
- /** Token mode of a capture: its colour scheme when the token set has that mode, else the environment's mode. */
311
- export const tokenModeOf = (variant, modes = [], fallback = null) => (variant?.color_scheme && modes.includes(variant.color_scheme) ? variant.color_scheme : fallback)
361
+ /**
362
+ * Token mode of a capture: the token mode it was rendered in (#46), else its colour scheme when the token set has
363
+ * that mode, else the environment's mode.
364
+ */
365
+ export const tokenModeOf = (variant, modes = [], fallback = null) => (variant?.token_mode && modes.includes(variant.token_mode) ? variant.token_mode : variant?.color_scheme && modes.includes(variant.color_scheme) ? variant.color_scheme : fallback)
312
366
 
313
367
  /** Engines a scope needs (for `kaloko browser install` hints and doctor). */
314
368
  export const enginesOf = (scope) => [...new Set(Object.keys(scope?.viewports || {}).map((k) => variantOf(scope, k).browser))]
@@ -2,8 +2,10 @@
2
2
  // Every check returns { name, ok, detail, hint } so the CLI and tests can read it.
3
3
  import { appTools, macPermissions } from './apps/drivers.mjs'
4
4
  import { createServer } from 'node:net'
5
- import { access as exists, stat } from 'node:fs/promises'
6
- import { resolve } from 'node:path'
5
+ import { access as exists, stat, mkdir, readFile, writeFile } from 'node:fs/promises'
6
+ import { resolve, join, dirname } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { spawn } from 'node:child_process'
7
9
  import { environment } from './config.mjs'
8
10
  import { gateOf, gateHeaders, accountFor } from './access.mjs'
9
11
  import { missing } from './secrets.mjs'
@@ -38,21 +40,38 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
38
40
  * @param {Function} [o.launchEngine] async (engine) => { version } — throws when the engine is not installed
39
41
  * @param {object} [o.tools] app tools found (default: looked up on PATH)
40
42
  * @param {object[]} [o.scenarios] { file, product, module } of the scenario files (products checks)
43
+ * @param {boolean} [o.fix] repair what can be repaired here: install missing Playwright browsers, create the run and
44
+ * scenario folders, write org: from the token into the config, ask macOS for permissions
45
+ * @param {Function} [o.installBrowser] async (engine) => void — installs a Playwright engine (--fix)
41
46
  */
42
- export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [] }) {
47
+ export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [], installBrowser = defaultInstall }) {
43
48
  const checks = []
44
- const add = (name, ok, detail = '', hint = '') => checks.push({ name, ok, detail, hint })
49
+ const add = (name, ok, detail = '', hint = '', fixed = null) => checks.push({ name, ok, detail, hint, ...(fixed ? { fixed } : {}) })
50
+ // --fix: install an engine, then look again; returns the version or throws what the install said
51
+ const installed = async (engine, retry) => { await installBrowser(engine); return retry() }
45
52
 
46
53
  const major = Number(process.versions.node.split('.')[0])
47
54
  add('node', major >= 20, `Node ${process.versions.node}`, major >= 20 ? '' : 'Kaloko needs Node 20 or newer.')
48
55
 
49
56
  add('config', true, `${file || `${root}/kaloko.config.yml`} · org ${config.org || '—'} · ${Object.keys(config.environments || {}).length} environments`)
50
- if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the organization slug from kaloko.app).')
57
+ if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the part after kaloko.app/ when you are signed in), or run kaloko doctor --fix with a token to fill it in.')
58
+ await folderChecks({ config, root, fix, add })
51
59
 
52
- try { const c = await launchChrome(config); add('chrome', true, c.version ? `Chrome ${c.version}` : 'Chrome found') } catch (e) { add('chrome', false, e.message.split('\n')[0].slice(0, 160), config.browser?.channel ? 'Install Google Chrome, or remove browser.channel to use bundled Chromium (npx playwright install chromium).' : 'Run: npx playwright install chromium') }
60
+ try { const c = await launchChrome(config); add('chrome', true, c.version ? `Chrome ${c.version}` : 'Chrome found') } catch (e) {
61
+ let done = null
62
+ // the bundled Chromium can be installed from here; a branded Chrome (browser.channel) is the person's to install
63
+ if (fix && !config.browser?.channel) { try { done = await installed('chromium', () => launchChrome(config)) } catch {} }
64
+ if (done) add('chrome', true, `Chromium ${done.version || ''}`.trim(), '', 'installed Chromium for Playwright')
65
+ else add('chrome', false, e.message.split('\n')[0].slice(0, 160), config.browser?.channel ? 'Install Google Chrome, or remove browser.channel to use bundled Chromium (kaloko browser install).' : fix ? 'Installing Chromium did not work; run kaloko browser install and read its output (on Linux add --with-deps).' : 'Run kaloko doctor --fix (or kaloko browser install) to install Chromium for Playwright.')
66
+ }
53
67
  // cross-browser runs (batch E): the Playwright engines the scenarios and walk.browsers name
54
68
  for (const engine of [...new Set([...(engines || []), ...(config.walk?.browsers || [])])].filter((e) => e !== 'chromium')) {
55
- try { const v = await launchEngine(engine); add(`browser ${engine}`, true, `${engine} ${v.version || ''}`.trim()) } catch { add(`browser ${engine}`, false, `${engine} is not installed for Playwright`, `Run: kaloko browser install --browsers ${engine}${os === 'linux' ? ' --with-deps' : ''}`) }
69
+ try { const v = await launchEngine(engine); add(`browser ${engine}`, true, `${engine} ${v.version || ''}`.trim()) } catch {
70
+ let done = null
71
+ if (fix) { try { done = await installed(engine, () => launchEngine(engine)) } catch {} }
72
+ if (done) add(`browser ${engine}`, true, `${engine} ${done.version || ''}`.trim(), '', `installed ${engine} for Playwright`)
73
+ else add(`browser ${engine}`, false, `${engine} is not installed for Playwright`, `Run kaloko doctor --fix, or kaloko browser install --browsers ${engine}${os === 'linux' ? ' --with-deps' : ''}`)
74
+ }
56
75
  }
57
76
 
58
77
  for (const name of Object.keys(config.environments || {})) {
@@ -132,16 +151,21 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
132
151
  else {
133
152
  try {
134
153
  const res = await fetchImpl(`${service}/api/v1/me`, { headers: { Authorization: `Bearer ${token}` } })
135
- if (!res.ok) add('token', false, `${service} → HTTP ${res.status}`, res.status === 401 ? `${tokenEnv} is invalid, expired or revoked; create a new one in Settings.` : 'The service did not accept the request.')
154
+ if (!res.ok) add('token', false, `${service} → HTTP ${res.status}`, res.status === 401 ? `${tokenEnv} is invalid, expired or revoked. Run kaloko login for a new one (it replaces the old token), or create one in Settings → API tokens.` : `The service did not accept the request; try again in a few minutes (${service}/status).`)
136
155
  else {
137
156
  const me = await res.json()
157
+ // --fix: a config without org: takes the token's organization
158
+ if (!config.org && fix && me.org && file) {
159
+ const wrote = await writeOrg(file, me.org).catch(() => false)
160
+ if (wrote) { config.org = me.org; const i = checks.findIndex((c) => c.name === 'org'); if (i >= 0) checks[i] = { name: 'org', ok: true, detail: `org ${me.org}`, hint: '', fixed: `wrote org: ${me.org} into ${file}` } }
161
+ }
138
162
  const sameOrg = !config.org || me.org === config.org
139
163
  // sharing runs needs the Developer / Tester role (or admin) and, on tokens with scope sets, the upload scope
140
164
  const canShare = ['tester', 'admin'].includes(me.role) && (!Array.isArray(me.scopes) || me.scopes.includes('upload'))
141
165
  add('token', sameOrg, `${me.email} · ${me.role} · org ${me.org}${canShare ? '' : ' (this token cannot share runs)'}`, sameOrg ? '' : `The token belongs to organization "${me.org}" but the config says "${config.org}".`)
142
166
  if (!canShare) add('token role', false, Array.isArray(me.scopes) ? `${me.role} · ${me.scopes.join(', ')}` : me.role, 'kaloko share needs a Developer / Tester token with the upload scope.')
143
167
  }
144
- } catch (e) { add('token', false, `${service} unreachable: ${e.message}`, 'Check your network; KALOKO_URL overrides the service URL.') }
168
+ } catch (e) { add('token', false, `${service} unreachable: ${e.cause?.code || e.message}`, 'Check your network or VPN; KALOKO_URL points the CLI to another service.') }
145
169
  }
146
170
 
147
171
  const jev = config.evaluators?.jev
@@ -173,6 +197,44 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
173
197
  return { ok: checks.every((c) => c.ok), checks }
174
198
  }
175
199
 
200
+ /** The run folder and the folders the scenario globs start in: reported, and created with --fix. */
201
+ async function folderChecks ({ config, root, fix, add }) {
202
+ const there = (d) => exists(d).then(() => true, () => false)
203
+ const out = resolve(root, config.output || 'tmp/kaloko')
204
+ if (!await there(out)) {
205
+ if (fix && await mkdir(out, { recursive: true }).then(() => true, () => false)) add('folders', true, `${config.output} created`, '', `created ${config.output}`)
206
+ }
207
+ for (const pattern of config.scenarios || []) {
208
+ const fixed = []
209
+ for (const p of pattern.split('/')) { if (/[*?{[]/.test(p)) break; fixed.push(p) }
210
+ if (!fixed.length || fixed.length === pattern.split('/').length) continue
211
+ const dir = resolve(root, ...fixed)
212
+ if (await there(dir)) continue
213
+ if (fix && await mkdir(dir, { recursive: true }).then(() => true, () => false)) add(`scenarios ${fixed.join('/')}`, true, 'folder created; it has no scenarios yet', 'Draft a first one: kaloko draft <url>', `created ${fixed.join('/')}`)
214
+ else add(`scenarios ${fixed.join('/')}`, false, `${fixed.join('/')} does not exist (scenarios: ${pattern})`, 'Run kaloko doctor --fix to create it, then kaloko draft <url> for a first scenario.')
215
+ }
216
+ }
217
+
218
+ /** Writes org: <slug> into the config file, keeping its comments and layout. */
219
+ export async function writeOrg (file, org) {
220
+ if (!/^[a-z0-9-]+$/.test(org)) return false
221
+ const text = await readFile(file, 'utf8')
222
+ let next
223
+ if (/^org:.*$/m.test(text)) next = text.replace(/^org:.*$/m, `org: ${org}`)
224
+ else if (/^schema:.*$/m.test(text)) next = text.replace(/^(schema:.*)$/m, `$1\norg: ${org}`)
225
+ else next = `org: ${org}\n${text}`
226
+ if (next === text) return false
227
+ await writeFile(file, next)
228
+ return true
229
+ }
230
+
231
+ /** Installs a Playwright engine with the Playwright that kaloko itself uses (same as kaloko browser install). */
232
+ async function defaultInstall (engine) {
233
+ const cli = join(dirname(fileURLToPath(import.meta.resolve('playwright/package.json'))), 'cli.js')
234
+ const code = await new Promise((done) => spawn(process.execPath, [cli, 'install', engine], { stdio: 'inherit' }).on('close', done).on('error', () => done(1)))
235
+ if (code) throw new Error(`playwright install ${engine} exited with ${code}`)
236
+ }
237
+
176
238
  async function defaultEngine (engine) {
177
239
  const pw = await import('playwright')
178
240
  const browser = await pw[engine].launch({ headless: true })
@@ -189,4 +251,4 @@ async function defaultLaunch (config) {
189
251
  return { version }
190
252
  }
191
253
 
192
- export const formatDoctor = ({ ok, checks }) => [...checks.map((c) => `${c.ok ? '✓' : '✕'} ${c.name.padEnd(16)} ${c.detail}${!c.ok && c.hint ? `\n → ${c.hint}` : ''}`), ok ? 'All good.' : 'Fix the ✕ items above.'].join('\n')
254
+ export const formatDoctor = ({ ok, checks }) => [...checks.map((c) => `${c.ok ? '✓' : '✕'} ${c.name.padEnd(16)} ${c.detail}${c.fixed ? ` (fixed: ${c.fixed})` : ''}${(!c.ok || c.fixed) && c.hint ? `\n → ${c.hint}` : ''}`), ok ? 'All good.' : checks.some((c) => !c.ok && /doctor --fix/.test(c.hint || '')) ? 'Fix the ✕ items above; kaloko doctor --fix repairs the ones that say so.' : 'Fix the ✕ items above.'].join('\n')
package/src/lib/drift.mjs CHANGED
@@ -85,7 +85,7 @@ export async function driftForCapture ({ node, entry, capture, runDir, ctx }) {
85
85
  const refCaps = !ctx.isDesign ? (await referenceStep(ctx, node.id, entry.locale))?.captures : null
86
86
  const refStyles = refCaps ? refCaps[referenceKey(entry.viewport, Object.keys(refCaps))]?.design?.styles : null
87
87
  // a dark capture is checked against the dark mode of the token set (DTCG modes light/dark)
88
- const off = tokenOffenders(capture.design?.styles, ctx.tokens, tokenModeOf({ color_scheme: capture.color_scheme }, modesOf(ctx.tokens), ctx.mode), refStyles)
88
+ const off = tokenOffenders(capture.design?.styles, ctx.tokens, tokenModeOf({ color_scheme: capture.color_scheme, token_mode: capture.token_mode }, modesOf(ctx.tokens), ctx.mode), refStyles)
89
89
  if (off == null) { results[c.id] = { ok: null, evidence: 'Capture has no style summary; capture this step again.' }; continue }
90
90
  results[c.id] = off.length
91
91
  ? { ok: false, evidence: `${off.length} value(s) outside the token set: ${off.slice(0, 5).map((o) => `${o.property} ${o.value}${o.suggest ? ` → ${o.suggest}` : ''}`).join('; ')}` }
@@ -8,6 +8,7 @@ import { KEYBOARD_ASSERTS } from './keyboard.mjs'
8
8
  import { needsDrift, driftForCapture, loadTokens, driftCacheDir } from './drift.mjs'
9
9
  import { packHelp } from './pack-help.mjs'
10
10
  import { tokenCriteria, storedValueChecks, STYLE_ASSERTS } from './assertions.mjs'
11
+ import { appliesToState } from './manifest-core.mjs'
11
12
 
12
13
  /** Latest capture per node/locale/viewport. */
13
14
  export function latestCaptures (entries) {
@@ -19,6 +20,8 @@ export function latestCaptures (entries) {
19
20
  export const criteriaHash = (node) => sha256(node.criteria || [])
20
21
  /** A criterion with `environments` (e.g. [production]) applies only there; without a known environment it applies. */
21
22
  export const appliesOn = (c, environment) => !environment || !Array.isArray(c.environments) || !c.environments.length || c.environments.includes(environment)
23
+ /** Environment and step state (#46): a criterion with `states:` applies only to captures of those states. */
24
+ export const appliesTo = (c, environment, capture) => appliesOn(c, environment) && appliesToState(c, capture?.step_state)
22
25
  /** Folder of a capture under steps/: the node, or steps/<node>/items/<item> for a page of a stack step. */
23
26
  export const stepIdOf = (e) => (e.item ? `${e.node}/items/${e.item}` : e.node)
24
27
  export const evaluationSource = (node, capture) => sha256({ capture, criteria: node.criteria || [], purpose: node.purpose || '' })
@@ -31,6 +34,7 @@ export const evaluationSource = (node, capture) => sha256({ capture, criteria: n
31
34
  export function combine ({ node, capture, jev, vision = null, thresholds, environment = null }) {
32
35
  return (node.criteria || []).map((c) => {
33
36
  if (!appliesOn(c, environment)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies on ${c.environments.join(', ')} only; this walk ran on ${environment}.`, na: true, not_applicable: 'environment' }
37
+ if (!appliesToState(c, capture?.step_state)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies to the state${c.states.length > 1 ? 's' : ''} ${c.states.join(', ')} only; this capture shows ${capture?.step_state || 'default'}.`, na: true, not_applicable: 'state' }
34
38
  if (c.check.type === 'deterministic') {
35
39
  const r = capture.checks?.[c.id]
36
40
  const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
@@ -106,7 +110,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
106
110
  const current = capture.criteria_hash === criteriaHash(node)
107
111
  // console triage needs the evaluator only when the capture has console errors
108
112
  const outOfSample = entry.item && !sampled.get(entry.node)?.has(entry.item)
109
- const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && appliesOn(c, environment) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
113
+ const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && appliesTo(c, environment, capture) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
110
114
  // quick signals ride along on every captured page state (also steps without semantic criteria)
111
115
  const signals = settings.signals !== false && !outOfSample && !!capture.state
112
116
  // an imported screen without page text (#11) has nothing to ask about: a person decides on the canvas
@@ -206,7 +210,7 @@ export function semanticPrefetch ({ scenario, config, root, environment = null }
206
210
  if (!semantic.length && settings.signals === false) continue
207
211
  queue.push(async () => {
208
212
  const capture = await readJson(join(runDir, e.file))
209
- const crit = semantic.filter((c) => !(c.check.about === 'console' && !(capture.console_errors || []).length))
213
+ const crit = semantic.filter((c) => appliesToState(c, capture.step_state) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
210
214
  const signals = settings.signals !== false && !!capture.state
211
215
  if ((!crit.length && !signals) || capture.criteria_hash !== criteriaHash(node)) return
212
216
  asked++
@@ -288,7 +292,7 @@ function hostedSender (config, report) {
288
292
  try { if (!client) { const { service } = await import('./service.mjs'); client = service(config) } } catch { return { skipped: 'no evaluator key (TYPESAFE_API_KEY) and no service token (KALOKO_TOKEN) for hosted evaluation' } }
289
293
  try { return { response: await client.request('POST', `/orgs/${config.org}/semantic`, JSON.parse(body)) } } catch (e) {
290
294
  const m = String(e.message || e)
291
- if (/HTTP (402|403|503)/.test(m)) { report.hostedStop = m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.hostedStop } }
295
+ if (/HTTP (402|403|503)/.test(m)) { report.hostedStop = e.serviceMessage ?? m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.hostedStop } }
292
296
  return { error: m }
293
297
  }
294
298
  }
package/src/lib/figma.mjs CHANGED
@@ -63,7 +63,7 @@ export function figmaClient ({ token, fetch: f = globalThis.fetch, base = figmaA
63
63
  variables: (key) => get(`/v1/files/${key}/variables/local`, { optional: true }),
64
64
  async download (url) {
65
65
  const res = await f(url)
66
- if (!res.ok) throw new UserError(`Downloading a frame image failed: HTTP ${res.status}.`)
66
+ if (!res.ok) throw new UserError(`Downloading a frame image from Figma failed (HTTP ${res.status}). Figma's image links expire after a while; run the import again.`)
67
67
  return Buffer.from(await res.arrayBuffer())
68
68
  }
69
69
  }