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.
@@ -36,6 +36,23 @@ export function statusOf (verdicts, captured) {
36
36
  return 'pass'
37
37
  }
38
38
 
39
+ // Step states (#46): a step can be captured in several states (default, empty, error, loading); the state is the
40
+ // last part of a capture key (`mobile.state-empty`, `mobile.dark.state-error`). A key without it is the default.
41
+ export const STATE_PREFIX = 'state-'
42
+ /** Fields of a capture record that name its run dimensions (batch E, step states, token modes). */
43
+ export const DIM_FIELDS = ['browser', 'color_scheme', 'reduced_motion', 'forced_colors', 'display_mode', 'token_mode', 'step_state', 'device_preset']
44
+ /** The step state of a capture key: 'default' unless its last part is `state-<name>`. */
45
+ export function stateOfKey (key) {
46
+ const last = String(key).split('.').slice(1).pop() || ''
47
+ return last.startsWith(STATE_PREFIX) ? last.slice(STATE_PREFIX.length) : 'default'
48
+ }
49
+ /** States a scenario step is captured in: its `states:` with `default` first (a step without states: ['default']). */
50
+ export const statesOf = (node) => ['default', ...(Array.isArray(node?.states) ? node.states.filter((s) => s !== 'default') : [])]
51
+ /** Does a capture key belong to this step (its state is one of the step's states)? */
52
+ export const keyAppliesTo = (key, node) => statesOf(node).includes(stateOfKey(key))
53
+ /** Does a criterion apply to a capture of this state (`states:` on the criterion; none = every state)? */
54
+ export const appliesToState = (c, state) => !Array.isArray(c?.states) || !c.states.length || c.states.includes(state || 'default')
55
+
39
56
  /** The capture fields a manifest keeps (the full probe record stays with the capture). */
40
57
  export function slimCapture (cap) {
41
58
  return {
@@ -45,7 +62,7 @@ export function slimCapture (cap) {
45
62
  ...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.platform ? { platform: cap.platform, device: cap.device || null } : {}),
46
63
  ...(cap.design ? { design: cap.design } : {}), ...(cap.capture ? { capture: cap.capture } : {}),
47
64
  ...(cap.criteria_hash ? { criteria_hash: cap.criteria_hash } : {}),
48
- ...Object.fromEntries(['browser', 'color_scheme', 'reduced_motion', 'forced_colors', 'display_mode', 'device_preset'].filter((k) => cap[k]).map((k) => [k, cap[k]])),
65
+ ...Object.fromEntries(DIM_FIELDS.filter((k) => cap[k]).map((k) => [k, cap[k]])),
49
66
  ...(cap.ignore?.length ? { ignore: cap.ignore } : {}), ...(cap.unstable ? { unstable: cap.unstable } : {}), ...(cap.keyboard ? { keyboard: cap.keyboard } : {})
50
67
  }
51
68
  }
@@ -58,9 +75,11 @@ export const keyboardOf = (k) => k ? { complete: !!k.complete, total: k.total ??
58
75
  * captures: [{ locale, viewport, cap (capture record or slimCapture), ev (valid evaluation or null),
59
76
  * files: { png, html, thumb?, eval? }, live? }]
60
77
  */
61
- export function assembleNode ({ node, pos, locales, viewportNames, captures, attachments = [] }) {
62
- const base = { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: pos.col, lane: pos.lane }
78
+ export function assembleNode ({ node, pos, locales, viewportNames: allKeys, captures, attachments = [] }) {
79
+ const base = { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: pos.col, lane: pos.lane, ...(node.states?.length ? { states: statesOf(node) } : {}) }
63
80
  if (node.kind === 'decision') return { ...base, status: null }
81
+ // keys of states this step does not have are not missing captures (a step without `states:` has the default only)
82
+ const viewportNames = allKeys.filter((k) => keyAppliesTo(k, node))
64
83
  const applicable = locales.filter((l) => !node.path || typeof node.path === 'string' || (node.path[l] ?? node.path.default))
65
84
  const mine = captures.filter((c) => applicable.includes(c.locale) && viewportNames.includes(c.viewport))
66
85
  const capturesOut = {}
@@ -73,7 +92,7 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
73
92
  png: c.files.png, thumb: c.files.thumb || null, html: c.files.html || null, url: cap.url ?? null,
74
93
  ...(cap.platform ? { platform: cap.platform, device: cap.device || null, tree: c.files.tree || null } : {}),
75
94
  // run dimensions (batch E): engine, colour scheme, motion, forced colours, display mode, device preset
76
- ...Object.fromEntries(['browser', 'color_scheme', 'reduced_motion', 'forced_colors', 'display_mode', 'device_preset'].filter((k) => cap[k]).map((k) => [k, cap[k]])),
95
+ ...Object.fromEntries(DIM_FIELDS.filter((k) => cap[k]).map((k) => [k, cap[k]])),
77
96
  ...(cap.http_status ? { http_status: cap.http_status } : {}),
78
97
  ...(cap.capture && (cap.capture.scale !== 1 || cap.capture.format === 'png' || cap.capture.masked || cap.capture.annotations?.length) ? { quality: cap.capture } : {}), size: [cap.size.width, cap.size.height],
79
98
  boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 20), blocked_list: (cap.blocked_requests || []).slice(0, 20),
@@ -111,7 +130,7 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
111
130
  // a criterion limited to other environments (environments: [production]) is not applicable on this walk:
112
131
  // reported as such, and it does not make the step partial
113
132
  const offEnv = entries.some((e) => e.not_applicable) && entries.every((e) => e.not_applicable || e.verdict === 'not_reached')
114
- return { ...agg, verdict: aggregate(verdicts), ...(offEnv ? { not_applicable: 'environment' } : {}) }
133
+ return { ...agg, verdict: aggregate(verdicts), ...(offEnv ? { not_applicable: entries.find((e) => e.not_applicable).not_applicable } : {}) }
115
134
  })
116
135
  const counting = criteria.filter((c) => !c.not_applicable)
117
136
  const byLocale = {}
@@ -150,3 +169,69 @@ export function summarize (nodes, sessions = null) {
150
169
  }
151
170
  return summary
152
171
  }
172
+
173
+ // ---- stack steps: one step, many pages of one template (or the variants of a design, #61) ----
174
+
175
+ export const STACK_DEFAULT_FIELDS = [
176
+ { id: 'title', label: 'Title', from: 'title' },
177
+ { id: 'h1', label: 'H1', from: 'h1' },
178
+ { id: 'words', label: 'Words', from: 'words' },
179
+ { id: 'http', label: 'HTTP', from: 'http' }
180
+ ]
181
+ export const STACK_SEMANTIC_SAMPLE = 20
182
+ export const stackFieldsOf = (node) => (node.fields?.length ? node.fields.map((f) => ({ label: f.label || f.id, from: f.from || f.id, ...f })) : STACK_DEFAULT_FIELDS)
183
+
184
+ /**
185
+ * A stack step's manifest node and its items index (qawalk.stack.v1), from the captures of its pages. Shared by the CLI
186
+ * build and the service, which composes design versions with stack steps from a draft.
187
+ * captures: [{ item, locale, viewport, cap (capture record), ev (valid evaluation or null), files: { png, thumb?, html?, eval } }]
188
+ * in walk order. Returns { node, index, file } where `file` is the index's path in the run.
189
+ */
190
+ export function assembleStack ({ node, base, environment = null, captures, attachments = [] }) {
191
+ const fields = stackFieldsOf(node).map((f) => ({ id: f.id, label: f.label || f.id }))
192
+ const crit = (node.criteria || []).map((c) => ({ id: c.id, text: c.text, type: c.check.type, pack: c.pack || null, ...(appliesOnEnv(c, environment) ? {} : { not_applicable: 'environment' }) }))
193
+ // criteria limited to other environments do not decide the status of a page
194
+ const live = new Set(crit.filter((c) => !c.not_applicable).map((c) => c.id))
195
+ const byItem = new Map()
196
+ for (const c of captures) {
197
+ const { cap, ev } = c
198
+ let it = byItem.get(c.item)
199
+ if (!it) { it = { id: c.item, path: cap.item?.path || null, url: cap.url, title: cap.item?.title || cap.fields?.title || cap.metadata?.title || null, fields: cap.fields || {}, captures: {}, results: {}, order: byItem.size }; byItem.set(c.item, it) }
200
+ ;(it.captures[c.locale] ||= {})[c.viewport] = { png: c.files.png, thumb: c.files.thumb || null, html: c.files.html || null, url: cap.url, size: cap.size ? (Array.isArray(cap.size) ? cap.size : [cap.size.width, cap.size.height]) : null, boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 10), overflow: (cap.overflow || []).length, meta: cap.meta, ...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.capture?.full_page === false ? { first_screen: true } : {}), ...(c.live ? { live: c.live } : {}), ...Object.fromEntries(DIM_FIELDS.filter((k) => cap[k]).map((k) => [k, cap[k]])), eval: ev ? c.files.eval : null }
201
+ // a criterion limited to other step states (#46) does not decide this capture
202
+ it.results[`${c.locale}|${c.viewport}`] = ev ? Object.fromEntries((ev.criteria || []).filter((r) => r.not_applicable !== 'state').map((r) => [r.id, [r.verdict, r.evidence ? String(r.evidence).slice(0, 200) : null, r.score ?? null]])) : Object.fromEntries(crit.filter((x) => x.type !== 'semantic').map((x) => [x.id, ['not_evaluated', null, null]]))
203
+ if (!ev) it.stale = true
204
+ }
205
+ const items = [...byItem.values()].sort((a, b) => a.order - b.order).map(({ order, ...it }) => {
206
+ const per = Object.values(it.results)
207
+ const verdictOf = (id) => worst(per.map((r) => r[id]?.[0]).filter(Boolean))
208
+ const verdicts = crit.filter((c) => live.has(c.id)).map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
209
+ const status = statusOf(verdicts, per.length > 0)
210
+ const by_locale = Object.fromEntries(Object.keys(it.captures).map((l) => [l, statusOf(Object.entries(it.results).filter(([k]) => k.startsWith(`${l}|`)).flatMap(([, r]) => Object.entries(r).filter(([id]) => live.has(id)).map(([, x]) => x[0])), true)]))
211
+ return { ...it, status, by_locale, fails: crit.filter((c) => live.has(c.id) && verdictOf(c.id) === 'fail').map((c) => c.id), review: crit.filter((c) => live.has(c.id) && ['needs_review', 'not_evaluated'].includes(verdictOf(c.id))).map((c) => c.id) }
212
+ })
213
+ const summary = { pass: 0, partial: 0, fail: 0, skip: 0 }
214
+ for (const it of items) summary[it.status]++
215
+ const criteria = crit.map((c) => {
216
+ const counts = {}
217
+ for (const it of items) { const v = worst(Object.values(it.results).map((r) => r[c.id]?.[0]).filter(Boolean)); if (v !== 'not_reached') counts[v] = (counts[v] || 0) + 1 }
218
+ return { ...c, counts, verdict: counts.fail ? 'fail' : counts.needs_review ? 'needs_review' : counts.not_evaluated ? 'not_evaluated' : counts.pass ? 'pass' : 'not_reached', by_locale: {} }
219
+ })
220
+ const semanticSample = node.items?.semantic_sample ?? STACK_SEMANTIC_SAMPLE
221
+ const file = `steps/${node.id}/items.json`
222
+ const index = { schema: 'qawalk.stack.v1', node: node.id, fields, criteria: crit, semantic_sample: semanticSample, items }
223
+ const thumbOf = (it) => { const c = Object.values(it.captures)[0]; const v = c && Object.values(c)[0]; return v ? v.thumb || v.png : null }
224
+ const sample = [...items.filter((it) => it.status === 'fail'), ...items.filter((it) => it.status !== 'fail')].slice(0, 4).map((it) => ({ id: it.id, title: it.title, path: it.path, status: it.status, thumb: thumbOf(it) }))
225
+ const status = !items.length ? 'skip' : summary.fail ? 'fail' : summary.partial || summary.skip ? 'partial' : 'pass'
226
+ const locales = [...new Set(items.flatMap((it) => Object.keys(it.by_locale)))]
227
+ const by_locale = Object.fromEntries(locales.map((l) => { const s = items.map((it) => it.by_locale[l]).filter(Boolean); return [l, s.includes('fail') ? 'fail' : s.some((x) => x !== 'pass') ? 'partial' : 'pass'] }))
228
+ const src = node.items || {}
229
+ const out = {
230
+ ...base, status, by_locale, captures: {}, criteria, semantic: {}, metadata: {}, session: node.session || null,
231
+ execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : { strategy: 'playwright', instructions: 'Open every page of the stack and capture it.' },
232
+ attachments: attachments.filter((a) => a.node === node.id && a.kind !== 'focus').map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
233
+ stack: { count: items.length, items: file, summary, fields, sample, semantic_sample: semanticSample, source: ['urls', 'file', 'sitemap', 'crawl', 'script'].find((k) => src[k]) || null, stale: items.filter((it) => it.stale).length }
234
+ }
235
+ return { node: out, index, file }
236
+ }
237
+ const appliesOnEnv = (c, environment) => !environment || !Array.isArray(c.environments) || !c.environments.length || c.environments.includes(environment)
@@ -7,6 +7,7 @@ import { readJson, UserError } from './util.mjs'
7
7
  import { readCaptures } from './run.mjs'
8
8
  import { latestCaptures } from './evaluate.mjs'
9
9
  import { specText, specDrift, driftText } from './devspec.mjs'
10
+ import { stateOfKey } from './manifest-core.mjs'
10
11
 
11
12
  const enc = encodeURIComponent
12
13
 
@@ -27,19 +28,21 @@ export async function specCmd ({ config, client, run, scenario, scenarioId, args
27
28
  const step = typeof args._[0] === 'string' ? args._[0] : typeof args.step === 'string' ? args.step : null
28
29
  const locale = typeof args.locale === 'string' ? args.locale : null
29
30
  const viewport = typeof args.viewport === 'string' ? args.viewport : null
31
+ // a step state (#46): its captures (keys <viewport>.state-<name>); default otherwise
32
+ const state = typeof args.state === 'string' ? args.state : null
30
33
  const steps = step ? [step] : (scenario?.nodes || []).filter((n) => n.kind !== 'decision' && n.kind !== 'external').map((n) => n.id)
31
- if (!steps.length) throw new UserError('Usage: kaloko spec <step> [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]')
34
+ if (!steps.length) throw new UserError('Usage: kaloko spec <step> [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--state s] [--local] [--json]')
32
35
  const out = []
33
36
  for (const s of steps) {
34
37
  if (args.local || !client) {
35
38
  if (!run) throw new UserError('--local needs a run: pass --run <id> or start one.')
36
- const list = (await localSpecs(run, { step: s, locale, viewport })).filter((x) => locale || x.locale === (run.meta.scope?.locales?.[0] || x.locale))
37
- const one = pickOne(list, viewport)
39
+ const list = (await localSpecs(run, { step: s, locale, viewport: viewport && state ? null : viewport })).filter((x) => (locale || x.locale === (run.meta.scope?.locales?.[0] || x.locale)) && stateOfKey(x.viewport) === (state || (viewport ? stateOfKey(viewport) : 'default')))
40
+ const one = pickOne(list, viewport && state && stateOfKey(viewport) === 'default' ? `${viewport}.state-${state}` : viewport)
38
41
  if (!one) continue
39
42
  out.push({ node: s, locale: one.locale, viewport: one.viewport, ref: 'local', spec: one.spec })
40
43
  } else {
41
44
  const ref = typeof args.ref === 'string' ? args.ref : typeof args.version === 'string' || typeof args.version === 'number' ? `v${args.version}` : 'design'
42
- const q = new URLSearchParams(Object.entries({ ref, locale, viewport }).filter(([, v]) => v))
45
+ const q = new URLSearchParams(Object.entries({ ref, locale, viewport, state }).filter(([, v]) => v))
43
46
  try {
44
47
  const d = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/steps/${enc(s)}/spec?${q}`)
45
48
  out.push({ node: s, locale: d.locale, viewport: d.viewport, ref: d.ref, spec: d.spec })
package/src/lib/stack.mjs CHANGED
@@ -6,16 +6,13 @@ import { resolve, extname } from 'node:path'
6
6
  import { pathToFileURL } from 'node:url'
7
7
  import { createHash } from 'node:crypto'
8
8
  import { UserError } from './util.mjs'
9
+ import { STACK_DEFAULT_FIELDS, STACK_SEMANTIC_SAMPLE, stackFieldsOf } from './manifest-core.mjs'
9
10
 
10
11
  export const DEFAULT_LIMIT = 500
11
- export const DEFAULT_SEMANTIC_SAMPLE = 20
12
- export const DEFAULT_FIELDS = [
13
- { id: 'title', label: 'Title', from: 'title' },
14
- { id: 'h1', label: 'H1', from: 'h1' },
15
- { id: 'words', label: 'Words', from: 'words' },
16
- { id: 'http', label: 'HTTP', from: 'http' }
17
- ]
18
- export const fieldsOf = (node) => (node.fields?.length ? node.fields.map((f) => ({ label: f.label || f.id, from: f.from || f.id, ...f })) : DEFAULT_FIELDS)
12
+ // fields and the semantic sample live with the pure stack assembly (manifest-core.mjs), which the service shares
13
+ export const DEFAULT_SEMANTIC_SAMPLE = STACK_SEMANTIC_SAMPLE
14
+ export const DEFAULT_FIELDS = STACK_DEFAULT_FIELDS
15
+ export const fieldsOf = stackFieldsOf
19
16
 
20
17
  const slug = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
21
18
  /** Stable, readable id of a page: the path as a slug (≤ 60 chars) plus a short hash when needed. */
@@ -174,6 +174,22 @@ export function cssValue (type, v) {
174
174
  }
175
175
 
176
176
  /** Structural problems of a set or resolver: names, references that point nowhere, unreadable values. */
177
+ /**
178
+ * A token mode as CSS (#46): every token with a plain value as a custom property on :root, named after its path
179
+ * (`color.brand.primary` → `--color-brand-primary`), the way live pages reference tokens. Composite values
180
+ * (typography, shadow) also get one property per part (`--type-body-font-size`). A design step rendered in a mode
181
+ * gets this sheet after its own styles.
182
+ */
183
+ export function modeCss (set, mode) {
184
+ const safe = (v) => String(v).replace(/[;{}<>\\]/g, '')
185
+ const lines = []
186
+ const prop = (name, v) => { if (v != null && (typeof v === 'string' || typeof v === 'number') && String(v).length < 400) lines.push(`--${name.replace(/[^A-Za-z0-9_-]+/g, '-')}:${safe(v)}`) }
187
+ for (const t of flattenTokens(set, mode)) {
188
+ if (t.value && typeof t.value === 'object' && !Array.isArray(t.value)) { for (const [k, v] of Object.entries(t.value)) prop(`${t.name}-${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}`, v) } else prop(t.name, t.value)
189
+ }
190
+ return lines.length ? `:root{${lines.join(';')}}` : ''
191
+ }
192
+
177
193
  export function validateTokens (set) {
178
194
  if (!set || typeof set !== 'object' || Array.isArray(set)) return ['A token set is a JSON object.']
179
195
  const problems = []
@@ -2,19 +2,40 @@
2
2
  // capture takes, and the lines the CLI prints. The rules themselves live in dimensions.mjs.
3
3
  import { UserError } from './util.mjs'
4
4
  import { isApp } from './apps/drivers.mjs'
5
- import { resolveViewports, expandScope, dimensionsFrom, appDevices, variantOf, pickKeys, DIMENSIONS } from './dimensions.mjs'
5
+ import { resolveViewports, expandScope, dimensionsFrom, appDevices, variantOf, pickKeys, stateOf, FREE_VALUE, DIMENSIONS } from './dimensions.mjs'
6
6
 
7
7
  const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : Array.isArray(v) ? v : null)
8
8
 
9
+ /**
10
+ * Token modes a design run renders (#46): the modes of the token set (`tokenModes`, from modesOf) whose names fit
11
+ * a capture key, the environment's tokens_mode first (it is the default, left out of keys). `token_modes:` in the
12
+ * environment, the scenario or --token-modes picks some (false: none). A set with only light and dark modes leaves
13
+ * them to the colour scheme (color_schemes: [light, dark]), as before, unless they are picked. Null when fewer than
14
+ * two modes are left.
15
+ */
16
+ export function designTokenModes ({ tokenModes = [], chosen = null, env = {} }) {
17
+ const modes = (tokenModes || []).filter((m) => FREE_VALUE.test(m))
18
+ if (chosen === false || env.token_modes === false) return null
19
+ const want = chosen || (Array.isArray(env.token_modes) ? env.token_modes : null)
20
+ if (want) for (const m of want) if (!modes.includes(m)) throw new UserError(`Token mode "${m}" is not in the token set (its modes: ${modes.join(', ') || 'none'}).`)
21
+ let out = want ? modes.filter((m) => want.includes(m)) : modes
22
+ if (!want && out.every((m) => ['light', 'dark'].includes(m))) out = []
23
+ if (out.length < 2) return null
24
+ const first = env.tokens_mode && out.includes(env.tokens_mode) ? env.tokens_mode : out[0]
25
+ return [first, ...out.filter((m) => m !== first)]
26
+ }
27
+
9
28
  /**
10
29
  * The scope of a new run: viewports (sizes and device presets), app devices and the dimensions from the CLI,
11
30
  * the scenario and the config. Web runs take every dimension; app runs take devices, colour scheme and motion;
12
- * Design environments render in Chromium with presets at their whole screen.
31
+ * Design environments render in Chromium with presets at their whole screen, in every mode of the token set
32
+ * (`tokenModes`). Steps with `states:` add the step state dimension on every platform.
13
33
  */
14
- export function startScope ({ args = {}, scenario, config, env }) {
34
+ export function startScope ({ args = {}, scenario, config, env, tokenModes = [] }) {
15
35
  const platform = scenario.platform || 'web'
16
36
  const dims = dimensionsFrom({ args, scenario, config })
17
37
  const design = env?.kind === 'design'
38
+ dims.token_modes = design ? designTokenModes({ tokenModes, chosen: dims.token_modes, env }) : null
18
39
  if (isApp(platform) || design) {
19
40
  for (const k of ['browsers', 'display_modes']) if (dims[k] && (args[k.replace(/_/g, '-')] || scenario[k])) throw new UserError(`${k.replace(/_/g, ' ')} apply to web runs; ${design ? 'design steps render in Chromium' : `a ${platform} app walks on its devices`}.`)
20
41
  dims.browsers = null; dims.display_modes = null
@@ -70,7 +91,8 @@ export function appDeviceGroups (scope, only = null) {
70
91
  const keys = Object.keys(scope?.viewports || {})
71
92
  const bases = baseViewports(scope)
72
93
  const devices = bases.filter((b) => scope.devices?.[b]?.app)
73
- const keysOf = (base) => keys.filter((k) => variantOf(scope, k).viewport === base)
94
+ // one walk per state of a step: the keys of the default state; the others follow from them (stateKeys)
95
+ const keysOf = (base) => keys.filter((k) => variantOf(scope, k).viewport === base && stateOf(k) === 'default')
74
96
  if (devices.length) {
75
97
  const picked = devices.filter((id) => !only || only.includes(id))
76
98
  if (!picked.length) throw new UserError(`This run has no device ${only.join(', ')} (its devices: ${devices.join(', ')}).`)
@@ -79,5 +101,8 @@ export function appDeviceGroups (scope, only = null) {
79
101
  return [{ device: null, title: null, keys: bases.length ? keysOf(bases[0]) : [] }]
80
102
  }
81
103
 
82
- /** Keys of one engine (a capture on the shared Chromium takes the Chromium keys). */
83
- export const engineKeys = (scope, engine = 'chromium') => Object.keys(scope?.viewports || {}).filter((k) => variantOf(scope, k).browser === engine)
104
+ /**
105
+ * Keys of one engine (a capture on the shared Chromium takes the Chromium keys), of the default step state unless
106
+ * `state` names another one (`*`: every state, a design walk).
107
+ */
108
+ export const engineKeys = (scope, engine = 'chromium', state = 'default') => Object.keys(scope?.viewports || {}).filter((k) => variantOf(scope, k).browser === engine && (state === '*' || stateOf(k) === state))