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 +1 -1
- package/schemas/config.v1.json +11 -0
- package/schemas/run.v1.json +12 -2
- package/schemas/scenario.v1.json +19 -0
- package/skills/kaloko/reference/integrations.md +24 -1
- package/skills/kaloko/reference/mcp.md +1 -0
- package/skills/kaloko/reference/stacks.md +87 -3
- package/src/cli.mjs +44 -13
- package/src/lib/build.mjs +24 -5
- package/src/lib/capture.mjs +1 -1
- package/src/lib/findings.mjs +30 -5
- package/src/lib/health-findings.mjs +116 -0
- package/src/lib/health-index.mjs +20 -5
- package/src/lib/integrations-cli.mjs +6 -4
- package/src/lib/manifest-core.mjs +83 -8
- package/src/lib/pack-help.mjs +2 -0
- package/src/lib/packs.mjs +1 -1
- package/src/lib/scenario.mjs +4 -0
- package/src/lib/seo-core.mjs +33 -5
- package/src/lib/stack-core.mjs +244 -0
- package/src/lib/stack.mjs +240 -170
- package/src/lib/traffic-core.mjs +289 -0
- package/src/lib/traffic.mjs +149 -0
- package/src/lib/variants-core.mjs +281 -0
- package/viewer/index.html +22 -11
package/package.json
CHANGED
package/schemas/config.v1.json
CHANGED
|
@@ -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,
|
package/schemas/run.v1.json
CHANGED
|
@@ -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
|
}
|
package/schemas/scenario.v1.json
CHANGED
|
@@ -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,
|
|
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.
|
|
217
|
-
|
|
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
|
|
813
|
-
const
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/capture.mjs
CHANGED
|
@@ -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
|
}
|
package/src/lib/findings.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|