kaloko 0.7.1 → 0.9.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 +2 -2
- package/schemas/scenario.v1.json +15 -1
- package/skills/kaloko/reference/acceptance.md +32 -2
- package/src/cli.mjs +30 -21
- 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/browser.mjs +14 -2
- package/src/lib/build.mjs +8 -5
- package/src/lib/changelog.mjs +30 -9
- package/src/lib/design-export.mjs +2 -2
- package/src/lib/dimensions.mjs +2 -1
- package/src/lib/docs-core.mjs +9 -6
- package/src/lib/docs.mjs +4 -2
- package/src/lib/doctor.mjs +72 -10
- package/src/lib/evaluate.mjs +11 -6
- 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 +11 -6
- package/src/lib/pack-help.mjs +5 -0
- package/src/lib/packs.mjs +32 -8
- package/src/lib/run.mjs +1 -1
- package/src/lib/scenario.mjs +63 -1
- package/src/lib/service.mjs +39 -9
- package/src/lib/stability.mjs +2 -2
- package/src/lib/stack.mjs +11 -1
- package/src/lib/unzip.mjs +22 -4
- package/src/lib/util.mjs +6 -0
- package/src/lib/video.mjs +11 -2
- package/src/lib/vision.mjs +1 -1
- package/templates/scenario.yml +1 -0
- package/viewer/index.html +154 -28
package/README.md
CHANGED
|
@@ -182,7 +182,9 @@ npx kaloko import walkthrough/ --adhoc # a Cursor or Antigravity walkthroug
|
|
|
182
182
|
lays screenshots, Playwright reports and traces out as a run: matched to the
|
|
183
183
|
scenario's steps (by step id in `steps.json`, URL or title), or as an ad-hoc
|
|
184
184
|
flow when there is no scenario. Build and share it like any run. When something does not work (Chrome,
|
|
185
|
-
token, environment URL, evaluator key), `npx kaloko doctor` says what to fix
|
|
185
|
+
token, environment URL, evaluator key), `npx kaloko doctor` says what to fix, and
|
|
186
|
+
`npx kaloko doctor --fix` installs a missing browser, creates missing folders and fills in `org:` from your token.
|
|
187
|
+
Every error the CLI prints says what to do next; if you are still stuck, write to support@sinfin.cz.
|
|
186
188
|
|
|
187
189
|
## Review only what changed
|
|
188
190
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kaloko",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"qa",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
|
-
"test": "node
|
|
35
|
+
"test": "node scripts/test.mjs"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"ajv": "^8.20.0",
|
package/schemas/scenario.v1.json
CHANGED
|
@@ -84,6 +84,10 @@
|
|
|
84
84
|
"default": "web",
|
|
85
85
|
"description": "What the flow runs on. web: pages in a browser (an installed PWA too: display_modes). android/ios/electron/desktop/windows: an app, driven by the environment's app block (adb, iOS Simulator or device, Playwright Electron, macOS window, Windows UI Automation) or captured with kaloko capture --image. Node paths are deep links for apps."
|
|
86
86
|
},
|
|
87
|
+
"page": {
|
|
88
|
+
"enum": ["landing", "work", "list", "settings", "form", "doc"],
|
|
89
|
+
"description": "Default page type of every step (a step may set its own). landing: a public or marketing page; work: an app screen where people work (overview, canvas, inbox); list: a list or table of items; settings: settings or billing; form: a form to fill in; doc: documentation or long text. The usability pack picks its questions by type. Not set: landing for steps with the seo pack or scenarios allowed on production, work otherwise."
|
|
90
|
+
},
|
|
87
91
|
"tags": {
|
|
88
92
|
"type": "array",
|
|
89
93
|
"items": {
|
|
@@ -307,6 +311,10 @@
|
|
|
307
311
|
],
|
|
308
312
|
"description": "For `kaloko walk --affected`: globs of repository files (relative to the config) that shape this step, next to the routes and imports Kaloko finds itself; `always` walks the step on every affected walk (sign-in, consent)."
|
|
309
313
|
},
|
|
314
|
+
"page": {
|
|
315
|
+
"enum": ["landing", "work", "list", "settings", "form", "doc"],
|
|
316
|
+
"description": "What kind of page this step is (see the scenario's page); the usability pack asks per type, e.g. one primary call to action only on landing pages."
|
|
317
|
+
},
|
|
310
318
|
"packs": {
|
|
311
319
|
"type": "array",
|
|
312
320
|
"items": {
|
|
@@ -321,7 +329,7 @@
|
|
|
321
329
|
"design"
|
|
322
330
|
]
|
|
323
331
|
},
|
|
324
|
-
"description": "Check packs whose criteria are added to this node (ids AC1xx seo, AC2xx usability, AC3xx a11y, AC4xx perf, AC5xx console, AC6xx design: checks against the design reference)."
|
|
332
|
+
"description": "Check packs whose criteria are added to this node (ids AC1xx seo, AC2xx usability, AC3xx a11y, AC4xx perf, AC5xx console, AC6xx errors, AC7xx app, AC8xx design: checks against the design reference)."
|
|
325
333
|
},
|
|
326
334
|
"capture": {
|
|
327
335
|
"$ref": "#/$defs/capture"
|
|
@@ -528,6 +536,12 @@
|
|
|
528
536
|
"pack": {
|
|
529
537
|
"type": "string",
|
|
530
538
|
"description": "Set by the CLI on criteria that came from a pack."
|
|
539
|
+
},
|
|
540
|
+
"environments": {
|
|
541
|
+
"type": "array",
|
|
542
|
+
"minItems": 1,
|
|
543
|
+
"items": { "type": "string" },
|
|
544
|
+
"description": "Environments where this criterion applies, e.g. [production] for hreflang to the live domain, the live demo or the sitemap. On any other environment the walk reports it as not applicable (not_evaluated with na: environment) and it does not make the step partial."
|
|
531
545
|
}
|
|
532
546
|
}
|
|
533
547
|
},
|
|
@@ -42,12 +42,42 @@ sharpen each purpose and add the criteria the page must really meet.
|
|
|
42
42
|
criteria (ids `AC1xx` for `seo`, `AC2xx` for `usability`) at load time:
|
|
43
43
|
`seo` checks one H1, title and description length, canonical, hreflang for
|
|
44
44
|
every locale, Open Graph, indexing, viewport, `html lang`, overflow and
|
|
45
|
-
console; `usability`
|
|
46
|
-
|
|
45
|
+
console; `usability` checks unlabeled inputs and link texts and asks the
|
|
46
|
+
evaluator questions that fit the step's page type. A node's own criterion with the
|
|
47
47
|
same id wins. Pack criteria are marked `pack` on the canvas and do not count
|
|
48
48
|
as drift in `--coverage`; the plan may still mention them by id. Use packs
|
|
49
49
|
for public pages and landings; task flows keep their own criteria.
|
|
50
50
|
|
|
51
|
+
Set `page` on every step (or once on the scenario) so the usability pack
|
|
52
|
+
asks the right questions:
|
|
53
|
+
|
|
54
|
+
| `page` | for | asks |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `landing` | public and marketing pages | purpose in the first screen (`AC201`), exactly one primary call to action (`AC202`) |
|
|
57
|
+
| `work` | app screens where people work: overview, canvas, detail, inbox item | the heading says what the screen is for (`AC205`) |
|
|
58
|
+
| `list` | lists and tables: runs, projects, inbox | `AC205`, each item says what it is and how to open it (`AC207`) |
|
|
59
|
+
| `settings` | settings, billing, tokens | `AC205`, each setting says what it changes and how it is saved (`AC208`) |
|
|
60
|
+
| `form` | sign-up, sign-in, a form to fill in | `AC205`, how to send it and what happens next (`AC206`) |
|
|
61
|
+
| `doc` | documentation, guides, terms | `AC201`, headings and short paragraphs (`AC209`) |
|
|
62
|
+
|
|
63
|
+
Every type also gets `AC203` and `AC204`. Without `page` a step with the
|
|
64
|
+
`seo` pack, or any step of a scenario allowed on production, is `landing`;
|
|
65
|
+
everything else is `work`. Do not put `landing` on signed-in screens: "one
|
|
66
|
+
primary call to action" is a marketing rule, and a work screen rightly offers
|
|
67
|
+
several actions.
|
|
68
|
+
|
|
69
|
+
A criterion that only makes sense on production (hreflang pairs pointing at
|
|
70
|
+
the live domain, an embedded live demo, the production sitemap) carries
|
|
71
|
+
`environments: [production]`. Walked anywhere else it is reported as not
|
|
72
|
+
applicable to that environment (`not_evaluated` with `not_applicable:
|
|
73
|
+
environment`) and does not make the step partial.
|
|
74
|
+
|
|
75
|
+
When a scenario walks several locales, a text check (`text_matches`,
|
|
76
|
+
`title_matches`, …) must match every language: write
|
|
77
|
+
`pattern: "(Your data|Vaše data)"`. `kaloko validate` warns when a pattern
|
|
78
|
+
reads in one language only (it looks for diacritics and common Czech or
|
|
79
|
+
English words; one plain word such as a brand name is left alone).
|
|
80
|
+
|
|
51
81
|
- `a11y` (`AC3xx`): images with alt, links and buttons with a name, labelled
|
|
52
82
|
fields, heading levels not skipped, WCAG AA text contrast, a `main`
|
|
53
83
|
landmark, unique ids (`AC301`–`AC307`, read from the page). After the
|
package/src/cli.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'
|
|
|
6
6
|
import { availableParallelism } from 'node:os'
|
|
7
7
|
import yaml from 'js-yaml'
|
|
8
8
|
import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FILE } from './lib/config.mjs'
|
|
9
|
-
import { loadScenario, validateScenario } from './lib/scenario.mjs'
|
|
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
12
|
import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
|
|
@@ -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'
|
|
@@ -94,7 +94,9 @@ Project
|
|
|
94
94
|
the organization and what the token may do; the token (at most 30 days) goes to
|
|
95
95
|
~/.config/kaloko/.env, or is printed with --print
|
|
96
96
|
doctor [--json] [--fix] Check Node, Chrome, config, environments, browser ports, token, evaluator key
|
|
97
|
-
and app tools; --fix
|
|
97
|
+
and app tools; --fix installs missing Playwright browsers, creates the run and
|
|
98
|
+
scenario folders, writes org: from the token and asks macOS for the desktop
|
|
99
|
+
capture permissions
|
|
98
100
|
validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]]
|
|
99
101
|
Validate scenarios (schema + references); --coverage compares criteria with the plan
|
|
100
102
|
With a token it also warns about scenarios the service still lists whose file is gone (--offline skips it)
|
|
@@ -233,7 +235,7 @@ Results
|
|
|
233
235
|
signoff --verify <file.json|file.pdf> [--key file|url] [--json]
|
|
234
236
|
Check a record someone handed over (no token needed); exit 1 when it does not verify
|
|
235
237
|
stability --pull [--scenario id] [--json]
|
|
236
|
-
Regions people drew on screenshots on kaloko.app (step detail →
|
|
238
|
+
Regions people drew on screenshots on kaloko.app (step detail → Mark a changing
|
|
237
239
|
region) go into the scenario's ignore: of their steps; walk them again after
|
|
238
240
|
screens [words] [--project p] [--product p] [--module m] [--scenario id] [--locale l] [--viewport v] [--device d]
|
|
239
241
|
[--status pass|partial|fail|skip] [--environment e] [--designs] [--all] [--json] [--cursor c]
|
|
@@ -449,7 +451,7 @@ async function runContext (args) {
|
|
|
449
451
|
}
|
|
450
452
|
|
|
451
453
|
function stepOf (scenario, id) {
|
|
452
|
-
if (!id) throw new UserError(
|
|
454
|
+
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
455
|
const node = scenario.nodes.find((n) => n.id === id)
|
|
454
456
|
if (!node) throw new UserError(`Unknown step "${id}". Steps: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}`)
|
|
455
457
|
return node
|
|
@@ -655,6 +657,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
|
|
|
655
657
|
page.on('response', onResponse)
|
|
656
658
|
const streak = failureStreak(5)
|
|
657
659
|
const failed = []; let ok = 0
|
|
660
|
+
const started = Date.now(); let told = started
|
|
658
661
|
try {
|
|
659
662
|
for (const [i, item] of chosen.entries()) {
|
|
660
663
|
if (streak.stopped('stack')) { failed.push(`${item.id}: not walked after repeated failures`); continue }
|
|
@@ -671,7 +674,8 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
|
|
|
671
674
|
failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
|
|
672
675
|
if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
|
|
673
676
|
}
|
|
674
|
-
|
|
677
|
+
// long stacks: progress every 25 pages (or 30 s) with the time left, estimated from the pages so far
|
|
678
|
+
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
679
|
}
|
|
676
680
|
} finally { page.off('response', onResponse) }
|
|
677
681
|
say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}${tag}: ${ok}/${chosen.length} page(s) captured`)
|
|
@@ -987,10 +991,12 @@ const commands = {
|
|
|
987
991
|
if (!args._.length) throw new UserError('Usage: kaloko validate <scenario.yml>...')
|
|
988
992
|
let failed = 0
|
|
989
993
|
for (const file of args._) {
|
|
990
|
-
|
|
994
|
+
let scenario
|
|
995
|
+
try { scenario = parseScenario(await readFile(file, 'utf8'), file) } catch (e) { if (!(e instanceof UserError)) throw e; failed++; say(`✕ ${e.message}`); continue }
|
|
991
996
|
const problems = validateScenario(scenario)
|
|
992
997
|
if (!problems.length) expandPacks(scenario)
|
|
993
998
|
if (problems.length) { failed++; say(`✕ ${file}\n - ${problems.join('\n - ')}`) } else say(`✓ ${file}`)
|
|
999
|
+
if (!problems.length) for (const w of scenarioWarnings(scenario)) say(` ! ${w}`)
|
|
994
1000
|
if (args.coverage) {
|
|
995
1001
|
// the plan is scenario.source (relative to the working directory or to the scenario file), or --source
|
|
996
1002
|
const candidates = typeof args.source === 'string' ? [resolve(args.source)] : scenario.source ? [resolve(scenario.source), resolve(file, '..', scenario.source), resolve(file, '..', '..', scenario.source)] : []
|
|
@@ -1223,7 +1229,7 @@ const commands = {
|
|
|
1223
1229
|
const parallel = concurrency > 1 && locales.length > 1
|
|
1224
1230
|
const scenarioDir = resolve(root, run.meta.scenario_file, '..')
|
|
1225
1231
|
// semantic questions of every new capture start right away; the walk ends with the verdicts (--no-evaluate: skip)
|
|
1226
|
-
const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root })
|
|
1232
|
+
const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root, environment: env?.name || null })
|
|
1227
1233
|
const walked = await readCaptures(run.dir)
|
|
1228
1234
|
// recordings and traces (batch D, #12): opt-in recordings, traces unless turned off; encoded in an own headless Chrome
|
|
1229
1235
|
const recSettings = recordSettings({ args, config, scenario })
|
|
@@ -2104,9 +2110,9 @@ const commands = {
|
|
|
2104
2110
|
}
|
|
2105
2111
|
let runId = args.run
|
|
2106
2112
|
if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
|
|
2107
|
-
if (!runId) throw new UserError('--run <id
|
|
2113
|
+
if (!runId) throw new UserError('No current run here. Pass --run <id>, or walk the scenario first (kaloko start, then kaloko walk).')
|
|
2108
2114
|
const run = await load(runId)
|
|
2109
|
-
if (!run) throw new UserError(`Run ${runId}
|
|
2115
|
+
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).`)
|
|
2110
2116
|
let baseId = typeof args.baseline === 'string' ? args.baseline : null
|
|
2111
2117
|
// --design: against the design reference of the scenario (#42)
|
|
2112
2118
|
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>).') }
|
|
@@ -2118,7 +2124,7 @@ const commands = {
|
|
|
2118
2124
|
}
|
|
2119
2125
|
if (!baseId) throw new UserError('No baseline or earlier run of this scenario to compare with; pass --baseline <id>.')
|
|
2120
2126
|
const base = await load(baseId)
|
|
2121
|
-
if (!base) throw new UserError(`
|
|
2127
|
+
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>.`)
|
|
2122
2128
|
const d = compareRuns(run, base)
|
|
2123
2129
|
if (args.json) { say(JSON.stringify(d, null, 2)); return }
|
|
2124
2130
|
say(formatComparison(d))
|
|
@@ -2197,7 +2203,7 @@ const commands = {
|
|
|
2197
2203
|
if (typeof args.scenario === 'string') return exportScenario({ args, config, root, client })
|
|
2198
2204
|
if (typeof args.format === 'string') {
|
|
2199
2205
|
// living docs and the changelog as files (docs/DOCS-CHANGELOG.md → Exports)
|
|
2200
|
-
if (!config.org) throw new UserError(
|
|
2206
|
+
if (!config.org) throw new UserError(NO_ORG)
|
|
2201
2207
|
const { pickProduct } = await import('./lib/changelog.mjs')
|
|
2202
2208
|
const { exportDocs } = await import('./lib/docs.mjs')
|
|
2203
2209
|
const product = pickProduct(config, root, args.product)
|
|
@@ -2218,7 +2224,7 @@ const commands = {
|
|
|
2218
2224
|
}
|
|
2219
2225
|
let runId = args.run
|
|
2220
2226
|
if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
|
|
2221
|
-
if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id>
|
|
2227
|
+
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.')
|
|
2222
2228
|
const out = typeof args.out === 'string' ? resolve(args.out) : resolve(root, config.output, `${runId}.zip`)
|
|
2223
2229
|
const { createWriteStream } = await import('node:fs')
|
|
2224
2230
|
const { Readable } = await import('node:stream')
|
|
@@ -2234,7 +2240,7 @@ const commands = {
|
|
|
2234
2240
|
const client = service(config)
|
|
2235
2241
|
let runId = args.run
|
|
2236
2242
|
if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
|
|
2237
|
-
if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id>
|
|
2243
|
+
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.')
|
|
2238
2244
|
const r = await client.request('POST', `/orgs/${config.org}/runs/${runId}/public`, { public: !args.off })
|
|
2239
2245
|
say(r.public ? `Public: ${r.url} (readable without sign-in; only one public run per scenario)` : `Private again: ${runId}`)
|
|
2240
2246
|
},
|
|
@@ -2282,7 +2288,7 @@ const commands = {
|
|
|
2282
2288
|
const product = pickProduct(config, root, args.product)
|
|
2283
2289
|
const version = typeof args.version === 'string' ? args.version : 'latest'
|
|
2284
2290
|
if (sub === 'publish') {
|
|
2285
|
-
if (!config.org && !args['dry-run']) throw new UserError(
|
|
2291
|
+
if (!config.org && !args['dry-run']) throw new UserError(NO_ORG)
|
|
2286
2292
|
const r = await docs.publishDocs({ root, config, product, client: args['dry-run'] ? null : service(config), version, reviewed: !!args.reviewed, dryRun: !!args['dry-run'] })
|
|
2287
2293
|
if (args.json) { say(JSON.stringify(r, null, 2)); return }
|
|
2288
2294
|
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 }
|
|
@@ -2310,12 +2316,12 @@ const commands = {
|
|
|
2310
2316
|
const { config, root } = await context(args)
|
|
2311
2317
|
const lib = await import('./lib/changelog.mjs')
|
|
2312
2318
|
if (sub === 'list') {
|
|
2313
|
-
if (!config.org) throw new UserError(
|
|
2319
|
+
if (!config.org) throw new UserError(NO_ORG)
|
|
2314
2320
|
const r = await lib.listProducts({ root, config, client: service(config) })
|
|
2315
2321
|
say(args.json ? JSON.stringify(r, null, 2) : lib.formatList(r))
|
|
2316
2322
|
return
|
|
2317
2323
|
}
|
|
2318
|
-
if (!config.org && !args['dry-run']) throw new UserError(
|
|
2324
|
+
if (!config.org && !args['dry-run']) throw new UserError(NO_ORG)
|
|
2319
2325
|
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'] })
|
|
2320
2326
|
say(args.json ? JSON.stringify(r, null, 2) : lib.formatSync(r))
|
|
2321
2327
|
if (r.products.some((p) => p.ok === false)) process.exitCode = 1
|
|
@@ -2324,7 +2330,7 @@ const commands = {
|
|
|
2324
2330
|
/** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
|
|
2325
2331
|
async release (args) {
|
|
2326
2332
|
const { config, root } = await context(args)
|
|
2327
|
-
if (!config.org) throw new UserError(
|
|
2333
|
+
if (!config.org) throw new UserError(NO_ORG)
|
|
2328
2334
|
const { pickProduct, releaseProduct } = await import('./lib/changelog.mjs')
|
|
2329
2335
|
const product = pickProduct(config, root, args.product)
|
|
2330
2336
|
const client = args['dry-run'] ? null : service(config)
|
|
@@ -2358,10 +2364,13 @@ export async function main (argv) {
|
|
|
2358
2364
|
const args = parse(argv)
|
|
2359
2365
|
const name = args._.shift() || 'help'
|
|
2360
2366
|
const cmd = commands[name] || (args.help ? commands.help : null)
|
|
2361
|
-
if (!cmd) { console.error(`
|
|
2367
|
+
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 }
|
|
2362
2368
|
try {
|
|
2363
2369
|
await cmd(args)
|
|
2364
2370
|
} catch (error) {
|
|
2365
|
-
if (error instanceof UserError) { console.error(error.message); process.exitCode = 1 } else
|
|
2371
|
+
if (error instanceof UserError) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = 1 } else {
|
|
2372
|
+
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.`)
|
|
2373
|
+
throw error
|
|
2374
|
+
}
|
|
2366
2375
|
}
|
|
2367
2376
|
}
|
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/browser.mjs
CHANGED
|
@@ -138,10 +138,22 @@ export async function currentPage (context, match) {
|
|
|
138
138
|
return real[real.length - 1] || pages[pages.length - 1] || context.newPage()
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* 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
|
+
* KALOKO_LAUNCH_TIMEOUT (seconds, default 45) sets the limit.
|
|
145
|
+
*/
|
|
146
|
+
export async function launchWithRetry (browserType, options = {}) {
|
|
147
|
+
const timeout = (Number(process.env.KALOKO_LAUNCH_TIMEOUT) || 45) * 1000
|
|
148
|
+
try { return await browserType.launch({ timeout, ...options }) } catch (e) {
|
|
149
|
+
if (!/Timeout/i.test(String(e?.name) + String(e?.message))) throw e
|
|
150
|
+
return browserType.launch({ timeout, ...options })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
141
154
|
export async function launchEphemeral (config) {
|
|
142
155
|
const { chromium } = await import('playwright')
|
|
143
|
-
|
|
144
|
-
return browser
|
|
156
|
+
return launchWithRetry(chromium, { headless: true, channel: config.browser.channel || undefined })
|
|
145
157
|
}
|
|
146
158
|
|
|
147
159
|
/**
|
package/src/lib/build.mjs
CHANGED
|
@@ -4,7 +4,7 @@ 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 } from './evaluate.mjs'
|
|
7
|
+
import { latestCaptures, evaluationSource, stepIdOf, appliesOn } from './evaluate.mjs'
|
|
8
8
|
import { fieldsOf, DEFAULT_SEMANTIC_SAMPLE } from './stack.mjs'
|
|
9
9
|
import { layout } from './scenario.mjs'
|
|
10
10
|
import { assembleNode, summarize, statusOf, worst } from './manifest-core.mjs'
|
|
@@ -149,7 +149,10 @@ async function runMedia ({ run, entries }) {
|
|
|
149
149
|
async function buildStack ({ run, node, base, captures, attachments }) {
|
|
150
150
|
const paths = runPaths(run.dir)
|
|
151
151
|
const fields = fieldsOf(node).map((f) => ({ id: f.id, label: f.label || f.id }))
|
|
152
|
-
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))
|
|
153
156
|
const byItem = new Map()
|
|
154
157
|
for (const c of captures.filter((x) => x.node === node.id && x.item)) {
|
|
155
158
|
const cap = await readJson(join(run.dir, c.file))
|
|
@@ -165,10 +168,10 @@ async function buildStack ({ run, node, base, captures, attachments }) {
|
|
|
165
168
|
const items = [...byItem.values()].sort((a, b) => a.order - b.order).map(({ order, ...it }) => {
|
|
166
169
|
const per = Object.values(it.results)
|
|
167
170
|
const verdictOf = (id) => worst(per.map((r) => r[id]?.[0]).filter(Boolean))
|
|
168
|
-
const verdicts = crit.map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
|
|
171
|
+
const verdicts = crit.filter((c) => live.has(c.id)).map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
|
|
169
172
|
const status = statusOf(verdicts, per.length > 0)
|
|
170
|
-
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.
|
|
171
|
-
return { ...it, status, by_locale, fails: crit.filter((c) => verdictOf(c.id) === 'fail').map((c) => c.id), review: crit.filter((c) => ['needs_review', 'not_evaluated'].includes(verdictOf(c.id))).map((c) => c.id) }
|
|
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) }
|
|
172
175
|
})
|
|
173
176
|
const summary = { pass: 0, partial: 0, fail: 0, skip: 0 }
|
|
174
177
|
for (const it of items) summary[it.status]++
|
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)
|
|
@@ -361,14 +361,35 @@ export function docsConfigBlock ({ products = null, full = false } = {}) {
|
|
|
361
361
|
return `${lines.join('\n')}\n`
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
/**
|
|
364
|
+
/**
|
|
365
|
+
* Apps of a monorepo that look like products, for kaloko init: the folders of the workspaces (package.json
|
|
366
|
+
* `workspaces`, pnpm-workspace.yaml `packages`), apps/* and packages/*. A folder needs a package.json; outside apps/*
|
|
367
|
+
* it must also run as an app (a dev, start or serve script), so shared libraries and configs are left out. Only
|
|
368
|
+
* offered when there are at least two.
|
|
369
|
+
*/
|
|
365
370
|
export async function detectProducts (root) {
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
371
|
+
const globs = new Set(['apps/*', 'packages/*'])
|
|
372
|
+
const rootPkg = await readJson(join(root, 'package.json'))
|
|
373
|
+
const ws = Array.isArray(rootPkg?.workspaces) ? rootPkg.workspaces : Array.isArray(rootPkg?.workspaces?.packages) ? rootPkg.workspaces.packages : []
|
|
374
|
+
for (const g of ws) globs.add(String(g))
|
|
375
|
+
const pnpm = await readFile(join(root, 'pnpm-workspace.yaml'), 'utf8').catch(() => '')
|
|
376
|
+
for (const m of pnpm.matchAll(/^\s*-\s*['"]?([^'"\n#]+?)['"]?\s*$/gm)) globs.add(m[1].trim())
|
|
377
|
+
const out = []; const seen = new Set()
|
|
378
|
+
for (const g of [...globs].sort()) {
|
|
379
|
+
if (g.startsWith('!')) continue
|
|
380
|
+
// "dir/*" (one level) or a plain folder; deeper patterns are left to the person
|
|
381
|
+
const m = g.replace(/\/+$/, '').match(/^([^*]+?)(\/\*)?$/)
|
|
382
|
+
if (!m) continue
|
|
383
|
+
const dirs = m[2] ? (await readdir(join(root, m[1])).catch(() => [])).sort().map((n) => `${m[1]}/${n}`) : [m[1]]
|
|
384
|
+
for (const dir of dirs) {
|
|
385
|
+
if (seen.has(dir)) continue
|
|
386
|
+
const pkg = await readJson(join(root, dir, 'package.json'))
|
|
387
|
+
if (!pkg) continue
|
|
388
|
+
const runs = ['dev', 'start', 'serve'].some((k) => pkg.scripts?.[k])
|
|
389
|
+
if (!dir.startsWith('apps/') && !runs) continue
|
|
390
|
+
seen.add(dir)
|
|
391
|
+
const n = basename(dir)
|
|
392
|
+
out.push({ slug: slugOf(n), name: pkg.name ? String(pkg.name).replace(/^@[^/]+\//, '') : n, dir, path: `${dir}/package.json` })
|
|
372
393
|
}
|
|
373
394
|
}
|
|
374
395
|
return out.length > 1 ? out : []
|
|
@@ -89,7 +89,7 @@ async function runIdOf ({ client, org, scenarioId, ref, branch }) {
|
|
|
89
89
|
}
|
|
90
90
|
if (/^v\d+$/.test(ref)) {
|
|
91
91
|
const v = (await client.request('GET', `/orgs/${org}/scenarios/${enc(scenarioId)}/versions`)).find((x) => String(x.version) === ref.slice(1))
|
|
92
|
-
if (!v) throw new UserError(`${scenarioId} has no version ${ref}.`)
|
|
92
|
+
if (!v) throw new UserError(`${scenarioId} has no version ${ref}. kaloko history lists its versions.`)
|
|
93
93
|
return v.id
|
|
94
94
|
}
|
|
95
95
|
return ref
|
|
@@ -111,7 +111,7 @@ export async function exportDesign ({ config, scenarioId, client, ref = 'design'
|
|
|
111
111
|
const sink = exportSink(out)
|
|
112
112
|
const fileOf = async (path) => {
|
|
113
113
|
const res = draft ? await fetch(manifest.files_base + path) : await client.fetch(`/orgs/${org}/runs/${runId}/files/${path.split('/').map(enc).join('/')}`)
|
|
114
|
-
if (!res.ok) throw new UserError(
|
|
114
|
+
if (!res.ok) throw new UserError(`Downloading ${path} failed (HTTP ${res.status}). Run the export again; if it keeps failing, the file may have expired with its run.`)
|
|
115
115
|
return Buffer.from(await res.arrayBuffer())
|
|
116
116
|
}
|
|
117
117
|
// the live files keep the design folder layout: links between steps work from disk exactly as in the folder
|
package/src/lib/dimensions.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// `scope.variants` names the dimensions of each key; `scope.dimensions` lists the values for the canvas switchers.
|
|
9
9
|
import { UserError } from './util.mjs'
|
|
10
10
|
import { devicePreset, emulationOf, presetSummary, presetNames } from './devices.mjs'
|
|
11
|
+
import { launchWithRetry } from './browser.mjs'
|
|
11
12
|
|
|
12
13
|
export const BROWSERS = ['chromium', 'webkit', 'firefox']
|
|
13
14
|
export const COLOR_SCHEMES = ['light', 'dark']
|
|
@@ -318,7 +319,7 @@ export async function launchEngine (engine, { channel, headless = true } = {}) {
|
|
|
318
319
|
const type = pw[engine]
|
|
319
320
|
if (!type) throw new UserError(`Unknown browser engine ${engine}. Use: ${BROWSERS.join(', ')}.`)
|
|
320
321
|
try {
|
|
321
|
-
return await type
|
|
322
|
+
return await launchWithRetry(type, { headless, ...(engine === 'chromium' && channel ? { channel } : {}) })
|
|
322
323
|
} catch (error) {
|
|
323
324
|
if (/Executable doesn't exist|install/i.test(error.message)) throw new UserError(`${engine} is not installed for Playwright. Run: kaloko browser install --browsers ${engine}`)
|
|
324
325
|
throw error
|
package/src/lib/docs-core.mjs
CHANGED
|
@@ -288,11 +288,12 @@ export const escapeHtml = esc
|
|
|
288
288
|
|
|
289
289
|
/** Inline Markdown: code, bold, italics, links (safe schemes; `link(href)` may rewrite or drop them). Images are removed. */
|
|
290
290
|
export function inlineHtml (s, { link = null } = {}) {
|
|
291
|
-
let out = esc(String(s).replace(/!\[[
|
|
291
|
+
let out = esc(String(s).replace(/!\[[^[\]]*\]\([^()]*\)/g, ''))
|
|
292
292
|
const codes = []
|
|
293
293
|
out = out.replace(/`([^`]+)`/g, (_, c) => { codes.push(`<code>${c}</code>`); return `\u0000${codes.length - 1}\u0000` })
|
|
294
294
|
out = out.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/__([^_]+)__/g, '<b>$1</b>').replace(/(^|[\s(])_([^_]+)_(?=$|[\s).,;:!?])/g, '$1<i>$2</i>').replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=$|[\s).,;:!?])/g, '$1<i>$2</i>')
|
|
295
|
-
|
|
295
|
+
// no [ inside the brackets, at most one pair of ( ) in the target: a long run of [ stays linear
|
|
296
|
+
out = out.replace(/\[([^[\]]+)\]\(([^()[\]\s]+(?:\([^()\s]*\)[^()[\]\s]*)?)(?:\s+"[^&]*")?\)/g, (_, t, href) => {
|
|
296
297
|
const raw = href.replace(/&/g, '&')
|
|
297
298
|
const target = link ? link(raw) : raw
|
|
298
299
|
if (target == null) return t
|
|
@@ -302,7 +303,7 @@ export function inlineHtml (s, { link = null } = {}) {
|
|
|
302
303
|
}
|
|
303
304
|
|
|
304
305
|
const LIST = /^(\s{0,3})([-*+]|\d{1,9}[.)])\s+(.*)$/
|
|
305
|
-
const IMG = /!\[([
|
|
306
|
+
const IMG = /!\[([^[\]]*)\]\(([^()[\]\s]+)(?:\s+"[^"]*")?\)/g
|
|
306
307
|
|
|
307
308
|
/**
|
|
308
309
|
* Markdown to HTML for docs: headings (with ids), paragraphs, lists (nested by indentation), fenced code,
|
|
@@ -361,16 +362,18 @@ function blocks (lines, o) {
|
|
|
361
362
|
continue
|
|
362
363
|
}
|
|
363
364
|
if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(l)) { flush(); out.push('<hr>'); i++; continue }
|
|
364
|
-
|
|
365
|
+
// quotes nest at most 16 deep; deeper > are text (100 000 levels would overflow the stack)
|
|
366
|
+
if (/^\s{0,3}>/.test(l) && (o.quoteDepth || 0) < 16) {
|
|
365
367
|
flush()
|
|
366
368
|
const buf = []
|
|
367
369
|
while (i < lines.length && /^\s{0,3}>/.test(lines[i])) buf.push(lines[i++].replace(/^\s{0,3}>\s?/, ''))
|
|
368
|
-
out.push(`<blockquote>${blocks(buf, { ...o, notice: null })}</blockquote>`)
|
|
370
|
+
out.push(`<blockquote>${blocks(buf, { ...o, notice: null, quoteDepth: (o.quoteDepth || 0) + 1 })}</blockquote>`)
|
|
369
371
|
continue
|
|
370
372
|
}
|
|
371
373
|
if (/^\s*\|.*\|\s*$/.test(l) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(lines[i + 1])) {
|
|
372
374
|
flush()
|
|
373
|
-
|
|
375
|
+
// GFM: `\|` is a pipe inside a cell (also inside code), not a column break
|
|
376
|
+
const row = (x) => x.trim().replace(/^\||(?<!\\)\|$/g, '').split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'))
|
|
374
377
|
const head = row(l); i += 2
|
|
375
378
|
const rows = []
|
|
376
379
|
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(row(lines[i++]))
|
package/src/lib/docs.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { readFile, readdir, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
6
6
|
import { execFile } from 'node:child_process'
|
|
7
7
|
import { promisify } from 'node:util'
|
|
8
|
-
import { join, resolve, relative, dirname } from 'node:path'
|
|
8
|
+
import { join, resolve, relative, dirname, sep, isAbsolute } from 'node:path'
|
|
9
9
|
import { tmpdir } from 'node:os'
|
|
10
10
|
import { UserError, sha256 } from './util.mjs'
|
|
11
11
|
import { findStepRefs, cleanVersion } from './changelog-core.mjs'
|
|
@@ -210,7 +210,9 @@ export async function unpack (buf, dir) {
|
|
|
210
210
|
for (const [name, read] of entries) {
|
|
211
211
|
if (name.endsWith('/')) continue
|
|
212
212
|
const target = resolve(dir, name)
|
|
213
|
-
|
|
213
|
+
// relative() rather than a '/' prefix test, so the check also holds with Windows separators (a\..\..\x)
|
|
214
|
+
const rel = relative(resolve(dir), target)
|
|
215
|
+
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) || name.includes('\0')) throw new UserError(`The export holds a path outside its folder: ${name}`)
|
|
214
216
|
await mkdir(dirname(target), { recursive: true })
|
|
215
217
|
await writeFile(target, read())
|
|
216
218
|
n++
|