kaloko 0.9.0 → 0.11.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/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 +99 -33
- package/src/lib/assertions.mjs +3 -1
- package/src/lib/browser-daemon.mjs +3 -1
- package/src/lib/browser.mjs +79 -10
- package/src/lib/build.mjs +9 -46
- package/src/lib/capture.mjs +1 -1
- package/src/lib/design-cli.mjs +121 -47
- package/src/lib/design-core.mjs +26 -4
- package/src/lib/design-export.mjs +26 -6
- 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 +4 -2
- package/src/lib/drift.mjs +1 -1
- package/src/lib/evaluate.mjs +6 -2
- package/src/lib/manifest-core.mjs +90 -5
- package/src/lib/spec-cli.mjs +7 -4
- package/src/lib/stack.mjs +5 -8
- package/src/lib/tokens.mjs +16 -0
- package/src/lib/walk-dimensions.mjs +31 -6
- package/viewer/index.html +142 -35
package/src/cli.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FI
|
|
|
9
9
|
import { loadScenario, parseScenario, validateScenario, scenarioWarnings } from './lib/scenario.mjs'
|
|
10
10
|
import { createRun, resolveRun, readCaptures, listRuns } from './lib/run.mjs'
|
|
11
11
|
import { gitMeta, projectAndIssue, appVersion } from './lib/git.mjs'
|
|
12
|
-
import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
|
|
12
|
+
import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, launchWithRetry, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
|
|
13
13
|
import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
|
|
14
14
|
import { observeConsole } from './lib/console.mjs'
|
|
15
15
|
import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.mjs'
|
|
@@ -50,6 +50,7 @@ import { walkDesign, syncDesign, publishVersion, previewNotes, historyCmd, refer
|
|
|
50
50
|
import { screensCmd } from './lib/screens-cli.mjs'
|
|
51
51
|
import { designDir, readBase, headKey } from './lib/design.mjs'
|
|
52
52
|
import { lintDesign, formatLint } from './lib/design-lint.mjs'
|
|
53
|
+
import { cleanAssetOrigins } from './lib/design-origins.mjs'
|
|
53
54
|
import { exportDesign } from './lib/design-export.mjs'
|
|
54
55
|
import { driverFor, isApp, pngSize, ocr as ocrImage } from './lib/apps/drivers.mjs'
|
|
55
56
|
import { captureApp } from './lib/apps/capture.mjs'
|
|
@@ -57,7 +58,7 @@ import { readTree, flatten } from './lib/apps/tree.mjs'
|
|
|
57
58
|
import { recordSettings, recordingSession, parseRecord, recordingFiles, traceFiles, stripRecordings, withoutRecordings } from './lib/recording.mjs'
|
|
58
59
|
import { nativeSession } from './lib/apps/recording-native.mjs'
|
|
59
60
|
import { videoCmd } from './lib/video-cli.mjs'
|
|
60
|
-
import { passesOf, enginesOf, contextOptionsOf, launchEngine, pickKeys, variantFields, variantOf, dimensionValues, BROWSERS } from './lib/dimensions.mjs'
|
|
61
|
+
import { passesOf, enginesOf, contextOptionsOf, launchEngine, pickKeys, variantFields, variantOf, dimensionValues, stateKeys, nodeStates, BROWSERS } from './lib/dimensions.mjs'
|
|
61
62
|
import { startScope, scopeKeys, dimensionLine, baseViewports, engineKeys, appDeviceGroups } from './lib/walk-dimensions.mjs'
|
|
62
63
|
import { deviceConfig, prepareAndroid, readyAndroid, finishAndroid, prepareSimulator, applyAppAppearance } from './lib/apps/devices.mjs'
|
|
63
64
|
import { attachAndroidWebView } from './lib/apps/webview.mjs'
|
|
@@ -113,6 +114,9 @@ Run
|
|
|
113
114
|
More dimensions: every step is captured in each combination (keys like
|
|
114
115
|
mobile.webkit.dark); default from the scenario, then walk: in the config
|
|
115
116
|
[--devices pixel-8-api-34,iphone-15-ios-18] App scenarios: the emulators, simulators or devices to walk on
|
|
117
|
+
[--states empty,error] Only these step states (states: on a step; keys like mobile.state-empty)
|
|
118
|
+
[--token-modes brand-a,brand-b | --token-modes none]
|
|
119
|
+
Design environments: the token modes to render (default every mode of the set)
|
|
116
120
|
browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
|
|
117
121
|
Shared Chromium with CDP (agents, scripts and people drive it)
|
|
118
122
|
browser install [--with-deps] [--browsers chromium,webkit,firefox]
|
|
@@ -121,8 +125,8 @@ Run
|
|
|
121
125
|
devices [--json] Device presets for viewports: (phones, foldables, tablets, laptop, desktop, TV)
|
|
122
126
|
open --step <id> [--locale l] Navigate the current page to the step URL
|
|
123
127
|
session reset New visitor: clear cookies and storage in the shared browser
|
|
124
|
-
capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b]
|
|
125
|
-
Capture the current page for a step in all viewports
|
|
128
|
+
capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b] [--state <name>]
|
|
129
|
+
Capture the current page for a step in all viewports (--state: as that step state)
|
|
126
130
|
capture --step <id> --crop <css> [--title <text>]
|
|
127
131
|
Attach an element screenshot to a step
|
|
128
132
|
capture --step <id> --image <shot.png> [--tree <dump.xml|json> | --ocr] [--platform android|ios|desktop]
|
|
@@ -307,9 +311,11 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
307
311
|
sync [--pull|--push] [--branch b] [--mine a,b|--theirs a,b] [--no-lint]
|
|
308
312
|
Exchange revisions with the shared draft; only changed steps and missing files travel
|
|
309
313
|
Lints the folder first: errors (things that break on the live step) stop the push
|
|
310
|
-
design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--json]
|
|
314
|
+
design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]
|
|
311
315
|
Check the folder against the portable HTML rules (relative URLs, no CDNs, no storage,
|
|
312
|
-
deterministic content, viewport, lang, links that also work from disk); --strict fails on warnings
|
|
316
|
+
deterministic content, viewport, lang, links that also work from disk); --strict fails on warnings.
|
|
317
|
+
Fonts, styles and images from origins of the environment are compared with the
|
|
318
|
+
organization's allowed origins (--offline skips that)
|
|
313
319
|
share [--name <text>] [--branch b] [--notes file|-] [--notes-preview]
|
|
314
320
|
Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged",
|
|
315
321
|
with "what changed" notes per step (screens per viewport, text, tokens); --notes-preview
|
|
@@ -321,7 +327,7 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
321
327
|
a folder exported from Figma works without a token. One way: no prototype links
|
|
322
328
|
history [--scenario id] [--step id] [--locale l] [--json]
|
|
323
329
|
Versions of the flow; revisions of a step with who (person or agent) and when
|
|
324
|
-
spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]
|
|
330
|
+
spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--state s] [--local] [--json]
|
|
325
331
|
What to build for a step as approved: elements with role, name and selector, styles
|
|
326
332
|
mapped to DTCG tokens (or the nearest one), :hover/:focus/:active/:disabled, contrast
|
|
327
333
|
drift [step] [--ref design|vN] [--remote <run id|latest>] [--json]
|
|
@@ -457,6 +463,21 @@ function stepOf (scenario, id) {
|
|
|
457
463
|
return node
|
|
458
464
|
}
|
|
459
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Font and asset origins for `kaloko design lint` (#46): `wanted` from `origins` of the design environment, and with
|
|
468
|
+
* `online` the organization's list from the service (GET /me design_origins; null when not signed in or unreachable).
|
|
469
|
+
*/
|
|
470
|
+
async function lintOrigins (config, env, { online = false } = {}) {
|
|
471
|
+
let wanted = []
|
|
472
|
+
try { wanted = cleanAssetOrigins(env?.origins || []) } catch (e) { throw new UserError(`Environment ${env.name}, origins: ${e.message}`) }
|
|
473
|
+
if (!online || !wanted.length) return { wanted, allowed: null }
|
|
474
|
+
let timer
|
|
475
|
+
try {
|
|
476
|
+
const me = await Promise.race([service(config).request('GET', '/me'), new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 8000) })])
|
|
477
|
+
return Array.isArray(me?.design_origins) ? { wanted, allowed: me.design_origins } : { wanted, allowed: null, unchecked: true }
|
|
478
|
+
} catch { return { wanted, allowed: null, unchecked: true } } finally { clearTimeout(timer) }
|
|
479
|
+
}
|
|
480
|
+
|
|
460
481
|
/** Scenario of --scenario (id or file) or of the current run. */
|
|
461
482
|
/**
|
|
462
483
|
* kaloko design import figma <file-url|key|folder>: Figma frames (or a folder exported from Figma) become the steps of
|
|
@@ -488,7 +509,7 @@ async function designImport (args) {
|
|
|
488
509
|
if (args.json) say(JSON.stringify(r, null, 2))
|
|
489
510
|
else say(formatImport(r, { dir: relative(process.cwd(), dir) || '.' }))
|
|
490
511
|
if (args['dry-run']) return r
|
|
491
|
-
const lint = await lintDesign({ dir, scenario })
|
|
512
|
+
const lint = await lintDesign({ dir, scenario, origins: await lintOrigins(config, env) })
|
|
492
513
|
if (lint.errors) say(`\n${formatLint(lint, { max: 10 })}`)
|
|
493
514
|
if (!args.publish) {
|
|
494
515
|
say(`\nNext: kaloko start --scenario ${relative(process.cwd(), scenarioFile)} --env ${env.name} && kaloko walk && kaloko share (or rerun with --publish).\nAccept the version on the canvas and set it as the design reference (kaloko reference --design --version <n>): implementation runs with packs: [design] are then checked against these frames.`)
|
|
@@ -521,6 +542,24 @@ function manualKeys (run, pick) {
|
|
|
521
542
|
return scopeKeys(scope, keys.length ? keys : engineKeys(scope, 'chromium'), null)
|
|
522
543
|
}
|
|
523
544
|
|
|
545
|
+
/** Modes of a design environment's token set (tokens.resolver.json or tokens.json); [] without one. */
|
|
546
|
+
async function designTokenModesOf (config, root, env) {
|
|
547
|
+
try {
|
|
548
|
+
const { loadTokens } = await import('./lib/drift.mjs')
|
|
549
|
+
const { modesOf } = await import('./lib/tokens.mjs')
|
|
550
|
+
const set = await loadTokens({ client: null, config, root, designDir: designDir(env, root) })
|
|
551
|
+
return set ? modesOf(set) : []
|
|
552
|
+
} catch { return [] }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** --state <name> (#46): the keys of that state of the step instead of the default ones. */
|
|
556
|
+
function inState (run, node, keys, state) {
|
|
557
|
+
if (!state || state === true || state === 'default') return keys
|
|
558
|
+
const have = nodeStates(run.meta.scope, node)
|
|
559
|
+
if (!have.includes(state)) throw new UserError(`Step ${node.id} has no state "${state}" in this run (${have.length > 1 ? `its states: ${have.join(', ')}` : 'add states: to the step and start a new run'}).`)
|
|
560
|
+
return Object.fromEntries(stateKeys(run.meta.scope, Object.keys(keys), state).map((k) => [k, run.meta.scope.viewports[k]]))
|
|
561
|
+
}
|
|
562
|
+
|
|
524
563
|
/** { key: [w, h] } a capture on a Chromium page takes: every Chromium key of the run, or those of `pick`. */
|
|
525
564
|
function scopeViewports (run, pick) {
|
|
526
565
|
return scopeKeys(run.meta.scope, engineKeys(run.meta.scope, 'chromium'), pick)
|
|
@@ -772,15 +811,17 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
|
|
|
772
811
|
if (!scripted && !(link && ['playwright', 'script'].includes(node.execution?.strategy))) { pending.push(`${node.id} (${locale}): ${node.execution?.strategy || 'agent'} — ${(node.execution?.instructions || '').split('\n')[0]}`); continue }
|
|
773
812
|
const streakKey = `${group.device || ''}|${locale}`
|
|
774
813
|
if (streak.stopped(streakKey)) { skipped.push(`${node.id} (${locale}${tag})`); continue }
|
|
775
|
-
|
|
814
|
+
let records = []
|
|
815
|
+
// step states (#46): the script (or the deep link) runs once per state; `state` tells the script which
|
|
816
|
+
let state = 'default'
|
|
776
817
|
try {
|
|
777
818
|
let captured = false
|
|
778
819
|
await rec?.beforeStep(node)
|
|
779
820
|
const capture = async (o = {}) => {
|
|
780
821
|
captured = true
|
|
781
|
-
await rec?.beforeCapture(node)
|
|
822
|
+
if (state === 'default') await rec?.beforeCapture(node)
|
|
782
823
|
// a named viewport: within this device, or any of the run when it has no devices (as before)
|
|
783
|
-
const keys = o.viewport ? pickKeys(scope, group.device ? group.keys : Object.keys(scope.viewports), [o.viewport]) : group.keys
|
|
824
|
+
const keys = stateKeys(scope, o.viewport ? pickKeys(scope, group.device ? group.keys : Object.keys(scope.viewports), [o.viewport]) : group.keys, state)
|
|
784
825
|
if (!keys.length) throw new UserError(`Unknown viewport "${o.viewport}" for ${group.device || platform}.`)
|
|
785
826
|
const out = []
|
|
786
827
|
for (const key of keys) {
|
|
@@ -803,14 +844,25 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
|
|
|
803
844
|
records.push(...out)
|
|
804
845
|
return out.length === 1 ? out[0] : out
|
|
805
846
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
847
|
+
const mod = scripted ? await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href) : null
|
|
848
|
+
const once = async () => {
|
|
849
|
+
captured = false
|
|
850
|
+
if (mod) await mod.default({ app: driver, page: driver.page || null, webview: driver.webview || null, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(scope, node) } : {}) }, device: group.device, env, run: run.meta, capture, link: (id = node.id) => linkFor(env, stepOf(scenario, id), locale), url: (id = node.id) => linkFor(env, stepOf(scenario, id), locale) })
|
|
851
|
+
else await driver.link(link)
|
|
852
|
+
if (!captured) await capture()
|
|
853
|
+
}
|
|
854
|
+
await once()
|
|
811
855
|
await rec?.afterStep(node, driver.web ? { records } : { record: records[0] })
|
|
812
856
|
say(`✓ ${node.id} ${locale}${tag}`)
|
|
813
857
|
streak.ok(streakKey)
|
|
858
|
+
for (const s of nodeStates(scope, node).slice(1)) {
|
|
859
|
+
state = s; records = []
|
|
860
|
+
try { await once(); say(`✓ ${node.id} ${locale}${tag} · ${s}`) } catch (error) {
|
|
861
|
+
failed.push(`${node.id} (${locale}${tag} · ${s}): ${error.message.split('\n')[0]}`)
|
|
862
|
+
say(`✗ ${node.id} ${locale}${tag} · ${s}: ${error.message.split('\n')[0]}`)
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
state = 'default'
|
|
814
866
|
} catch (error) {
|
|
815
867
|
await rec?.afterStep(node, driver.web ? { error, records } : { error, record: records[0] }).catch(() => {})
|
|
816
868
|
failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
|
|
@@ -1026,7 +1078,9 @@ const commands = {
|
|
|
1026
1078
|
if (scenario.environments && !scenario.environments.includes(env.name)) throw new UserError(`Scenario ${scenario.id} is not allowed on ${env.name} (allowed: ${scenario.environments.join(', ')}).`)
|
|
1027
1079
|
if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
|
|
1028
1080
|
const locales = list(args.locales) || scenario.locales || ['default']
|
|
1029
|
-
|
|
1081
|
+
// design environments render every mode of their token set (#46): the modes come from the folder's set
|
|
1082
|
+
const tokenModes = env.kind === 'design' ? await designTokenModesOf(config, root, env) : []
|
|
1083
|
+
const scope = { ...startScope({ args, scenario, config, env, tokenModes }), ...(args.note ? { note: args.note } : {}) }
|
|
1030
1084
|
scope.locales = locales
|
|
1031
1085
|
const vpNames = baseViewports(scope)
|
|
1032
1086
|
const git = await gitMeta(root)
|
|
@@ -1105,7 +1159,8 @@ const commands = {
|
|
|
1105
1159
|
}
|
|
1106
1160
|
if (isApp(scenario.platform)) {
|
|
1107
1161
|
// the app's current screen, through the environment's driver (no navigation); --viewport may name a device key
|
|
1108
|
-
const
|
|
1162
|
+
const key0 = typeof args.viewport === 'string' ? args.viewport : Object.keys(run.meta.scope.viewports || {})[0]
|
|
1163
|
+
const key = Object.keys(inState(run, node, { [key0]: run.meta.scope.viewports[key0] }, args.state))[0] || key0
|
|
1109
1164
|
const device = run.meta.scope.devices?.[variantOf(run.meta.scope, key).viewport]?.app ? variantOf(run.meta.scope, key).viewport : null
|
|
1110
1165
|
const cfg = deviceConfig(scenario.platform, env.app?.[scenario.platform] || {}, device)
|
|
1111
1166
|
const driver = driverFor(scenario.platform, { ...env, app: { ...(env.app || {}), [scenario.platform]: cfg } }, { root, scenarioDir: resolve(root, run.meta.scenario_file, '..'), locale })
|
|
@@ -1139,7 +1194,7 @@ const commands = {
|
|
|
1139
1194
|
return
|
|
1140
1195
|
}
|
|
1141
1196
|
const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
|
|
1142
|
-
const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: manualKeys(run, list(args.viewports)), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, stability: Number(args.stability) || 0 })
|
|
1197
|
+
const results = await captureStep({ page, node, locale, options: captureOptions(scenario, node), viewports: inState(run, node, manualKeys(run, list(args.viewports)), args.state), run, consoleFile: conn ? consoleLog(root, config.output, env.name) : null, maxChars: config.evaluators?.jev?.max_state_chars, since, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, stability: Number(args.stability) || 0 })
|
|
1143
1198
|
for (const r of results) {
|
|
1144
1199
|
const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
|
|
1145
1200
|
say(`✓ ${node.id} ${locale} ${r.viewport} ${r.size.width}×${r.size.height}px ${failed.length ? `failing checks: ${failed.join(', ')}` : 'checks ok'}${r.blocked_requests.length ? ` blocked: ${r.blocked_requests.length}` : ''}`)
|
|
@@ -1197,8 +1252,8 @@ const commands = {
|
|
|
1197
1252
|
}
|
|
1198
1253
|
}
|
|
1199
1254
|
if (env.kind === 'design') {
|
|
1200
|
-
await walkDesign({ config, root, run, scenario, env, pick: list(args.steps), locales: list(args.locales) || run.meta.scope.locales, viewports:
|
|
1201
|
-
const lint = await lintDesign({ dir: designDir(env, root), scenario })
|
|
1255
|
+
await walkDesign({ config, root, run, scenario, env, pick: list(args.steps), locales: list(args.locales) || run.meta.scope.locales, viewports: scopeKeys(run.meta.scope, engineKeys(run.meta.scope, 'chromium', '*'), list(args.viewports)), say, noCache: !!args['no-cache'] })
|
|
1256
|
+
const lint = await lintDesign({ dir: designDir(env, root), scenario, origins: await lintOrigins(config, env) })
|
|
1202
1257
|
if (lint.errors || lint.warnings) say(`\nPortable HTML: ${lint.errors} error(s), ${lint.warnings} warning(s); kaloko design lint lists them${lint.errors ? ' (errors stop kaloko sync: they break on the live step)' : ''}.`)
|
|
1203
1258
|
return
|
|
1204
1259
|
}
|
|
@@ -1341,22 +1396,32 @@ const commands = {
|
|
|
1341
1396
|
const scriptFile = resolve(scenarioDir, node.execution.script)
|
|
1342
1397
|
// one invocation of the step script; the automatic capture runs unless the script captured the step itself:
|
|
1343
1398
|
// a crop is an attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
|
|
1344
|
-
const
|
|
1345
|
-
|
|
1399
|
+
const rec0 = await recorderFor(sPage)
|
|
1400
|
+
// step states (#46): the script runs once per state with `state`; each run captures that state's keys
|
|
1401
|
+
// (the recording follows the default state)
|
|
1402
|
+
const runStep = async (mod, state = 'default') => {
|
|
1346
1403
|
let captured = false
|
|
1404
|
+
const rec = state === 'default' ? rec0 : null
|
|
1347
1405
|
rec?.beforeStep(node)
|
|
1348
1406
|
const records = []
|
|
1349
|
-
const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeKeys(run.meta.scope, pass.keys, opts.viewports || null), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); records.push(...r); return r }) }
|
|
1407
|
+
const capture = (opts = {}) => { captured = true; rec?.beforeCapture(node); return captureStep({ page: sPage, node, locale, options: captureOptions(scenario, node), viewports: scopeKeys(run.meta.scope, stateKeys(run.meta.scope, Object.keys(scopeKeys(run.meta.scope, pass.keys, opts.viewports || null)), state)), run, htmlView: htmlViewAllowed(env, scenario) ? { scenarioId: scenario.id } : null, consoleFile, maxChars: config.evaluators?.jev?.max_state_chars, extra: node.session ? { session: node.session } : {}, stability: Number(args.stability) || 0 }).then((r) => { prefetch?.add(r, run.dir); records.push(...r); return r }) }
|
|
1350
1408
|
try {
|
|
1351
|
-
await mod.default({ page: sPage, context: sCtx, locale, node: { id: node.id, title: localized(node.title, locale), kind: node.kind }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0 }), acceptDialogs: () => acceptDialogs(sPage) })
|
|
1409
|
+
await mod.default({ page: sPage, context: sCtx, locale, state, node: { id: node.id, title: localized(node.title, locale), kind: node.kind, ...(node.states ? { states: nodeStates(run.meta.scope, node) } : {}) }, session: sessionInfo(node.session), url: (id = node.id) => urlFor(env, stepOf(scenario, id), locale), env, run: run.meta, capture, crop: (selector, title, o = {}) => captureCrop({ page: sPage, node, locale, selector, title, run, padding: Number(o.padding) || 0 }), acceptDialogs: () => acceptDialogs(sPage) })
|
|
1352
1410
|
if (!captured) await capture()
|
|
1353
1411
|
} catch (error) { await rec?.afterStep(node, { error }); throw error }
|
|
1354
1412
|
await rec?.afterStep(node, { records })
|
|
1355
1413
|
}
|
|
1356
1414
|
try {
|
|
1357
|
-
|
|
1415
|
+
const mod = await import(pathToFileURL(scriptFile).href)
|
|
1416
|
+
await runStep(mod)
|
|
1358
1417
|
say(`✓ ${node.id} ${locale}${tag}`)
|
|
1359
1418
|
streak.ok(streakKey)
|
|
1419
|
+
for (const state of nodeStates(run.meta.scope, node).slice(1)) {
|
|
1420
|
+
try { await runStep(mod, state); say(`✓ ${node.id} ${locale}${tag} · ${state}`) } catch (error) {
|
|
1421
|
+
failed.push(`${node.id} (${locale}${tag} · ${state}): ${error.message.split('\n')[0]}`)
|
|
1422
|
+
say(`✗ ${node.id} ${locale}${tag} · ${state}: ${error.message.split('\n')[0]}`)
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1360
1425
|
} catch (error) {
|
|
1361
1426
|
// a selector the page no longer has: propose one from the page (#13); --heal applies it once the step passes
|
|
1362
1427
|
const healed = args['no-heal'] ? null : await healStep({ error, page: sPage, scriptFile, root, output: config.output, run, node, locale, apply: !!args.heal, rerun: runStep }).catch(() => null)
|
|
@@ -1598,7 +1663,7 @@ const commands = {
|
|
|
1598
1663
|
if (!account.configured) throw new UserError(`Add the account first: environments.${env.name}.accounts.${args.account} in ${CONFIG_FILE} (it may be empty: {}).`)
|
|
1599
1664
|
if (!process.stdin.isTTY) throw new UserError('kaloko auth save needs a person at the terminal: they sign in in the browser window and press Enter.')
|
|
1600
1665
|
const { chromium } = await import('playwright')
|
|
1601
|
-
const browser = await chromium
|
|
1666
|
+
const browser = await launchWithRetry(chromium, { headless: false, channel: config.browser.channel || undefined })
|
|
1602
1667
|
try {
|
|
1603
1668
|
const page = await (await newContext(browser, env, { root, config })).newPage()
|
|
1604
1669
|
await page.goto(typeof args.url === 'string' ? args.url : baseUrl(env), { waitUntil: 'domcontentloaded' })
|
|
@@ -1796,7 +1861,7 @@ const commands = {
|
|
|
1796
1861
|
const client = service(config)
|
|
1797
1862
|
if (env.kind === 'design' && !args.pull && !args['no-lint']) {
|
|
1798
1863
|
// what renders locally may still break on the live step (CSP, sandbox): say so before anything is pushed
|
|
1799
|
-
const lint = await lintDesign({ dir: designDir(env, root), scenario })
|
|
1864
|
+
const lint = await lintDesign({ dir: designDir(env, root), scenario, origins: await lintOrigins(config, env, { online: true }) })
|
|
1800
1865
|
if (lint.errors || lint.warnings) say(formatLint(lint, { max: 15 }) + '\n')
|
|
1801
1866
|
if (lint.errors) throw new UserError(`${lint.errors} error(s) above break the design on the live step; fix them, or sync with --no-lint to push anyway.`)
|
|
1802
1867
|
}
|
|
@@ -1805,19 +1870,19 @@ const commands = {
|
|
|
1805
1870
|
|
|
1806
1871
|
async design (args) {
|
|
1807
1872
|
if (args._[0] === 'import') return designImport(args)
|
|
1808
|
-
if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--json]\n kaloko design import figma <file-url|key|export folder> [--frames a,b] [--map frames.json] [--token-env FIGMA_TOKEN] [--publish]')
|
|
1873
|
+
if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]\n kaloko design import figma <file-url|key|export folder> [--frames a,b] [--map frames.json] [--token-env FIGMA_TOKEN] [--publish]')
|
|
1809
1874
|
const { config, root } = await context(args)
|
|
1810
1875
|
let run = null
|
|
1811
1876
|
try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
|
|
1812
1877
|
let dir = typeof args.dir === 'string' ? resolve(args.dir) : null
|
|
1878
|
+
const runEnv = run ? environment(config, run.meta.environment.name) : null
|
|
1879
|
+
const env = typeof args.env === 'string' ? environment(config, args.env) : runEnv?.kind === 'design' ? runEnv : Object.keys(config.environments || {}).map((n) => environment(config, n)).find((e) => e.kind === 'design')
|
|
1813
1880
|
if (!dir) {
|
|
1814
|
-
const runEnv = run ? environment(config, run.meta.environment.name) : null
|
|
1815
|
-
const env = typeof args.env === 'string' ? environment(config, args.env) : runEnv?.kind === 'design' ? runEnv : Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design')
|
|
1816
1881
|
if (!env) throw new UserError('No design environment: pass --dir <folder> or --env <design env>.')
|
|
1817
1882
|
dir = designDir(env, root)
|
|
1818
1883
|
}
|
|
1819
1884
|
const scenario = typeof args.scenario === 'string' && /\.ya?ml$/.test(args.scenario) ? await loadScenario(resolve(args.scenario)) : run ? await loadScenario(resolve(root, run.meta.scenario_file)).catch(() => null) : null
|
|
1820
|
-
const report = await lintDesign({ dir, scenario })
|
|
1885
|
+
const report = await lintDesign({ dir, scenario, origins: env?.kind === 'design' ? await lintOrigins(config, env, { online: !args.offline }) : null })
|
|
1821
1886
|
say(args.json ? JSON.stringify(report, null, 2) : formatLint(report, { all: !!args.all, max: args.all ? 500 : 60 }))
|
|
1822
1887
|
if (report.errors || (args.strict && report.warnings)) process.exitCode = 1
|
|
1823
1888
|
},
|
|
@@ -2209,7 +2274,8 @@ const commands = {
|
|
|
2209
2274
|
const product = pickProduct(config, root, args.product)
|
|
2210
2275
|
const printPdf = async (htmlFile, outFile) => {
|
|
2211
2276
|
const { chromium } = await import('playwright')
|
|
2212
|
-
|
|
2277
|
+
// the configured channel when it is installed, else Playwright's Chromium; a browser that stalls is not retried twice over
|
|
2278
|
+
const browser = await launchWithRetry(chromium, config.browser?.channel ? { channel: config.browser.channel } : {}).catch((e) => e?.name === 'TimeoutError' || !config.browser?.channel ? Promise.reject(e) : launchWithRetry(chromium, {}))
|
|
2213
2279
|
try {
|
|
2214
2280
|
const page = await browser.newPage()
|
|
2215
2281
|
await page.goto(pathToFileURL(htmlFile).href, { waitUntil: 'load', timeout: 60000 })
|
package/src/lib/assertions.mjs
CHANGED
|
@@ -200,10 +200,12 @@ export async function jsValues (page, checks, timeout = JS_TIMEOUT) {
|
|
|
200
200
|
return { type, value }
|
|
201
201
|
})()`
|
|
202
202
|
try {
|
|
203
|
+
// the page's own timer limits the expression; this one only guards against a page that stopped answering, with
|
|
204
|
+
// room for a slow round trip on a busy machine (a 1 s margin failed true checks under load)
|
|
203
205
|
let timer
|
|
204
206
|
const result = await Promise.race([
|
|
205
207
|
page.evaluate(wrapped),
|
|
206
|
-
new Promise((_, no) => { timer = setTimeout(() => no(new Error(`did not
|
|
208
|
+
new Promise((_, no) => { timer = setTimeout(() => no(new Error(`the page did not answer within ${Math.round((timeout + 15000) / 1000)} s`)), timeout + 15000) })
|
|
207
209
|
]).finally(() => clearTimeout(timer))
|
|
208
210
|
const text = JSON.stringify(result.value)
|
|
209
211
|
out[id] = { expression, result: text && text.length > 2000 ? { type: result.type, value: `${text.slice(0, 2000)}…` } : result }
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
import { chromium } from 'playwright'
|
|
6
6
|
import { writeJson, appendJsonl } from './util.mjs'
|
|
7
7
|
import { prepareContext, clientCertificates } from './access.mjs'
|
|
8
|
+
import { launchPersistentWithRetry } from './browser.mjs'
|
|
8
9
|
|
|
9
10
|
const opts = JSON.parse(process.argv[2])
|
|
10
11
|
const certs = opts.env ? clientCertificates(opts.env, opts.root) : []
|
|
11
|
-
|
|
12
|
+
// a time limit and one more try (KALOKO_LAUNCH_TIMEOUT): a start that stalls on a busy machine does not hang here
|
|
13
|
+
const context = await launchPersistentWithRetry(chromium, opts.profile, {
|
|
12
14
|
headless: !opts.headed,
|
|
13
15
|
channel: opts.channel || undefined,
|
|
14
16
|
viewport: { width: 1280, height: 800 },
|
package/src/lib/browser.mjs
CHANGED
|
@@ -69,12 +69,16 @@ export async function startBrowser ({ root, config, env, headed }) {
|
|
|
69
69
|
})
|
|
70
70
|
const child = spawn(process.execPath, [join(here, 'browser-daemon.mjs'), args], { detached: true, stdio: 'ignore' })
|
|
71
71
|
child.unref()
|
|
72
|
-
|
|
72
|
+
// the daemon launches with launchLimit() and tries once more after a timeout
|
|
73
|
+
const wait = 2 * launchLimit() + 10000
|
|
74
|
+
for (const until = Date.now() + wait; Date.now() < until;) {
|
|
73
75
|
await new Promise((r) => setTimeout(r, 200))
|
|
74
76
|
const state = await readJson(stateFile, null)
|
|
75
77
|
if (state && state.pid === child.pid) return state
|
|
78
|
+
if (!await alive(child.pid)) throw new UserError('Browser did not start (its process ended). Try "kaloko browser start --headed" to see what happens, or "kaloko doctor".')
|
|
76
79
|
}
|
|
77
|
-
|
|
80
|
+
try { process.kill(child.pid, 'SIGTERM') } catch {} // Playwright ends the browser it was starting
|
|
81
|
+
throw new UserError(`Browser did not start within ${Math.round(wait / 1000)} s. Try "kaloko browser start --headed" to see what happens; on a busy machine raise KALOKO_LAUNCH_TIMEOUT (seconds).`)
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
/** Stops the browser of one environment, or every shared browser of the project. */
|
|
@@ -102,7 +106,7 @@ export async function connectBrowser ({ root, config, env }) {
|
|
|
102
106
|
if (!state) return null
|
|
103
107
|
assertPolicy(state, env)
|
|
104
108
|
const { chromium } = await import('playwright')
|
|
105
|
-
const browser = await chromium
|
|
109
|
+
const browser = await connectWithRetry(chromium, state.cdp)
|
|
106
110
|
return { browser, context: browser.contexts()[0], state }
|
|
107
111
|
}
|
|
108
112
|
|
|
@@ -138,17 +142,82 @@ export async function currentPage (context, match) {
|
|
|
138
142
|
return real[real.length - 1] || pages[pages.length - 1] || context.newPage()
|
|
139
143
|
}
|
|
140
144
|
|
|
145
|
+
/** The time limit of a browser launch or connection: KALOKO_LAUNCH_TIMEOUT seconds, default 45. */
|
|
146
|
+
export const launchLimit = () => (Number(process.env.KALOKO_LAUNCH_TIMEOUT) || 45) * 1000
|
|
147
|
+
/** The time limit of the first context of a new browser and of every later newContext/newPage: 20 s, or KALOKO_LAUNCH_TIMEOUT when set. */
|
|
148
|
+
export const answerLimit = () => (Number(process.env.KALOKO_LAUNCH_TIMEOUT) > 0 ? launchLimit() : 20000)
|
|
149
|
+
|
|
150
|
+
const isTimeout = (e) => /Timeout|did not (finish|answer)/i.test(String(e?.name) + ' ' + String(e?.message))
|
|
151
|
+
|
|
152
|
+
/** `promise`, or a TimeoutError naming `what` when it has not settled within `ms`. */
|
|
153
|
+
export function withTimeout (promise, ms, what) {
|
|
154
|
+
let timer
|
|
155
|
+
const limit = new Promise((resolve, reject) => {
|
|
156
|
+
timer = setTimeout(() => reject(Object.assign(new Error(`${what} did not answer within ${Math.round(ms / 1000)} s (KALOKO_LAUNCH_TIMEOUT)`), { name: 'TimeoutError' })), ms)
|
|
157
|
+
})
|
|
158
|
+
return Promise.race([promise, limit]).finally(() => clearTimeout(timer))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Runs `attempt` and, when it timed out, once more; any other error reaches the caller. */
|
|
162
|
+
export async function retryOnTimeout (attempt) {
|
|
163
|
+
try { return await attempt() } catch (e) {
|
|
164
|
+
if (!isTimeout(e)) throw e
|
|
165
|
+
return attempt()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Puts a time limit on newContext and newPage of a browser: a browser that stopped answering (seen on loaded
|
|
171
|
+
* machines: launched fine, then Target.createBrowserContext never returned) fails the command instead of holding it
|
|
172
|
+
* forever. A context that arrives after the limit is closed.
|
|
173
|
+
*/
|
|
174
|
+
export function limitBrowser (browser, ms = answerLimit()) {
|
|
175
|
+
if (!browser || typeof browser.newContext !== 'function' || browser.__kalokoLimited) return browser
|
|
176
|
+
for (const name of ['newContext', 'newPage']) {
|
|
177
|
+
const call = browser[name].bind(browser)
|
|
178
|
+
browser[name] = (...a) => {
|
|
179
|
+
const pending = call(...a)
|
|
180
|
+
return withTimeout(pending, ms, `browser.${name}`).catch((e) => {
|
|
181
|
+
if (isTimeout(e)) pending.then((x) => x?.close?.().catch(() => {}), () => {})
|
|
182
|
+
throw e
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
browser.__kalokoLimited = true
|
|
187
|
+
return browser
|
|
188
|
+
}
|
|
189
|
+
|
|
141
190
|
/**
|
|
142
191
|
* Launches a browser with a time limit and one more try: on a busy machine a browser now and then starts but never
|
|
143
|
-
* answers, and Playwright would wait for it forever. Playwright ends the process of a launch that timed out.
|
|
144
|
-
*
|
|
192
|
+
* answers, and Playwright would wait for it forever. Playwright ends the process of a launch that timed out. A
|
|
193
|
+
* launched browser must open a context within answerLimit(); one that does not is closed (Playwright kills it when it
|
|
194
|
+
* does not close within 30 s) and the launch is tried once more. KALOKO_LAUNCH_TIMEOUT (seconds, default 45).
|
|
145
195
|
*/
|
|
146
196
|
export async function launchWithRetry (browserType, options = {}) {
|
|
147
|
-
const timeout = (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
197
|
+
const timeout = launchLimit()
|
|
198
|
+
return retryOnTimeout(async () => {
|
|
199
|
+
const browser = await browserType.launch({ timeout, ...options })
|
|
200
|
+
if (typeof browser?.newContext !== 'function') return browser
|
|
201
|
+
try {
|
|
202
|
+
await withTimeout(browser.newContext().then((c) => c.close()), answerLimit(), 'the new browser')
|
|
203
|
+
} catch (e) {
|
|
204
|
+
browser.close().catch(() => {}) // not awaited: the next try starts while Playwright closes or kills this one
|
|
205
|
+
throw e
|
|
206
|
+
}
|
|
207
|
+
return limitBrowser(browser)
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** chromium.connectOverCDP with a time limit and one more try; the browser's newContext/newPage are limited too. */
|
|
212
|
+
export async function connectWithRetry (browserType, endpoint, options = {}) {
|
|
213
|
+
const timeout = launchLimit()
|
|
214
|
+
return limitBrowser(await retryOnTimeout(() => browserType.connectOverCDP(endpoint, { timeout, ...options })))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** chromium.launchPersistentContext with a time limit and one more try (the shared browser's daemon). */
|
|
218
|
+
export async function launchPersistentWithRetry (browserType, profile, options = {}) {
|
|
219
|
+
const timeout = launchLimit()
|
|
220
|
+
return retryOnTimeout(() => browserType.launchPersistentContext(profile, { timeout, ...options }))
|
|
152
221
|
}
|
|
153
222
|
|
|
154
223
|
export async function launchEphemeral (config) {
|
package/src/lib/build.mjs
CHANGED
|
@@ -4,10 +4,9 @@ import { existsSync } from 'node:fs'
|
|
|
4
4
|
import { join, resolve } from 'node:path'
|
|
5
5
|
import { readJson, writeJson } from './util.mjs'
|
|
6
6
|
import { readCaptures, runPaths, listRuns } from './run.mjs'
|
|
7
|
-
import { latestCaptures, evaluationSource, stepIdOf
|
|
8
|
-
import { fieldsOf, DEFAULT_SEMANTIC_SAMPLE } from './stack.mjs'
|
|
7
|
+
import { latestCaptures, evaluationSource, stepIdOf } from './evaluate.mjs'
|
|
9
8
|
import { layout } from './scenario.mjs'
|
|
10
|
-
import { assembleNode,
|
|
9
|
+
import { assembleNode, assembleStack, summarize } from './manifest-core.mjs'
|
|
11
10
|
import { packageRoot } from './schema.mjs'
|
|
12
11
|
import { variantOf } from './dimensions.mjs'
|
|
13
12
|
|
|
@@ -148,53 +147,17 @@ async function runMedia ({ run, entries }) {
|
|
|
148
147
|
*/
|
|
149
148
|
async function buildStack ({ run, node, base, captures, attachments }) {
|
|
150
149
|
const paths = runPaths(run.dir)
|
|
151
|
-
const
|
|
152
|
-
const environment = run.meta?.environment?.name || null
|
|
153
|
-
const crit = (node.criteria || []).map((c) => ({ id: c.id, text: c.text, type: c.check.type, pack: c.pack || null, ...(appliesOn(c, environment) ? {} : { not_applicable: 'environment' }) }))
|
|
154
|
-
// criteria limited to other environments do not decide the status of a page
|
|
155
|
-
const live = new Set(crit.filter((c) => !c.not_applicable).map((c) => c.id))
|
|
156
|
-
const byItem = new Map()
|
|
150
|
+
const list = []
|
|
157
151
|
for (const c of captures.filter((x) => x.node === node.id && x.item)) {
|
|
158
152
|
const cap = await readJson(join(run.dir, c.file))
|
|
159
|
-
const
|
|
160
|
-
const stored = await readJson(evPath, null)
|
|
153
|
+
const stored = await readJson(paths.step(stepIdOf(c), c.locale, c.viewport, 'eval.json'), null)
|
|
161
154
|
const ev = stored?.source === evaluationSource(node, cap) ? stored : null
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
;(it.captures[c.locale] ||= {})[c.viewport] = { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html, url: cap.url, size: cap.size ? [cap.size.width, cap.size.height] : null, boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 10), overflow: (cap.overflow || []).length, meta: cap.meta, ...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.capture?.full_page === false ? { first_screen: true } : {}), eval: ev ? `steps/${stepIdOf(c)}/${c.locale}/${c.viewport}.eval.json` : null }
|
|
165
|
-
it.results[`${c.locale}|${c.viewport}`] = ev ? Object.fromEntries(ev.criteria.map((r) => [r.id, [r.verdict, r.evidence ? String(r.evidence).slice(0, 200) : null, r.score ?? null]])) : Object.fromEntries(crit.filter((x) => x.type !== 'semantic').map((x) => [x.id, ['not_evaluated', null, null]]))
|
|
166
|
-
if (!ev) it.stale = true
|
|
167
|
-
}
|
|
168
|
-
const items = [...byItem.values()].sort((a, b) => a.order - b.order).map(({ order, ...it }) => {
|
|
169
|
-
const per = Object.values(it.results)
|
|
170
|
-
const verdictOf = (id) => worst(per.map((r) => r[id]?.[0]).filter(Boolean))
|
|
171
|
-
const verdicts = crit.filter((c) => live.has(c.id)).map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
|
|
172
|
-
const status = statusOf(verdicts, per.length > 0)
|
|
173
|
-
const by_locale = Object.fromEntries(Object.keys(it.captures).map((l) => [l, statusOf(Object.entries(it.results).filter(([k]) => k.startsWith(`${l}|`)).flatMap(([, r]) => Object.entries(r).filter(([id]) => live.has(id)).map(([, x]) => x[0])), true)]))
|
|
174
|
-
return { ...it, status, by_locale, fails: crit.filter((c) => live.has(c.id) && verdictOf(c.id) === 'fail').map((c) => c.id), review: crit.filter((c) => live.has(c.id) && ['needs_review', 'not_evaluated'].includes(verdictOf(c.id))).map((c) => c.id) }
|
|
175
|
-
})
|
|
176
|
-
const summary = { pass: 0, partial: 0, fail: 0, skip: 0 }
|
|
177
|
-
for (const it of items) summary[it.status]++
|
|
178
|
-
const criteria = crit.map((c) => {
|
|
179
|
-
const counts = {}
|
|
180
|
-
for (const it of items) { const v = worst(Object.values(it.results).map((r) => r[c.id]?.[0]).filter(Boolean)); if (v !== 'not_reached') counts[v] = (counts[v] || 0) + 1 }
|
|
181
|
-
return { ...c, counts, verdict: counts.fail ? 'fail' : counts.needs_review ? 'needs_review' : counts.not_evaluated ? 'not_evaluated' : counts.pass ? 'pass' : 'not_reached', by_locale: {} }
|
|
182
|
-
})
|
|
183
|
-
const semanticSample = node.items?.semantic_sample ?? DEFAULT_SEMANTIC_SAMPLE
|
|
184
|
-
const file = `steps/${node.id}/items.json`
|
|
185
|
-
await writeJson(join(run.dir, file), { schema: 'qawalk.stack.v1', node: node.id, fields, criteria: crit, semantic_sample: semanticSample, items })
|
|
186
|
-
const thumbOf = (it) => { const c = Object.values(it.captures)[0]; const v = c && Object.values(c)[0]; return v ? v.thumb || v.png : null }
|
|
187
|
-
const sample = [...items.filter((it) => it.status === 'fail'), ...items.filter((it) => it.status !== 'fail')].slice(0, 4).map((it) => ({ id: it.id, title: it.title, path: it.path, status: it.status, thumb: thumbOf(it) }))
|
|
188
|
-
const status = !items.length ? 'skip' : summary.fail ? 'fail' : summary.partial || summary.skip ? 'partial' : 'pass'
|
|
189
|
-
const locales = [...new Set(items.flatMap((it) => Object.keys(it.by_locale)))]
|
|
190
|
-
const by_locale = Object.fromEntries(locales.map((l) => { const s = items.map((it) => it.by_locale[l]).filter(Boolean); return [l, s.includes('fail') ? 'fail' : s.some((x) => x !== 'pass') ? 'partial' : 'pass'] }))
|
|
191
|
-
const src = node.items || {}
|
|
192
|
-
return {
|
|
193
|
-
...base, status, by_locale, captures: {}, criteria, semantic: {}, metadata: {}, session: node.session || null,
|
|
194
|
-
execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : { strategy: 'playwright', instructions: 'Open every page of the stack and capture it.' },
|
|
195
|
-
attachments: attachments.filter((a) => a.node === node.id && a.kind !== 'focus').map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
|
|
196
|
-
stack: { count: items.length, items: file, summary, fields, sample, semantic_sample: semanticSample, source: ['urls', 'file', 'sitemap', 'crawl', 'script'].find((k) => src[k]) || null, stale: items.filter((it) => it.stale).length }
|
|
155
|
+
// a variant of a design stack (#61) opens live in the lightbox: its page in the step's revision
|
|
156
|
+
list.push({ item: c.item, locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html, eval: `steps/${stepIdOf(c)}/${c.locale}/${c.viewport}.eval.json` }, ...(cap.live ? { live: cap.live } : {}) })
|
|
197
157
|
}
|
|
158
|
+
const { node: out, index, file } = assembleStack({ node, base, environment: run.meta?.environment?.name || null, captures: list, attachments })
|
|
159
|
+
await writeJson(join(run.dir, file), index)
|
|
160
|
+
return out
|
|
198
161
|
}
|
|
199
162
|
|
|
200
163
|
/**
|
package/src/lib/capture.mjs
CHANGED
|
@@ -165,7 +165,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
// the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
|
|
168
|
-
if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens, scope?.variants?.[viewport]?.color_scheme || null)
|
|
168
|
+
if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens, scope?.variants?.[viewport]?.token_mode || scope?.variants?.[viewport]?.color_scheme || null)
|
|
169
169
|
// regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
|
|
170
170
|
const ignore = await resolveIgnore(page, node, locale, viewport)
|
|
171
171
|
// what moves while the page sits still (walk --stability N): repeated screenshots, compared here
|