kaloko 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/schemas/scenario.v1.json +15 -1
- package/skills/kaloko/reference/acceptance.md +32 -2
- package/src/cli.mjs +6 -4
- package/src/lib/browser.mjs +14 -2
- package/src/lib/build.mjs +8 -5
- package/src/lib/changelog.mjs +28 -7
- 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/evaluate.mjs +10 -5
- 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/scenario.mjs +63 -1
- package/src/lib/stability.mjs +1 -1
- package/src/lib/unzip.mjs +22 -4
- package/templates/scenario.yml +1 -0
- package/viewer/index.html +43 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kaloko",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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'
|
|
@@ -233,7 +233,7 @@ Results
|
|
|
233
233
|
signoff --verify <file.json|file.pdf> [--key file|url] [--json]
|
|
234
234
|
Check a record someone handed over (no token needed); exit 1 when it does not verify
|
|
235
235
|
stability --pull [--scenario id] [--json]
|
|
236
|
-
Regions people drew on screenshots on kaloko.app (step detail →
|
|
236
|
+
Regions people drew on screenshots on kaloko.app (step detail → Mark a changing
|
|
237
237
|
region) go into the scenario's ignore: of their steps; walk them again after
|
|
238
238
|
screens [words] [--project p] [--product p] [--module m] [--scenario id] [--locale l] [--viewport v] [--device d]
|
|
239
239
|
[--status pass|partial|fail|skip] [--environment e] [--designs] [--all] [--json] [--cursor c]
|
|
@@ -987,10 +987,12 @@ const commands = {
|
|
|
987
987
|
if (!args._.length) throw new UserError('Usage: kaloko validate <scenario.yml>...')
|
|
988
988
|
let failed = 0
|
|
989
989
|
for (const file of args._) {
|
|
990
|
-
|
|
990
|
+
let scenario
|
|
991
|
+
try { scenario = parseScenario(await readFile(file, 'utf8'), file) } catch (e) { if (!(e instanceof UserError)) throw e; failed++; say(`✕ ${e.message}`); continue }
|
|
991
992
|
const problems = validateScenario(scenario)
|
|
992
993
|
if (!problems.length) expandPacks(scenario)
|
|
993
994
|
if (problems.length) { failed++; say(`✕ ${file}\n - ${problems.join('\n - ')}`) } else say(`✓ ${file}`)
|
|
995
|
+
if (!problems.length) for (const w of scenarioWarnings(scenario)) say(` ! ${w}`)
|
|
994
996
|
if (args.coverage) {
|
|
995
997
|
// the plan is scenario.source (relative to the working directory or to the scenario file), or --source
|
|
996
998
|
const candidates = typeof args.source === 'string' ? [resolve(args.source)] : scenario.source ? [resolve(scenario.source), resolve(file, '..', scenario.source), resolve(file, '..', '..', scenario.source)] : []
|
|
@@ -1223,7 +1225,7 @@ const commands = {
|
|
|
1223
1225
|
const parallel = concurrency > 1 && locales.length > 1
|
|
1224
1226
|
const scenarioDir = resolve(root, run.meta.scenario_file, '..')
|
|
1225
1227
|
// 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 })
|
|
1228
|
+
const prefetch = args['no-evaluate'] ? null : semanticPrefetch({ scenario, config, root, environment: env?.name || null })
|
|
1227
1229
|
const walked = await readCaptures(run.dir)
|
|
1228
1230
|
// recordings and traces (batch D, #12): opt-in recordings, traces unless turned off; encoded in an own headless Chrome
|
|
1229
1231
|
const recSettings = recordSettings({ args, config, scenario })
|
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
|
@@ -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 : []
|
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++
|
package/src/lib/evaluate.mjs
CHANGED
|
@@ -17,6 +17,8 @@ export function latestCaptures (entries) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const criteriaHash = (node) => sha256(node.criteria || [])
|
|
20
|
+
/** A criterion with `environments` (e.g. [production]) applies only there; without a known environment it applies. */
|
|
21
|
+
export const appliesOn = (c, environment) => !environment || !Array.isArray(c.environments) || !c.environments.length || c.environments.includes(environment)
|
|
20
22
|
/** Folder of a capture under steps/: the node, or steps/<node>/items/<item> for a page of a stack step. */
|
|
21
23
|
export const stepIdOf = (e) => (e.item ? `${e.node}/items/${e.item}` : e.node)
|
|
22
24
|
export const evaluationSource = (node, capture) => sha256({ capture, criteria: node.criteria || [], purpose: node.purpose || '' })
|
|
@@ -26,8 +28,9 @@ export const evaluationSource = (node, capture) => sha256({ capture, criteria: n
|
|
|
26
28
|
* asked, the vision evaluator. Every semantic verdict with a score carries a one-sentence `reason`: the vision
|
|
27
29
|
* model's own, or a sentence saying what the text evaluator read (it returns a probability only).
|
|
28
30
|
*/
|
|
29
|
-
export function combine ({ node, capture, jev, vision = null, thresholds }) {
|
|
31
|
+
export function combine ({ node, capture, jev, vision = null, thresholds, environment = null }) {
|
|
30
32
|
return (node.criteria || []).map((c) => {
|
|
33
|
+
if (!appliesOn(c, environment)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies on ${c.environments.join(', ')} only; this walk ran on ${environment}.`, na: true, not_applicable: 'environment' }
|
|
31
34
|
if (c.check.type === 'deterministic') {
|
|
32
35
|
const r = capture.checks?.[c.id]
|
|
33
36
|
const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
|
|
@@ -64,6 +67,8 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
64
67
|
const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
|
|
65
68
|
const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
|
|
66
69
|
const paths = runPaths(run.dir)
|
|
70
|
+
// criteria limited to other environments (environments: [production]) are reported as not applicable here
|
|
71
|
+
const environment = run.meta?.environment?.name || null
|
|
67
72
|
const report = { evaluated: 0, semantic_calls: 0, semantic_cached: 0, skipped: [], model: settings.model, drift: 0, outdated: new Set(), via: {}, hosted: null, hostedStop: null, findings: [] }
|
|
68
73
|
// the vision evaluator (#17): flagged and uncertain criteria, a bounded number of calls per evaluation
|
|
69
74
|
const vset = visionSettings(config)
|
|
@@ -101,7 +106,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
101
106
|
const current = capture.criteria_hash === criteriaHash(node)
|
|
102
107
|
// console triage needs the evaluator only when the capture has console errors
|
|
103
108
|
const outOfSample = entry.item && !sampled.get(entry.node)?.has(entry.item)
|
|
104
|
-
const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
109
|
+
const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && appliesOn(c, environment) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
105
110
|
// quick signals ride along on every captured page state (also steps without semantic criteria)
|
|
106
111
|
const signals = settings.signals !== false && !outOfSample && !!capture.state
|
|
107
112
|
// an imported screen without page text (#11) has nothing to ask about: a person decides on the canvas
|
|
@@ -126,7 +131,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
126
131
|
const source = evaluationSource(node, capture)
|
|
127
132
|
if (drift && current && needsDrift(node)) { capture.checks = { ...capture.checks, ...await driftForCapture({ node, entry, capture, runDir: run.dir, ctx: drift }) }; report.drift++ }
|
|
128
133
|
if (styleTokens?.tokens && current && tokenCriteria(node).length) capture.checks = { ...capture.checks, ...storedValueChecks(node, capture, { ...styleTokens, recorded: !!capture.rechecked_at, only: (c) => !!c.check.token && STYLE_ASSERTS.has(c.check.assert) }) }
|
|
129
|
-
let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds })
|
|
134
|
+
let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds, environment })
|
|
130
135
|
// pages outside the semantic sample carry only the deterministic results
|
|
131
136
|
if (outOfSample) criteria = criteria.filter((c) => c.type !== 'semantic' || c.evidence === 'no console errors')
|
|
132
137
|
const evaluation = {
|
|
@@ -175,7 +180,7 @@ function signalsOf (jev) {
|
|
|
175
180
|
return Object.keys(out).length ? out : null
|
|
176
181
|
}
|
|
177
182
|
|
|
178
|
-
export function semanticPrefetch ({ scenario, config, root }) {
|
|
183
|
+
export function semanticPrefetch ({ scenario, config, root, environment = null }) {
|
|
179
184
|
const settings = { ...DEFAULTS, ...(config.evaluators?.jev || {}), thresholds: { ...DEFAULTS.thresholds, ...(config.evaluators?.jev?.thresholds || {}) } }
|
|
180
185
|
const cacheDir = join(resolve(root, config.output), '.cache', 'jev')
|
|
181
186
|
const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
|
|
@@ -197,7 +202,7 @@ export function semanticPrefetch ({ scenario, config, root }) {
|
|
|
197
202
|
for (const e of [].concat(entries || [])) {
|
|
198
203
|
if (e?.type !== 'capture' || e.item) continue
|
|
199
204
|
const node = nodes.get(e.node)
|
|
200
|
-
const semantic = (node?.criteria || []).filter((c) => c.check.type === 'semantic')
|
|
205
|
+
const semantic = (node?.criteria || []).filter((c) => c.check.type === 'semantic' && appliesOn(c, environment))
|
|
201
206
|
if (!semantic.length && settings.signals === false) continue
|
|
202
207
|
queue.push(async () => {
|
|
203
208
|
const capture = await readJson(join(runDir, e.file))
|
|
@@ -99,24 +99,29 @@ export function assembleNode ({ node, pos, locales, viewportNames, captures, att
|
|
|
99
99
|
// semantic answers carry the thresholds they were judged by and where they were evaluated (own key or Kaloko)
|
|
100
100
|
// semantic answers carry their one-sentence reason (#17) and whether the evaluator saw the screenshot
|
|
101
101
|
const seen = r.provider && /-vision$/.test(r.provider)
|
|
102
|
-
loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(r.na ? { na: true } : {}), ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && (seen ? ev.vision?.via : ev.semantic?.via) ? { via: seen ? ev.vision.via : ev.semantic.via } : {}), ...(r.reason ? { reason: r.reason, ...(r.reason_source === 'model' ? { reason_by: 'model' } : {}) } : {}), ...(seen ? { vision: true, ...(r.text_score != null ? { text_score: r.text_score } : {}) } : {}) }
|
|
102
|
+
loc[c.viewport] = { verdict: r.verdict, evidence: r.evidence, score, note: r.note ?? null, ...(r.na ? { na: true } : {}), ...(r.not_applicable ? { not_applicable: r.not_applicable } : {}), ...(score != null && ev.thresholds ? { th: [ev.thresholds.fail, ev.thresholds.pass] } : {}), ...(r.type === 'semantic' && (seen ? ev.vision?.via : ev.semantic?.via) ? { via: seen ? ev.vision.via : ev.semantic.via } : {}), ...(r.reason ? { reason: r.reason, ...(r.reason_source === 'model' ? { reason_by: 'model' } : {}) } : {}), ...(seen ? { vision: true, ...(r.text_score != null ? { text_score: r.text_score } : {}) } : {}) }
|
|
103
103
|
if (r.evidence && (!agg.evidence || r.verdict !== 'pass')) agg.evidence = r.evidence
|
|
104
104
|
if (r.reason && (!agg.reason || (r.verdict !== 'pass' && agg.reason_verdict === 'pass'))) { agg.reason = r.reason; agg.reason_verdict = r.verdict }
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
const criteria = [...perCriterion.values()].map(({ reason_verdict: _rv, ...agg }) => {
|
|
108
108
|
for (const l of applicable) for (const vp of viewportNames) ((agg.by_locale[l] ||= {})[vp] ||= { verdict: 'not_reached' })
|
|
109
|
-
const
|
|
110
|
-
|
|
109
|
+
const entries = applicable.flatMap((l) => viewportNames.map((vp) => agg.by_locale[l][vp]))
|
|
110
|
+
const verdicts = counted(entries)
|
|
111
|
+
// a criterion limited to other environments (environments: [production]) is not applicable on this walk:
|
|
112
|
+
// reported as such, and it does not make the step partial
|
|
113
|
+
const offEnv = entries.some((e) => e.not_applicable) && entries.every((e) => e.not_applicable || e.verdict === 'not_reached')
|
|
114
|
+
return { ...agg, verdict: aggregate(verdicts), ...(offEnv ? { not_applicable: 'environment' } : {}) }
|
|
111
115
|
})
|
|
116
|
+
const counting = criteria.filter((c) => !c.not_applicable)
|
|
112
117
|
const byLocale = {}
|
|
113
118
|
for (const l of locales) {
|
|
114
119
|
const captured = !!capturesOut[l]
|
|
115
|
-
const vs =
|
|
120
|
+
const vs = counting.map((c) => aggregate(counted(Object.values(c.by_locale[l] || {}))))
|
|
116
121
|
if (applicable.includes(l) && viewportNames.some((vp) => !capturesOut[l]?.[vp])) vs.push('not_reached')
|
|
117
122
|
byLocale[l] = statusOf(vs, captured)
|
|
118
123
|
}
|
|
119
|
-
const verdicts =
|
|
124
|
+
const verdicts = counting.map((c) => c.verdict)
|
|
120
125
|
if (applicable.some((l) => viewportNames.some((vp) => !capturesOut[l]?.[vp]))) verdicts.push('not_reached')
|
|
121
126
|
return {
|
|
122
127
|
...base,
|
|
@@ -137,7 +142,7 @@ export function summarize (nodes, sessions = null) {
|
|
|
137
142
|
const screens = nodes.filter((n) => n.kind !== 'decision')
|
|
138
143
|
const summary = { pass: 0, partial: 0, fail: 0, skip: 0, criteria: {} }
|
|
139
144
|
for (const n of screens) summary[n.status]++
|
|
140
|
-
for (const n of screens) for (const c of n.criteria) summary.criteria[c.verdict] = (summary.criteria[c.verdict] || 0) + 1
|
|
145
|
+
for (const n of screens) for (const c of n.criteria) if (!c.not_applicable) summary.criteria[c.verdict] = (summary.criteria[c.verdict] || 0) + 1
|
|
141
146
|
if (sessions) {
|
|
142
147
|
summary.by_session = {}
|
|
143
148
|
for (const key of Object.keys(sessions)) summary.by_session[key] = { pass: 0, partial: 0, fail: 0, skip: 0 }
|
package/src/lib/pack-help.mjs
CHANGED
|
@@ -18,6 +18,11 @@ export const PACK_HELP = {
|
|
|
18
18
|
AC202: { cs: 'Jedna jasná hlavní akce', why: ['Several equally loud buttons make people hesitate; none leaves them without a next step.', 'Několik stejně výrazných tlačítek lidi zdrží; žádné je nechá bez dalšího kroku.'], fix: ['Give one action the primary style; make the others secondary or links.', 'Jedné akci dejte hlavní styl, ostatní udělejte vedlejší nebo jako odkazy.'] },
|
|
19
19
|
AC203: { cs: 'Formulářová pole mají viditelný popisek', why: ['A placeholder disappears as people type; they forget what the field was.', 'Placeholder při psaní zmizí a lidé zapomenou, co do pole patří.'], fix: ['Add a <label for="…"> (or aria-label) to every visible input.', 'Ke každému viditelnému poli doplňte <label for="…"> (nebo aria-label).'] },
|
|
20
20
|
AC204: { cs: 'Odkazy a tlačítka říkají, co udělají', why: ['"Click here" or "More" does not say where it leads, especially out of context or to a screen reader.', '„Klikněte zde“ nebo „Více“ neřekne, kam vede, zvlášť mimo kontext nebo ve čtečce.'], fix: ['Name the result: "Download the price list", "Continue to payment".', 'Pojmenujte výsledek: „Stáhnout ceník“, „Pokračovat k platbě“.'] },
|
|
21
|
+
AC205: { cs: 'Z nadpisu je jasné, k čemu obrazovka je', why: ['People switch between screens all day; a vague heading makes them check where they are.', 'Lidé mezi obrazovkami přepínají celý den; s mlhavým nadpisem musí hledat, kde vlastně jsou.'], fix: ['Name the screen after what it shows or does: "Runs of the checkout flow", not "Dashboard".', 'Pojmenujte obrazovku podle toho, co ukazuje nebo dělá: „Běhy flow pokladny“, ne „Nástěnka“.'] },
|
|
22
|
+
AC206: { cs: 'Je jasné, jak formulář odeslat a co bude dál', why: ['When the send button or the next step is unclear, people submit twice or give up.', 'Když není jasné odesílací tlačítko nebo další krok, lidé odešlou dvakrát nebo to vzdají.'], fix: ['Label the button with the result ("Send the link") and say in one sentence what happens next.', 'Tlačítko pojmenujte podle výsledku („Poslat odkaz“) a jednou větou řekněte, co bude dál.'] },
|
|
23
|
+
AC207: { cs: 'Každá položka seznamu říká, co je zač a jak ji otevřít', why: ['A row with only an id or a date makes people open items one by one to find the right one.', 'Řádek jen s identifikátorem nebo datem nutí lidi otevírat položky jednu po druhé.'], fix: ['Show a name and a state in every row and make the name the link.', 'V každém řádku ukažte název a stav a z názvu udělejte odkaz.'] },
|
|
24
|
+
AC208: { cs: 'Každé nastavení říká, co změní a jak se uloží', why: ['People leave settings unsure whether the change took effect.', 'Lidé odcházejí z nastavení a nevědí, jestli se změna projevila.'], fix: ['Add a short hint under each setting and either a save button or a visible "Saved".', 'Pod každé nastavení přidejte krátkou nápovědu a buď tlačítko Uložit, nebo viditelné „Uloženo“.'] },
|
|
25
|
+
AC209: { cs: 'Text jde rychle projít: nadpisy a krátké odstavce', why: ['Readers of documentation scan for their question; a wall of text hides the answer.', 'Čtenář dokumentace hledá svou otázku očima; souvislý text odpověď schová.'], fix: ['Split the text under headings that name the question and keep paragraphs to a few sentences.', 'Rozdělte text pod nadpisy, které pojmenují otázku, a odstavce držte na pár větách.'] },
|
|
21
26
|
AC301: { cs: 'Obrázky mají alternativní text', why: ['Without alt text, blind visitors and search engines do not know what an image shows.', 'Bez alt textu nevidomí ani vyhledávače nevědí, co obrázek ukazuje.'], fix: ['Add alt="…" describing the image; decorative images get alt="".', 'Doplňte alt="…" s popisem obrázku; dekorativní obrázky dostanou alt="".'] },
|
|
22
27
|
AC302: { cs: 'Odkazy a tlačítka mají přístupný název', why: ['An icon button without a name is read as just "button".', 'Tlačítko s ikonou bez názvu čtečka přečte jen jako „tlačítko“.'], fix: ['Add visible text or aria-label to icon-only links and buttons.', 'Odkazům a tlačítkům jen s ikonou doplňte text nebo aria-label.'] },
|
|
23
28
|
AC303: { cs: 'Formulářová pole mají popisky', why: ['Screen reader users hear only "edit text" for a field without a label.', 'Uživatel čtečky uslyší u pole bez popisku jen „editační pole“.'], fix: ['Connect a <label> to each field (for/id) or use aria-label / aria-labelledby.', 'Ke každému poli připojte <label> (for/id) nebo použijte aria-label / aria-labelledby.'] },
|
package/src/lib/packs.mjs
CHANGED
|
@@ -33,13 +33,23 @@ export const PACKS = {
|
|
|
33
33
|
},
|
|
34
34
|
usability: {
|
|
35
35
|
base: 200,
|
|
36
|
-
title: 'Usability: purpose, primary action, forms,
|
|
37
|
-
criteria: () =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
title: 'Usability by page type (`page` on the step or scenario): purpose, a primary action on landing pages, forms, lists, settings, link texts',
|
|
37
|
+
criteria: ({ page = 'landing' } = {}) => {
|
|
38
|
+
const sem = (question) => ({ type: 'semantic', question, scope: 'main, body' })
|
|
39
|
+
const on = (...types) => types.includes(page)
|
|
40
|
+
return [
|
|
41
|
+
...(on('landing', 'doc') ? [{ id: 'AC201', text: 'The purpose of the page is clear within the first screen', check: sem('Is the purpose of this page clear from its heading and first paragraph alone?') }] : []),
|
|
42
|
+
...(on('work', 'list', 'settings', 'form') ? [{ id: 'AC205', text: 'The heading says what the screen is for', check: sem('Is it clear from the heading and the content of this screen what it is for?') }] : []),
|
|
43
|
+
// one loud call to action is a landing-page rule; work screens, lists and settings rightly offer several actions
|
|
44
|
+
...(on('landing') ? [{ id: 'AC202', text: 'One clear primary action', check: sem('Is there exactly one clearly primary call to action on the page?') }] : []),
|
|
45
|
+
...(on('form') ? [{ id: 'AC206', text: 'It is clear how to send the form and what happens next', check: sem('Is it clear which button sends the form and what happens after it is sent?') }] : []),
|
|
46
|
+
...(on('list') ? [{ id: 'AC207', text: 'Each item in the list says what it is and how to open it', check: sem('Does each item in the list show what it is (for example a name and its state) and how to open it?') }] : []),
|
|
47
|
+
...(on('settings') ? [{ id: 'AC208', text: 'Each setting says what it changes and how it is saved', check: sem('Does each setting say what it changes, and is it clear whether a change is saved by itself or with a button?') }] : []),
|
|
48
|
+
...(on('doc') ? [{ id: 'AC209', text: 'The text can be scanned: headings and short paragraphs', check: sem('Is the text split into headings and short paragraphs that a reader can scan?') }] : []),
|
|
49
|
+
{ id: 'AC203', text: 'Form fields have visible labels', check: det('count', { selector: 'input:not([type=hidden]):not([type=submit]):not([aria-label]):not([id])', min: 0, max: 0 }) },
|
|
50
|
+
{ id: 'AC204', text: 'Links and buttons say what they do', check: sem('Do the link and button texts describe what happens when they are used, rather than generic words like "click here"?') }
|
|
51
|
+
]
|
|
52
|
+
}
|
|
43
53
|
},
|
|
44
54
|
a11y: {
|
|
45
55
|
base: 300,
|
|
@@ -103,6 +113,20 @@ export const PACKS = {
|
|
|
103
113
|
}
|
|
104
114
|
}
|
|
105
115
|
|
|
116
|
+
/** Page types a step (or the whole scenario) declares with `page`; the usability pack asks per type. */
|
|
117
|
+
export const PAGE_TYPES = ['landing', 'work', 'list', 'settings', 'form', 'doc']
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The page type of a node: its own `page`, then the scenario's `page`, else inferred. A step with the seo pack or a
|
|
121
|
+
* scenario allowed on production is a public page (landing); anything else is an app screen (work).
|
|
122
|
+
*/
|
|
123
|
+
export function pageTypeOf (node, scenario = {}) {
|
|
124
|
+
if (node?.page) return node.page
|
|
125
|
+
if (scenario?.page) return scenario.page
|
|
126
|
+
if ((node?.packs || []).includes('seo') || (scenario?.environments || []).includes('production')) return 'landing'
|
|
127
|
+
return 'work'
|
|
128
|
+
}
|
|
129
|
+
|
|
106
130
|
/**
|
|
107
131
|
* Adds the criteria of each pack listed on a node (`node.packs`) that the node does not already define.
|
|
108
132
|
* Mutates and returns the scenario; safe to call twice.
|
|
@@ -117,7 +141,7 @@ export function expandPacks (scenario) {
|
|
|
117
141
|
for (const name of node.packs) {
|
|
118
142
|
const pack = PACKS[name]
|
|
119
143
|
if (!pack) throw new Error(`node "${node.id}": unknown pack "${name}" (known: ${Object.keys(PACKS).join(', ')})`)
|
|
120
|
-
for (const c of pack.criteria({ locales })) {
|
|
144
|
+
for (const c of pack.criteria({ locales, page: pageTypeOf(node, scenario) })) {
|
|
121
145
|
if (have.has(c.id)) continue
|
|
122
146
|
node.criteria.push({ ...c, pack: name })
|
|
123
147
|
have.add(c.id)
|
package/src/lib/scenario.mjs
CHANGED
|
@@ -5,8 +5,31 @@ import { UserError } from './util.mjs'
|
|
|
5
5
|
import { expandPacks } from './packs.mjs'
|
|
6
6
|
import { jsGuard } from './assertions.mjs'
|
|
7
7
|
|
|
8
|
+
// a scenario holds thousands of values, not millions: aliases (&a, *a) can expand a few kilobytes of YAML into
|
|
9
|
+
// billions that validation and its JSON copy would walk ("billion laughs"), so the expanded size has a ceiling
|
|
10
|
+
const MAX_VALUES = 1_000_000
|
|
11
|
+
const MAX_DEPTH = 64
|
|
12
|
+
|
|
13
|
+
/** The data of a scenario's YAML text; a UserError when it is no YAML or expands beyond MAX_VALUES or MAX_DEPTH. */
|
|
14
|
+
export function parseScenario (text, file = 'scenario') {
|
|
15
|
+
let data
|
|
16
|
+
try { data = yaml.load(text) } catch (e) { throw new UserError(`Scenario ${file} is not valid YAML: ${String(e?.message || e).split('\n')[0]}`) }
|
|
17
|
+
let n = 0
|
|
18
|
+
const stack = [[data, 0]]
|
|
19
|
+
while (stack.length) {
|
|
20
|
+
const [v, depth] = stack.pop()
|
|
21
|
+
if (!v || typeof v !== 'object') continue
|
|
22
|
+
if (depth >= MAX_DEPTH) throw new UserError(`Scenario ${file} is nested deeper than ${MAX_DEPTH} levels.`)
|
|
23
|
+
const kids = Object.values(v)
|
|
24
|
+
// counted before they are pushed, so a shared alias cannot grow the stack past the ceiling either
|
|
25
|
+
if ((n += kids.length) > MAX_VALUES) throw new UserError(`Scenario ${file} expands to more than ${MAX_VALUES} values (YAML aliases?).`)
|
|
26
|
+
for (const k of kids) stack.push([k, depth + 1])
|
|
27
|
+
}
|
|
28
|
+
return data
|
|
29
|
+
}
|
|
30
|
+
|
|
8
31
|
export async function loadScenario (file) {
|
|
9
|
-
const data =
|
|
32
|
+
const data = parseScenario(await readFile(file, 'utf8'), file)
|
|
10
33
|
const problems = validateScenario(data)
|
|
11
34
|
if (!problems.length) expandPacks(data)
|
|
12
35
|
if (problems.length) throw new UserError(`Scenario ${file} is invalid:\n - ${problems.join('\n - ')}`)
|
|
@@ -54,6 +77,45 @@ export function validateScenario (data) {
|
|
|
54
77
|
return problems
|
|
55
78
|
}
|
|
56
79
|
|
|
80
|
+
const TEXT_ASSERTS = new Set(['text_matches', 'text_absent', 'title_matches', 'meta_matches'])
|
|
81
|
+
const ACCENTED = /[áčďéěíňóřšťúůýžäöüßąćęłńśźżàâçèêëîïôûùÿœñ]/i
|
|
82
|
+
const EN_WORDS = new Set(['the', 'and', 'of', 'to', 'in', 'for', 'with', 'your', 'you', 'is', 'are', 'on', 'by', 'or', 'not', 'no', 'an', 'this', 'what', 'how'])
|
|
83
|
+
const CS_WORDS = new Set(['a', 'i', 'v', 've', 'na', 'do', 'se', 'je', 'jsou', 'pro', 's', 'z', 'k', 'od', 'po', 'jak', 'co', 'nebo', 'ani', 'už', 'vaše', 'váš'])
|
|
84
|
+
|
|
85
|
+
/** Language of one alternative of a text pattern: 'other' (diacritics or a Czech word), 'en' (an English word) or null. */
|
|
86
|
+
function branchLanguage (branch) {
|
|
87
|
+
const text = branch.replace(/\\./g, ' ').replace(/\[[^\]]*\]/g, ' ')
|
|
88
|
+
if (ACCENTED.test(text)) return 'other'
|
|
89
|
+
const words = (text.match(/\p{L}+/gu) || []).map((w) => w.toLowerCase())
|
|
90
|
+
if (words.some((w) => CS_WORDS.has(w) && w.length > 1)) return 'other'
|
|
91
|
+
if (words.some((w) => EN_WORDS.has(w))) return 'en'
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Warnings that do not make a scenario invalid. A text criterion in a scenario with several locales whose pattern
|
|
97
|
+
* reads in one language only fails (or passes by accident) in the others. The heuristic is deliberately simple:
|
|
98
|
+
* the pattern is split at `|` and each alternative gets a language: a letter with a diacritic or a common Czech
|
|
99
|
+
* word means not English, a common English word ("the", "and", "your") means English, anything else (a brand,
|
|
100
|
+
* one plain word, "SOC 2") is unknown. It warns only when every alternative has a language and they all agree, so
|
|
101
|
+
* "Plans and payment" or "Vaše data" warn and "Your data|Vaše data", "Slack" or "Design drafts" do not.
|
|
102
|
+
* Pack criteria are left out.
|
|
103
|
+
*/
|
|
104
|
+
export function scenarioWarnings (data) {
|
|
105
|
+
const out = []
|
|
106
|
+
if (!data || (data.locales || []).length < 2 || !Array.isArray(data.nodes)) return out
|
|
107
|
+
for (const node of data.nodes) {
|
|
108
|
+
for (const c of node.criteria || []) {
|
|
109
|
+
const k = c.check || {}
|
|
110
|
+
if (c.pack || k.type !== 'deterministic' || !TEXT_ASSERTS.has(k.assert) || typeof k.pattern !== 'string') continue
|
|
111
|
+
const langs = k.pattern.split('|').map(branchLanguage)
|
|
112
|
+
if (langs.some((l) => !l) || new Set(langs).size !== 1) continue
|
|
113
|
+
out.push(`node "${node.id}": ${c.id} pattern /${k.pattern}/ reads in one language only, but the scenario walks ${data.locales.join(', ')}; add the other language as an alternative (English|Czech) or move the check to a per-locale step`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
57
119
|
/**
|
|
58
120
|
* Layered layout: column = longest path from a root, lane = explicit `lane`
|
|
59
121
|
* or inherited from the parent (+1 for each extra `alt` branch).
|
package/src/lib/stability.mjs
CHANGED
|
@@ -292,7 +292,7 @@ export async function pullDrawn ({ root, config, run, args, say, client }) {
|
|
|
292
292
|
const scenarioId = typeof args.scenario === 'string' ? args.scenario : run.meta.scenario.id
|
|
293
293
|
const data = await client.request('GET', `/orgs/${config.org}/scenarios/${encodeURIComponent(scenarioId)}/ignore-regions`)
|
|
294
294
|
const regions = data.regions || []
|
|
295
|
-
if (!regions.length) { say(`No drawn regions wait for ${scenarioId}. Draw them on a screenshot on kaloko.app (step detail →
|
|
295
|
+
if (!regions.length) { say(`No drawn regions wait for ${scenarioId}. Draw them on a screenshot on kaloko.app (step detail → Mark a changing region).`); return { applied: [], skipped: [], regions: [] } }
|
|
296
296
|
const byNode = new Map()
|
|
297
297
|
for (const r of regions) (byNode.get(r.node_id) || byNode.set(r.node_id, []).get(r.node_id)).push(r.scenario_entry)
|
|
298
298
|
const file = resolve(root, run.meta.scenario_file)
|
package/src/lib/unzip.mjs
CHANGED
|
@@ -2,30 +2,48 @@
|
|
|
2
2
|
// deflated entries from the central directory. No ZIP64; entries are read when asked for.
|
|
3
3
|
import { inflateRawSync } from 'node:zlib'
|
|
4
4
|
|
|
5
|
+
// a zip comes from someone else (a trace, a downloaded export): an entry inflates to at most what the central directory
|
|
6
|
+
// declares, the declarations have a ceiling, and entries may not share their data (overlapping entries multiply a small
|
|
7
|
+
// deflate stream, the "non-recursive zip bomb")
|
|
8
|
+
export const MAX_ENTRY_BYTES = 1024 * 1024 * 1024
|
|
9
|
+
export const MAX_TOTAL_BYTES = 4 * 1024 * 1024 * 1024
|
|
10
|
+
|
|
5
11
|
/** Entries of a zip archive: Map name → () => Buffer. */
|
|
6
12
|
export function readZip (buf) {
|
|
7
13
|
let eocd = -1
|
|
8
14
|
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break }
|
|
9
15
|
if (eocd < 0) throw new Error('not a zip archive (no end of central directory)')
|
|
16
|
+
const broken = (why) => new Error(`broken zip archive: ${why}`)
|
|
10
17
|
const count = buf.readUInt16LE(eocd + 10)
|
|
11
18
|
let at = buf.readUInt32LE(eocd + 16)
|
|
12
19
|
const out = new Map()
|
|
20
|
+
const spans = []
|
|
21
|
+
let total = 0
|
|
13
22
|
for (let n = 0; n < count; n++) {
|
|
14
|
-
if (buf.readUInt32LE(at) !== 0x02014b50) throw new Error('broken zip central directory')
|
|
23
|
+
if (at + 46 > eocd || buf.readUInt32LE(at) !== 0x02014b50) throw new Error('broken zip central directory')
|
|
15
24
|
const method = buf.readUInt16LE(at + 10)
|
|
16
25
|
const size = buf.readUInt32LE(at + 20)
|
|
26
|
+
const declared = buf.readUInt32LE(at + 24)
|
|
17
27
|
const nameLen = buf.readUInt16LE(at + 28); const extraLen = buf.readUInt16LE(at + 30); const commentLen = buf.readUInt16LE(at + 32)
|
|
18
28
|
const local = buf.readUInt32LE(at + 42)
|
|
29
|
+
if (at + 46 + nameLen > eocd) throw broken('a name runs past the central directory')
|
|
19
30
|
const name = buf.subarray(at + 46, at + 46 + nameLen).toString('utf8')
|
|
20
31
|
at += 46 + nameLen + extraLen + commentLen
|
|
21
32
|
if (name.endsWith('/')) continue
|
|
33
|
+
if (local + 30 > buf.length || buf.readUInt32LE(local) !== 0x04034b50) throw broken(`${name} has no local header`)
|
|
34
|
+
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
|
|
35
|
+
if (start + size > buf.length) throw broken(`${name} runs past the end`)
|
|
36
|
+
if (declared > MAX_ENTRY_BYTES || (total += declared) > MAX_TOTAL_BYTES) throw broken(`${name} would inflate beyond the size we accept`)
|
|
37
|
+
spans.push([start, start + size, name])
|
|
22
38
|
out.set(name, () => {
|
|
23
|
-
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
|
|
24
39
|
const data = buf.subarray(start, start + size)
|
|
25
|
-
if (method === 0) return Buffer.from(data)
|
|
26
|
-
|
|
40
|
+
if (method === 0) { if (size !== declared) throw broken(`${name}: stored size differs from its declared size`); return Buffer.from(data) }
|
|
41
|
+
// inflating stops at the declared size: an entry that claims little and inflates to more is refused
|
|
42
|
+
if (method === 8) { try { return inflateRawSync(data, { maxOutputLength: Math.max(1, declared) }) } catch (e) { throw broken(`${name}: ${e.code === 'ERR_BUFFER_TOO_LARGE' ? 'inflates beyond its declared size' : 'cannot be inflated'}`) } }
|
|
27
43
|
throw new Error(`${name}: unsupported compression method ${method}`)
|
|
28
44
|
})
|
|
29
45
|
}
|
|
46
|
+
spans.sort((a, b) => a[0] - b[0])
|
|
47
|
+
for (let k = 1; k < spans.length; k++) if (spans[k][0] < spans[k - 1][1]) throw broken(`${spans[k][2]} overlaps ${spans[k - 1][2]}`)
|
|
30
48
|
return out
|
|
31
49
|
}
|
package/templates/scenario.yml
CHANGED
|
@@ -30,6 +30,7 @@ nodes:
|
|
|
30
30
|
title: Landing page
|
|
31
31
|
path: /
|
|
32
32
|
purpose: A visitor understands the offer and can start the flow.
|
|
33
|
+
page: landing # landing | work | list | settings | form | doc: the usability questions fit the type
|
|
33
34
|
# packs: [seo, usability] # adds the SEO basics (AC101…) and usability questions (AC201…) to this step
|
|
34
35
|
execution:
|
|
35
36
|
strategy: playwright # playwright | agent | manual
|
package/viewer/index.html
CHANGED
|
@@ -294,6 +294,7 @@ table.runs{border-collapse:collapse;width:100%;font-size:12.5px}
|
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
.toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);background:var(--ink);color:var(--panel);padding:8px 14px;border-radius:999px;font-size:13px;z-index:30;box-shadow:var(--shadow)}
|
|
297
|
+
.sess-lost{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:60;display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;background:var(--panel);color:var(--ink);border:1px solid var(--fail);border-radius:10px;padding:10px 14px;font-size:13px;box-shadow:var(--shadow);width:max-content;max-width:calc(100vw - 32px)}
|
|
297
298
|
|
|
298
299
|
@media (max-width:1100px){
|
|
299
300
|
.main{grid-template-columns:minmax(0,1fr)}
|
|
@@ -1099,6 +1100,11 @@ Object.assign(I18N.cs, { dim_browser:'Prohlížeč', dim_color_scheme:'Barevné
|
|
|
1099
1100
|
Object.assign(I18N.en, { dim_browser:'Browser', dim_color_scheme:'Colour scheme', dim_reduced_motion:'Motion', dim_forced_colors:'Forced colours', dim_display_mode:'Display mode', frame:'Frame', frameTitle:'Show the capture in the device frame', safeAreas:'Safe areas', safeTitle:'Show the notch, the home indicator and where the first screen ends', safeArea:'Safe area: keep content out of it', firstScreenEnd:'end of the first screen', altCompare:'Beside:', pickSearch:'Search…', pickNone:'Nothing matches.', pickFailing:'How many values have failing steps', keyLoc:'L picks the locale, [ and ] move to the previous and next' });
|
|
1100
1101
|
Object.assign(I18N.cs, { devMode:'Dev mode', devTab:'Dev', devTitle:'Míry, tokeny a stavy prvků pro vývoj', devTabEl:'Prvek', devTabTokens:'Tokeny', devEmpty:'Najeďte myší na prvek v náhledu a klikněte na něj. Shift + šipky procházejí strom prvků, se stisknutým Alt uvidíte vzdálenosti k dalšímu prvku.', devStart:'Vybrat hlavní obsah', devNavLabel:'Procházet prvky', navParent:'Rodič', navChild:'Potomek', navPrev:'Předchozí', navNext:'Další', g_type:'Typografie', g_color:'Barvy', g_space:'Mezery', g_size:'Rozměry', g_shape:'Tvar', g_layout:'Rozložení', notToken:'není token', nearest:'nejbližší', typedRaw:'Hodnota je v CSS napsaná natvrdo, i když token existuje; použijte proměnnou.', typedRawShort:'natvrdo', copyTokenPath:'Kopírovat cestu tokenu', contrast:'Kontrast', aaPass:'splňuje AA', aaFail:'nesplňuje AA', aaNeed:'potřeba', largeText:'velký text', contrastApprox:'Pod textem je obrázek, kontrast je přibližný.', states:'Stavy', stDefault:'výchozí', stNone:'Styly stránky pro tento prvek žádné stavy nedefinují.', changedInState:'Zvýrazněné hodnoty se v tomto stavu mění.', distances:'Vzdálenosti', toParent:'K rodiči', toTarget:'K prvku', measure:'Měřit', measureHint:'Podržte Alt a najeďte na jiný prvek, nebo zapněte Měřit a klepněte na něj.', assets:'Obrázky a ikony', download:'Stáhnout', assetFail:'Soubor se nepodařilo stáhnout.', copyCssVars:'Kopírovat CSS s proměnnými', copyTokens:'Kopírovat tokeny (JSON)', copyPrompt:'Kopírovat pokyn pro agenta', copyLine:'Kopírovat pokyn k této hodnotě', pinFromDev:'Komentovat tento prvek', tokUsed:'Použité na tomto kroku', tokOff:'Hodnoty mimo sadu tokenů', tokHard:'V CSS natvrdo místo tokenu', tokUnused:'Nepoužité', tokNone:'Krok nemá sadu tokenů (tokens.json).', tokHlN:'Zvýrazněno prvků:', tokHint:'Klikněte na token a náhled zvýrazní prvky, které ho používají.', devShotNote:'Míry a tokeny jsou ze snímku, jak ho zachytilo CLI; stavy a stahování obrázků potřebují živou stránku.', devNoSpec:'Tento snímek nemá spec. Projděte krok znovu aktuálním CLI (kaloko walk).', devLocalLive:'Ze souboru na disku čte Dev mode snímek; živou stránku měří na kaloko.app.', devAgentHint:'Totéž dostane agent: kaloko spec %N nebo MCP get_step_spec.', devLoading:'Načítám…', keyDev:'Dev mode v detailu kroku: míry, tokeny a stavy prvků (M měří, Shift + šipky procházejí prvky)', promptHead:'Postavte %E podle schváleného návrhu v Kaloku (krok %S).', promptUse:'Pro %W použijte token %T.', promptOff:'%P %V není token; nejbližší je %T. Pevnou hodnotu použijte jen po domluvě s designérem.', promptOffNone:'%P %V není token.', promptSpec:'Spec celého kroku: kaloko spec %N' });
|
|
1101
1102
|
Object.assign(I18N.en, { devMode:'Dev mode', devTab:'Dev', devTitle:'Measurements, tokens and states of elements for development', devTabEl:'Element', devTabTokens:'Tokens', devEmpty:'Hover an element in the preview and click it. Shift + arrows move through the element tree; hold Alt to see the distances to another element.', devStart:'Select the main content', devNavLabel:'Move through elements', navParent:'Parent', navChild:'Child', navPrev:'Previous', navNext:'Next', g_type:'Typography', g_color:'Colors', g_space:'Spacing', g_size:'Size', g_shape:'Shape', g_layout:'Layout', notToken:'not a token', nearest:'nearest', typedRaw:'The CSS has this value typed raw although a token exists; use the custom property.', typedRawShort:'typed raw', copyTokenPath:'Copy the token path', contrast:'Contrast', aaPass:'passes AA', aaFail:'fails AA', aaNeed:'needs', largeText:'large text', contrastApprox:'There is an image behind the text; the contrast is approximate.', states:'States', stDefault:'default', stNone:'The page styles define no states for this element.', changedInState:'Highlighted values change in this state.', distances:'Distances', toParent:'To the parent', toTarget:'To', measure:'Measure', measureHint:'Hold Alt and hover another element, or turn on Measure and tap it.', assets:'Images and icons', download:'Download', assetFail:'The file could not be downloaded.', copyCssVars:'Copy CSS with variables', copyTokens:'Copy tokens (JSON)', copyPrompt:'Copy prompt for an agent', copyLine:'Copy the prompt line for this value', pinFromDev:'Comment on this element', tokUsed:'Used on this step', tokOff:'Values outside the token set', tokHard:'Typed raw in the CSS instead of a token', tokUnused:'Not used', tokNone:'This step has no token set (tokens.json).', tokHlN:'Elements highlighted:', tokHint:'Click a token to highlight the elements that use it.', devShotNote:'Measurements and tokens come from the screenshot as the CLI captured it; states and image downloads need the live page.', devNoSpec:'This capture has no spec. Walk the step again with the current CLI (kaloko walk).', devLocalLive:'From disk, Dev mode reads the screenshot; the live page is measured on kaloko.app.', devAgentHint:'An agent gets the same: kaloko spec %N or MCP get_step_spec.', devLoading:'Loading…', keyDev:'Dev mode in the step detail: measurements, tokens and states of elements (M measures, Shift + arrows move through elements)', promptHead:'Build %E as approved in Kaloko (step %S).', promptUse:'Use token %T for the %W.', promptOff:'%P %V is not a token; the nearest is %T. Use the raw value only if the designer agrees.', promptOffNone:'%P %V is not a token.', promptSpec:'Spec of the whole step: kaloko spec %N' });
|
|
1103
|
+
Object.assign(I18N.cs, { sessionLost:'Přihlášení vypršelo. Přihlaste se znovu, rozepsaný text tu na vás počká.', signInAgain:'Přihlásit se znovu' });
|
|
1104
|
+
Object.assign(I18N.en, { sessionLost:'Your session has expired. Sign in again; what you were writing waits here.', signInAgain:'Sign in again' });
|
|
1105
|
+
// back after signing in again: the session expired on the canvas (sessionLost), the server kept the path, but the
|
|
1106
|
+
// sign-in mail cannot carry the fragment (the open step, the comments), so the canvas stored it for this moment
|
|
1107
|
+
(() => { try { const r = JSON.parse(localStorage.getItem('qawalk-return') || 'null'); if (!r) return; localStorage.removeItem('qawalk-return'); if (r.path === location.pathname && r.hash && !location.hash && Date.now() - r.at < 864e5) history.replaceState(null, '', location.pathname + location.search + r.hash); } catch {} })();
|
|
1102
1108
|
const storedLang = (() => { try { return localStorage.getItem('qawalk.lang'); } catch { return null; } })();
|
|
1103
1109
|
// #lang=cs|en in a link forces the language of the canvas for that page (a shared link, a walk in another locale):
|
|
1104
1110
|
// it wins over the account, the cookie and the browser, and stays in the address while the canvas rewrites it
|
|
@@ -1357,7 +1363,7 @@ const sessColor = (key) => SESSION_COLORS[Math.max(0, sessions().indexOf(key)) %
|
|
|
1357
1363
|
const sessTitle = (key) => key ? loc(RUN.sessions?.[key]?.title) || key : '';
|
|
1358
1364
|
const sessChip = (key) => key && RUN.sessions?.[key] ? `<span class="sess" style="--sc:${sessColor(key)}" title="${esc(key)}"><i></i>${esc(sessTitle(key))}</span>` : '';
|
|
1359
1365
|
const nodeSt = (n) => { const status = n.by_locale?.[S.loc] || n.status; return ['pass', 'partial', 'fail', 'skip'].includes(status) ? status : 'skip'; };
|
|
1360
|
-
const critVerdict = (c, l = S.loc) => { if (c.counts) return c.verdict || 'not_reached'; const vs = viewports().map((v) => c.by_locale?.[l]?.[v.name]?.verdict || 'not_reached'); return vs.every((v) => v === 'not_reached') ? 'not_reached' : worst(vs.map((v) => v === 'not_reached' ? 'not_evaluated' : v)); };
|
|
1366
|
+
const critVerdict = (c, l = S.loc) => { if (c.not_applicable) return 'not_reached'; if (c.counts) return c.verdict || 'not_reached'; const vs = viewports().map((v) => c.by_locale?.[l]?.[v.name]?.verdict || 'not_reached'); return vs.every((v) => v === 'not_reached') ? 'not_reached' : worst(vs.map((v) => v === 'not_reached' ? 'not_evaluated' : v)); };
|
|
1361
1367
|
const critSt = (c) => VSTAT[critVerdict(c)];
|
|
1362
1368
|
const prevRun = () => (RUN.history || []).find((h) => h.started_at < RUN.started_at);
|
|
1363
1369
|
const fmtDate = (iso) => { if (!iso) return ''; const d = new Date(iso); return `${d.getDate()}. ${d.getMonth() + 1}. ${d.getFullYear()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
|
@@ -1426,6 +1432,7 @@ function commentsBase (scope) {
|
|
|
1426
1432
|
}
|
|
1427
1433
|
async function cApi (scope, path, method, body) {
|
|
1428
1434
|
const res = await fetch(commentsBase(scope) + path, { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify({ ...body, ...(isDraft() ? { branch: RUN.draft.branch } : {}) }) : undefined });
|
|
1435
|
+
if (res.status === 401) throw sessionLost();
|
|
1429
1436
|
const data = await res.json().catch(() => ({}));
|
|
1430
1437
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
1431
1438
|
return data;
|
|
@@ -1452,6 +1459,8 @@ function rerenderComments (all = false) {
|
|
|
1452
1459
|
const draftKey = (scope, nodeId, sub) => `qawalk-draft|${APP()?.org || 'local'}|${scope === 'flow' ? `flow:${RUN.scenario.id}` : isDraft() ? `draft:${RUN.scenario.id}` : RUN.id}|${nodeId || ''}|${sub || 'new'}`;
|
|
1453
1460
|
const loadDraft = (k) => { try { return localStorage.getItem(k) || ''; } catch { return ''; } };
|
|
1454
1461
|
const saveDraft = (k, v) => { try { if (v) localStorage.setItem(k, v); else localStorage.removeItem(k); } catch {} };
|
|
1462
|
+
// notes outside the comment composer (verdict, return, issue) carry their draft key: typed text is kept the same way
|
|
1463
|
+
document.addEventListener('input', (e) => { const k = e.target?.dataset?.draft; if (k) saveDraft(k, e.target.value); });
|
|
1455
1464
|
const noteHtml = (s) => esc(s).replace(/(^|\s)@([\w.+-]+(?:@[\w.-]+)?)/g, '$1<b class="c-at">@$2</b>').replace(/\n/g, '<br>');
|
|
1456
1465
|
/** Numbers of the threads pinned on this step's screenshot (the same numbers as the marks on the image). */
|
|
1457
1466
|
function pinNumbers (nodeId) {
|
|
@@ -2197,9 +2206,13 @@ function closeStack (keepHash) {
|
|
|
2197
2206
|
if (S.st?.inline) { if (keepHash === true) return; const id = S.st.id; S.st = null; riRender({ focus: id }); setHash(null); return; } // folder → back to the run index
|
|
2198
2207
|
$('si').hidden = true; if (keepHash !== true) setHash(null);
|
|
2199
2208
|
}
|
|
2209
|
+
// a CSV cell: text from crawled pages that starts like a formula (= + - @ tab CR) gets a leading ' so a spreadsheet
|
|
2210
|
+
// shows it instead of running it, also after a ; or a line break (Excel with a semicolon separator splits there; the
|
|
2211
|
+
// same rule as the audit CSV, worker/src/audit-log.js); numbers stay numbers
|
|
2212
|
+
function csvCell (v) { if (typeof v === 'number') return String(v); const s = v == null ? '' : String(v); const x = (/^[=+\-@\t\r]/.test(s) ? `'${s}` : s).replace(/([;\r\n]["\s]*)(?=[=+\-@])/g, "$1'"); return /[",\n\r]/.test(x) ? `"${x.replace(/"/g, '""')}"` : x; }
|
|
2200
2213
|
function stackCsv (n, data) {
|
|
2201
2214
|
const cols = data.fields || [];
|
|
2202
|
-
const q =
|
|
2215
|
+
const q = csvCell;
|
|
2203
2216
|
const rows = [['path', 'url', 'status', 'failing', 'review', ...cols.map((f) => f.label || f.id)], ...S.st.filtered.map((it) => [it.path, it.url, it.status, (it.fails || []).join(' '), (it.review || []).join(' '), ...cols.map((f) => it.fields?.[f.id])])];
|
|
2204
2217
|
const blob = new Blob(['' + rows.map((r) => r.map(q).join(',')).join('\n')], { type: 'text/csv;charset=utf-8' });
|
|
2205
2218
|
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${RUN.scenario?.id || 'run'}-${n.id}.csv`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
@@ -2359,7 +2372,7 @@ function riKey (e) {
|
|
|
2359
2372
|
return false;
|
|
2360
2373
|
}
|
|
2361
2374
|
function riCsv () {
|
|
2362
|
-
const q =
|
|
2375
|
+
const q = csvCell;
|
|
2363
2376
|
const rows = [['step', 'title', 'kind', 'path', 'status', 'criteria_passed', 'criteria', 'failing', 'http', 'console_errors', 'pages']];
|
|
2364
2377
|
for (const e of S.ri.entries || []) {
|
|
2365
2378
|
if (e.type === 'page') { rows.push([`${e.n.id}~${e.it.id}`, e.it.title || '', 'page', e.it.path, e.it.status, '', '', (e.it.fails || []).join(' '), e.it.fields?.http ?? '', '', '']); continue; }
|
|
@@ -2640,6 +2653,7 @@ function renderAll () { renderChrome(); renderNodes(); if (S.right) renderReport
|
|
|
2640
2653
|
/* Evidence of the built-in checks is a fixed English template (kept as is in the data, for agents and the API);
|
|
2641
2654
|
the canvas says it as a sentence in the reader's language. Unknown text and HTML snippets stay as they are. */
|
|
2642
2655
|
const EVIDENCE = [
|
|
2656
|
+
[/^Applies on (.+) only; this walk ran on (.+)\.$/, (m) => [`Does not apply on ${m[2]}: this check runs on ${m[1]} only`, `Na prostředí ${m[2]} se netýká: tahle kontrola běží jen na ${m[1]}`]],
|
|
2643
2657
|
[/^no visible link to the home page$/, () => ['No visible link to the home page', 'Chybí viditelný odkaz na úvodní stránku']],
|
|
2644
2658
|
[/^no visible (.+)$/, (m) => [`Nothing visible matches ${m[1]}`, `Na stránce není vidět nic, co odpovídá ${m[1]}`]],
|
|
2645
2659
|
[/^(\d+)× (.+)$/, (m) => [`Found ${m[1]}× ${m[2]}`, `Nalezeno ${m[1]}× ${m[2]}`]],
|
|
@@ -3545,9 +3559,9 @@ function renderReport () {
|
|
|
3545
3559
|
${APP() && isDraft() ? `<div class="sec"><h4>${T('draft')} #${esc(RUN.draft.seq)}</h4><p class="note">${T('draftInfo')}</p><div id="draftChanges" class="note"></div></div>` : ''}
|
|
3546
3560
|
${versionNotesHtml()}
|
|
3547
3561
|
${APP() && !isDraft() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${isDesign() ? T('versionVerdict') : T('runVerdict')}</span><span class="badge st-${RUN.verdict?.verdict === 'accepted' ? 'pass' : RUN.verdict?.verdict === 'returned' ? 'fail' : 'skip'}">${T(RUN.verdict?.verdict || 'pending')}</span>${RUN.verdict?.baseline ? `<span class="badge st-partial">${T('baseline')}</span>` : ''}</h4>${RUN.verdict?.by || (RUN.baseline_id && RUN.baseline_id !== RUN.id) ? `<p class="note" style="margin:0 0 8px">${RUN.verdict?.by ? `${esc(RUN.verdict.by)}${RUN.verdict.via === 'token' || RUN.verdict.via === 'agent' ? ` <span class="badge st-skip">${T(RUN.verdict.via === 'agent' ? 'viaAgent' : 'viaToken')}</span>` : ''} · ${fmtDate(RUN.verdict.at)}` : ''}${RUN.baseline_id && RUN.baseline_id !== RUN.id ? `${RUN.verdict?.by ? ' · ' : ''}<a class="pr" href="${runHref(RUN.baseline_id)}">${T('baseline')} →</a>` : ''}</p>` : ''}${RUN.verdict?.note ? `<p class="note">${esc(RUN.verdict.note)}</p>` : ''}${RUN.verdict?.approvers ? `<p class="note">${RUN.verdict.awaiting.length ? `${T('awaiting')}: <b>${RUN.verdict.awaiting.map(esc).join(', ')}</b>` : `${T('approvedByAll')}`}${RUN.verdict.approved_by.length ? ` · ${T('approvedBy')}: ${RUN.verdict.approved_by.map(esc).join(', ')}` : ''}${RUN.verdict.approved_by_tokens?.length ? ` · ${T('approvedBy')} ${T('approvedByTokens')}: ${RUN.verdict.approved_by_tokens.map((a) => esc(a.email)).join(', ')}` : ''}</p>` : ''}
|
|
3548
|
-
${canWrite() ? `<textarea id="rvVerdictNote" hidden rows="2" placeholder="${T('verdictNotePh')}" style="width:100%;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:0 0 8px"
|
|
3562
|
+
${canWrite() ? `<textarea id="rvVerdictNote" ${loadDraft(draftKey('run', '', 'verdict')) ? '' : 'hidden '}rows="2" placeholder="${T('verdictNotePh')}" data-draft="${esc(draftKey('run', '', 'verdict'))}" style="width:100%;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:0 0 8px">${esc(loadDraft(draftKey('run', '', 'verdict')))}</textarea>
|
|
3549
3563
|
<div class="share__row"><button class="btn btn--primary" data-verdict="accepted">${isDesign() ? T('acceptVersion') : T('accept')}</button><button class="btn" data-verdict="returned">${isDesign() ? T('returnVersion') : T('ret')}</button></div>
|
|
3550
|
-
<div class="vmore"
|
|
3564
|
+
<div class="vmore">${loadDraft(draftKey('run', '', 'verdict')) ? '' : `<button class="linkish" id="rvNoteShow">${T('addNote')}</button>`}${RUN.verdict?.verdict === 'accepted' && !isDesign() ? `<button class="linkish" data-baseline="${RUN.verdict.baseline ? 'unset' : 'set'}">${RUN.verdict.baseline ? T('unsetBaseline') : T('setBaseline')}</button>` : ''}${APP().user?.role === 'admin' ? `<button class="linkish" data-public="${RUN.public ? 'off' : 'on'}" title="${T('publicHint')}">${RUN.public ? T('makePrivate') : isDesign() ? T('makePublicVersion') : T('makePublic')}</button>` : ''}</div>` : ''}${RUN.public ? `<p class="note" style="margin:6px 0 0"><span class="badge st-partial">${T('publicRun')}</span> ${T('publicNote')}</p>` : ''}${signoffHtml()}</div>` : ''}
|
|
3551
3565
|
${commentsPanel()}
|
|
3552
3566
|
<div class="sec"><p class="verdict">${esc(c.pass)} / ${tot} ${T('stepsDone')} <em>· ${ok}/${all.length} ${T('criteriaWord')} (${esc(S.loc)})</em></p>
|
|
3553
3567
|
<div class="stack">${['pass', 'partial', 'fail', 'skip'].map((k) => `<i style="width:${c[k] / tot * 100}%;background:var(--${k})"></i>`).join('')}</div>
|
|
@@ -3578,7 +3592,7 @@ function renderReport () {
|
|
|
3578
3592
|
const sc = $('shareCopy'); if (sc) sc.onclick = async () => { const link = $('shareUrl').textContent; try { await navigator.clipboard.writeText(link); toast(T('linkCopied')); } catch { toast(link); } };
|
|
3579
3593
|
const showNote = () => { const t = $('rvVerdictNote'); if (!t) return; t.hidden = false; $('rvNoteShow')?.remove(); t.focus(); };
|
|
3580
3594
|
if ($('rvNoteShow')) $('rvNoteShow').onclick = showNote;
|
|
3581
|
-
$('rside').querySelectorAll('[data-verdict]').forEach((b) => { b.onclick = async () => { const note = $('rvVerdictNote').value.trim(); if (b.dataset.verdict === 'returned' && !note) { showNote(); toast(T('noteRequired')); return; } b.disabled = true; try { await postJson('/verdict', { verdict: b.dataset.verdict, note }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
3595
|
+
$('rside').querySelectorAll('[data-verdict]').forEach((b) => { b.onclick = async () => { const note = $('rvVerdictNote').value.trim(); if (b.dataset.verdict === 'returned' && !note) { showNote(); toast(T('noteRequired')); return; } b.disabled = true; try { await postJson('/verdict', { verdict: b.dataset.verdict, note }); saveDraft(draftKey('run', '', 'verdict'), ''); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
3582
3596
|
$('rside').querySelectorAll('[data-public]').forEach((b) => { b.onclick = async () => { if (b.dataset.public === 'on' && !confirm(T('publicConfirm'))) return; b.disabled = true; try { await postJson('/public', { public: b.dataset.public === 'on' }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
3583
3597
|
$('rside').querySelectorAll('[data-baseline]').forEach((b) => { b.onclick = async () => { b.disabled = true; try { await postJson('/verdict', { verdict: 'accepted', note: RUN.verdict?.note || '', baseline: b.dataset.baseline === 'set' }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
3584
3598
|
$('rside').querySelectorAll('[data-go],tr[data-id]').forEach((el) => { el.onclick = () => { if (byId(el.dataset.go || el.dataset.id)) openLb(el.dataset.go || el.dataset.id); }; });
|
|
@@ -3756,8 +3770,18 @@ const sameAsCmp = (n) => !!(S.cmp && isDesign() && n.kind !== 'decision' && revO
|
|
|
3756
3770
|
const headOf = (n, loc = S.loc) => RUN.revisions?.[`${n.id}|${loc}`] || null;
|
|
3757
3771
|
const who = (a) => a ? (a.agent ? `${a.agent} (${T('viaAgent')} ${a.email})` : a.email) : '';
|
|
3758
3772
|
|
|
3773
|
+
/**
|
|
3774
|
+
* The session ended while the canvas was open (an API call answered 401): one bar says so and links to sign in with
|
|
3775
|
+
* the way back; every unsent text is already in localStorage (drafts) and stays. Returns the error to throw.
|
|
3776
|
+
*/
|
|
3777
|
+
function sessionLost () {
|
|
3778
|
+
try { localStorage.setItem('qawalk-return', JSON.stringify({ path: location.pathname, hash: location.hash, at: Date.now() })); } catch {}
|
|
3779
|
+
if (!$('sessLost')) document.body.insertAdjacentHTML('beforeend', `<div id="sessLost" class="sess-lost" role="alert"><span>${esc(T('sessionLost'))}</span><a class="btn btn--primary" href="/login?next=${encodeURIComponent(location.pathname + location.search)}">${esc(T('signInAgain'))}</a></div>`);
|
|
3780
|
+
return new Error(T('sessionLost'));
|
|
3781
|
+
}
|
|
3759
3782
|
async function apiJson (method, url, body) {
|
|
3760
3783
|
const res = await fetch(url, { method, credentials: 'same-origin', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
|
|
3784
|
+
if (res.status === 401) throw sessionLost();
|
|
3761
3785
|
const data = await res.json().catch(() => ({}));
|
|
3762
3786
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
3763
3787
|
return data;
|
|
@@ -4710,7 +4734,7 @@ function issuesBlock (n, v) {
|
|
|
4710
4734
|
${trackers.length > 1 ? `<label>${esc(T('issTracker'))} <select id="issTracker">${trackers.map((t) => `<option value="${esc(t.kind)}">${esc(t.label)} · ${esc(t.target)}</option>`).join('')}</select></label>` : ''}
|
|
4711
4735
|
<label>${esc(T('issCriterion'))} <select id="issCrit"><option value="">${esc(T('issWholeStep'))}</option>${crit.map((c) => `<option value="${esc(c.id)}" ${pick && pick.id === c.id ? 'selected' : ''}>${esc(c.id)} · ${esc(String(critText(c)).slice(0, 60))}</option>`).join('')}</select></label>
|
|
4712
4736
|
<label>${esc(T('issName'))} <input id="issName" maxlength="200" placeholder="${esc(T('issNamePh'))}"></label>
|
|
4713
|
-
<label>${esc(T('issNote'))} <textarea id="issNote" rows="2"
|
|
4737
|
+
<label>${esc(T('issNote'))} <textarea id="issNote" rows="2" data-draft="${esc(draftKey('step', n.id, 'issue'))}">${esc(loadDraft(draftKey('step', n.id, 'issue')))}</textarea></label>
|
|
4714
4738
|
<div class="share__row"><button class="btn btn--primary" data-iss-send="${esc(n.id)}" data-iss-vp="${esc(v?.name || '')}">${esc(T('issSend'))}</button><button class="btn" data-iss-cancel>${esc(T('cancel'))}</button></div></div>`;
|
|
4715
4739
|
} else if (can) tail = `<div class="share__row">${trackers.map((t) => `<button class="btn" data-iss-new="${esc(n.id)}" data-iss-kind="${esc(t.kind)}">${esc(T('issNew')(t.label))}</button>`).join('')}</div>`;
|
|
4716
4740
|
return `<div class="sec iss"><h4>${esc(T('issTitle'))}</h4>${items}${tail}</div>`;
|
|
@@ -4722,9 +4746,10 @@ async function issSend (step, vp) {
|
|
|
4722
4746
|
ISS.busy = true; toast(T('issSending'));
|
|
4723
4747
|
try {
|
|
4724
4748
|
const res = await fetch(`${a.api}/orgs/${a.org}/issues`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ run_id: a.run, step, to, criterion: $('issCrit')?.value || undefined, title: $('issName')?.value.trim() || undefined, note: $('issNote')?.value.trim() || undefined, locale: S.loc, viewport: vp || undefined }) });
|
|
4749
|
+
if (res.status === 401) throw sessionLost();
|
|
4725
4750
|
const out = await res.json().catch(() => ({}));
|
|
4726
4751
|
if (!res.ok) throw new Error(out.error || `HTTP ${res.status}`);
|
|
4727
|
-
ISS.form = null;
|
|
4752
|
+
ISS.form = null; saveDraft(draftKey('step', step, 'issue'), '');
|
|
4728
4753
|
toast(T('issSent')(out.key));
|
|
4729
4754
|
await issLoad();
|
|
4730
4755
|
await refreshComments();
|
|
@@ -4794,13 +4819,13 @@ const rvItems = () => [...(RV.data?.items || []), ...RV.extra];
|
|
|
4794
4819
|
const rvCur = () => rvItems()[RV.idx] || null;
|
|
4795
4820
|
const RV_GROUP = { fail: 0, returned: 0, needs_review: 1, not_evaluated: 1, revoked: 1, changed: 2, new: 2, comments: 3 };
|
|
4796
4821
|
const rvDay = (iso) => (iso ? fmtDate(iso).replace(/ \d{2}:\d{2}$/, '') : '');
|
|
4797
|
-
const rvOpenCrit = (c) => Object.values(c.by_locale || {}).some((vps) => Object.values(vps || {}).some((r) => ['needs_review', 'not_evaluated'].includes(r?.verdict)));
|
|
4822
|
+
const rvOpenCrit = (c) => !c.not_applicable && Object.values(c.by_locale || {}).some((vps) => Object.values(vps || {}).some((r) => ['needs_review', 'not_evaluated'].includes(r?.verdict)));
|
|
4798
4823
|
|
|
4799
4824
|
/** A read-only queue from the run itself (local preview, export, public run): no carry-over, no comparison. */
|
|
4800
4825
|
function rvLocalQueue () {
|
|
4801
4826
|
const items = []; let clear = 0;
|
|
4802
4827
|
flowOrder().forEach((n, position) => {
|
|
4803
|
-
const vs = (n.criteria || []).map((c) => c.verdict).filter(Boolean);
|
|
4828
|
+
const vs = (n.criteria || []).filter((c) => !c.not_applicable).map((c) => c.verdict).filter(Boolean);
|
|
4804
4829
|
const reasons = [];
|
|
4805
4830
|
if (n.status === 'fail' || vs.includes('fail')) reasons.push('fail');
|
|
4806
4831
|
if (vs.includes('needs_review')) reasons.push('needs_review');
|
|
@@ -4960,7 +4985,7 @@ function rvEndHtml () {
|
|
|
4960
4985
|
const carried = (d.carried || []).length ? `<div class="sec"><h4>${esc(T('rvCarriedTitle'))}</h4><p class="note">${esc(T('rvCarriedLead'))}</p><ul class="rvm__list">${d.carried.map((x) => `<li><span class="badge st-pass">✓</span><button class="linkish" data-rv-show="${esc(x.step)}">${esc(loc(byId(x.step)?.title) || x.step)}</button><span class="note">${esc((x.kind === 'run' ? T('rvCarriedRun') : T('rvCarriedStep'))(x.from_run, x.approved_by, rvDay(x.approved_at)))}</span>${rvWritable() ? `<button class="linkish" data-rv-revoke="${esc(x.step)}">${esc(T('rvRevoke'))}</button>` : ''}</li>`).join('')}</ul></div>` : '';
|
|
4961
4986
|
const v = RUN.verdict || d.verdict || {};
|
|
4962
4987
|
const verdict = rvWritable() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${T('runVerdict')}</span><span class="badge st-${v.verdict === 'accepted' ? 'pass' : v.verdict === 'returned' ? 'fail' : 'skip'}">${T(v.verdict || 'pending')}</span></h4><p class="note">${esc(T('rvVerdictLead'))}${returned ? ` ${esc(T('rvReturnedHint'))}` : ''}</p>${v.approvers ? `<p class="note">${v.awaiting?.length ? `${T('awaiting')}: <b>${v.awaiting.map(esc).join(', ')}</b>` : T('approvedByAll')}</p>` : ''}
|
|
4963
|
-
<textarea id="rvVNote" rows="2" placeholder="${esc(T('verdictNotePh'))}" style="width:100%;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:0 0 8px"
|
|
4988
|
+
<textarea id="rvVNote" rows="2" placeholder="${esc(T('verdictNotePh'))}" data-draft="${esc(draftKey('run', '', 'verdict'))}" style="width:100%;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:0 0 8px">${esc(loadDraft(draftKey('run', '', 'verdict')))}</textarea>
|
|
4964
4989
|
<div class="share__row"><button class="btn btn--primary" data-rv-verdict="accepted">${T('accept')}</button><button class="btn" data-rv-verdict="returned">${T('ret')}</button></div></div>` : '';
|
|
4965
4990
|
return `<div class="rvm__end"><h3>${esc(head)}</h3><p class="note">${esc(sum)}</p>${d.reference ? `<p class="note">${esc(T('rvComparedWith'))}: <a class="pr" href="${runHref(d.reference.id)}">${fmtDate(d.reference.started_at)}${d.reference.accepted_by ? ` · ${esc(d.reference.accepted_by)}` : ''}</a></p>` : ''}${list}${carried}${verdict}</div>`;
|
|
4966
4991
|
}
|
|
@@ -5088,7 +5113,7 @@ async function rvVerdict (verdict, b) {
|
|
|
5088
5113
|
const note = $('rvVNote')?.value.trim() || '';
|
|
5089
5114
|
if (verdict === 'returned' && !note) { toast(T('noteRequired')); $('rvVNote')?.focus(); return; }
|
|
5090
5115
|
b.disabled = true;
|
|
5091
|
-
try { await postJson('/verdict', { verdict, note }); toast(T('saved')); await reloadRun(); await rvLoad(); rvRender(); } catch (e) { toast(e.message); b.disabled = false; }
|
|
5116
|
+
try { await postJson('/verdict', { verdict, note }); saveDraft(draftKey('run', '', 'verdict'), ''); toast(T('saved')); await reloadRun(); await rvLoad(); rvRender(); } catch (e) { toast(e.message); b.disabled = false; }
|
|
5092
5117
|
}
|
|
5093
5118
|
/** Starts review mode support once the run is on screen: the button, its count and the hash. */
|
|
5094
5119
|
function rvInit () {
|
|
@@ -5150,9 +5175,11 @@ function applyReviewerVerdicts () {
|
|
|
5150
5175
|
const summary = { pass: 0, partial: 0, fail: 0, skip: 0 };
|
|
5151
5176
|
for (const n of RUN.nodes) {
|
|
5152
5177
|
if (n.kind === 'decision') continue;
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5178
|
+
// a criterion limited to another environment (not_applicable) stays out of the status
|
|
5179
|
+
const live = n.criteria.filter((c) => !c.not_applicable);
|
|
5180
|
+
for (const c of live) { const vs = RUN.scope.locales.map((l) => { const vps = Object.values(c.by_locale?.[l] || {}); return vps.length ? worstV(vps.map((v) => v.verdict)) : 'not_reached'; }).filter((v) => v !== 'not_reached'); c.verdict = vs.length ? worstV(vs) : 'not_reached'; }
|
|
5181
|
+
n.by_locale = Object.fromEntries(RUN.scope.locales.map((l) => [l, statusOf(live.map((c) => { const vps = Object.values(c.by_locale?.[l] || {}); return vps.length ? worstV(vps.map((v) => v.verdict)) : 'not_reached'; }), !!n.captures?.[l])]));
|
|
5182
|
+
n.status = statusOf(live.filter((c) => c.verdict !== 'not_reached').map((c) => c.verdict), Object.keys(n.captures || {}).length > 0);
|
|
5156
5183
|
summary[n.status]++;
|
|
5157
5184
|
}
|
|
5158
5185
|
RUN.summary = { ...RUN.summary, ...summary };
|
|
@@ -5160,6 +5187,7 @@ function applyReviewerVerdicts () {
|
|
|
5160
5187
|
async function postJson (path, body) {
|
|
5161
5188
|
const a = APP();
|
|
5162
5189
|
const res = await fetch(`${a.api}/orgs/${a.org}/runs/${a.run}${path}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
5190
|
+
if (res.status === 401) throw sessionLost();
|
|
5163
5191
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
|
5164
5192
|
return res.json();
|
|
5165
5193
|
}
|