kaloko 0.4.11 → 0.5.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 +45 -4
  2. package/package.json +1 -1
  3. package/schemas/config.v1.json +42 -2
  4. package/schemas/run.v1.json +33 -2
  5. package/schemas/scenario.v1.json +107 -16
  6. package/skills/kaloko/SKILL.md +8 -2
  7. package/skills/kaloko/reference/acceptance.md +70 -1
  8. package/skills/kaloko/reference/apps.md +103 -8
  9. package/skills/kaloko/reference/ci.md +3 -1
  10. package/skills/kaloko/reference/design.md +39 -0
  11. package/skills/kaloko/reference/devices.md +109 -0
  12. package/skills/kaloko/reference/environments.md +6 -0
  13. package/skills/kaloko/reference/execution.md +6 -0
  14. package/src/cli.mjs +326 -190
  15. package/src/lib/access.mjs +5 -2
  16. package/src/lib/apps/devices.mjs +187 -0
  17. package/src/lib/apps/drivers.mjs +11 -3
  18. package/src/lib/apps/ios-device.mjs +78 -0
  19. package/src/lib/apps/tree.mjs +9 -1
  20. package/src/lib/apps/webview.mjs +54 -0
  21. package/src/lib/apps/windows.mjs +108 -0
  22. package/src/lib/apps/windows.ps1 +78 -0
  23. package/src/lib/assertions.mjs +300 -0
  24. package/src/lib/build.mjs +4 -1
  25. package/src/lib/capture.mjs +146 -119
  26. package/src/lib/ci.mjs +8 -4
  27. package/src/lib/design-cli.mjs +35 -6
  28. package/src/lib/design.mjs +3 -1
  29. package/src/lib/devices.mjs +85 -0
  30. package/src/lib/devspec.mjs +3 -2
  31. package/src/lib/dimensions.mjs +326 -0
  32. package/src/lib/doctor.mjs +31 -6
  33. package/src/lib/drift.mjs +8 -4
  34. package/src/lib/evaluate.mjs +25 -7
  35. package/src/lib/import.mjs +2 -1
  36. package/src/lib/jev.mjs +3 -1
  37. package/src/lib/manifest-core.mjs +12 -3
  38. package/src/lib/probe.mjs +60 -0
  39. package/src/lib/recheck.mjs +6 -1
  40. package/src/lib/scenario.mjs +3 -0
  41. package/src/lib/stability-core.mjs +4 -2
  42. package/src/lib/walk-dimensions.mjs +83 -0
  43. package/viewer/index.html +282 -19
@@ -0,0 +1,300 @@
1
+ // Value assertions: the page URL after the step (url_matches, url_equals), computed CSS of elements
2
+ // (style_equals, style_matches) and a read-only expression in the page (js_equals). They exist so a step never has
3
+ // to compute a value itself and leave a data-qa marker for a criterion to find.
4
+ //
5
+ // The page records the raw values (the probe for styles, page.evaluate for expressions, page.url() for the URL);
6
+ // the decision is made here, in Node, from those records. The same decision runs at capture, on
7
+ // `kaloko evaluate` (token values from the run's token set) and on `kaloko evaluate --recheck`, which reuses the
8
+ // recorded values when the criterion still reads the same thing.
9
+ import { normColor, flattenTokens } from './tokens.mjs'
10
+
11
+ export const URL_ASSERTS = new Set(['url_matches', 'url_equals'])
12
+ export const STYLE_ASSERTS = new Set(['style_equals', 'style_matches'])
13
+ export const VALUE_ASSERTS = new Set([...URL_ASSERTS, ...STYLE_ASSERTS, 'js_equals'])
14
+ export const isColorProperty = (prop) => /(^|-)color$|^fill$|^stroke$/.test(String(prop || '').trim().toLowerCase())
15
+
16
+ const regexOf = (check) => check.pattern != null ? new RegExp(check.pattern, 'u' + [...new Set(String(check.flags || '').replace(/[^ims]/g, ''))].join('')) : null
17
+ const OPS = { '<': (a, b) => a < b, '<=': (a, b) => a <= b, '>': (a, b) => a > b, '>=': (a, b) => a >= b, '==': (a, b) => a === b }
18
+
19
+ // ---- URL ----
20
+
21
+ /** The part of a URL a check reads: href (default), path, query, hash, host or origin. */
22
+ export function urlPart (url, part = 'href') {
23
+ let u
24
+ try { u = new URL(url) } catch { return url == null ? null : String(url) }
25
+ switch (part) {
26
+ case 'path': return u.pathname
27
+ case 'query': return u.search
28
+ case 'hash': return u.hash
29
+ case 'host': return u.host
30
+ case 'origin': return u.origin
31
+ default: return u.href
32
+ }
33
+ }
34
+
35
+ /** url_matches / url_equals against the page URL after the step. */
36
+ export function urlCheck (check, url) {
37
+ if (!url || url === 'about:blank') return { ok: null, evidence: 'the capture has no page URL' }
38
+ if (check.assert === 'url_matches') {
39
+ const part = check.part || 'href'
40
+ const v = urlPart(url, part)
41
+ const ok = regexOf(check).test(v)
42
+ return { ok, evidence: `URL ${url}${part !== 'href' ? ` (${part} ${v || '""'})` : ''} ${ok ? 'matches' : 'does not match'} /${check.pattern}/` }
43
+ }
44
+ // url_equals: an absolute value compares the whole URL, a path compares path + query (+ hash when given)
45
+ const want = String(check.value ?? '')
46
+ let u
47
+ try { u = new URL(url) } catch { return { ok: false, evidence: `URL ${url} cannot be read` } }
48
+ const absolute = /^[a-z][\w+.-]*:\/\//i.test(want)
49
+ const strip = (s) => (check.ignore_query ? s.replace(/[?#].*$/, '') : s)
50
+ const actual = absolute ? strip(u.href) : strip(u.pathname + u.search + (want.includes('#') ? u.hash : ''))
51
+ const ok = actual === strip(want)
52
+ return { ok, evidence: `URL ${url}${absolute ? '' : ` (path ${actual})`} ${ok ? '=' : '≠'} ${want}${check.ignore_query ? ' (query ignored)' : ''}` }
53
+ }
54
+
55
+ // ---- computed styles ----
56
+
57
+ const fmt = (n) => String(Math.round(n * 1000) / 1000)
58
+ /** A CSS value as a list of numbers in px (px, rem, unitless), or null when any part is not a length or number. */
59
+ export function lengths (value, rootPx = 16) {
60
+ const parts = String(value ?? '').trim().split(/\s+/).filter(Boolean)
61
+ if (!parts.length) return null
62
+ const out = []
63
+ for (const p of parts) {
64
+ const m = p.match(/^(-?(?:\d+\.?\d*|\.\d+)(?:e-?\d+)?)(px|rem)?$/i)
65
+ if (!m) return null
66
+ out.push(Number(m[1]) * (m[2]?.toLowerCase() === 'rem' ? rootPx : 1))
67
+ }
68
+ return out
69
+ }
70
+ /** Canonical colour: #rrggbb, or #rrggbbaa with transparency (the token module's form). */
71
+ export const canonColor = (v) => normColor(typeof v === 'string' ? v.trim() : v)
72
+ const plain = (v) => String(v ?? '').trim().replace(/\s+/g, ' ').replace(/["']/g, '').toLowerCase()
73
+
74
+ /**
75
+ * Compares one computed value with the expected one. Colours compare in canonical hex, lengths in px (rem uses
76
+ * the page's root font size) within `tolerance`, the rest as text without quotes and case.
77
+ * Returns { ok, how } where how names the canonical form used.
78
+ */
79
+ export function compareStyle ({ property, actual, actualColor = null, expected, expectedColor = null, rootPx = 16, tolerance = 0, op = null }) {
80
+ if (isColorProperty(property)) {
81
+ const a = canonColor(actualColor || actual); const e = canonColor(expectedColor || expected)
82
+ if (a && e) return { ok: a === e, how: 'color', a, e }
83
+ }
84
+ const a = lengths(actual, rootPx); const e = lengths(expected, rootPx)
85
+ if (a && e) {
86
+ if (op && op !== '==') return { ok: a.length === 1 && e.length === 1 && OPS[op](a[0], e[0]), how: 'length', a, e }
87
+ return { ok: a.length === e.length && a.every((x, i) => Math.abs(x - e[i]) <= (tolerance || 0) + 1e-6), how: 'length', a, e }
88
+ }
89
+ if (op && op !== '==') return { ok: false, how: 'text', note: 'not a number' }
90
+ return { ok: plain(actual) === plain(expected), how: 'text' }
91
+ }
92
+
93
+ /** The CSS value of a DTCG token by dotted name, from a token set (or resolver) in a mode, or null. */
94
+ export function tokenValue (tokens, name, mode = null) {
95
+ if (!tokens || !name) return null
96
+ try {
97
+ const t = flattenTokens(tokens, mode).find((x) => x.name === name)
98
+ return t && (typeof t.value === 'string' || typeof t.value === 'number') ? String(t.value) : null
99
+ } catch { return null }
100
+ }
101
+
102
+ /**
103
+ * style_equals / style_matches from the computed values the probe recorded:
104
+ * rec = { selector, property, values: [{ el, value, color? }], root_px, token_var?, expected_color? }.
105
+ */
106
+ export function styleCheck (check, rec, { tokens = null, mode = null } = {}) {
107
+ if (!rec || !Array.isArray(rec.values)) return { ok: null, evidence: 'no computed values recorded; capture the step again' }
108
+ const prop = check.property
109
+ if (!rec.values.length) return { ok: false, evidence: `no ${check.selector}` }
110
+ const show = (x) => x.value || '""'
111
+ const all = check.all === true
112
+ let results
113
+ let label
114
+ if (check.assert === 'style_matches') {
115
+ const re = regexOf(check)
116
+ results = rec.values.map((x) => ({ x, ok: re.test(x.value) }))
117
+ label = `/${check.pattern}/`
118
+ } else {
119
+ let expected = check.value != null ? String(check.value) : null
120
+ let expectedColor = check.value != null ? rec.expected_color || null : null
121
+ if (check.token) {
122
+ const fromSet = tokenValue(tokens, check.token, mode)
123
+ const fromVar = rec.token_var?.value || null
124
+ expected = fromSet ?? fromVar
125
+ expectedColor = fromSet ? null : rec.token_var?.color || null
126
+ if (expected == null) return { ok: null, evidence: `token ${check.token} is not in the token set${rec.token_var === null ? ` and the page has no --${check.token.replace(/\./g, '-')}` : ''}; the check runs on kaloko evaluate with the project's tokens` }
127
+ }
128
+ if (expected == null) return { ok: null, evidence: `${check.assert} needs value or token` }
129
+ const shownExpected = isColorProperty(prop) && canonColor(expectedColor || expected) ? canonColor(expectedColor || expected) : expected
130
+ label = check.token ? `${check.token} (${shownExpected})` : (shownExpected !== expected ? `${expected} (${shownExpected})` : expected)
131
+ results = rec.values.map((x) => ({ x, ...compareStyle({ property: prop, actual: x.value, actualColor: x.color, expected, expectedColor, rootPx: rec.root_px || 16, tolerance: Number(check.tolerance) || 0, op: check.op || null }) }))
132
+ }
133
+ const passed = results.filter((r) => r.ok)
134
+ const ok = all ? passed.length === results.length : passed.length > 0
135
+ const rel = check.assert === 'style_matches' ? ['matches', 'does not match'] : check.op && check.op !== '==' ? [check.op, `not ${check.op}`] : ['=', '≠']
136
+ const tol = check.tolerance ? ` ± ${fmt(Number(check.tolerance))}px` : ''
137
+ const recorded = rec.recorded ? ' (computed values recorded at capture)' : ''
138
+ if (results.length === 1) {
139
+ const r = results[0]
140
+ return { ok, evidence: `${prop} is ${show(r.x)} ${r.ok ? rel[0] : rel[1]} ${label}${tol}${recorded}` }
141
+ }
142
+ const shown = all ? (results.find((r) => !r.ok) || results[0]) : (results.find((r) => r.ok) || results[0])
143
+ return { ok, evidence: `${prop}: ${passed.length} of ${results.length} × ${check.selector} ${rel[0]} ${label}${tol}${all ? ' (all must)' : ''}; ${shown.x.el} is ${show(shown.x)}${recorded}` }
144
+ }
145
+
146
+ // ---- read-only expressions ----
147
+
148
+ const CALLS = new Set(('querySelector querySelectorAll getElementById getElementsByClassName getElementsByTagName getAttribute hasAttribute ' +
149
+ 'getItem key getComputedStyle getPropertyValue getBoundingClientRect checkVisibility matches closest contains includes startsWith ' +
150
+ 'endsWith indexOf lastIndexOf trim trimStart trimEnd toLowerCase toUpperCase slice substring split join at some every filter map ' +
151
+ 'find findIndex findLast reduce flat flatMap keys values entries from isArray parse stringify has get getAll toString toFixed ' +
152
+ 'Number String Boolean parseInt parseFloat isNaN isFinite test match item max min round abs floor ceil ' +
153
+ 'getEntriesByType getEntriesByName URL URLSearchParams').split(/\s+/))
154
+ const FORBIDDEN = /\b(delete|new|function|class|import|await|async|yield|eval|Function|fetch|XMLHttpRequest|WebSocket|sendBeacon|setTimeout|setInterval|requestAnimationFrame|while|for|do|with|debugger|constructor|__proto__|prototype|defineProperty|assign|document\.write|open|close|click|submit|focus|blur|dispatchEvent|remove|append|prepend|insertAdjacentHTML|innerHTML|outerHTML|setItem|removeItem|clear|setAttribute|removeAttribute|toggleAttribute|push|pop|shift|unshift|splice|sort|reverse|fill|copyWithin|reload|replaceState|pushState|go|back|forward)\s*\(/
155
+
156
+ /**
157
+ * Why an expression is not allowed for js_equals, or null. The guard keeps expressions read-only by convention:
158
+ * one expression, no assignment, no ++/--, no templates, and only calls from a list of reading methods. It is not a
159
+ * sandbox: a getter in the page can still do anything, so read data the page exposes, nothing that acts.
160
+ */
161
+ export function jsGuard (expression) {
162
+ const src = String(expression ?? '').trim()
163
+ if (!src) return 'empty expression'
164
+ if (src.length > 500) return 'longer than 500 characters'
165
+ if (src.includes('`')) return 'template strings are not allowed; use quotes'
166
+ // `new URL(…)` and `new URLSearchParams(…)` read a URL; every other `new` is refused below
167
+ const code = src.replace(/'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g, '""').replace(/\bnew\s+(URL|URLSearchParams)\s*\(/g, '$1(')
168
+ if (/['"]/.test(code.replace(/""/g, ''))) return 'unclosed string'
169
+ if (/;/.test(code)) return 'one expression only (no ;)'
170
+ if (/(^|[^=!<>])=(?![=>])/.test(code)) return 'assignment is not allowed'
171
+ if (/\+\+|--/.test(code)) return '++ and -- are not allowed'
172
+ const bad = code.match(FORBIDDEN)
173
+ if (bad) return `${bad[1]}() is not allowed`
174
+ if (/\b(delete|new|function|class|import|await|async|yield|eval|while|for|do|with|debugger|constructor|__proto__|prototype)\b/.test(code)) return `${code.match(/\b(delete|new|function|class|import|await|async|yield|eval|while|for|do|with|debugger|constructor|__proto__|prototype)\b/)[1]} is not allowed`
175
+ if (/[)\]]\s*(\?\.)?\s*\(/.test(code)) return 'calling the result of a call or index is not allowed'
176
+ for (const m of code.matchAll(/([A-Za-z_$][\w$]*)\s*(?:\?\.)?\s*\(/g)) if (!CALLS.has(m[1])) return `${m[1]}() is not on the list of reading calls`
177
+ return null
178
+ }
179
+
180
+ export const JS_TIMEOUT = 2000
181
+
182
+ /** Evaluates the js_equals expressions of `checks` in the page: { [id]: { expression, result | error } }. */
183
+ export async function jsValues (page, checks, timeout = JS_TIMEOUT) {
184
+ const out = {}
185
+ for (const { id, check } of checks) {
186
+ const expression = String(check.expression ?? '')
187
+ const problem = jsGuard(expression)
188
+ if (problem) { out[id] = { expression, error: `not allowed: ${problem}`, refused: true }; continue }
189
+ // Playwright evaluates a string as an expression through the debugger protocol, so a page's CSP does not
190
+ // block it; promises are awaited up to the timeout
191
+ const wrapped = `(async () => {
192
+ const timer = new Promise((_, no) => setTimeout(() => no(new Error('did not finish within ${timeout} ms')), ${timeout}))
193
+ const v = await Promise.race([Promise.resolve().then(() => (${expression})), timer])
194
+ const type = v === null ? 'null' : Array.isArray(v) ? 'array' : v instanceof Element ? 'element' : typeof v
195
+ let value = v
196
+ if (type === 'element') value = v.tagName.toLowerCase() + (v.id ? '#' + v.id : '')
197
+ else if (type === 'undefined') value = null
198
+ else if (type === 'object' || type === 'array') { try { value = JSON.parse(JSON.stringify(v)) } catch { value = String(v) } }
199
+ else if (type === 'function' || type === 'symbol' || type === 'bigint') value = String(v)
200
+ return { type, value }
201
+ })()`
202
+ try {
203
+ let timer
204
+ const result = await Promise.race([
205
+ page.evaluate(wrapped),
206
+ new Promise((_, no) => { timer = setTimeout(() => no(new Error(`did not finish within ${timeout} ms`)), timeout + 1000) })
207
+ ]).finally(() => clearTimeout(timer))
208
+ const text = JSON.stringify(result.value)
209
+ out[id] = { expression, result: text && text.length > 2000 ? { type: result.type, value: `${text.slice(0, 2000)}…` } : result }
210
+ } catch (error) {
211
+ out[id] = { expression, error: String(error.message || error).split('\n')[0].replace(/^page\.evaluate: /, '').slice(0, 300) }
212
+ }
213
+ }
214
+ return out
215
+ }
216
+
217
+ const showValue = (v) => (typeof v === 'string' ? JSON.stringify(v) : JSON.stringify(v) ?? 'undefined')
218
+ /** js_equals from a recorded result: value (equality), op (numeric comparison), pattern (regex) or truthy. */
219
+ export function jsCheck (check, rec) {
220
+ if (!rec) return { ok: null, evidence: 'no expression result recorded; capture the step again' }
221
+ const expr = rec.expression.length > 80 ? `${rec.expression.slice(0, 77)}…` : rec.expression
222
+ if (rec.error) return { ok: rec.refused ? null : false, evidence: `${expr}: ${rec.error}` }
223
+ const { type, value } = rec.result || {}
224
+ const recorded = rec.recorded ? ' (result recorded at capture)' : ''
225
+ let ok
226
+ let how
227
+ if (check.pattern != null) {
228
+ const s = typeof value === 'string' ? value : JSON.stringify(value)
229
+ ok = regexOf(check).test(s ?? ''); how = `${ok ? 'matches' : 'does not match'} /${check.pattern}/`
230
+ } else if (check.op && check.op !== '==') {
231
+ const a = Number(value); const b = Number(check.value)
232
+ ok = value !== null && value !== '' && Number.isFinite(a) && Number.isFinite(b) && OPS[check.op](a, b); how = `${ok ? check.op : `not ${check.op}`} ${check.value}`
233
+ } else if (check.value !== undefined) {
234
+ const want = check.value
235
+ if (want === null) ok = value === null
236
+ else if (typeof want === 'number') ok = value !== null && value !== '' && Number(value) === want
237
+ else if (typeof want === 'boolean') ok = value === want
238
+ else ok = value !== null && (typeof value === 'object' ? JSON.stringify(value) : String(value)) === want
239
+ how = `${ok ? '=' : '≠'} ${showValue(want)}`
240
+ } else {
241
+ ok = !!value && !(Array.isArray(value) && !value.length); how = ok ? 'is truthy' : 'is not truthy'
242
+ }
243
+ return { ok, evidence: `${expr} → ${showValue(value)}${type && !['string', 'number', 'boolean', 'null'].includes(type) ? ` (${type})` : ''} ${how}${recorded}` }
244
+ }
245
+
246
+ // ---- wiring ----
247
+
248
+ const valueCriteria = (node) => (node.criteria || []).filter((c) => c.check.type === 'deterministic' && VALUE_ASSERTS.has(c.check.assert))
249
+
250
+ /**
251
+ * Decides the value assertions of one capture in the live page: reads the expressions, takes the style records
252
+ * of the probe and the URL. Fills result.checks and result.values (stored with the capture for --recheck).
253
+ */
254
+ export async function liveValueChecks ({ page, node, result, url, tokens = null, mode = null }) {
255
+ const crit = valueCriteria(node)
256
+ if (!crit.length) return
257
+ const js = crit.filter((c) => c.check.assert === 'js_equals')
258
+ const evaluated = js.length && page ? await jsValues(page, js.map((c) => ({ id: c.id, check: c.check }))) : {}
259
+ result.values = { ...(result.values || {}), ...evaluated }
260
+ for (const c of crit) {
261
+ const a = c.check.assert
262
+ if (URL_ASSERTS.has(a)) result.checks[c.id] = safely(() => urlCheck(c.check, url))
263
+ else if (STYLE_ASSERTS.has(a)) result.checks[c.id] = safely(() => styleCheck(c.check, result.values?.[c.id], { tokens, mode }))
264
+ else result.checks[c.id] = safely(() => jsCheck(c.check, result.values?.[c.id]))
265
+ }
266
+ }
267
+
268
+ const safely = (fn) => { try { return fn() } catch (error) { return { ok: false, evidence: `check error: ${error.message}` } } }
269
+
270
+ /** The recorded values still answer this check: same selector and property, or the same expression. */
271
+ export function sameReading (check, rec) {
272
+ if (!rec) return false
273
+ if (STYLE_ASSERTS.has(check.assert)) return rec.selector === check.selector && rec.property === check.property
274
+ if (check.assert === 'js_equals') return rec.expression === String(check.expression ?? '')
275
+ return false
276
+ }
277
+
278
+ /**
279
+ * The value assertions of a capture decided again without the live page (recheck, evaluate): the URL from the
280
+ * capture, styles and expressions from the values recorded at capture when the criterion reads the same thing.
281
+ */
282
+ export function storedValueChecks (node, cap, { tokens = null, mode = null, only = null, recorded = true } = {}) {
283
+ const out = {}
284
+ for (const c of valueCriteria(node)) {
285
+ const a = c.check.assert
286
+ if (only && !only(c)) continue
287
+ if (URL_ASSERTS.has(a)) { out[c.id] = safely(() => urlCheck(c.check, cap.url)); continue }
288
+ const rec = cap.values?.[c.id]
289
+ if (!sameReading(c.check, rec)) {
290
+ out[c.id] = { ok: null, evidence: STYLE_ASSERTS.has(a) ? 'not evaluated on recheck: computed styles come from the live page and this selector or property was not recorded; capture the step again' : 'not evaluated on recheck: the expression runs in the live page and was not recorded; capture the step again' }
291
+ continue
292
+ }
293
+ const stored = { ...rec, recorded }
294
+ out[c.id] = safely(() => (STYLE_ASSERTS.has(a) ? styleCheck(c.check, stored, { tokens, mode }) : jsCheck(c.check, stored)))
295
+ }
296
+ return out
297
+ }
298
+
299
+ /** Criteria whose expected value is a token of the token set (decided again on evaluate with the project's set). */
300
+ export const tokenCriteria = (node) => valueCriteria(node).filter((c) => STYLE_ASSERTS.has(c.check.assert) && c.check.token)
package/src/lib/build.mjs CHANGED
@@ -9,6 +9,7 @@ import { fieldsOf, DEFAULT_SEMANTIC_SAMPLE } from './stack.mjs'
9
9
  import { layout } from './scenario.mjs'
10
10
  import { assembleNode, summarize, statusOf, worst } from './manifest-core.mjs'
11
11
  import { packageRoot } from './schema.mjs'
12
+ import { variantOf } from './dimensions.mjs'
12
13
 
13
14
  export { targetOf, statusOf } from './manifest-core.mjs'
14
15
 
@@ -21,6 +22,8 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
21
22
  const pos = new Map(layout(scenario).map((p) => [p.id, p]))
22
23
  const locales = meta.scope.locales
23
24
  const viewportNames = Object.keys(meta.scope.viewports)
25
+ // an e-mail is rendered once, in Chromium: its other engines' keys are not missing captures
26
+ const mailKeys = viewportNames.filter((k) => variantOf(meta.scope, k).browser === 'chromium')
24
27
  const nodes = []
25
28
  for (const node of scenario.nodes) {
26
29
  if (node.kind === 'stack') { const p = pos.get(node.id); nodes.push(await buildStack({ run, node, base: { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: p.col, lane: p.lane }, captures, attachments })); continue }
@@ -37,7 +40,7 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
37
40
  }
38
41
  // focus screenshots of the keyboard walk (#19): only those of the current captures, not of earlier walks
39
42
  const shots = new Set(caps.flatMap((c) => (c.cap.keyboard?.shots || []).map((s) => s.file)))
40
- const out = assembleNode({ node, pos: pos.get(node.id), locales, viewportNames, captures: caps, attachments: attachments.filter((a) => a.node === node.id && (a.kind !== 'focus' || shots.has(a.file))) })
43
+ const out = assembleNode({ node, pos: pos.get(node.id), locales, viewportNames: node.kind === 'email' ? mailKeys : viewportNames, captures: caps, attachments: attachments.filter((a) => a.node === node.id && (a.kind !== 'focus' || shots.has(a.file))) })
41
44
  await attachMedia(out, { run, entries: media, captures: caps })
42
45
  nodes.push(out)
43
46
  }