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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/schemas/config.v1.json +2 -0
- package/schemas/revision.v1.json +11 -0
- package/schemas/run.v1.json +12 -3
- package/schemas/scenario.v1.json +14 -0
- package/skills/kaloko/reference/design.md +93 -1
- package/skills/kaloko/reference/execution.md +6 -0
- package/skills/kaloko/reference/portable-html.md +12 -3
- package/skills/kaloko/reference/stacks.md +6 -0
- package/src/cli.mjs +119 -47
- package/src/lib/access.mjs +1 -1
- package/src/lib/apps/drivers.mjs +1 -1
- package/src/lib/apps/webview.mjs +3 -3
- package/src/lib/build.mjs +9 -46
- package/src/lib/capture.mjs +1 -1
- package/src/lib/changelog.mjs +2 -2
- package/src/lib/design-cli.mjs +121 -47
- package/src/lib/design-core.mjs +26 -4
- package/src/lib/design-export.mjs +28 -8
- package/src/lib/design-lint.mjs +45 -11
- package/src/lib/design-origins.mjs +86 -0
- package/src/lib/design.mjs +112 -8
- package/src/lib/dimensions.mjs +66 -12
- package/src/lib/doctor.mjs +72 -10
- package/src/lib/drift.mjs +1 -1
- package/src/lib/evaluate.mjs +7 -3
- package/src/lib/figma.mjs +1 -1
- package/src/lib/integrations-cli.mjs +3 -3
- package/src/lib/login.mjs +1 -1
- package/src/lib/mail.mjs +1 -1
- package/src/lib/manifest-core.mjs +90 -5
- package/src/lib/run.mjs +1 -1
- package/src/lib/service.mjs +39 -9
- package/src/lib/spec-cli.mjs +7 -4
- package/src/lib/stability.mjs +1 -1
- package/src/lib/stack.mjs +16 -9
- package/src/lib/tokens.mjs +16 -0
- package/src/lib/util.mjs +6 -0
- package/src/lib/video.mjs +11 -2
- package/src/lib/vision.mjs +1 -1
- package/src/lib/walk-dimensions.mjs +31 -6
- 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
|
+
}
|
package/src/lib/design.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/lib/dimensions.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
311
|
-
|
|
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))]
|
package/src/lib/doctor.mjs
CHANGED
|
@@ -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
|
|
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) {
|
|
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 {
|
|
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
|
|
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
|
|
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('; ')}` }
|
package/src/lib/evaluate.mjs
CHANGED
|
@@ -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' &&
|
|
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
|
|
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
|
}
|