kaloko 0.21.4 → 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.
@@ -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 = { desktop: [1280, 800], mobile: [390, 844] } } = {}) {
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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&nbsp;/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: [1280, 800], mobile: [390, 844] },
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
+ }
@@ -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: [1280, 800], mobile: [390, 844] },
483
+ viewports: { desktop: [...NEW_VIEWPORTS.desktop], mobile: [...NEW_VIEWPORTS.mobile] },
483
484
  environments: ['local'],
484
485
  lanes: ['Visitor'],
485
486
  nodes,
@@ -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
- const headers = { ...(GATE.toGate(url) ? GATE.headers : {}), ...(ua ? { 'user-agent': ua } : {}), ...(lang ? { 'accept-language': lang } : {}) }
143
- const res = await request.get(url, { maxRedirects: 5, timeout: 15000, failOnStatusCode: false, ...(Object.keys(headers).length ? { headers } : {}) })
144
- const out = { status: res.status(), url: res.url() }
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
@@ -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
 
@@ -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 390 × 844.
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 390 × 844.
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
 
@@ -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: [390, 844]
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