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.
- package/README.md +45 -4
- package/package.json +1 -1
- package/schemas/config.v1.json +42 -2
- package/schemas/run.v1.json +33 -2
- package/schemas/scenario.v1.json +107 -16
- package/skills/kaloko/SKILL.md +8 -2
- package/skills/kaloko/reference/acceptance.md +70 -1
- package/skills/kaloko/reference/apps.md +103 -8
- package/skills/kaloko/reference/ci.md +3 -1
- package/skills/kaloko/reference/design.md +39 -0
- package/skills/kaloko/reference/devices.md +109 -0
- package/skills/kaloko/reference/environments.md +6 -0
- package/skills/kaloko/reference/execution.md +6 -0
- package/src/cli.mjs +326 -190
- package/src/lib/access.mjs +5 -2
- package/src/lib/apps/devices.mjs +187 -0
- package/src/lib/apps/drivers.mjs +11 -3
- package/src/lib/apps/ios-device.mjs +78 -0
- package/src/lib/apps/tree.mjs +9 -1
- package/src/lib/apps/webview.mjs +54 -0
- package/src/lib/apps/windows.mjs +108 -0
- package/src/lib/apps/windows.ps1 +78 -0
- package/src/lib/assertions.mjs +300 -0
- package/src/lib/build.mjs +4 -1
- package/src/lib/capture.mjs +146 -119
- package/src/lib/ci.mjs +8 -4
- package/src/lib/design-cli.mjs +35 -6
- package/src/lib/design.mjs +3 -1
- package/src/lib/devices.mjs +85 -0
- package/src/lib/devspec.mjs +3 -2
- package/src/lib/dimensions.mjs +326 -0
- package/src/lib/doctor.mjs +31 -6
- package/src/lib/drift.mjs +8 -4
- package/src/lib/evaluate.mjs +25 -7
- package/src/lib/import.mjs +2 -1
- package/src/lib/jev.mjs +3 -1
- package/src/lib/manifest-core.mjs +12 -3
- package/src/lib/probe.mjs +60 -0
- package/src/lib/recheck.mjs +6 -1
- package/src/lib/scenario.mjs +3 -0
- package/src/lib/stability-core.mjs +4 -2
- package/src/lib/walk-dimensions.mjs +83 -0
- 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
|
}
|