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
package/src/cli.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
|
|
|
20
20
|
import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
|
|
21
21
|
import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
|
|
22
22
|
import { signoffCmd } from './lib/signoff.mjs'
|
|
23
|
-
import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote } from './lib/util.mjs'
|
|
23
|
+
import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG } from './lib/util.mjs'
|
|
24
24
|
import { packageRoot } from './lib/schema.mjs'
|
|
25
25
|
import { compareRuns, formatComparison } from './lib/compare.mjs'
|
|
26
26
|
import { doctor, formatDoctor } from './lib/doctor.mjs'
|
|
@@ -29,7 +29,7 @@ import { calibrate, formatCalibration, casesFromGolden, casesFromRun } from './l
|
|
|
29
29
|
import { pageFacts, draftScenario, scenarioYaml } from './lib/draft.mjs'
|
|
30
30
|
import { detectProject, initScenario, formatDetection } from './lib/init-detect.mjs'
|
|
31
31
|
import { recheckCaptures } from './lib/recheck.mjs'
|
|
32
|
-
import { resolveItems, fieldsOf, DEFAULT_LIMIT } from './lib/stack.mjs'
|
|
32
|
+
import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft } from './lib/stack.mjs'
|
|
33
33
|
import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
|
|
34
34
|
import { expandPacks } from './lib/packs.mjs'
|
|
35
35
|
import { upsertPrComment } from './lib/pr.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'
|
|
@@ -94,7 +95,9 @@ Project
|
|
|
94
95
|
the organization and what the token may do; the token (at most 30 days) goes to
|
|
95
96
|
~/.config/kaloko/.env, or is printed with --print
|
|
96
97
|
doctor [--json] [--fix] Check Node, Chrome, config, environments, browser ports, token, evaluator key
|
|
97
|
-
and app tools; --fix
|
|
98
|
+
and app tools; --fix installs missing Playwright browsers, creates the run and
|
|
99
|
+
scenario folders, writes org: from the token and asks macOS for the desktop
|
|
100
|
+
capture permissions
|
|
98
101
|
validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]]
|
|
99
102
|
Validate scenarios (schema + references); --coverage compares criteria with the plan
|
|
100
103
|
With a token it also warns about scenarios the service still lists whose file is gone (--offline skips it)
|
|
@@ -111,6 +114,9 @@ Run
|
|
|
111
114
|
More dimensions: every step is captured in each combination (keys like
|
|
112
115
|
mobile.webkit.dark); default from the scenario, then walk: in the config
|
|
113
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)
|
|
114
120
|
browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
|
|
115
121
|
Shared Chromium with CDP (agents, scripts and people drive it)
|
|
116
122
|
browser install [--with-deps] [--browsers chromium,webkit,firefox]
|
|
@@ -119,8 +125,8 @@ Run
|
|
|
119
125
|
devices [--json] Device presets for viewports: (phones, foldables, tablets, laptop, desktop, TV)
|
|
120
126
|
open --step <id> [--locale l] Navigate the current page to the step URL
|
|
121
127
|
session reset New visitor: clear cookies and storage in the shared browser
|
|
122
|
-
capture --step <id> [--locale l] [--url <url>] [--page <substring>] [--viewports a,b]
|
|
123
|
-
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)
|
|
124
130
|
capture --step <id> --crop <css> [--title <text>]
|
|
125
131
|
Attach an element screenshot to a step
|
|
126
132
|
capture --step <id> --image <shot.png> [--tree <dump.xml|json> | --ocr] [--platform android|ios|desktop]
|
|
@@ -305,9 +311,11 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
305
311
|
sync [--pull|--push] [--branch b] [--mine a,b|--theirs a,b] [--no-lint]
|
|
306
312
|
Exchange revisions with the shared draft; only changed steps and missing files travel
|
|
307
313
|
Lints the folder first: errors (things that break on the live step) stop the push
|
|
308
|
-
design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--json]
|
|
314
|
+
design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]
|
|
309
315
|
Check the folder against the portable HTML rules (relative URLs, no CDNs, no storage,
|
|
310
|
-
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)
|
|
311
319
|
share [--name <text>] [--branch b] [--notes file|-] [--notes-preview]
|
|
312
320
|
Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged",
|
|
313
321
|
with "what changed" notes per step (screens per viewport, text, tokens); --notes-preview
|
|
@@ -319,7 +327,7 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
319
327
|
a folder exported from Figma works without a token. One way: no prototype links
|
|
320
328
|
history [--scenario id] [--step id] [--locale l] [--json]
|
|
321
329
|
Versions of the flow; revisions of a step with who (person or agent) and when
|
|
322
|
-
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]
|
|
323
331
|
What to build for a step as approved: elements with role, name and selector, styles
|
|
324
332
|
mapped to DTCG tokens (or the nearest one), :hover/:focus/:active/:disabled, contrast
|
|
325
333
|
drift [step] [--ref design|vN] [--remote <run id|latest>] [--json]
|
|
@@ -449,12 +457,27 @@ async function runContext (args) {
|
|
|
449
457
|
}
|
|
450
458
|
|
|
451
459
|
function stepOf (scenario, id) {
|
|
452
|
-
if (!id) throw new UserError(
|
|
460
|
+
if (!id) throw new UserError(`Which step? Pass --step <id>; this scenario has: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}.`)
|
|
453
461
|
const node = scenario.nodes.find((n) => n.id === id)
|
|
454
462
|
if (!node) throw new UserError(`Unknown step "${id}". Steps: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}`)
|
|
455
463
|
return node
|
|
456
464
|
}
|
|
457
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
|
+
|
|
458
481
|
/** Scenario of --scenario (id or file) or of the current run. */
|
|
459
482
|
/**
|
|
460
483
|
* kaloko design import figma <file-url|key|folder>: Figma frames (or a folder exported from Figma) become the steps of
|
|
@@ -486,7 +509,7 @@ async function designImport (args) {
|
|
|
486
509
|
if (args.json) say(JSON.stringify(r, null, 2))
|
|
487
510
|
else say(formatImport(r, { dir: relative(process.cwd(), dir) || '.' }))
|
|
488
511
|
if (args['dry-run']) return r
|
|
489
|
-
const lint = await lintDesign({ dir, scenario })
|
|
512
|
+
const lint = await lintDesign({ dir, scenario, origins: await lintOrigins(config, env) })
|
|
490
513
|
if (lint.errors) say(`\n${formatLint(lint, { max: 10 })}`)
|
|
491
514
|
if (!args.publish) {
|
|
492
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.`)
|
|
@@ -519,6 +542,24 @@ function manualKeys (run, pick) {
|
|
|
519
542
|
return scopeKeys(scope, keys.length ? keys : engineKeys(scope, 'chromium'), null)
|
|
520
543
|
}
|
|
521
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
|
+
|
|
522
563
|
/** { key: [w, h] } a capture on a Chromium page takes: every Chromium key of the run, or those of `pick`. */
|
|
523
564
|
function scopeViewports (run, pick) {
|
|
524
565
|
return scopeKeys(run.meta.scope, engineKeys(run.meta.scope, 'chromium'), pick)
|
|
@@ -655,6 +696,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
|
|
|
655
696
|
page.on('response', onResponse)
|
|
656
697
|
const streak = failureStreak(5)
|
|
657
698
|
const failed = []; let ok = 0
|
|
699
|
+
const started = Date.now(); let told = started
|
|
658
700
|
try {
|
|
659
701
|
for (const [i, item] of chosen.entries()) {
|
|
660
702
|
if (streak.stopped('stack')) { failed.push(`${item.id}: not walked after repeated failures`); continue }
|
|
@@ -671,7 +713,8 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
|
|
|
671
713
|
failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
|
|
672
714
|
if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
|
|
673
715
|
}
|
|
674
|
-
|
|
716
|
+
// long stacks: progress every 25 pages (or 30 s) with the time left, estimated from the pages so far
|
|
717
|
+
if (i + 1 < chosen.length && ((i + 1) % 25 === 0 || Date.now() - told > 30000)) { told = Date.now(); const left = timeLeft(i + 1, chosen.length, told - started); say(` ${node.id} ${locale}: ${i + 1}/${chosen.length}${left ? ` · ${left}` : ''}`) }
|
|
675
718
|
}
|
|
676
719
|
} finally { page.off('response', onResponse) }
|
|
677
720
|
say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}${tag}: ${ok}/${chosen.length} page(s) captured`)
|
|
@@ -768,15 +811,17 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
|
|
|
768
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 }
|
|
769
812
|
const streakKey = `${group.device || ''}|${locale}`
|
|
770
813
|
if (streak.stopped(streakKey)) { skipped.push(`${node.id} (${locale}${tag})`); continue }
|
|
771
|
-
|
|
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'
|
|
772
817
|
try {
|
|
773
818
|
let captured = false
|
|
774
819
|
await rec?.beforeStep(node)
|
|
775
820
|
const capture = async (o = {}) => {
|
|
776
821
|
captured = true
|
|
777
|
-
await rec?.beforeCapture(node)
|
|
822
|
+
if (state === 'default') await rec?.beforeCapture(node)
|
|
778
823
|
// a named viewport: within this device, or any of the run when it has no devices (as before)
|
|
779
|
-
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)
|
|
780
825
|
if (!keys.length) throw new UserError(`Unknown viewport "${o.viewport}" for ${group.device || platform}.`)
|
|
781
826
|
const out = []
|
|
782
827
|
for (const key of keys) {
|
|
@@ -799,14 +844,25 @@ async function walkApp ({ config, root, run, scenario, env, args }) {
|
|
|
799
844
|
records.push(...out)
|
|
800
845
|
return out.length === 1 ? out[0] : out
|
|
801
846
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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()
|
|
807
855
|
await rec?.afterStep(node, driver.web ? { records } : { record: records[0] })
|
|
808
856
|
say(`✓ ${node.id} ${locale}${tag}`)
|
|
809
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'
|
|
810
866
|
} catch (error) {
|
|
811
867
|
await rec?.afterStep(node, driver.web ? { error, records } : { error, record: records[0] }).catch(() => {})
|
|
812
868
|
failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
|
|
@@ -1022,7 +1078,9 @@ const commands = {
|
|
|
1022
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(', ')}).`)
|
|
1023
1079
|
if (env.readonly && !scenario.readonly) throw new UserError(`Environment ${env.name} is readonly; the scenario must declare readonly: true.`)
|
|
1024
1080
|
const locales = list(args.locales) || scenario.locales || ['default']
|
|
1025
|
-
|
|
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 } : {}) }
|
|
1026
1084
|
scope.locales = locales
|
|
1027
1085
|
const vpNames = baseViewports(scope)
|
|
1028
1086
|
const git = await gitMeta(root)
|
|
@@ -1101,7 +1159,8 @@ const commands = {
|
|
|
1101
1159
|
}
|
|
1102
1160
|
if (isApp(scenario.platform)) {
|
|
1103
1161
|
// the app's current screen, through the environment's driver (no navigation); --viewport may name a device key
|
|
1104
|
-
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
|
|
1105
1164
|
const device = run.meta.scope.devices?.[variantOf(run.meta.scope, key).viewport]?.app ? variantOf(run.meta.scope, key).viewport : null
|
|
1106
1165
|
const cfg = deviceConfig(scenario.platform, env.app?.[scenario.platform] || {}, device)
|
|
1107
1166
|
const driver = driverFor(scenario.platform, { ...env, app: { ...(env.app || {}), [scenario.platform]: cfg } }, { root, scenarioDir: resolve(root, run.meta.scenario_file, '..'), locale })
|
|
@@ -1135,7 +1194,7 @@ const commands = {
|
|
|
1135
1194
|
return
|
|
1136
1195
|
}
|
|
1137
1196
|
const since = (await readCaptures(run.dir)).filter((c) => c.node === node.id).map((c) => c.at).pop()
|
|
1138
|
-
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 })
|
|
1139
1198
|
for (const r of results) {
|
|
1140
1199
|
const failed = Object.entries(r.checks).filter(([, v]) => v.ok === false).map(([k]) => k)
|
|
1141
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}` : ''}`)
|
|
@@ -1193,8 +1252,8 @@ const commands = {
|
|
|
1193
1252
|
}
|
|
1194
1253
|
}
|
|
1195
1254
|
if (env.kind === 'design') {
|
|
1196
|
-
await walkDesign({ config, root, run, scenario, env, pick: list(args.steps), locales: list(args.locales) || run.meta.scope.locales, viewports:
|
|
1197
|
-
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) })
|
|
1198
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)' : ''}.`)
|
|
1199
1258
|
return
|
|
1200
1259
|
}
|
|
@@ -1337,22 +1396,32 @@ const commands = {
|
|
|
1337
1396
|
const scriptFile = resolve(scenarioDir, node.execution.script)
|
|
1338
1397
|
// one invocation of the step script; the automatic capture runs unless the script captured the step itself:
|
|
1339
1398
|
// a crop is an attachment (#49), and a capture from an earlier walk must not stand in for this one (#50)
|
|
1340
|
-
const
|
|
1341
|
-
|
|
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') => {
|
|
1342
1403
|
let captured = false
|
|
1404
|
+
const rec = state === 'default' ? rec0 : null
|
|
1343
1405
|
rec?.beforeStep(node)
|
|
1344
1406
|
const records = []
|
|
1345
|
-
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 }) }
|
|
1346
1408
|
try {
|
|
1347
|
-
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) })
|
|
1348
1410
|
if (!captured) await capture()
|
|
1349
1411
|
} catch (error) { await rec?.afterStep(node, { error }); throw error }
|
|
1350
1412
|
await rec?.afterStep(node, { records })
|
|
1351
1413
|
}
|
|
1352
1414
|
try {
|
|
1353
|
-
|
|
1415
|
+
const mod = await import(pathToFileURL(scriptFile).href)
|
|
1416
|
+
await runStep(mod)
|
|
1354
1417
|
say(`✓ ${node.id} ${locale}${tag}`)
|
|
1355
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
|
+
}
|
|
1356
1425
|
} catch (error) {
|
|
1357
1426
|
// a selector the page no longer has: propose one from the page (#13); --heal applies it once the step passes
|
|
1358
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)
|
|
@@ -1792,7 +1861,7 @@ const commands = {
|
|
|
1792
1861
|
const client = service(config)
|
|
1793
1862
|
if (env.kind === 'design' && !args.pull && !args['no-lint']) {
|
|
1794
1863
|
// what renders locally may still break on the live step (CSP, sandbox): say so before anything is pushed
|
|
1795
|
-
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 }) })
|
|
1796
1865
|
if (lint.errors || lint.warnings) say(formatLint(lint, { max: 15 }) + '\n')
|
|
1797
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.`)
|
|
1798
1867
|
}
|
|
@@ -1801,19 +1870,19 @@ const commands = {
|
|
|
1801
1870
|
|
|
1802
1871
|
async design (args) {
|
|
1803
1872
|
if (args._[0] === 'import') return designImport(args)
|
|
1804
|
-
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]')
|
|
1805
1874
|
const { config, root } = await context(args)
|
|
1806
1875
|
let run = null
|
|
1807
1876
|
try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
|
|
1808
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')
|
|
1809
1880
|
if (!dir) {
|
|
1810
|
-
const runEnv = run ? environment(config, run.meta.environment.name) : null
|
|
1811
|
-
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')
|
|
1812
1881
|
if (!env) throw new UserError('No design environment: pass --dir <folder> or --env <design env>.')
|
|
1813
1882
|
dir = designDir(env, root)
|
|
1814
1883
|
}
|
|
1815
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
|
|
1816
|
-
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 })
|
|
1817
1886
|
say(args.json ? JSON.stringify(report, null, 2) : formatLint(report, { all: !!args.all, max: args.all ? 500 : 60 }))
|
|
1818
1887
|
if (report.errors || (args.strict && report.warnings)) process.exitCode = 1
|
|
1819
1888
|
},
|
|
@@ -2106,9 +2175,9 @@ const commands = {
|
|
|
2106
2175
|
}
|
|
2107
2176
|
let runId = args.run
|
|
2108
2177
|
if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
|
|
2109
|
-
if (!runId) throw new UserError('--run <id
|
|
2178
|
+
if (!runId) throw new UserError('No current run here. Pass --run <id>, or walk the scenario first (kaloko start, then kaloko walk).')
|
|
2110
2179
|
const run = await load(runId)
|
|
2111
|
-
if (!run) throw new UserError(`Run ${runId}
|
|
2180
|
+
if (!run) throw new UserError(`Run ${runId} is neither in ${config.output} nor on the service. Check the id (kaloko runs lists the local ones; the run's address on Kaloko shows a shared one).`)
|
|
2112
2181
|
let baseId = typeof args.baseline === 'string' ? args.baseline : null
|
|
2113
2182
|
// --design: against the design reference of the scenario (#42)
|
|
2114
2183
|
if (args.design && run.scenario?.id) { client ||= service(config); baseId = (await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(run.scenario.id)}/references`)).design; if (!baseId) throw new UserError('This scenario has no design reference yet (kaloko reference --design --version <n>).') }
|
|
@@ -2120,7 +2189,7 @@ const commands = {
|
|
|
2120
2189
|
}
|
|
2121
2190
|
if (!baseId) throw new UserError('No baseline or earlier run of this scenario to compare with; pass --baseline <id>.')
|
|
2122
2191
|
const base = await load(baseId)
|
|
2123
|
-
if (!base) throw new UserError(`
|
|
2192
|
+
if (!base) throw new UserError(`Baseline run ${baseId} is neither in ${config.output} nor on the service. Check the id, or pass another --baseline <id>.`)
|
|
2124
2193
|
const d = compareRuns(run, base)
|
|
2125
2194
|
if (args.json) { say(JSON.stringify(d, null, 2)); return }
|
|
2126
2195
|
say(formatComparison(d))
|
|
@@ -2199,7 +2268,7 @@ const commands = {
|
|
|
2199
2268
|
if (typeof args.scenario === 'string') return exportScenario({ args, config, root, client })
|
|
2200
2269
|
if (typeof args.format === 'string') {
|
|
2201
2270
|
// living docs and the changelog as files (docs/DOCS-CHANGELOG.md → Exports)
|
|
2202
|
-
if (!config.org) throw new UserError(
|
|
2271
|
+
if (!config.org) throw new UserError(NO_ORG)
|
|
2203
2272
|
const { pickProduct } = await import('./lib/changelog.mjs')
|
|
2204
2273
|
const { exportDocs } = await import('./lib/docs.mjs')
|
|
2205
2274
|
const product = pickProduct(config, root, args.product)
|
|
@@ -2220,7 +2289,7 @@ const commands = {
|
|
|
2220
2289
|
}
|
|
2221
2290
|
let runId = args.run
|
|
2222
2291
|
if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
|
|
2223
|
-
if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id>
|
|
2292
|
+
if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('Which run? Pass --run <id> with the id of a shared run (kaloko share prints it), or run this where kaloko start made a current run.')
|
|
2224
2293
|
const out = typeof args.out === 'string' ? resolve(args.out) : resolve(root, config.output, `${runId}.zip`)
|
|
2225
2294
|
const { createWriteStream } = await import('node:fs')
|
|
2226
2295
|
const { Readable } = await import('node:stream')
|
|
@@ -2236,7 +2305,7 @@ const commands = {
|
|
|
2236
2305
|
const client = service(config)
|
|
2237
2306
|
let runId = args.run
|
|
2238
2307
|
if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
|
|
2239
|
-
if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id>
|
|
2308
|
+
if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('Which run? Pass --run <id> with the id of a shared run (kaloko share prints it), or run this where kaloko start made a current run.')
|
|
2240
2309
|
const r = await client.request('POST', `/orgs/${config.org}/runs/${runId}/public`, { public: !args.off })
|
|
2241
2310
|
say(r.public ? `Public: ${r.url} (readable without sign-in; only one public run per scenario)` : `Private again: ${runId}`)
|
|
2242
2311
|
},
|
|
@@ -2284,7 +2353,7 @@ const commands = {
|
|
|
2284
2353
|
const product = pickProduct(config, root, args.product)
|
|
2285
2354
|
const version = typeof args.version === 'string' ? args.version : 'latest'
|
|
2286
2355
|
if (sub === 'publish') {
|
|
2287
|
-
if (!config.org && !args['dry-run']) throw new UserError(
|
|
2356
|
+
if (!config.org && !args['dry-run']) throw new UserError(NO_ORG)
|
|
2288
2357
|
const r = await docs.publishDocs({ root, config, product, client: args['dry-run'] ? null : service(config), version, reviewed: !!args.reviewed, dryRun: !!args['dry-run'] })
|
|
2289
2358
|
if (args.json) { say(JSON.stringify(r, null, 2)); return }
|
|
2290
2359
|
if (r.dry_run) { say(`${product.slug}: ${r.pages} page(s) from ${r.dir} would go to Kaloko as ${r.version} (${r.languages.join(', ')}); dry run, nothing sent.${r.broken.length ? `\n ! broken step references: ${r.broken.join('; ')}` : ''}`); return }
|
|
@@ -2312,12 +2381,12 @@ const commands = {
|
|
|
2312
2381
|
const { config, root } = await context(args)
|
|
2313
2382
|
const lib = await import('./lib/changelog.mjs')
|
|
2314
2383
|
if (sub === 'list') {
|
|
2315
|
-
if (!config.org) throw new UserError(
|
|
2384
|
+
if (!config.org) throw new UserError(NO_ORG)
|
|
2316
2385
|
const r = await lib.listProducts({ root, config, client: service(config) })
|
|
2317
2386
|
say(args.json ? JSON.stringify(r, null, 2) : lib.formatList(r))
|
|
2318
2387
|
return
|
|
2319
2388
|
}
|
|
2320
|
-
if (!config.org && !args['dry-run']) throw new UserError(
|
|
2389
|
+
if (!config.org && !args['dry-run']) throw new UserError(NO_ORG)
|
|
2321
2390
|
const r = await lib.syncProducts({ root, config, client: args['dry-run'] ? null : service(config), only: typeof args.product === 'string' ? args.product : null, dryRun: !!args['dry-run'] })
|
|
2322
2391
|
say(args.json ? JSON.stringify(r, null, 2) : lib.formatSync(r))
|
|
2323
2392
|
if (r.products.some((p) => p.ok === false)) process.exitCode = 1
|
|
@@ -2326,7 +2395,7 @@ const commands = {
|
|
|
2326
2395
|
/** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
|
|
2327
2396
|
async release (args) {
|
|
2328
2397
|
const { config, root } = await context(args)
|
|
2329
|
-
if (!config.org) throw new UserError(
|
|
2398
|
+
if (!config.org) throw new UserError(NO_ORG)
|
|
2330
2399
|
const { pickProduct, releaseProduct } = await import('./lib/changelog.mjs')
|
|
2331
2400
|
const product = pickProduct(config, root, args.product)
|
|
2332
2401
|
const client = args['dry-run'] ? null : service(config)
|
|
@@ -2360,10 +2429,13 @@ export async function main (argv) {
|
|
|
2360
2429
|
const args = parse(argv)
|
|
2361
2430
|
const name = args._.shift() || 'help'
|
|
2362
2431
|
const cmd = commands[name] || (args.help ? commands.help : null)
|
|
2363
|
-
if (!cmd) { console.error(`
|
|
2432
|
+
if (!cmd) { console.error(`There is no command "${name}". The commands are below; kaloko help <command> explains one.\n`); say(HELP); process.exitCode = 2; return }
|
|
2364
2433
|
try {
|
|
2365
2434
|
await cmd(args)
|
|
2366
2435
|
} catch (error) {
|
|
2367
|
-
if (error instanceof UserError) { console.error(error.message); process.exitCode = 1 } else
|
|
2436
|
+
if (error instanceof UserError) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = 1 } else {
|
|
2437
|
+
console.error(`Kaloko stopped on an unexpected error (a bug on our side). Send the output below to ${SUPPORT_EMAIL} with the command you ran.`)
|
|
2438
|
+
throw error
|
|
2439
|
+
}
|
|
2368
2440
|
}
|
|
2369
2441
|
}
|
package/src/lib/access.mjs
CHANGED
|
@@ -167,7 +167,7 @@ export function totp (base32, { step = 30, digits = 6, now = Date.now() } = {})
|
|
|
167
167
|
let bits = ''
|
|
168
168
|
for (const ch of base32.replace(/[\s=]/g, '').toUpperCase()) {
|
|
169
169
|
const v = alphabet.indexOf(ch)
|
|
170
|
-
if (v < 0) throw new UserError('The TOTP secret is not base32.')
|
|
170
|
+
if (v < 0) throw new UserError('The TOTP secret is not base32 (letters A–Z and digits 2–7). Copy the secret again from the setup screen of the account, not the six-digit code.')
|
|
171
171
|
bits += v.toString(2).padStart(5, '0')
|
|
172
172
|
}
|
|
173
173
|
const key = Buffer.from((bits.match(/.{8}/g) || []).map((b) => parseInt(b, 2)))
|
package/src/lib/apps/drivers.mjs
CHANGED
|
@@ -78,7 +78,7 @@ function android (cfg, ctx) {
|
|
|
78
78
|
platform: 'android',
|
|
79
79
|
async open ({ locale, reset, attach = false } = {}) {
|
|
80
80
|
const { stdout } = await adb(['get-state']).catch((e) => { throw new UserError(`No Android device: ${e.message}. Start an emulator or connect a device (adb devices).`) })
|
|
81
|
-
if (!/device/.test(stdout)) throw new UserError('The Android device is not ready (adb get-state).')
|
|
81
|
+
if (!/device/.test(stdout)) throw new UserError('The Android device is connected but not ready (adb get-state). Unlock it and allow USB debugging, or wait until the emulator has booted, then run the command again.')
|
|
82
82
|
const dens = (await shell('wm', 'density')).stdout.match(/(\d+)\s*$/m)
|
|
83
83
|
density = dens ? Number(dens[1]) / 160 : 1
|
|
84
84
|
if (!cfg.device) device = (await shell('getprop', 'ro.product.model')).stdout.trim() || 'android'
|
package/src/lib/apps/webview.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export function webviewSockets (procNetUnix) {
|
|
|
27
27
|
* local port and connects Playwright over CDP. Returns { browser, page, pages, close }.
|
|
28
28
|
*/
|
|
29
29
|
export async function attachAndroidWebView (cfg, { timeout = 15000 } = {}) {
|
|
30
|
-
const adb = (args) => run(cfg.adb || 'adb', [...(cfg.serial ? ['-s', cfg.serial] : []), ...args], { timeout: 30000 }).then((r) => r.stdout).catch((e) => { if (e.code === 'ENOENT') throw new UserError('adb is not installed or not on PATH.'); throw e })
|
|
30
|
+
const adb = (args) => run(cfg.adb || 'adb', [...(cfg.serial ? ['-s', cfg.serial] : []), ...args], { timeout: 30000 }).then((r) => r.stdout).catch((e) => { if (e.code === 'ENOENT') throw new UserError('adb is not installed or not on PATH. Install Android platform-tools (kaloko doctor names the command for this system) and open a new terminal.'); throw e })
|
|
31
31
|
const end = Date.now() + timeout
|
|
32
32
|
let socket = null
|
|
33
33
|
while (!socket && Date.now() < end) {
|
|
@@ -43,12 +43,12 @@ export async function attachAndroidWebView (cfg, { timeout = 15000 } = {}) {
|
|
|
43
43
|
let browser
|
|
44
44
|
try { browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout }) } catch (e) {
|
|
45
45
|
await adb(['forward', '--remove', `tcp:${port}`]).catch(() => {})
|
|
46
|
-
throw new UserError(`Could not connect to the WebView (${socket.socket}): ${e.message.split('\n')[0]}
|
|
46
|
+
throw new UserError(`Could not connect to the WebView (${socket.socket}): ${e.message.split('\n')[0]}. Bring the app to the front and run the command again.`)
|
|
47
47
|
}
|
|
48
48
|
const pages = browser.contexts().flatMap((c) => c.pages())
|
|
49
49
|
// the visible page first: a WebView app often keeps hidden ones around
|
|
50
50
|
let page = pages[0] || null
|
|
51
51
|
for (const p of pages) { if (await p.evaluate(() => document.visibilityState === 'visible').catch(() => false)) { page = p; break } }
|
|
52
|
-
if (!page) { await browser.close().catch(() => {}); throw new UserError('The WebView has no page open.') }
|
|
52
|
+
if (!page) { await browser.close().catch(() => {}); throw new UserError('The WebView has no page open. Open the screen with the WebView in the app, then run the command again.') }
|
|
53
53
|
return { browser, page, pages, socket: socket.socket, close: async () => { await browser.close().catch(() => {}); await adb(['forward', '--remove', `tcp:${port}`]).catch(() => {}) } }
|
|
54
54
|
}
|
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
|
package/src/lib/changelog.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { promisify } from 'node:util'
|
|
|
8
8
|
import { join, resolve, relative, dirname, basename } from 'node:path'
|
|
9
9
|
import { UserError, sha256 } from './util.mjs'
|
|
10
10
|
import { repoSlug } from './git.mjs'
|
|
11
|
-
import { cleanVersion, compareVersions, tagFor, versionOfTag, latestVersionIn, sectionOf, applyDraft, finalizeRelease, draftEntry,
|
|
11
|
+
import { cleanVersion, compareVersions, tagFor, versionOfTag, latestVersionIn, sectionOf, applyDraft, finalizeRelease, draftEntry, parseEntry, findStepRefs, CHANGELOG_SKELETON } from './changelog-core.mjs'
|
|
12
12
|
|
|
13
13
|
const run = promisify(execFile)
|
|
14
14
|
async function out (cmd, args, cwd, timeout = 20000) {
|
|
@@ -236,7 +236,7 @@ export async function releaseProduct ({ root, config, product, version = null, d
|
|
|
236
236
|
const src = await detectVersionSource(root, product)
|
|
237
237
|
const v = cleanVersion(version || src.version || '')
|
|
238
238
|
if (!v) throw new UserError(`Which version? Pass it (kaloko release 2.4.0); ${src.source === 'git-tag' ? `no tag matches ${src.pattern}` : `${src.path || src.source} names none`}.`)
|
|
239
|
-
if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,79}$/.test(v)) throw new UserError(
|
|
239
|
+
if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,79}$/.test(v)) throw new UserError(`${v} is not a version Kaloko can use: letters, digits, dots, + and -, at most 80 characters (e.g. 2.4.0).`)
|
|
240
240
|
const notes = []
|
|
241
241
|
if (src.version && src.version !== v && src.source !== 'git-tag' && src.source !== 'semantic-release') notes.push(`${src.tool || src.source} (${src.path}) says ${src.version}; recording ${v} as you asked.`)
|
|
242
242
|
const file = changelogFile(root, product)
|