kaloko 0.19.0 → 0.20.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Your AI agents build; Kaloko walks every screen, puts it on one canvas and records who approved what.",
5
5
  "keywords": [
6
6
  "qa",
@@ -124,6 +124,17 @@
124
124
  }
125
125
  }
126
126
  },
127
+ "traffic": {
128
+ "type": "object",
129
+ "additionalProperties": false,
130
+ "description": "Traffic data per page (#157), opt-in: Search Console clicks, impressions, CTR and position and GA4 sessions and conversions, joined by URL onto stack pages (items.json traffic) and the rows of kaloko export findings (impact, sorted by it); with the seo pack a stack also gets HC127 (high impressions, low CTR). Nothing is requested unless gsc or ga4 is set. A scenario's traffic block replaces the config's.",
131
+ "properties": {
132
+ "file": { "oneOf": [{ "type": "string" }, { "type": "array", "minItems": 1, "items": { "type": "string" } }], "description": "CSV exports of Search Console (Pages) or GA4 (landing pages), English or Czech headers; a path relative to the file that names it." },
133
+ "gsc": { "type": "object", "additionalProperties": false, "required": ["property"], "properties": { "property": { "type": "string", "minLength": 1, "description": "The Search Console property: sc-domain:example.com or https://www.example.com/." }, "credentials_env": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "description": "Variable with the path of the service account JSON key (or the JSON itself). Default GOOGLE_APPLICATION_CREDENTIALS." }, "days": { "type": "integer", "minimum": 1, "maximum": 480, "description": "Days of data, default 28 (ending two days ago; Search Console data comes in late)." } }, "description": "Search Console API (searchanalytics.query by page) with a service account added to the property as a user. Requested once per run and cached in the run folder .cache." },
134
+ "ga4": { "type": "object", "additionalProperties": false, "required": ["property"], "properties": { "property": { "type": "string", "minLength": 1, "pattern": "^(properties/)?[0-9]+$", "description": "The GA4 property id (123456789 or properties/123456789)." }, "credentials_env": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "description": "Variable with the path of the service account JSON key (or the JSON itself). Default GOOGLE_APPLICATION_CREDENTIALS." }, "days": { "type": "integer", "minimum": 1, "maximum": 480, "description": "Days of data, default 28 (ending yesterday)." } }, "description": "GA4 Data API (runReport: sessions and key events by landing page) with a service account added to the property as a viewer. Requested once per run and cached in the run folder .cache." },
135
+ "low_ctr": { "type": "object", "additionalProperties": false, "description": "HC127 thresholds: a page with at least min_impressions impressions fails when its CTR is below ratio x the site's median CTR at similar positions (1-3, 4-10, 11-20).", "properties": { "min_impressions": { "type": "integer", "minimum": 1, "default": 100 }, "ratio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1, "default": 0.5 } } }
136
+ }
137
+ },
127
138
  "walk": {
128
139
  "type": "object",
129
140
  "additionalProperties": false,
@@ -700,7 +700,7 @@
700
700
  "evidence": { "type": ["string", "null"] },
701
701
  "check": { "type": "object" },
702
702
  "help": { "type": "object" },
703
- "cross_page": { "type": "boolean", "description": "Stack steps (#151): a check across the stack's pages (HC121–HC126: duplicates, canonicals, hreflang pairs, sitemap pages). counts says how many pages are affected; it cannot be adopted as a criterion." },
703
+ "cross_page": { "type": "boolean", "description": "Stack steps (#151): a check across the stack's pages (HC121–HC126: duplicates, canonicals, hreflang pairs, sitemap pages; HC1005: pages without their own version in a walked locale, with items.variants). counts says how many pages are affected; it cannot be adopted as a criterion." },
704
704
  "urls": { "type": "array", "items": { "type": "string" }, "description": "Cross-page checks: the first affected addresses (at most 20); the stack's items.json has every group." }
705
705
  }
706
706
  }
@@ -766,9 +766,19 @@
766
766
  "description": "Rotating sample (items.rotate), per locale: mode (week or run), week or run, offset, limit, total, rounds (runs to cover every page), covered, never (pages not checked yet) and oldest_checked.",
767
767
  "additionalProperties": { "type": "object" }
768
768
  },
769
+ "variants": {
770
+ "type": "object",
771
+ "description": "Locale variants (items.variants: hreflang): mode, source_locale, and per other locale the number of source pages without their own version there (missing) and of versions several source pages share (shared). items.json lists them under variants.locales.",
772
+ "properties": {
773
+ "mode": { "type": "string" },
774
+ "source_locale": { "type": ["string", "null"] },
775
+ "missing": { "type": "object", "additionalProperties": { "type": "integer" } },
776
+ "shared": { "type": "object", "additionalProperties": { "type": "integer" } }
777
+ }
778
+ },
769
779
  "cross_page": {
770
780
  "type": "array",
771
- "description": "Cross-page checks of a stack with the seo pack (#151): id, verdict, pages compared and pages affected. items.json has them in full under cross_page (groups with the affected addresses) and marks each affected page with cross_fails.",
781
+ "description": "Cross-page checks of a stack with the seo pack (#151), and HC1005 with locale variants: id, verdict, pages compared and pages affected. items.json has them in full under cross_page (groups with the affected addresses) and marks each affected page with cross_fails.",
772
782
  "items": { "type": "object" }
773
783
  }
774
784
  }
@@ -113,6 +113,17 @@
113
113
  }
114
114
  }
115
115
  },
116
+ "traffic": {
117
+ "type": "object",
118
+ "additionalProperties": false,
119
+ "description": "Traffic data per page (#157), opt-in: Search Console clicks, impressions, CTR and position and GA4 sessions and conversions, joined by URL onto stack pages (items.json traffic) and the rows of kaloko export findings (impact, sorted by it); with the seo pack a stack also gets HC127 (high impressions, low CTR). Nothing is requested unless gsc or ga4 is set. A scenario's traffic block replaces the config's.",
120
+ "properties": {
121
+ "file": { "oneOf": [{ "type": "string" }, { "type": "array", "minItems": 1, "items": { "type": "string" } }], "description": "CSV exports of Search Console (Pages) or GA4 (landing pages), English or Czech headers; a path relative to the file that names it." },
122
+ "gsc": { "type": "object", "additionalProperties": false, "required": ["property"], "properties": { "property": { "type": "string", "minLength": 1, "description": "The Search Console property: sc-domain:example.com or https://www.example.com/." }, "credentials_env": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "description": "Variable with the path of the service account JSON key (or the JSON itself). Default GOOGLE_APPLICATION_CREDENTIALS." }, "days": { "type": "integer", "minimum": 1, "maximum": 480, "description": "Days of data, default 28 (ending two days ago; Search Console data comes in late)." } }, "description": "Search Console API (searchanalytics.query by page) with a service account added to the property as a user. Requested once per run and cached in the run folder .cache." },
123
+ "ga4": { "type": "object", "additionalProperties": false, "required": ["property"], "properties": { "property": { "type": "string", "minLength": 1, "pattern": "^(properties/)?[0-9]+$", "description": "The GA4 property id (123456789 or properties/123456789)." }, "credentials_env": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "description": "Variable with the path of the service account JSON key (or the JSON itself). Default GOOGLE_APPLICATION_CREDENTIALS." }, "days": { "type": "integer", "minimum": 1, "maximum": 480, "description": "Days of data, default 28 (ending yesterday)." } }, "description": "GA4 Data API (runReport: sessions and key events by landing page) with a service account added to the property as a viewer. Requested once per run and cached in the run folder .cache." },
124
+ "low_ctr": { "type": "object", "additionalProperties": false, "description": "HC127 thresholds: a page with at least min_impressions impressions fails when its CTR is below ratio x the site's median CTR at similar positions (1-3, 4-10, 11-20).", "properties": { "min_impressions": { "type": "integer", "minimum": 1, "default": 100 }, "ratio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1, "default": 0.5 } } }
125
+ }
126
+ },
116
127
  "tags": {
117
128
  "type": "array",
118
129
  "items": {
@@ -491,6 +502,14 @@
491
502
  "maximum": 10000,
492
503
  "description": "At most this many pages (default 500)"
493
504
  },
505
+ "variants": {
506
+ "enum": ["hreflang"],
507
+ "description": "Locale variants of one page: the list, limit and rotation come from items.source_locale, and every other locale walks each page's own hreflang alternate for that locale (from the page's HTML head, its Link header or the sitemap's xhtml:link). The item id comes from the source page, so a page and its translations are one item; a page without a version in a locale is reported (HC1005) instead of dropped silently. x-default never stands for a locale."
508
+ },
509
+ "source_locale": {
510
+ "type": "string",
511
+ "description": "With variants: the locale whose list is walked (default: the scenario's first locale). One of the scenario's locales."
512
+ },
494
513
  "rotate": {
495
514
  "enum": ["week", "run"],
496
515
  "description": "Rotating sample: each run takes limit pages starting at a stable offset, the ISO week (week) or the number of runs so far in this output folder (run), so the whole list is covered over several runs."
@@ -72,6 +72,22 @@ kaloko schedule hook <id> # URL + secret that start the run after a deploy
72
72
  script that only calls `page.goto`). Steps that click or type are left out and
73
73
  named in the answer. Criteria decided: `exists`, `absent`, `count`, `text_*`,
74
74
  `attr_*`, `title_matches`, `meta_matches`; the rest shows "not evaluated".
75
+ - Stack steps: pages from `items.urls`, `items.sitemap` or `items.robots`
76
+ (gzip and indexes too) with `match`/`exclude` (string or per locale), `limit`
77
+ and `rotate: week`, picked like `kaloko walk` picks them. Read without
78
+ screenshots (one `/scrape` per page in the step's first viewport): the
79
+ criteria above, index fields (title, h1, description, words, jsonld_types,
80
+ meta and selector fields; `http` stays empty) and `items.json` on the canvas.
81
+ Read over several 15-minute ticks (150 pages each), at most 1,000 stack pages
82
+ per run and 5 × limit sitemap addresses per locale; each page counts against
83
+ the monthly budget, and a spent budget waits up to a day, then shares with a
84
+ note. Run now, hooks and edits queue behind a run under way. Patterns: ≤ 300
85
+ characters, no backreferences or lookaround. `crawl`, `script` and `file` sources, item scripts and
86
+ `items.resolve` edit links need the full walk.
87
+ - Base URL per locale: an environment with `base_url: { default: …, cs: … }`
88
+ is scheduled as it is; each locale opens its own address, else `default`.
89
+ `kaloko schedule list` shows stack progress and the last run's new and fixed
90
+ health results.
75
91
  - Health alerts: a hosted run (and a run shared from CI) compares its health
76
92
  with the previous run of the scenario and alerts the followers, the channels
77
93
  and the `health.worse` webhook on a new error-level finding. Each schedule
@@ -83,8 +99,15 @@ kaloko schedule hook <id> # URL + secret that start the run after a deploy
83
99
  run per page (`…/health/diff`: new, fixed, still open, first seen). A
84
100
  `read:health` token reads these and nothing else; the `health.changed`
85
101
  webhook says when something is new or fixed. help/reference/health-api.md.
102
+ - Findings: `GET …/scenarios/<id>/findings` (MCP `list_findings`) groups a
103
+ scenario's open results by check, site, locale and step, with a status
104
+ people set (acknowledged, ignored with a reason) and fixed when no page
105
+ fails. A scenario may open one Jira / Linear issue per new finding
106
+ (`PUT …/findings/sync { tracker, min_severity }`, Developer / Tester): at
107
+ most 20 per run, a comment on big changes, closed when fixed. Switching it
108
+ on or ignoring findings is the team's call, not an agent's.
86
109
  - Does not work for: native apps, local or private addresses (localhost, IPs,
87
- `.local`), header credentials, base URLs per locale.
110
+ `.local`), header credentials, stacks listed by crawl, script or file.
88
111
  - For those, or for the full walk on a schedule, write a GitHub Actions
89
112
  workflow instead: `kaloko schedule export github --scenario <file> --env <name>
90
113
  [--every week] [--at 5]`. It names the repository secrets it needs.
@@ -54,6 +54,7 @@ is null. Projects and products come complete, without pages.
54
54
  | `get_health` | the advisory health of a run (`run_id`), the newest run of a scenario (`scenario`) or the trend of a project (`project`): findings by severity, pack and step with evidence and a fix, and what is new or fixed since the previous run; never a verdict, see [evaluation.md#health](evaluation.md#health) |
55
55
  | `get_health_aggregate` | health counted for a dashboard (`run_id`, or `scenario` and `environment` for its newest run; `by` locale, step, check, pack; filters; `top` pages per group): pass / fail / unsure / not evaluated per group, the most affected pages, and new / fixed / still open since the previous run; group ids are stable across runs |
56
56
  | `get_health_diff` | per page, locale and check what is new, fixed or still open since the previous run (`state` picks one list), each with `first_seen` (run and date); ids `step\|locale\|check\|page` are stable across runs |
57
+ | `list_findings` | a scenario's open results grouped into findings (`scenario`, `environment`; filters `locale`, `step`, `check`, `status`): fingerprint `check\|site\|locale\|step`, severity, pages affected, up to 10 sample pages with `edit_url`, first / last seen, status (open, acknowledged, ignored with a reason, fixed) and the tracker issue. Read-only: fix the worst first; acknowledging or ignoring is the team's call |
57
58
  | `adopt_health_check` | turns a health check into an acceptance criterion of the scenario (`run_id`, `step`, `check` such as `HC103`, `text` to reword it; it becomes `AC103` with `from: HC103`); Developer / Tester role. Only when the person asks; `kaloko health pull` then writes it into the scenario file |
58
59
  | `get_affected_steps` | which steps of a scenario changed files reach, with the reason per step (`scenario`, `changed_files`, `run_id`, `upstream`); route files are matched against the step paths and URLs of the newest run. Imports and `affects:` globs need the repository: `kaloko affected` follows them |
59
60
 
@@ -24,7 +24,10 @@ the languages and the biggest content families and writes
24
24
  language, packs seo and aeo). Read the comment at the top of the file:
25
25
  families it paired across languages by size are guesses to check. Then
26
26
  `kaloko validate`, `kaloko walk --limit 20`, and
27
- `kaloko schedule export github`. See help/how-to/seo-monitor.md.
27
+ `kaloko schedule export github`. See help/how-to/seo-monitor.md. On
28
+ Business, `kaloko schedule add` runs urls, sitemap and robots stacks on
29
+ Kaloko's side without screenshots (DOM checks and index fields only; see
30
+ reference/integrations.md → Hosted runs).
28
31
 
29
32
  Not for a flow of different screens: those stay separate steps. A stack
30
33
  step does not click through anything. It opens each page by URL. Use a
@@ -45,6 +48,7 @@ step script only to prepare each page, for example to dismiss a dialog
45
48
  # or a map by locale: { default: '^/blog/', fi: '^/fi/blogi/' }
46
49
  limit: 800 # default 500, at most 10000
47
50
  # rotate: week # each ISO week the next `limit` pages (run: each run)
51
+ # variants: hreflang # translations as variants of one page (see below)
48
52
  semantic_sample: 20 # semantic criteria on 20 pages only
49
53
  # semantic_order: priority # failing and least recently checked pages first
50
54
  # viewports: [desktop] # default: the first viewport of the scenario
@@ -155,10 +159,80 @@ HC1101–HC1107, `site.json` in the run, `site` in the manifest). Off the
155
159
  `production` environment HC1102 is a warning and HC1103 is skipped;
156
160
  `health.site.environments: [production]` runs them there only.
157
161
 
162
+ Traffic data (#157) is opt-in: `traffic:` in kaloko.config.yml or the
163
+ scenario, with `file` (a Search Console or GA4 CSV export, English or Czech
164
+ headers), `gsc: { property }` and `ga4: { property }` (APIs through a service
165
+ account key named by `credentials_env`, default
166
+ GOOGLE_APPLICATION_CREDENTIALS; nothing is requested without them; answers
167
+ cached per run in <output>/.cache/traffic). Each page in `items.json` then has
168
+ `traffic: { clicks, impressions, ctr, position, sessions, conversions }`, the
169
+ node `stack.traffic` counts the pages with data, `kaloko export findings`
170
+ adds the numbers and `impact` (weight 3 for errors and failed acceptance, 1
171
+ for warnings, × impressions + 10 × (clicks + sessions) + 100 × conversions)
172
+ and sorts by it, and the `seo` pack adds HC127 (CTR below half the site's
173
+ median at similar positions, pages with 100+ impressions). Sort a fix list
174
+ by impact before handing it over; never put the key in the scenario.
175
+
158
176
  A step `execution.script` still works. It is called with `item` in the
159
177
  context, opens `item.url` itself and may call `capture()`. Without a script,
160
178
  the walk opens each page and captures it.
161
179
 
180
+ ## One page in every language (`variants: hreflang`)
181
+
182
+ One item is one page; its locales are variants of it. Without more setup,
183
+ each locale reads its own list, so a Czech product and its Slovak
184
+ translation become two items with different ids, the locales sample
185
+ different pages and a missing translation is invisible. For a multilingual
186
+ stack, set:
187
+
188
+ ```yaml
189
+ items:
190
+ robots: true
191
+ match: '^/eshop/'
192
+ variants: hreflang
193
+ source_locale: cs # default: the scenario's first locale
194
+ limit: 100
195
+ rotate: week
196
+ ```
197
+
198
+ - The list, `match`/`exclude`, `limit` and `rotate` are taken in
199
+ `source_locale` (its base URL, `{locale}` = the source locale) for every
200
+ locale, so all locales walk the same window. Item ids come from the
201
+ source pages; `--items` takes an id or a source path.
202
+ - In another locale each chosen page's alternate for that locale is walked:
203
+ `<link rel="alternate" hreflang>` in the raw HTML head (no JavaScript),
204
+ else the `Link` header, else the sitemap's `xhtml:link`. Matching: the
205
+ same tag (`en-us` for `en_US`), else the bare language for a locale with a
206
+ region, else the first regional tag for a bare locale (`sk-sk` for `sk`).
207
+ `x-default` never stands for a locale. The page is read with the
208
+ environment's gate (headers, HTTP Basic, client certificates) and the
209
+ walk's cookies, on the environment's own origins only; 15 s and 1 MB at
210
+ most, stopping at `</head>`; a timeout or 5xx is tried once more. When no
211
+ alternate is on the host the page was read from (staging naming
212
+ production, also one domain per language), every alternate opens with its
213
+ path on the walked locale's origin.
214
+ - A page with no alternate for the locale, only `x-default`, an alternate
215
+ that points back to the page itself (another language), or a page that
216
+ cannot be read is not walked in that locale and is reported: the walk
217
+ says how many, `steps/<step>/variants.<locale>.json` keeps the list,
218
+ `items.json` marks the item (`missing: [sk]`) and lists every page under
219
+ `variants.locales`, the node gets `stack.variants`, HC1005 counts them
220
+ (advisory, also without the `seo` pack), and `kaloko export findings`
221
+ gives one row per page and locale at the source URL. Several source pages
222
+ leading to one alternate (a fallback to a listing or home) also count in
223
+ HC1005; they are walked. A translation that answers 4xx/5xx is walked
224
+ and also counts as missing (`error 404`). Reciprocity is HC125 (`seo` pack).
225
+ - The source page is read once per walk, whichever locale needs it. Each
226
+ capture of a variant keeps its own `path` in `items.json`; the item's
227
+ `path` and id are the source page's, and its `url`, title and fields too
228
+ when the run walks the source locale (else those of the first walked
229
+ locale).
230
+ - A script that lists pages per locale instead: give each page the same
231
+ `key` in every locale (for example the source path), so they are one item.
232
+ - Hosted runs (`kaloko schedule add`) do not group variants yet: there each
233
+ locale reads its own list and HC1005 is not reported (`kaloko validate`
234
+ warns). Use `kaloko walk` or the scheduled GitHub workflow.
235
+
162
236
  ## Cost and size: choose deliberately
163
237
 
164
238
  - Deterministic criteria and packs run on every page. They are cheap.
@@ -213,8 +287,9 @@ the walk opens each page and captures it.
213
287
 
214
288
  Say how many pages were walked, how many fail and on which criteria (with
215
289
  two or three example paths), whether the limit cut the list, and that
216
- semantic criteria covered only the sample. Put the full list in the CSV or
217
- the MCP result rather than in the issue.
290
+ semantic criteria covered only the sample. With `variants: hreflang`, also
291
+ say per locale how many source pages have no version there (HC1005). Put the
292
+ full list in the CSV or the MCP result rather than in the issue.
218
293
 
219
294
  ## Design environments
220
295
 
package/src/cli.mjs CHANGED
@@ -18,7 +18,7 @@ import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher, gateFo
18
18
  import { configureGate, disposeShared } from './lib/linkcheck.mjs'
19
19
  import { rememberEnvironment } from './lib/secrets.mjs'
20
20
  import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
21
- import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
21
+ import { buildManifest, writePreview, attentionLines, runTraffic } from './lib/build.mjs'
22
22
  import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
23
23
  import { signoffCmd, evidenceCmd } from './lib/signoff.mjs'
24
24
  import { localized, readJson, writeJson, UserError, UsageError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG, EXIT, parseDuration } from './lib/util.mjs'
@@ -33,7 +33,7 @@ import { detectProject, initScenario, skeletonScenario, scenarioHeader, formatDe
33
33
  import { checkSlug, checkName, setConfigKeys, createProject, renameProject, syncProject, shareProjectNote } from './lib/projects-cli.mjs'
34
34
  import { recheckCaptures } from './lib/recheck.mjs'
35
35
  import { runFindings, findingsText } from './lib/findings.mjs'
36
- import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage } from './lib/stack.mjs'
36
+ import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage, sourceLocaleOf, variantReader } from './lib/stack.mjs'
37
37
  import { walkSiteChecks, siteChecksOn } from './lib/site.mjs'
38
38
  import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
39
39
  import { expandPacks } from './lib/packs.mjs'
@@ -319,7 +319,9 @@ Results
319
319
  export findings [<run>] [--format csv|json] [--locale l] [--step id] [--out file]
320
320
  The fix list of a local run: every failing criterion and health check per page and
321
321
  locale with its evidence, the page's edit_url and content_ref (from the item script
322
- or items.resolve) and the walk that checks it again (kaloko walk --steps s --items p)
322
+ or items.resolve) and the walk that checks it again (kaloko walk --steps s --items p);
323
+ with traffic: in the config, also clicks, impressions, sessions and an impact
324
+ score, sorted by it
323
325
  public [--run id] [--off] Make a shared run readable without sign-in (admin; testers may refresh an already public scenario)
324
326
  stability [--last 4 | --runs a,b] [--same-commit] [--apply] [--json]
325
327
  Regions that change on every run or move while the page is idle (clocks, ads,
@@ -807,11 +809,31 @@ async function exportScenario ({ args, config, root, client }) {
807
809
  * Walks a stack step: resolves its pages, then opens and captures each one with the step's criteria and index
808
810
  * fields (HTTP status of the main document included). A step script, when given, runs per page with `item`.
809
811
  */
810
- async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, args, keys = null, tag = '' }) {
812
+ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, args, keys = null, tag = '', cache = null, gate = null }) {
811
813
  // a rotating sample (items.rotate) remembers between runs how many runs walked it and when each page was checked
812
- const state = node.items?.rotate ? await rotationState(join(run.dir, '..', '.cache', 'stack', scenario.id, `${node.id}.${locale}.json`), run.meta.id) : null
813
- const { items, total, truncated, all, rotation, resolve_failed: unresolved, resolve_error: unresolvedWhy } = await resolveItems({ node, base: baseUrl(env, locale), page, scenarioDir, env, locale, scenario, limit: Number(args.limit) || undefined, rotation: { run: state?.run || 0 }, checked: state?.checked || null, only: list(args.items) })
814
+ const statePath = (l) => join(run.dir, '..', '.cache', 'stack', scenario.id, `${node.id}.${l}.json`)
815
+ const state = node.items?.rotate ? await rotationState(statePath(locale), run.meta.id) : null
816
+ // locale variants (#176): every locale walks the window of the source list, so a rotation by runs counts the runs of
817
+ // the step once for all locales (whichever of them a run walks), not per locale
818
+ const sourceLocale = node.items?.variants ? sourceLocaleOf(node, scenario, locale) : locale
819
+ const counter = state && node.items.variants && node.items.rotate === 'run' ? await rotationState(statePath('variants'), run.meta.id) : null
820
+ const round = counter ? counter.run : state?.run || 0
821
+ // locale variants read the source pages for their hreflang with the environment's access (variantReader)
822
+ const reader = node.items?.variants ? await variantReader({ page, gate, origins: envOrigins(env, scenario, locale) }) : null
823
+ let resolved
824
+ try { resolved = await resolveItems({ node, base: baseUrl(env, locale), sourceBase: baseUrl(env, sourceLocale), page, scenarioDir, env, locale, scenario, limit: Number(args.limit) || undefined, rotation: { run: round }, checked: state?.checked || null, only: list(args.items), cache, readPage: reader?.read || null }) } finally { await reader?.dispose() }
825
+ const { items, total, truncated, all, rotation, partial, variants, resolve_failed: unresolved, resolve_error: unresolvedWhy } = resolved
814
826
  if (unresolved) say(`! ${node.id} ${locale}${tag}: items.resolve failed for ${unresolved} page(s), walked without an edit link (${unresolvedWhy})`)
827
+ if (variants) {
828
+ // the build marks the pages without a version in this locale; a partial walk (--items) updates its pages only
829
+ const file = join(run.dir, 'steps', node.id, `variants.${locale}.json`)
830
+ const before = partial ? await readJson(file, null) : null
831
+ const asked = new Set([...items.map((it) => it.id), ...variants.missing.map((m) => m.id)])
832
+ const keep = (list) => (before?.[list] || []).filter((m) => !asked.has(m.id) && !(m.ids || []).some((id) => asked.has(id)))
833
+ await writeJson(file, before ? { ...variants, pages: before.pages, missing: [...keep('missing'), ...variants.missing], shared: [...keep('shared'), ...variants.shared] } : variants)
834
+ if (variants.missing.length) say(`! ${node.id} ${locale}${tag}: ${variants.missing.length} of ${variants.pages} ${variants.source_locale} page(s) have no ${locale} version (hreflang), e.g. ${variants.missing[0].path}: ${variants.missing[0].reason}`)
835
+ if (variants.shared.length) say(`! ${node.id} ${locale}${tag}: ${variants.shared.length} ${locale} page(s) stand for several ${variants.source_locale} pages, e.g. ${variants.shared[0].url}`)
836
+ }
815
837
  // --items picked the pages from the whole list (resolveItems only); a partial walk leaves the rotation state alone
816
838
  const chosen = items
817
839
  const passKeys = keys || engineKeys(run.meta.scope, 'chromium')
@@ -820,7 +842,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
820
842
  const passBases = baseViewports({ ...run.meta.scope, viewports: Object.fromEntries(passKeys.map((k) => [k, run.meta.scope.viewports[k]])) })
821
843
  const viewports = scopeKeys(run.meta.scope, passKeys, vpNames.length ? vpNames : [passBases[0] || bases[0]])
822
844
  if (!Object.keys(viewports).length) { say(`▦ ${node.id} ${locale}${tag}: no viewport of this step in this pass`); return [] }
823
- if (rotation) say(`▦ ${node.id} ${locale}${tag}: rotating sample, ${rotation.week ? `week ${rotation.week}` : `run ${rotation.run}`} — pages ${rotation.offset + 1}…${((rotation.offset + items.length - 1) % Math.max(1, total)) + 1} of ${total}, all covered in ${rotation.rounds} ${rotation.week ? 'weeks' : 'runs'}`)
845
+ if (rotation) say(`▦ ${node.id} ${locale}${tag}: rotating sample, ${rotation.week ? `week ${rotation.week}` : `run ${rotation.run}`} — pages ${rotation.offset + 1}…${((rotation.offset + (variants?.pages ?? items.length) - 1) % Math.max(1, total)) + 1} of ${total}, all covered in ${rotation.rounds} ${rotation.week ? 'weeks' : 'runs'}`)
824
846
  say(`▦ ${node.id} ${locale}${tag}: ${chosen.length} page(s)${rotation ? '' : truncated ? ` — limit ${Number(args.limit) || node.items.limit || DEFAULT_LIMIT} reached (${total}+ found; raise items.limit or pass --limit)` : ''}`)
825
847
  const fields = fieldsOf(node)
826
848
  const script = node.execution?.script ? await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href) : null
@@ -841,7 +863,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
841
863
  if (script) await script.default({ page, item, url: () => item.url, locale, env, run: run.meta, capture, acceptDialogs: () => acceptDialogs(page) })
842
864
  else await page.goto(item.url, { waitUntil: 'domcontentloaded', timeout: 30000 })
843
865
  if (!captured) await capture()
844
- ok++; streak.ok('stack'); checkedNow.push(item.path)
866
+ ok++; streak.ok('stack'); checkedNow.push(item.source_path || item.path)
845
867
  } catch (error) {
846
868
  failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
847
869
  if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
@@ -853,12 +875,16 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
853
875
  // the rotation of this locale goes into the run (the stack index shows it) and the pages checked into the state
854
876
  if (state && rotation) {
855
877
  const checked = await state.save(checkedNow, all)
878
+ if (counter) await counter.save([], [])
856
879
  await writeJson(join(run.dir, 'steps', node.id, `rotation.${locale}.json`), { ...rotation, ...rotationCoverage(all, checked) })
857
880
  }
858
881
  say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}${tag}: ${ok}/${chosen.length} page(s) captured`)
859
882
  return failed
860
883
  }
861
884
 
885
+ /** The origins of the environment for the scenario's locales: where the gate and the walk's cookies may go. */
886
+ const envOrigins = (env, scenario, locale) => [...new Set([...(scenario.locales || []), locale].map((l) => { try { return new URL(baseUrl(env, l)).origin } catch { return null } }).filter(Boolean))]
887
+
862
888
  /** Deep link of an app step: a URL scheme as written, a path under the environment's base URL (universal links). */
863
889
  function linkFor (env, node, locale) {
864
890
  const raw = typeof node.path === 'string' ? node.path : (node.path?.[locale] ?? node.path?.default)
@@ -1107,16 +1133,17 @@ async function exportFindings (args) {
1107
1133
  const { config, root, run, scenario } = await runContext(args)
1108
1134
  // read-only: the manifest and the stack indexes are assembled in memory from the captures, nothing is written
1109
1135
  const indexes = new Map()
1110
- const manifest = await buildManifest({ run, scenario, config, root, catalog: [], indexes })
1136
+ const traffic = await runTraffic({ run, scenario, config, root })
1137
+ const manifest = await buildManifest({ run, scenario, config, root, catalog: [], indexes, traffic })
1111
1138
  const step = typeof args.step === 'string' ? args.step : null
1112
1139
  if (step && !(manifest.nodes || []).some((n) => n.id === step)) throw new UsageError(`No step ${step} in this run. Steps: ${(manifest.nodes || []).filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}.`)
1113
- const rows = await runFindings(manifest, { stackIndex: (n) => indexes.get(n.id) || null, locale: typeof args.locale === 'string' ? args.locale : null, step })
1140
+ const rows = await runFindings(manifest, { stackIndex: (n) => indexes.get(n.id) || null, locale: typeof args.locale === 'string' ? args.locale : null, step, traffic })
1114
1141
  const format = typeof args.format === 'string' ? args.format : args.json ? 'json' : 'csv'
1115
1142
  const text = findingsText(rows, format, { run: manifest.id, scenario: manifest.scenario?.id || null })
1116
1143
  if (typeof args.out === 'string') {
1117
1144
  await writeFile(resolve(args.out), text)
1118
1145
  const pages = new Set(rows.map((r) => `${r.step}|${r.page || ''}|${r.locale}`)).size
1119
- say(args.json ? JSON.stringify({ file: resolve(args.out), count: rows.length, pages }) : `${rows.length} finding(s) on ${pages} page(s) → ${relative(process.cwd(), resolve(args.out)) || args.out}${rows.some((r) => r.edit_url) ? ' (with edit links)' : ''}`)
1146
+ say(args.json ? JSON.stringify({ file: resolve(args.out), count: rows.length, pages }) : `${rows.length} finding(s) on ${pages} page(s) → ${relative(process.cwd(), resolve(args.out)) || args.out}${rows.some((r) => r.edit_url) ? ' (with edit links)' : ''}${rows.some((r) => r.impact) ? ', sorted by impact (search and GA4 traffic of the page)' : ''}`)
1120
1147
  } else process.stdout.write(format === 'json' ? `${text}\n` : text)
1121
1148
  return { count: rows.length }
1122
1149
  }
@@ -1581,6 +1608,9 @@ const commands = {
1581
1608
  return
1582
1609
  }
1583
1610
  const pick = list(args.steps)
1611
+ // stacks with locale variants read their source list and each page's hreflang once per walk, for every locale
1612
+ const stackCache = new Map()
1613
+ const stackGate = await gateForRequests(env, { root }) // the gate for requests outside the browser: link checks, hreflang of stack pages
1584
1614
  const locales = list(args.locales) || run.meta.scope.locales
1585
1615
  const scope = run.meta.scope
1586
1616
  // browser engines and device profiles (batch E): one pass each, capturing only its own keys; a run without
@@ -1590,7 +1620,7 @@ const commands = {
1590
1620
  if (!passes.length) throw new UserError(`This run has no captures in ${only.join(', ')} (its browsers: ${enginesOf(scope).join(', ')}).`)
1591
1621
  const needChromium = passes.some((p) => p.engine === 'chromium')
1592
1622
  // --ephemeral: an own browser (own cookies and tabs) even when the shared one runs, so runs can walk in parallel
1593
- configureGate(await gateForRequests(env, { root }))
1623
+ configureGate(stackGate)
1594
1624
  const conn = args.ephemeral || !needChromium ? null : await connectBrowser({ root, config, env })
1595
1625
  const releaseLock = conn ? await acquireWalkLock({ root, config, env, run: run.meta.id }).catch(async (e) => { await conn.browser.close(); throw e }) : null
1596
1626
  const ephemeral = conn || !needChromium ? null : await launchEphemeral(config)
@@ -1686,7 +1716,7 @@ const commands = {
1686
1716
  const consoleFile = shared && !sess ? consoleLog(root, config.output, env.name) : null
1687
1717
  if (node.kind === 'stack') {
1688
1718
  try {
1689
- const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, args, keys: pass.keys, tag })
1719
+ const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, args, keys: pass.keys, tag, cache: stackCache, gate: stackGate })
1690
1720
  for (const b of bad) failed.push(`${node.id} (${locale}${tag}) ${b}`)
1691
1721
  } catch (error) {
1692
1722
  failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
package/src/lib/build.mjs CHANGED
@@ -9,15 +9,18 @@ import { layout } from './scenario.mjs'
9
9
  import { assembleNode, assembleStack, summarize, runPii } from './manifest-core.mjs'
10
10
  import { packageRoot } from './schema.mjs'
11
11
  import { variantOf } from './dimensions.mjs'
12
+ import { loadTraffic } from './traffic.mjs'
12
13
 
13
14
  export { targetOf, statusOf } from './manifest-core.mjs'
14
15
 
15
16
  /**
16
17
  * The run manifest from the captures. `indexes` (a Map) makes it read-only: stack indexes go into the map by step id
17
- * instead of steps/<step>/items.json and step specs are not written (kaloko export findings).
18
+ * instead of steps/<step>/items.json and step specs are not written (kaloko export findings). `traffic` is the run's
19
+ * traffic data (traffic.mjs loadTraffic); left out, it is loaded when the config or the scenario has a traffic block.
18
20
  */
19
- export async function buildManifest ({ run, scenario, config, root, catalog = [], indexes = null }) {
21
+ export async function buildManifest ({ run, scenario, config, root, catalog = [], indexes = null, traffic }) {
20
22
  const meta = run.meta
23
+ if (traffic === undefined) traffic = await runTraffic({ run, scenario, config, root })
21
24
  const entries = await readCaptures(run.dir)
22
25
  const captures = latestCaptures(entries)
23
26
  const attachments = entries.filter((e) => e.type === 'attachment')
@@ -29,7 +32,7 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
29
32
  const mailKeys = viewportNames.filter((k) => variantOf(meta.scope, k).browser === 'chromium')
30
33
  const nodes = []
31
34
  for (const node of scenario.nodes) {
32
- if (node.kind === 'stack') { const p = pos.get(node.id); nodes.push(await buildStack({ run, node, base: { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: p.col, lane: p.lane, ...(node.refs?.length ? { refs: node.refs } : {}) }, captures, attachments, indexes })); continue }
35
+ if (node.kind === 'stack') { const p = pos.get(node.id); nodes.push(await buildStack({ run, node, base: { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: p.col, lane: p.lane, ...(node.refs?.length ? { refs: node.refs } : {}) }, captures, attachments, indexes, traffic })); continue }
33
36
  const caps = []
34
37
  // pages of a stack live in its items index; normal steps take their own captures only
35
38
  for (const c of captures.filter((x) => x.node === node.id && !x.item)) {
@@ -102,6 +105,19 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
102
105
  }
103
106
  }
104
107
 
108
+ /**
109
+ * The traffic data of a run (#157) when the config or the scenario asks for it; a source that cannot be read is said
110
+ * on stderr (tokens redacted) and left out, so the run still builds.
111
+ */
112
+ export async function runTraffic ({ run, scenario, config, root }) {
113
+ // a scenario's traffic.file is relative to the scenario file; a run without one (older runs) uses the project root
114
+ const scenarioDir = run.meta.scenario_file ? resolve(root, run.meta.scenario_file, '..') : root
115
+ const started = run.meta.started_at ? new Date(run.meta.started_at) : new Date()
116
+ const traffic = await loadTraffic({ config, scenario, root, scenarioDir, runId: run.meta.id, now: Number.isNaN(started.getTime()) ? new Date() : started })
117
+ for (const p of traffic?.problems || []) process.stderr.write(`! ${p}\n`)
118
+ return traffic
119
+ }
120
+
105
121
  /** The tools of a run: kaloko and its version, Node.js, the OS, and every browser version a capture reports. */
106
122
  export function toolsOf (meta, nodes) {
107
123
  const browsers = new Set()
@@ -165,7 +181,7 @@ async function runMedia ({ run, entries }) {
165
181
  * A stack step: its pages go to steps/<node>/items.json (qawalk.stack.v1: fields, criteria, and per page the path,
166
182
  * status, index fields, capture files and compact results); the node keeps counts, a visual sample and the path.
167
183
  */
168
- async function buildStack ({ run, node, base, captures, attachments, indexes = null }) {
184
+ async function buildStack ({ run, node, base, captures, attachments, indexes = null, traffic = null }) {
169
185
  const paths = runPaths(run.dir)
170
186
  const list = []
171
187
  for (const c of captures.filter((x) => x.node === node.id && x.item)) {
@@ -178,7 +194,10 @@ async function buildStack ({ run, node, base, captures, attachments, indexes = n
178
194
  // rotating samples: steps/<node>/rotation.<locale>.json, written by the walk
179
195
  const rotation = {}
180
196
  for (const l of run.meta?.scope?.locales || []) { const r = await readJson(join(run.dir, 'steps', node.id, `rotation.${l}.json`), null); if (r) rotation[l] = r }
181
- const { node: out, index, file } = assembleStack({ node, base, environment: run.meta?.environment?.name || null, captures: list, attachments, rotation })
197
+ // locale variants (items.variants: hreflang): steps/<node>/variants.<locale>.json, the pages without a version there
198
+ const variants = {}
199
+ if (node.items?.variants) for (const l of run.meta?.scope?.locales || []) { const v = await readJson(join(run.dir, 'steps', node.id, `variants.${l}.json`), null); if (v) variants[l] = v }
200
+ const { node: out, index, file } = assembleStack({ node, base, environment: run.meta?.environment?.name || null, captures: list, attachments, rotation, variants, traffic })
182
201
  if (indexes) indexes.set(node.id, index); else await writeJson(join(run.dir, file), index)
183
202
  return out
184
203
  }
@@ -336,7 +336,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
336
336
  ...(unstable ? { unstable } : {}),
337
337
  ...(keyboard ? { keyboard } : {}),
338
338
  ...(details.length ? { details } : {}),
339
- ...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}), ...(item.edit_url ? { edit_url: item.edit_url } : {}), ...(item.content_ref ? { content_ref: item.content_ref } : {}), ...(item.last_checked !== undefined ? { last_checked: item.last_checked } : {}) } } : {}),
339
+ ...(item ? { item: { id: item.id, path: item.path, ...(item.source_path ? { source_path: item.source_path } : {}), ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}), ...(item.edit_url ? { edit_url: item.edit_url } : {}), ...(item.content_ref ? { content_ref: item.content_ref } : {}), ...(item.last_checked !== undefined ? { last_checked: item.last_checked } : {}) } } : {}),
340
340
  ...(fields ? { fields: { ...(await page.evaluate(readFields, fields).catch(() => ({}))), ...(fields.some((f) => (f.from || f.id) === 'http') ? Object.fromEntries(fields.filter((f) => (f.from || f.id) === 'http').map((f) => [f.id, extra.http_status ?? result.http_status ?? null])) : {}) } } : {}),
341
341
  files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
342
342
  }
@@ -1,11 +1,16 @@
1
1
  // `kaloko export findings` (#160): the fix list of a run. Every failing acceptance criterion and health check, per
2
2
  // page and locale, with the evidence and the way back to the source (edit_url, content_ref of stack pages), as CSV for
3
3
  // a content team or JSON for an agent that feeds a content API. Stack steps are read page by page from their items
4
- // index; the viewports a check fails in are one row.
4
+ // index; the viewports a check fails in are one row. With traffic data (#157) every row carries the page's numbers and
5
+ // an impact score, and the list is sorted by it.
5
6
  import { healthOfNode, levelOf, isHealthCriterion } from './health-core.mjs'
6
7
  import { toCsv } from './evidence-core.mjs'
8
+ import { VARIANT_CHECK } from './variants-core.mjs'
9
+ import { impactOf, trafficView } from './traffic-core.mjs'
7
10
 
8
11
  export const FINDING_COLUMNS = ['step', 'page', 'locale', 'viewports', 'url', 'kind', 'pack', 'check', 'level', 'text', 'evidence', 'edit_url', 'content_ref', 'recheck']
12
+ /** Columns the CSV adds when the run has traffic data. */
13
+ export const TRAFFIC_COLUMNS = ['clicks', 'impressions', 'ctr', 'position', 'sessions', 'conversions', 'impact']
9
14
 
10
15
  const recheckOf = (step, locale, page) => `kaloko walk --steps ${step}${page ? ` --items ${page}` : ''}${locale ? ` --locales ${locale}` : ''}`
11
16
 
@@ -24,10 +29,12 @@ function collect (rows, base, results) {
24
29
  /**
25
30
  * The fix list of a manifest: [{ step, page, locale, viewports, url, kind (acceptance|health), pack, check, level
26
31
  * (error|warning for health, fail for acceptance), text, evidence, edit_url, content_ref, recheck }]. `stackIndex(node)`
27
- * returns a stack step's items index (qawalk.stack.v1). `locale` and `step` filter.
32
+ * returns a stack step's items index (qawalk.stack.v1). `locale` and `step` filter. `traffic` (traffic.mjs
33
+ * loadTraffic) adds `traffic` and `impact` to every row and sorts the list by impact, highest first.
28
34
  */
29
- export async function runFindings (manifest, { stackIndex, locale = null, step = null } = {}) {
35
+ export async function runFindings (manifest, { stackIndex, locale = null, step = null, traffic = null } = {}) {
30
36
  const rows = []
37
+ const pageTraffic = new Map()
31
38
  for (const n of manifest?.nodes || []) {
32
39
  if (n.kind === 'decision' || (step && n.id !== step)) continue
33
40
  if (n.kind === 'stack' && n.stack?.items) {
@@ -45,8 +52,16 @@ export async function runFindings (manifest, { stackIndex, locale = null, step =
45
52
  results.push({ locale: l, viewport: vp, url: it.captures?.[l]?.[vp]?.url || it.url, check: id, kind: c.health ? 'health' : 'acceptance', pack: c.pack, level: c.health ? (c.level || levelOf(id)) : 'fail', text: c.text, evidence })
46
53
  }
47
54
  }
55
+ if (it.traffic) pageTraffic.set(`${n.id}|${it.id}`, it.traffic)
48
56
  collect(rows, { step: n.id, page: it.id, url: it.url, edit_url: it.edit_url || null, content_ref: it.content_ref || null }, results)
49
57
  }
58
+ // locale variants (#176): a page without its own version in a locale is a finding of that locale, at the source
59
+ // page, where the hreflang (or the translation) is missing
60
+ const byId = new Map((idx.items || []).map((it) => [it.id, it]))
61
+ for (const [l, v] of Object.entries(idx.variants?.locales || {})) {
62
+ if (v.source || (locale && l !== locale)) continue
63
+ for (const m of v.missing || []) { const it = byId.get(m.id); rows.push({ step: n.id, page: m.id, locale: l, viewports: [], url: m.url || it?.url || null, kind: 'health', pack: VARIANT_CHECK.pack, check: VARIANT_CHECK.id, level: 'warning', text: VARIANT_CHECK.text, evidence: m.reason || null, edit_url: it?.edit_url || null, content_ref: it?.content_ref || null, recheck: recheckOf(n.id, l, m.id) }) }
64
+ }
50
65
  continue
51
66
  }
52
67
  const checks = [...(n.criteria || []).filter((c) => !isHealthCriterion(c)).map((c) => ({ ...c, kind: 'acceptance' })), ...healthOfNode(n).list.map((c) => ({ ...c, kind: 'health' }))]
@@ -63,11 +78,21 @@ export async function runFindings (manifest, { stackIndex, locale = null, step =
63
78
  }
64
79
  collect(rows, { step: n.id, page: null, url: null, edit_url: null, content_ref: null }, results)
65
80
  }
66
- return rows
81
+ if (!traffic?.table && !pageTraffic.size) return rows
82
+ // the page's own numbers in this language (its address), else what the stack index joined for the page; GA4 paths
83
+ // without a host only when the run walks one host
84
+ const hosts = new Set(rows.map((r) => { try { return new URL(r.url).host } catch { return null } }).filter(Boolean))
85
+ for (const r of rows) {
86
+ const t = trafficView((r.url && traffic?.table?.of(r.url, { paths: hosts.size <= 1 })) || pageTraffic.get(`${r.step}|${r.page}`) || null)
87
+ r.traffic = t; r.impact = impactOf(t, r.level)
88
+ }
89
+ return rows.map((r, i) => [r, i]).sort((a, b) => b[0].impact - a[0].impact || a[1] - b[1]).map(([r]) => r)
67
90
  }
68
91
 
69
92
  /** The fix list as CSV (one row per finding, viewports space-separated) or JSON ({ run, scenario, findings }). */
70
93
  export function findingsText (rows, format, { run = null, scenario = null } = {}) {
71
94
  if (format === 'json') return JSON.stringify({ schema: 'qawalk.findings.v1', run, scenario, count: rows.length, findings: rows }, null, 2)
72
- return toCsv(rows.map((r) => ({ ...r, viewports: r.viewports.join(' ') })), FINDING_COLUMNS)
95
+ // traffic columns only when the run has traffic data, so the list of a run without it keeps its columns
96
+ const withTraffic = rows.some((r) => 'impact' in r)
97
+ return toCsv(rows.map((r) => ({ ...r, ...(r.traffic || {}), viewports: r.viewports.join(' ') })), withTraffic ? [...FINDING_COLUMNS, ...TRAFFIC_COLUMNS] : FINDING_COLUMNS)
73
98
  }