kaloko 0.19.0 → 0.20.1

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.1",
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
@@ -76,6 +80,12 @@ Item sources:
76
80
  `sitemap-de.xml`; `en_US` finds `en-us`, `en` does not), only that
77
81
  locale's sitemaps are read; otherwise all of them. Use it when a site
78
82
  lists one sitemap per language, so the scenario does not hard-code paths.
83
+ - `kaloko walk` reads robots.txt and sitemaps with the environment's gate
84
+ (headers, HTTP Basic, client certificates) and the walk's cookies, on the
85
+ environment's own origins only, so a staging behind Basic auth or mTLS
86
+ needs no public sitemap. A sitemap on another host (a CDN, an index
87
+ pointing elsewhere) or a redirect to another host gets none of them.
88
+ Redirects are followed by hand (at most 5); 60 s and 60 MB per file.
79
89
  - `crawl: { from: /clanky, links: "a.card", next: "a.next", pages: 20 }`
80
90
  collects links from a listing and follows its pagination.
81
91
  - `script: qa/steps/items.mjs` uses the default export
@@ -155,10 +165,83 @@ HC1101–HC1107, `site.json` in the run, `site` in the manifest). Off the
155
165
  `production` environment HC1102 is a warning and HC1103 is skipped;
156
166
  `health.site.environments: [production]` runs them there only.
157
167
 
168
+ Traffic data (#157) is opt-in: `traffic:` in kaloko.config.yml or the
169
+ scenario, with `file` (a Search Console or GA4 CSV export, English or Czech
170
+ headers), `gsc: { property }` and `ga4: { property }` (APIs through a service
171
+ account key named by `credentials_env`, default
172
+ GOOGLE_APPLICATION_CREDENTIALS; nothing is requested without them; answers
173
+ cached per run in <output>/.cache/traffic). Each page in `items.json` then has
174
+ `traffic: { clicks, impressions, ctr, position, sessions, conversions }`, the
175
+ node `stack.traffic` counts the pages with data, `kaloko export findings`
176
+ adds the numbers and `impact` (weight 3 for errors and failed acceptance, 1
177
+ for warnings, × impressions + 10 × (clicks + sessions) + 100 × conversions)
178
+ and sorts by it, and the `seo` pack adds HC127 (CTR below half the site's
179
+ median at similar positions, pages with 100+ impressions). Sort a fix list
180
+ by impact before handing it over; never put the key in the scenario.
181
+
158
182
  A step `execution.script` still works. It is called with `item` in the
159
183
  context, opens `item.url` itself and may call `capture()`. Without a script,
160
184
  the walk opens each page and captures it.
161
185
 
186
+ ## One page in every language (`variants: hreflang`)
187
+
188
+ One item is one page; its locales are variants of it. Without more setup,
189
+ each locale reads its own list, so a Czech product and its Slovak
190
+ translation become two items with different ids, the locales sample
191
+ different pages and a missing translation is invisible. For a multilingual
192
+ stack, set:
193
+
194
+ ```yaml
195
+ items:
196
+ robots: true
197
+ match: '^/eshop/'
198
+ variants: hreflang
199
+ source_locale: cs # default: the scenario's first locale
200
+ limit: 100
201
+ rotate: week
202
+ ```
203
+
204
+ - The list, `match`/`exclude`, `limit` and `rotate` are taken in
205
+ `source_locale` (its base URL, `{locale}` = the source locale) for every
206
+ locale, so all locales walk the same window. Item ids come from the
207
+ source pages; `--items` takes an id or a source path.
208
+ - In another locale each chosen page's alternate for that locale is walked:
209
+ `<link rel="alternate" hreflang>` in the raw HTML head (no JavaScript),
210
+ else the `Link` header, else the sitemap's `xhtml:link`. Matching: the
211
+ same tag (`en-us` for `en_US`), else the bare language for a locale with a
212
+ region, else the first regional tag for a bare locale (`sk-sk` for `sk`).
213
+ `x-default` never stands for a locale. The page is read with the
214
+ environment's gate (headers, HTTP Basic, client certificates) and the
215
+ walk's cookies, on the environment's own origins only; 15 s and 1 MB at
216
+ most, stopping at `</head>`; a timeout or 5xx is tried once more. When no
217
+ alternate is on the host the page was read from (staging naming
218
+ production, also one domain per language), every alternate opens with its
219
+ path on the walked locale's origin.
220
+ - A page with no alternate for the locale, only `x-default`, an alternate
221
+ that points back to the page itself (another language), or a page that
222
+ cannot be read is not walked in that locale and is reported: the walk
223
+ says how many, `steps/<step>/variants.<locale>.json` keeps the list,
224
+ `items.json` marks the item (`missing: [sk]`) and lists every page under
225
+ `variants.locales`, the node gets `stack.variants`, HC1005 counts them
226
+ (advisory, also without the `seo` pack), and `kaloko export findings`
227
+ gives one row per page and locale at the source URL. Several source pages
228
+ leading to one alternate (a fallback to a listing or home) also count in
229
+ HC1005; they are walked. A translation that answers 4xx/5xx is walked
230
+ and also counts as missing (`error 404`). Reciprocity is HC125 (`seo` pack).
231
+ - The source page is read once per walk, whichever locale needs it. Each
232
+ capture of a variant keeps its own `path` in `items.json`; the item's
233
+ `path` and id are the source page's, and its `url`, title and fields too
234
+ when the run walks the source locale (else those of the first walked
235
+ locale).
236
+ - A script that lists pages per locale instead: give each page the same
237
+ `key` in every locale (for example the source path), so they are one item.
238
+ - Hosted runs (`kaloko schedule add`) group variants the same way: same
239
+ ids, window, `missing` and HC1005. They read each source page's head for
240
+ its hreflang on the environment's own addresses (with the schedule's
241
+ password or cookies), and each read counts as one hosted page of the
242
+ monthly budget. Budget a stack of N source pages in L locales as
243
+ N reads + N × L pages at most.
244
+
162
245
  ## Cost and size: choose deliberately
163
246
 
164
247
  - Deterministic criteria and packs run on every page. They are cheap.
@@ -213,8 +296,9 @@ the walk opens each page and captures it.
213
296
 
214
297
  Say how many pages were walked, how many fail and on which criteria (with
215
298
  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.
299
+ semantic criteria covered only the sample. With `variants: hreflang`, also
300
+ say per locale how many source pages have no version there (HC1005). Put the
301
+ full list in the CSV or the MCP result rather than in the issue.
218
302
 
219
303
  ## Design environments
220
304
 
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, stackReader, readsOutside } 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,32 @@ 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
+ // sitemaps, robots.txt and the hreflang of source pages are read with the environment's access (stackReader): its
822
+ // gate and the walk's cookies on its own origins only
823
+ const reader = readsOutside(node.items) ? await stackReader({ page, gate, origins: envOrigins(env, scenario, locale) }) : null
824
+ let resolved
825
+ 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() }
826
+ const { items, total, truncated, all, rotation, partial, variants, resolve_failed: unresolved, resolve_error: unresolvedWhy } = resolved
814
827
  if (unresolved) say(`! ${node.id} ${locale}${tag}: items.resolve failed for ${unresolved} page(s), walked without an edit link (${unresolvedWhy})`)
828
+ if (variants) {
829
+ // the build marks the pages without a version in this locale; a partial walk (--items) updates its pages only
830
+ const file = join(run.dir, 'steps', node.id, `variants.${locale}.json`)
831
+ const before = partial ? await readJson(file, null) : null
832
+ const asked = new Set([...items.map((it) => it.id), ...variants.missing.map((m) => m.id)])
833
+ const keep = (list) => (before?.[list] || []).filter((m) => !asked.has(m.id) && !(m.ids || []).some((id) => asked.has(id)))
834
+ await writeJson(file, before ? { ...variants, pages: before.pages, missing: [...keep('missing'), ...variants.missing], shared: [...keep('shared'), ...variants.shared] } : variants)
835
+ 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}`)
836
+ 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}`)
837
+ }
815
838
  // --items picked the pages from the whole list (resolveItems only); a partial walk leaves the rotation state alone
816
839
  const chosen = items
817
840
  const passKeys = keys || engineKeys(run.meta.scope, 'chromium')
@@ -820,7 +843,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
820
843
  const passBases = baseViewports({ ...run.meta.scope, viewports: Object.fromEntries(passKeys.map((k) => [k, run.meta.scope.viewports[k]])) })
821
844
  const viewports = scopeKeys(run.meta.scope, passKeys, vpNames.length ? vpNames : [passBases[0] || bases[0]])
822
845
  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'}`)
846
+ 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
847
  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
848
  const fields = fieldsOf(node)
826
849
  const script = node.execution?.script ? await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href) : null
@@ -841,7 +864,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
841
864
  if (script) await script.default({ page, item, url: () => item.url, locale, env, run: run.meta, capture, acceptDialogs: () => acceptDialogs(page) })
842
865
  else await page.goto(item.url, { waitUntil: 'domcontentloaded', timeout: 30000 })
843
866
  if (!captured) await capture()
844
- ok++; streak.ok('stack'); checkedNow.push(item.path)
867
+ ok++; streak.ok('stack'); checkedNow.push(item.source_path || item.path)
845
868
  } catch (error) {
846
869
  failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
847
870
  if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
@@ -853,12 +876,16 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
853
876
  // the rotation of this locale goes into the run (the stack index shows it) and the pages checked into the state
854
877
  if (state && rotation) {
855
878
  const checked = await state.save(checkedNow, all)
879
+ if (counter) await counter.save([], [])
856
880
  await writeJson(join(run.dir, 'steps', node.id, `rotation.${locale}.json`), { ...rotation, ...rotationCoverage(all, checked) })
857
881
  }
858
882
  say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}${tag}: ${ok}/${chosen.length} page(s) captured`)
859
883
  return failed
860
884
  }
861
885
 
886
+ /** The origins of the environment for the scenario's locales: where the gate and the walk's cookies may go. */
887
+ 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))]
888
+
862
889
  /** Deep link of an app step: a URL scheme as written, a path under the environment's base URL (universal links). */
863
890
  function linkFor (env, node, locale) {
864
891
  const raw = typeof node.path === 'string' ? node.path : (node.path?.[locale] ?? node.path?.default)
@@ -1107,16 +1134,17 @@ async function exportFindings (args) {
1107
1134
  const { config, root, run, scenario } = await runContext(args)
1108
1135
  // read-only: the manifest and the stack indexes are assembled in memory from the captures, nothing is written
1109
1136
  const indexes = new Map()
1110
- const manifest = await buildManifest({ run, scenario, config, root, catalog: [], indexes })
1137
+ const traffic = await runTraffic({ run, scenario, config, root })
1138
+ const manifest = await buildManifest({ run, scenario, config, root, catalog: [], indexes, traffic })
1111
1139
  const step = typeof args.step === 'string' ? args.step : null
1112
1140
  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 })
1141
+ const rows = await runFindings(manifest, { stackIndex: (n) => indexes.get(n.id) || null, locale: typeof args.locale === 'string' ? args.locale : null, step, traffic })
1114
1142
  const format = typeof args.format === 'string' ? args.format : args.json ? 'json' : 'csv'
1115
1143
  const text = findingsText(rows, format, { run: manifest.id, scenario: manifest.scenario?.id || null })
1116
1144
  if (typeof args.out === 'string') {
1117
1145
  await writeFile(resolve(args.out), text)
1118
1146
  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)' : ''}`)
1147
+ 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
1148
  } else process.stdout.write(format === 'json' ? `${text}\n` : text)
1121
1149
  return { count: rows.length }
1122
1150
  }
@@ -1581,6 +1609,9 @@ const commands = {
1581
1609
  return
1582
1610
  }
1583
1611
  const pick = list(args.steps)
1612
+ // stacks with locale variants read their source list and each page's hreflang once per walk, for every locale
1613
+ const stackCache = new Map()
1614
+ const stackGate = await gateForRequests(env, { root }) // the gate for requests outside the browser: link checks, stack sitemaps and hreflang
1584
1615
  const locales = list(args.locales) || run.meta.scope.locales
1585
1616
  const scope = run.meta.scope
1586
1617
  // browser engines and device profiles (batch E): one pass each, capturing only its own keys; a run without
@@ -1590,7 +1621,7 @@ const commands = {
1590
1621
  if (!passes.length) throw new UserError(`This run has no captures in ${only.join(', ')} (its browsers: ${enginesOf(scope).join(', ')}).`)
1591
1622
  const needChromium = passes.some((p) => p.engine === 'chromium')
1592
1623
  // --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 }))
1624
+ configureGate(stackGate)
1594
1625
  const conn = args.ephemeral || !needChromium ? null : await connectBrowser({ root, config, env })
1595
1626
  const releaseLock = conn ? await acquireWalkLock({ root, config, env, run: run.meta.id }).catch(async (e) => { await conn.browser.close(); throw e }) : null
1596
1627
  const ephemeral = conn || !needChromium ? null : await launchEphemeral(config)
@@ -1686,7 +1717,7 @@ const commands = {
1686
1717
  const consoleFile = shared && !sess ? consoleLog(root, config.output, env.name) : null
1687
1718
  if (node.kind === 'stack') {
1688
1719
  try {
1689
- const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, args, keys: pass.keys, tag })
1720
+ const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, args, keys: pass.keys, tag, cache: stackCache, gate: stackGate })
1690
1721
  for (const b of bad) failed.push(`${node.id} (${locale}${tag}) ${b}`)
1691
1722
  } catch (error) {
1692
1723
  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
  }