kaloko 0.21.3 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schemas/run.v1.json +5 -0
- package/schemas/scenario.v1.json +1 -0
- package/skills/kaloko/SKILL.md +1 -0
- package/skills/kaloko/reference/acceptance.md +9 -1
- package/skills/kaloko/reference/collaboration.md +4 -1
- package/skills/kaloko/reference/design.md +6 -1
- package/skills/kaloko/reference/devices.md +10 -0
- package/skills/kaloko/reference/docs.md +5 -0
- package/skills/kaloko/reference/environments.md +3 -1
- package/src/cli.mjs +54 -13
- package/src/lib/access.mjs +109 -16
- package/src/lib/browser-daemon.mjs +4 -1
- package/src/lib/build.mjs +2 -0
- package/src/lib/changelog.mjs +46 -4
- package/src/lib/cli-args.mjs +1 -1
- package/src/lib/design-cli.mjs +1 -1
- package/src/lib/dimensions.mjs +16 -2
- package/src/lib/draft.mjs +2 -1
- package/src/lib/gate-cdp.mjs +120 -0
- package/src/lib/health-catalogue.mjs +122 -0
- package/src/lib/init-detect.mjs +2 -1
- package/src/lib/linkcheck.mjs +12 -3
- package/src/lib/pack-help.mjs +17 -0
- package/src/lib/packs.mjs +27 -0
- package/src/lib/stack-core.mjs +9 -0
- package/src/lib/stack.mjs +2 -2
- package/templates/ACCEPTANCE.md +1 -1
- package/templates/cs/ACCEPTANCE.md +1 -1
- package/templates/scenario.yml +2 -2
- package/viewer/index.html +185 -62
package/src/lib/dimensions.mjs
CHANGED
|
@@ -87,15 +87,29 @@ export function scenarioStates (scenario, pick = null) {
|
|
|
87
87
|
return out.length > 1 ? out : null
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The viewports kaloko init, kaloko draft and the templates write into a NEW scenario (#204, owner decision D4): a
|
|
92
|
+
* common Android phone width (360 CSS px; most phones in use are 360 to 414 wide, and 360 is the narrowest of them)
|
|
93
|
+
* and a laptop. No tablet by default: it adds captures most tasks do not need. An existing scenario keeps the sizes it
|
|
94
|
+
* names, and one without `viewports:` keeps UNSET_VIEWPORTS below, so no baseline changes.
|
|
95
|
+
*/
|
|
96
|
+
export const NEW_VIEWPORTS = Object.freeze({ desktop: [1280, 800], mobile: [360, 800] })
|
|
97
|
+
/** A tablet when someone asks for one (`viewports: { …, tablet: [744, 1133] }` or the bare name `tablet`): iPad mini portrait. */
|
|
98
|
+
export const TABLET_VIEWPORT = [744, 1133]
|
|
99
|
+
// what a scenario without `viewports:` (and the bare names desktop and mobile) has always been captured at
|
|
100
|
+
const UNSET_VIEWPORTS = { desktop: [1280, 800], mobile: [390, 844] }
|
|
101
|
+
// bare names that are no device preset: the two above, and a tablet (an error before, so nothing changes for it)
|
|
102
|
+
const NAMED_SIZES = { ...UNSET_VIEWPORTS, tablet: TABLET_VIEWPORT }
|
|
103
|
+
|
|
90
104
|
/**
|
|
91
105
|
* Viewports of a scenario: { name: [w, h] } as before, or names of device presets — as an array
|
|
92
106
|
* (`[iphone-15, ipad-landscape, desktop]`) or as values (`phone: iphone-15`, `tablet: { device: ipad-portrait }`,
|
|
93
107
|
* `tv: { device: tv-1080p, width: 1280, height: 720 }`). Returns { viewports: { name: [w, h] }, devices: { name: preset } }.
|
|
94
108
|
* `fullScreen` (Design environments) sizes presets to the whole screen.
|
|
95
109
|
*/
|
|
96
|
-
export function resolveViewports (spec, { fullScreen = false, fallback =
|
|
110
|
+
export function resolveViewports (spec, { fullScreen = false, fallback = NAMED_SIZES } = {}) {
|
|
97
111
|
const viewports = {}; const devices = {}
|
|
98
|
-
const entries = spec == null ? Object.entries(fallback) : Array.isArray(spec) ? spec.map((s) => (typeof s === 'string' ? [s, s] : Object.entries(s)[0])) : Object.entries(spec)
|
|
112
|
+
const entries = spec == null ? Object.entries(fallback === NAMED_SIZES ? UNSET_VIEWPORTS : fallback) : Array.isArray(spec) ? spec.map((s) => (typeof s === 'string' ? [s, s] : Object.entries(s)[0])) : Object.entries(spec)
|
|
99
113
|
for (const [name, value] of entries) {
|
|
100
114
|
if (Array.isArray(value)) { viewports[name] = [Number(value[0]), Number(value[1])]; continue }
|
|
101
115
|
const ref = typeof value === 'string' ? { device: value } : value || {}
|
package/src/lib/draft.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// themselves (title, description, H1, language). Read-only production scenario with the seo, a11y, perf and
|
|
3
3
|
// console packs; the agent or person then sharpens purposes and criteria. No service or model call.
|
|
4
4
|
import yaml from 'js-yaml'
|
|
5
|
+
import { NEW_VIEWPORTS } from './dimensions.mjs'
|
|
5
6
|
|
|
6
7
|
const decode = (s) => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'|'/g, "'").replace(/ /g, ' ').replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
|
7
8
|
const text = (html) => decode(String(html || '').replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim()
|
|
@@ -57,7 +58,7 @@ export function draftScenario (pages, { id, title } = {}) {
|
|
|
57
58
|
const scenario = {
|
|
58
59
|
schema: 'qawalk.scenario.v1', id: scenarioId, title: title || `Public pages of ${host}`, kind: 'flow', version: 1,
|
|
59
60
|
locales: [...new Set(pages.map((p) => p.facts?.lang).filter(Boolean))].slice(0, 3).concat(pages.some((p) => p.facts?.lang) ? [] : ['en']),
|
|
60
|
-
viewports: { desktop: [
|
|
61
|
+
viewports: { desktop: [...NEW_VIEWPORTS.desktop], mobile: [...NEW_VIEWPORTS.mobile] },
|
|
61
62
|
environments: ['production'], readonly: true, lanes: ['Visitor'],
|
|
62
63
|
nodes,
|
|
63
64
|
edges: nodes.slice(1).map((n) => ({ from: nodes[0].id, to: n.id, label: 'visit', kind: 'alt' }))
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The gate's headers per request and per redirect hop on Chromium (#224).
|
|
2
|
+
//
|
|
3
|
+
// Playwright applies headers given to route.continue() to every redirect the request causes and never routes the
|
|
4
|
+
// redirected requests, so a gate header added there would reach a host the redirect leads to (audio on S3, an
|
|
5
|
+
// identity provider). Chromium's Fetch domain pauses every redirect hop as a request of its own, so the headers can be
|
|
6
|
+
// added hop by hop, only where the hop's URL is on a gate origin. The interception runs on a browser-level CDP session:
|
|
7
|
+
// that one sees a request after Playwright's routing of the page (route handlers, mocks, the readonly policy), so a
|
|
8
|
+
// handler's own header overrides, which Playwright repeats on every hop, never carry the gate along, and whatever
|
|
9
|
+
// Playwright decided is final before the gate is added. Only URLs on gate origins are paused; a header added to a
|
|
10
|
+
// hop is not carried to the next one by Chromium.
|
|
11
|
+
//
|
|
12
|
+
// One session per browser serves every prepared context in it; a request belongs to the context of its frame
|
|
13
|
+
// (pages, popups, iframes, workers), so contexts of other environments or without a gate in the same browser are
|
|
14
|
+
// left alone.
|
|
15
|
+
|
|
16
|
+
const hubs = new WeakMap() // Browser → Promise<hub | null>
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Adds `gate.headers` to the context's requests to `gate.origins` (`gate.toGate` decides), per redirect hop.
|
|
20
|
+
* Resolves true when it is set up (Chromium), false when the caller has to add the headers some other way.
|
|
21
|
+
*/
|
|
22
|
+
export async function gateHeadersPerHop (context, gate) {
|
|
23
|
+
let browser = null
|
|
24
|
+
try { browser = context.browser?.() } catch {}
|
|
25
|
+
if (!browser || browser.browserType?.().name() !== 'chromium' || typeof browser.newBrowserCDPSession !== 'function') return false
|
|
26
|
+
if (!hubs.has(browser)) hubs.set(browser, startHub(browser).catch(() => null))
|
|
27
|
+
const hub = await hubs.get(browser)
|
|
28
|
+
if (!hub) return false
|
|
29
|
+
try { await hub.add(context, gate); return true } catch { return false }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Fetch pattern of a gate origin (`https://*.example.com` stays a wildcard); default ports are left out of URLs. */
|
|
33
|
+
export function patternOf (origin) {
|
|
34
|
+
const m = String(origin).replace(/\/$/, '').match(/^(https?):\/\/(.+?)(?::(\d+))?$/)
|
|
35
|
+
if (!m) return null
|
|
36
|
+
const port = m[3] && !((m[1] === 'https' && m[3] === '443') || (m[1] === 'http' && m[3] === '80')) ? `:${m[3]}` : ''
|
|
37
|
+
return `${m[1]}://${m[2].toLowerCase()}${port}/*`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const hasFrame = (tree, id) => tree?.frame?.id === id || (tree?.childFrames || []).some((c) => hasFrame(c, id))
|
|
41
|
+
|
|
42
|
+
async function startHub (browser) {
|
|
43
|
+
const session = await browser.newBrowserCDPSession()
|
|
44
|
+
const entries = new Set() // { context, gate, contextId }
|
|
45
|
+
const owners = new Map() // CDP frame or target id → entry
|
|
46
|
+
const pageSessions = new WeakMap()
|
|
47
|
+
const pageSession = (page) => {
|
|
48
|
+
if (!pageSessions.has(page)) pageSessions.set(page, page.context().newCDPSession(page))
|
|
49
|
+
return pageSessions.get(page)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// the browser context id of a Playwright context, from one of its pages
|
|
53
|
+
async function contextIdOf (entry) {
|
|
54
|
+
if (entry.contextId) return entry.contextId
|
|
55
|
+
for (const page of entry.context.pages()) {
|
|
56
|
+
try {
|
|
57
|
+
entry.contextId = (await (await pageSession(page)).send('Target.getTargetInfo')).targetInfo.browserContextId
|
|
58
|
+
return entry.contextId
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// the prepared context a request comes from: its frame is a target (page, popup, out-of-process iframe, worker)
|
|
65
|
+
// or a frame inside a page of one of the contexts
|
|
66
|
+
async function ownerOf (frameId) {
|
|
67
|
+
if (!frameId || !entries.size) return null
|
|
68
|
+
if (owners.has(frameId)) return owners.get(frameId)
|
|
69
|
+
const { targetInfos } = await session.send('Target.getTargets')
|
|
70
|
+
const target = targetInfos.find((t) => t.targetId === frameId)
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (target) {
|
|
73
|
+
if (await contextIdOf(entry) === target.browserContextId) { owners.set(frameId, entry); return entry }
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
for (const page of entry.context.pages()) {
|
|
77
|
+
try {
|
|
78
|
+
if (hasFrame((await (await pageSession(page)).send('Page.getFrameTree')).frameTree, frameId)) { owners.set(frameId, entry); return entry }
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function paused (event) {
|
|
86
|
+
const entry = await ownerOf(event.frameId)
|
|
87
|
+
let headers
|
|
88
|
+
if (entry?.gate.toGate(event.request.url)) {
|
|
89
|
+
const have = new Set(Object.keys(event.request.headers).map((n) => n.toLowerCase()))
|
|
90
|
+
// headers the page set itself win, so an application's own Authorization header is left alone
|
|
91
|
+
const extra = Object.entries(entry.gate.headers).filter(([name]) => !have.has(name.toLowerCase()))
|
|
92
|
+
if (extra.length) headers = [...Object.entries(event.request.headers), ...extra].map(([name, value]) => ({ name, value: String(value) }))
|
|
93
|
+
}
|
|
94
|
+
await session.send('Fetch.continueRequest', { requestId: event.requestId, ...(headers ? { headers } : {}) })
|
|
95
|
+
}
|
|
96
|
+
session.on('Fetch.requestPaused', (event) => {
|
|
97
|
+
// a request is never left paused, whatever went wrong while looking at it
|
|
98
|
+
paused(event).catch(() => session.send('Fetch.continueRequest', { requestId: event.requestId }).catch(() => {}))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
async function update () {
|
|
102
|
+
const patterns = [...new Set([...entries].flatMap((e) => e.gate.origins.map(patternOf).filter(Boolean)))]
|
|
103
|
+
if (patterns.length) await session.send('Fetch.enable', { patterns: patterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })) })
|
|
104
|
+
else await session.send('Fetch.disable')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
async add (context, gate) {
|
|
109
|
+
const entry = { context, gate, contextId: null }
|
|
110
|
+
const remove = () => {
|
|
111
|
+
entries.delete(entry)
|
|
112
|
+
for (const [id, owner] of owners) if (owner === entry) owners.delete(id)
|
|
113
|
+
return update()
|
|
114
|
+
}
|
|
115
|
+
entries.add(entry)
|
|
116
|
+
try { await update() } catch (error) { await remove().catch(() => {}); throw error }
|
|
117
|
+
context.once('close', () => { remove().catch(() => {}) })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The catalogue of health checks (#206): every pack and every check it runs, with why it matters, its severity and
|
|
2
|
+
// where it applies, in English and Czech. Built from the code that runs the checks (packs.mjs, pack-help.mjs, the
|
|
3
|
+
// cross-page checks of seo-core.mjs, HC1005 of variants-core.mjs, the site checks of site.mjs), so the help pages it
|
|
4
|
+
// writes (help/reference/health-checks.md and .cs.md, scripts/health-catalogue.mjs) cannot drift from the CLI.
|
|
5
|
+
import { PACKS, PAGE_TYPES, CODE_ONLY_PACKS, errorPageOnly } from './packs.mjs'
|
|
6
|
+
import { PACK_HELP, PACK_ORDER, PACK_NAMES } from './pack-help.mjs'
|
|
7
|
+
import { CHECK_LEVEL } from './health-core.mjs'
|
|
8
|
+
import { STACK_CHECKS } from './seo-core.mjs'
|
|
9
|
+
import { VARIANT_CHECK } from './variants-core.mjs'
|
|
10
|
+
import { SITE_CHECKS } from './site.mjs'
|
|
11
|
+
|
|
12
|
+
/** What each pack is for, [en, cs]. */
|
|
13
|
+
const ABOUT = {
|
|
14
|
+
seo: ['What search engines read from a page: the title, description, canonical, hreflang, Open Graph, robots, viewport and structured data. On a stack step it also compares the pages with each other.', 'Co si ze stránky přečtou vyhledávače: titulek, popis, kanonickou adresu, hreflang, Open Graph, robots, viewport a strukturovaná data. Na kroku typu stack navíc porovná stránky mezi sebou.'],
|
|
15
|
+
aeo: ['What AI search and assistants pick up: FAQ structured data, a short answer under the H1, llms.txt, a meta description, and the page as GPTBot gets it without JavaScript. Cheap signals from the page, not a ranking in any assistant.', 'Co převezmou AI vyhledávače a asistenti: strukturovaná data FAQ, krátkou odpověď pod H1, llms.txt, meta popis a stránku tak, jak ji bez JavaScriptu dostane GPTBot. Levné signály ze stránky, ne pozice v žádném asistentovi.'],
|
|
16
|
+
a11y: ['The part of WCAG 2.2 AA that can be measured on the page, a walk with the Tab key (without clicking or typing) and the accessibility tree. It does not replace an audit with a screen reader.', 'Část WCAG 2.2 AA, kterou jde na stránce změřit, průchod tabulátorem (bez klikání a psaní) a strom přístupnosti. Audit se čtečkou obrazovky nenahradí.'],
|
|
17
|
+
usability: ['Questions for the evaluator, picked by the page type of the step (`page`: landing, work, list, settings, form, doc), and labelled form fields.', 'Otázky pro evaluátor vybrané podle typu stránky kroku (`page`: landing, work, list, settings, form, doc) a popsaná formulářová pole.'],
|
|
18
|
+
perf: ['Lab values measured in the walk\'s browser: LCP, CLS, TTFB and page weight. Real visitors on slower phones may see other numbers.', 'Laboratorní hodnoty změřené v prohlížeči průchodu: LCP, CLS, TTFB a velikost stránky. Skuteční návštěvníci na pomalejších telefonech mohou vidět jiná čísla.'],
|
|
19
|
+
console: ['The evaluator reads the console errors of the step and says whether they touch what the step accepts or are noise from third-party scripts.', 'Evaluátor přečte chyby v konzoli kroku a řekne, jestli se týkají toho, co krok přijímá, nebo jde o šum cizích skriptů.'],
|
|
20
|
+
errors: ['For 404, 410, 500 and maintenance pages: a real error status, a way back, and no technical detail leaking out. Meant for steps that are error pages.', 'Pro stránky 404, 410, 500 a údržbu: skutečný chybový stav, cesta zpět a žádné technické podrobnosti navenek. Patří na kroky, které jsou chybovými stránkami.'],
|
|
21
|
+
locale: ['For multilingual sites: html lang, the page\'s own hreflang, the language of the text and the decimal separator of prices match the walked locale. Only in scenarios that list their `locales`.', 'Pro vícejazyčné weby: html lang, vlastní hreflang stránky, jazyk textu a desetinný oddělovač cen odpovídají procházenému jazyku. Jen ve scénářích, které uvádějí `locales`.'],
|
|
22
|
+
app: ['Native apps on Android, iOS and desktop: labelled controls, touch targets, cut-off text, crashes and a clear next step.', 'Nativní aplikace na Androidu, iOS a desktopu: popsané ovládací prvky, dotykové plochy, oříznutý text, pády a jasný další krok.'],
|
|
23
|
+
design: ['The implementation against the accepted design version: pixels, structure and tokens. The only pack that always counts for acceptance, so its ids are AC.', 'Implementace proti schválené verzi designu: pixely, struktura a tokeny. Jediný pack, který se vždy počítá do přijetí, proto má id AC.'],
|
|
24
|
+
site: ['Once per site and run, not per step: robots.txt, the sitemaps it lists, llms.txt and the redirects to https and to one host name. Off unless the scenario sets `health: { site: true }`, because they make requests of their own. They cannot be adopted as criteria.', 'Jednou za web a běh, ne za krok: robots.txt, sitemapy, které uvádí, llms.txt a přesměrování na https a na jednu podobu domény. Vypnuté, dokud scénář nenastaví `health: { site: true }`, protože posílají vlastní požadavky. Nejde je převzít jako kritéria.']
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const WORDS = {
|
|
28
|
+
en: {
|
|
29
|
+
title: 'Health checks',
|
|
30
|
+
lead: [
|
|
31
|
+
'Every pack and every check it runs, listed from the same code the CLI runs, so the list matches the version you install. Health checks are advisory: the canvas shows them on the **Health** tab, and they never change the verdict of a step or a run. When a check matters to your team, adopt it as a criterion (`kaloko health adopt <step>:<check>`); from then on it counts for acceptance.',
|
|
32
|
+
'A step turns packs on with `packs: [seo, a11y]` in the scenario file ([Add criteria and packs](../how-to/add-criteria-and-packs.md)). The kinds of check, their options and the ids are in [Criteria, checks and packs](checks-and-packs.md).',
|
|
33
|
+
'**Severity** of a failed check: an **error** when people or search engines are shut out, a **warning** otherwise. An answer the evaluator was unsure about shows as **unsure**.'
|
|
34
|
+
],
|
|
35
|
+
designOff: 'Off on design environments, where the steps are your own HTML served from a folder. To keep it on there, list it in `health: { design_packs: [...] }` in the scenario.',
|
|
36
|
+
designOn: 'Runs on design environments too.',
|
|
37
|
+
cols: ['Id', 'Check', 'Why it matters', 'Severity', 'Applies to'],
|
|
38
|
+
error: 'error', warning: 'warning', acceptance: 'acceptance criterion', hc1102: 'error on `production`, warning elsewhere',
|
|
39
|
+
every: 'every step with the pack', locales: 'scenarios with more than one locale', errorPages: 'error pages only (`expect_status`, or a page answering 4xx/5xx)',
|
|
40
|
+
pageTypes: (list) => `${list.length === 1 ? 'page type' : 'page types'} ${list.map((p) => `\`${p}\``).join(', ')}`,
|
|
41
|
+
stack: 'stack steps, across their pages', sitemap: 'stacks read from a sitemap', traffic: 'stacks with traffic data', variants: 'stacks with `items.variants: hreflang`',
|
|
42
|
+
site: 'once per site', production: 'once per site, on `production` only'
|
|
43
|
+
},
|
|
44
|
+
cs: {
|
|
45
|
+
title: 'Kontroly kondice',
|
|
46
|
+
lead: [
|
|
47
|
+
'Všechny packy a všechny kontroly, které spouštějí, vypsané ze stejného kódu, jaký spouští CLI, takže seznam odpovídá verzi, kterou si nainstalujete. Kontroly kondice jen radí: plachta je ukazuje na kartě **Kondice** a verdikt kroku ani běhu nikdy nemění. Když na kontrole vašemu týmu záleží, převezměte ji jako kritérium (`kaloko health adopt <krok>:<kontrola>`); od té chvíle se počítá do přijetí.',
|
|
48
|
+
'Krok si packy zapne přes `packs: [seo, a11y]` v souboru scénáře ([Přidat kritéria a packy](../how-to/add-criteria-and-packs.md)). Druhy kontrol, jejich volby a id najdete v [Kritéria, kontroly a packy](checks-and-packs.md).',
|
|
49
|
+
'**Závažnost** neúspěšné kontroly: **chyba**, když stránka zavře dveře lidem nebo vyhledávačům, jinak **varování**. Odpověď, kterou si evaluátor nebyl jistý, se ukáže jako **nejisté**.'
|
|
50
|
+
],
|
|
51
|
+
designOff: 'Na designových prostředích, kde jsou kroky vaše vlastní HTML ze složky, je vypnutý. Pokud ho tam chcete, uveďte ho ve scénáři v `health: { design_packs: [...] }`.',
|
|
52
|
+
designOn: 'Běží i na designových prostředích.',
|
|
53
|
+
cols: ['Id', 'Kontrola', 'Proč na ní záleží', 'Závažnost', 'Kde platí'],
|
|
54
|
+
error: 'chyba', warning: 'varování', acceptance: 'akceptační kritérium', hc1102: 'chyba na `production`, jinde varování',
|
|
55
|
+
every: 'každý krok s packem', locales: 'scénáře s více jazyky', errorPages: 'jen chybové stránky (`expect_status` nebo stránka s odpovědí 4xx/5xx)',
|
|
56
|
+
pageTypes: (list) => `${list.length === 1 ? 'typ stránky' : 'typy stránek'} ${list.map((p) => `\`${p}\``).join(', ')}`,
|
|
57
|
+
stack: 'kroky typu stack, napříč stránkami', sitemap: 'stacky načtené ze sitemapy', traffic: 'stacky s daty o návštěvnosti', variants: 'stacky s `items.variants: hreflang`',
|
|
58
|
+
site: 'jednou za web', production: 'jednou za web, jen na `production`'
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The checks of a pack: [{ id, text, where }], where says when the check applies (a key of WORDS or page types). */
|
|
63
|
+
function checksOf (name) {
|
|
64
|
+
if (name === 'site') return SITE_CHECKS.map((c) => ({ id: c.id, text: c.text, where: c.id === 'HC1103' ? 'production' : 'site' }))
|
|
65
|
+
const pack = PACKS[name]
|
|
66
|
+
const seen = new Map()
|
|
67
|
+
for (const page of PAGE_TYPES) {
|
|
68
|
+
for (const c of pack.criteria({ locales: ['en', 'cs'], page, declared: true })) {
|
|
69
|
+
if (!seen.has(c.id)) seen.set(c.id, { id: c.id, text: c.text, pages: [] })
|
|
70
|
+
seen.get(c.id).pages.push(page)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const out = [...seen.values()].map((c) => ({
|
|
74
|
+
id: c.id,
|
|
75
|
+
text: c.text,
|
|
76
|
+
where: c.id === 'HC105' ? 'locales' : errorPageOnly({ pack: name, id: c.id }) ? 'errorPages' : c.pages.length < PAGE_TYPES.length ? c.pages : 'every'
|
|
77
|
+
}))
|
|
78
|
+
if (name === 'seo') for (const c of STACK_CHECKS) out.push({ id: c.id, text: c.text, where: c.id === 'HC126' ? 'sitemap' : c.id === 'HC127' ? 'traffic' : 'stack' })
|
|
79
|
+
if (name === 'locale') out.push({ id: VARIANT_CHECK.id, text: VARIANT_CHECK.text, where: 'variants' })
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The catalogue as data: [{ pack, name: [en, cs], about: [en, cs], design_off, checks: [{ id, text, cs, why, severity, where }] }]. */
|
|
84
|
+
export function healthCatalogue () {
|
|
85
|
+
const missing = Object.keys(PACKS).filter((p) => !PACK_ORDER.includes(p))
|
|
86
|
+
if (missing.length) throw new Error(`health catalogue: no place for the pack(s) ${missing.join(', ')} in PACK_ORDER (pack-help.mjs)`)
|
|
87
|
+
return PACK_ORDER.map((pack) => {
|
|
88
|
+
return {
|
|
89
|
+
pack,
|
|
90
|
+
name: PACK_NAMES[pack],
|
|
91
|
+
about: ABOUT[pack],
|
|
92
|
+
design_off: CODE_ONLY_PACKS.includes(pack),
|
|
93
|
+
checks: checksOf(pack).map((c) => ({
|
|
94
|
+
id: c.id,
|
|
95
|
+
text: c.text,
|
|
96
|
+
cs: PACK_HELP[c.id]?.cs || null,
|
|
97
|
+
why: PACK_HELP[c.id]?.why || null,
|
|
98
|
+
severity: pack === 'design' ? 'acceptance' : c.id === 'HC1102' ? 'hc1102' : CHECK_LEVEL[c.id] || 'warning',
|
|
99
|
+
where: c.where
|
|
100
|
+
}))
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// `<h1>` and the like read as code in the table; a pipe would end the cell
|
|
106
|
+
const cell = (s) => String(s ?? '').replace(/\|/g, '\\|').replace(/<[a-z!/][^<>]*>/gi, (m) => `\`${m}\``)
|
|
107
|
+
|
|
108
|
+
/** The help page in one language (`en` or `cs`), with front matter, as Markdown. */
|
|
109
|
+
export function catalogueMarkdown (lang = 'en') {
|
|
110
|
+
const w = WORDS[lang === 'cs' ? 'cs' : 'en']
|
|
111
|
+
const i = lang === 'cs' ? 1 : 0
|
|
112
|
+
const where = (x) => Array.isArray(x) ? w.pageTypes(x) : w[x]
|
|
113
|
+
const out = ['---', `title: ${w.title}`, 'order: 9', '---', `# ${w.title}`, '', ...w.lead.flatMap((p) => [p, ''])]
|
|
114
|
+
for (const p of healthCatalogue()) {
|
|
115
|
+
out.push(`## ${p.name[i]} (\`${p.pack}\`)`, '', p.about[i], '')
|
|
116
|
+
if (!['site', 'app', 'design'].includes(p.pack)) out.push(p.design_off ? w.designOff : w.designOn, '')
|
|
117
|
+
out.push(`| ${w.cols.join(' | ')} |`, `|${w.cols.map(() => '---').join('|')}|`)
|
|
118
|
+
for (const c of p.checks) out.push(`| ${c.id} | ${cell(i ? c.cs || c.text : c.text)} | ${cell(c.why?.[i] || '')} | ${w[c.severity]} | ${where(c.where)} |`)
|
|
119
|
+
out.push('')
|
|
120
|
+
}
|
|
121
|
+
return out.join('\n')
|
|
122
|
+
}
|
package/src/lib/init-detect.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { readFile, readdir, stat } from 'node:fs/promises'
|
|
|
11
11
|
import { join, basename } from 'node:path'
|
|
12
12
|
import { routeOf } from './affected-core.mjs'
|
|
13
13
|
import { UsageError } from './util.mjs'
|
|
14
|
+
import { NEW_VIEWPORTS } from './dimensions.mjs'
|
|
14
15
|
|
|
15
16
|
const read = (root, f) => readFile(join(root, f), 'utf8').catch(() => null)
|
|
16
17
|
const exists = (root, f) => stat(join(root, f)).then(() => true, () => false)
|
|
@@ -479,7 +480,7 @@ export function initScenario (d, { max = 6, project = null } = {}) {
|
|
|
479
480
|
title: `Main pages of ${name}`,
|
|
480
481
|
kind: 'flow', version: 1,
|
|
481
482
|
locales: d.locales?.list?.slice(0, 6) || ['en'],
|
|
482
|
-
viewports: { desktop: [
|
|
483
|
+
viewports: { desktop: [...NEW_VIEWPORTS.desktop], mobile: [...NEW_VIEWPORTS.mobile] },
|
|
483
484
|
environments: ['local'],
|
|
484
485
|
lanes: ['Visitor'],
|
|
485
486
|
nodes,
|
package/src/lib/linkcheck.mjs
CHANGED
|
@@ -139,9 +139,18 @@ export function requester (request, cache = new Map(), scope = '') {
|
|
|
139
139
|
if (cache.has(key)) return cache.get(key)
|
|
140
140
|
const job = (async () => {
|
|
141
141
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
// redirects are followed by hand (at most 5): Playwright would hand the gate's headers to every hop, so a
|
|
143
|
+
// link that redirects to another host would carry them there
|
|
144
|
+
let at = url; let res
|
|
145
|
+
for (let hop = 0; ; hop++) {
|
|
146
|
+
const headers = { ...(GATE.toGate(at) ? GATE.headers : {}), ...(ua ? { 'user-agent': ua } : {}), ...(lang ? { 'accept-language': lang } : {}) }
|
|
147
|
+
res = await request.get(at, { maxRedirects: 0, timeout: 15000, failOnStatusCode: false, ...(Object.keys(headers).length ? { headers } : {}) })
|
|
148
|
+
const location = [301, 302, 303, 307, 308].includes(res.status()) ? res.headers().location : null
|
|
149
|
+
if (!location) break
|
|
150
|
+
if (hop >= 5) throw new Error('Max redirect count exceeded')
|
|
151
|
+
at = new URL(location, at).href
|
|
152
|
+
}
|
|
153
|
+
const out = { status: res.status(), url: at }
|
|
145
154
|
if (!html && !parse) return out
|
|
146
155
|
const text = await res.text()
|
|
147
156
|
const body = text.length > BODY_MAX ? text.slice(0, BODY_MAX) : text
|
package/src/lib/pack-help.mjs
CHANGED
|
@@ -86,6 +86,23 @@ export const PACK_HELP = {
|
|
|
86
86
|
HC501: { cs: 'Chyby v konzoli se netýkají toho, co krok přijímá', why: ['Separates real failures from third-party noise (analytics, extensions).', 'Odděluje skutečné chyby od šumu cizích skriptů (analytika, rozšíření).'], fix: ['If the AI says an error matters, fix that script; otherwise it is noise.', 'Pokud AI říká, že chyba vadí, opravte ten skript; jinak je to šum.'] }
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** The packs in the order people read them (the health catalogue, the scenario page), `site` (the site checks) last. */
|
|
90
|
+
export const PACK_ORDER = ['seo', 'aeo', 'a11y', 'usability', 'perf', 'console', 'errors', 'locale', 'app', 'design', 'site']
|
|
91
|
+
/** The name of each pack, [en, cs]. */
|
|
92
|
+
export const PACK_NAMES = {
|
|
93
|
+
seo: ['SEO basics', 'Základy SEO'],
|
|
94
|
+
aeo: ['Answer engines', 'Odpovědní vyhledávače'],
|
|
95
|
+
a11y: ['Accessibility', 'Přístupnost'],
|
|
96
|
+
usability: ['Usability', 'Použitelnost'],
|
|
97
|
+
perf: ['Performance', 'Výkon'],
|
|
98
|
+
console: ['Console', 'Konzole'],
|
|
99
|
+
errors: ['Error pages', 'Chybové stránky'],
|
|
100
|
+
locale: ['Language versions', 'Jazykové verze'],
|
|
101
|
+
app: ['App screens', 'Obrazovky aplikací'],
|
|
102
|
+
design: ['Design reference', 'Reference designu'],
|
|
103
|
+
site: ['Site checks', 'Kontroly webu']
|
|
104
|
+
}
|
|
105
|
+
|
|
89
106
|
/** Help for one pack criterion in a language: { name, why, fix }, or null. */
|
|
90
107
|
export function packHelp (id, lang = 'en') {
|
|
91
108
|
const h = PACK_HELP[id] || PACK_HELP[String(id ?? '').replace(/^AC(?=[0-9])/, 'HC')]
|
package/src/lib/packs.mjs
CHANGED
|
@@ -156,6 +156,33 @@ export function expectedErrorStatus (node) {
|
|
|
156
156
|
/** Whether a capture shows an error page: its step expects an error status, or the page answered 400 or more. */
|
|
157
157
|
export const isErrorPage = (node, capture) => expectedErrorStatus(node) || (capture?.http_status ?? 0) >= 400
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Packs that check what only code shows: meta tags and crawlers (seo, aeo), a server's speed (perf), the browser
|
|
161
|
+
* console (console) and real error statuses (errors). A design environment serves the designer's own HTML from a local
|
|
162
|
+
* folder, so on a design run these say nothing about the design; they are off there unless the scenario lists them in
|
|
163
|
+
* `health: { design_packs: [...] }` (#205). a11y, usability and locale look at what people see and stay on.
|
|
164
|
+
*/
|
|
165
|
+
export const CODE_ONLY_PACKS = ['seo', 'aeo', 'perf', 'console', 'errors']
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The scenario as an environment walks it. On a design environment: the design reference checks are dropped (a design
|
|
169
|
+
* is not checked against itself) and so are the code-only packs the scenario does not keep on with
|
|
170
|
+
* `health.design_packs`; those packs are named in `scenario.packs_off` ({ seo: 'design' }), which the run manifest
|
|
171
|
+
* carries so the app can say why a pack is off. Other environments are left as they are. Mutates and returns the scenario.
|
|
172
|
+
*/
|
|
173
|
+
export function forEnvironment (scenario, env) {
|
|
174
|
+
if (env?.kind !== 'design' || !Array.isArray(scenario?.nodes)) return scenario
|
|
175
|
+
const keep = new Set(Array.isArray(scenario.health?.design_packs) ? scenario.health.design_packs : [])
|
|
176
|
+
const off = new Set()
|
|
177
|
+
const drop = (name) => CODE_ONLY_PACKS.includes(name) && !keep.has(name)
|
|
178
|
+
for (const n of scenario.nodes) {
|
|
179
|
+
if (Array.isArray(n.packs)) { for (const p of n.packs) if (drop(p)) off.add(p); n.packs = n.packs.filter((p) => !drop(p)) }
|
|
180
|
+
if (n.criteria) n.criteria = n.criteria.filter((c) => !['design_pixels', 'design_structure'].includes(c.check?.assert) && !(c.pack && drop(c.pack)))
|
|
181
|
+
}
|
|
182
|
+
if (off.size) scenario.packs_off = Object.fromEntries([...off].sort().map((p) => [p, 'design']))
|
|
183
|
+
return scenario
|
|
184
|
+
}
|
|
185
|
+
|
|
159
186
|
/** Page types a step (or the whole scenario) declares with `page`; the usability pack asks per type. */
|
|
160
187
|
export const PAGE_TYPES = ['landing', 'work', 'list', 'settings', 'form', 'doc']
|
|
161
188
|
|
package/src/lib/stack-core.mjs
CHANGED
|
@@ -102,6 +102,15 @@ export function isoWeek (date = new Date()) {
|
|
|
102
102
|
return { year, week, label: `${year}-W${String(week).padStart(2, '0')}`, index: Math.floor((monday - Date.UTC(1970, 0, 5)) / (7 * 86400000)) }
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The date that picks the window of `rotate: week`: the start of the run, so every locale and step of one run walks
|
|
107
|
+
* the same week even when the walk crosses midnight into a new week (a run started on Sunday night). Falls back to now.
|
|
108
|
+
*/
|
|
109
|
+
export function rotationDate (startedAt) {
|
|
110
|
+
const d = startedAt ? new Date(startedAt) : null
|
|
111
|
+
return d && !Number.isNaN(d.getTime()) ? d : new Date()
|
|
112
|
+
}
|
|
113
|
+
|
|
105
114
|
/**
|
|
106
115
|
* The window of a rotating sample: `limit` entries of `list` starting at (round × limit) mod length, wrapping around,
|
|
107
116
|
* so consecutive rounds cover the whole list in ceil(length / limit) rounds. Returns { slice, offset }.
|
package/src/lib/stack.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { gunzipSync } from 'node:zlib'
|
|
|
9
9
|
import { UserError, localized, readJson, writeJson } from './util.mjs'
|
|
10
10
|
import { STACK_DEFAULT_FIELDS, STACK_SEMANTIC_SAMPLE, stackFieldsOf } from './manifest-core.mjs'
|
|
11
11
|
import { variantItems, sitemapAlternates, pageAlternates, readHeadFrom } from './variants-core.mjs'
|
|
12
|
-
import { DEFAULT_LIMIT, SITEMAP_MAX_BYTES, SITEMAP_MAX_CHILDREN, ROTATE_MODES, itemId, sitemapLocs, robotsSitemaps, sitemapsForLocale, handoffOf, isoWeek, rotateWindow, patternFor as corePatternFor, withLocale, sitemapPages, selectItems } from './stack-core.mjs'
|
|
12
|
+
import { DEFAULT_LIMIT, SITEMAP_MAX_BYTES, SITEMAP_MAX_CHILDREN, ROTATE_MODES, itemId, sitemapLocs, robotsSitemaps, sitemapsForLocale, handoffOf, isoWeek, rotateWindow, rotationDate, patternFor as corePatternFor, withLocale, sitemapPages, selectItems } from './stack-core.mjs'
|
|
13
13
|
|
|
14
14
|
/** How long items.resolve may take for one page (ms) before the page is walked without its edit link. */
|
|
15
15
|
export const RESOLVE_TIMEOUT = 15000
|
|
@@ -18,7 +18,7 @@ export const DEFAULT_SEMANTIC_SAMPLE = STACK_SEMANTIC_SAMPLE
|
|
|
18
18
|
export const DEFAULT_FIELDS = STACK_DEFAULT_FIELDS
|
|
19
19
|
export const fieldsOf = stackFieldsOf
|
|
20
20
|
// sources, filters and the rotating window are pure (stack-core.mjs): hosted runs on the service pick the same pages
|
|
21
|
-
export { DEFAULT_LIMIT, SITEMAP_MAX_BYTES, SITEMAP_MAX_CHILDREN, ROTATE_MODES, itemId, sitemapLocs, robotsSitemaps, sitemapsForLocale, handoffOf, isoWeek, rotateWindow }
|
|
21
|
+
export { DEFAULT_LIMIT, SITEMAP_MAX_BYTES, SITEMAP_MAX_CHILDREN, ROTATE_MODES, itemId, sitemapLocs, robotsSitemaps, sitemapsForLocale, handoffOf, isoWeek, rotateWindow, rotationDate }
|
|
22
22
|
|
|
23
23
|
/** The text of a sitemap body: gzip (a .gz URL or the magic bytes 1f 8b) is unpacked, anything else read as UTF-8. */
|
|
24
24
|
export function sitemapText (bytes, url = '') {
|
package/templates/ACCEPTANCE.md
CHANGED
|
@@ -35,7 +35,7 @@ Branches of the flow and the data that triggers them.
|
|
|
35
35
|
|
|
36
36
|
## Locales and viewports
|
|
37
37
|
|
|
38
|
-
Locales; desktop 1280 × 800, mobile
|
|
38
|
+
Locales; desktop 1280 × 800, mobile 360 × 800 (a tablet, 744 × 1133, only when the task needs it).
|
|
39
39
|
|
|
40
40
|
## Environments and limits
|
|
41
41
|
|
|
@@ -35,7 +35,7 @@ Větve flow a data, která je vyvolají.
|
|
|
35
35
|
|
|
36
36
|
## Jazyky a viewporty
|
|
37
37
|
|
|
38
|
-
Seznam jazyků; desktop 1280 × 800, mobil
|
|
38
|
+
Seznam jazyků; desktop 1280 × 800, mobil 360 × 800 (tablet 744 × 1133 jen tehdy, když ho úkol potřebuje).
|
|
39
39
|
|
|
40
40
|
## Prostředí a omezení
|
|
41
41
|
|
package/templates/scenario.yml
CHANGED
|
@@ -6,9 +6,9 @@ source: docs/tasks/TASK-123-example/ACCEPTANCE.md
|
|
|
6
6
|
# project: shop # optional: sort this flow into a project other than the config's / repository's
|
|
7
7
|
version: 1
|
|
8
8
|
locales: [en]
|
|
9
|
-
viewports:
|
|
9
|
+
viewports: # a tablet only when the task needs one: tablet: [744, 1133]
|
|
10
10
|
desktop: [1280, 800]
|
|
11
|
-
mobile: [
|
|
11
|
+
mobile: [360, 800]
|
|
12
12
|
environments: [local, staging]
|
|
13
13
|
# platform: android # web (default) | android | ios | electron | desktop — an app instead of pages:
|
|
14
14
|
# # the environment's app block in kaloko.config.yml names it, paths are deep links
|