kaloko 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 +78 -3
- package/src/cli.mjs +43 -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 +5 -0
- package/src/lib/seo-core.mjs +33 -5
- package/src/lib/stack-core.mjs +244 -0
- package/src/lib/stack.mjs +201 -168
- package/src/lib/traffic-core.mjs +289 -0
- package/src/lib/traffic.mjs +149 -0
- package/src/lib/variants-core.mjs +198 -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
|
|
@@ -155,10 +159,80 @@ HC1101–HC1107, `site.json` in the run, `site` in the manifest). Off the
|
|
|
155
159
|
`production` environment HC1102 is a warning and HC1103 is skipped;
|
|
156
160
|
`health.site.environments: [production]` runs them there only.
|
|
157
161
|
|
|
162
|
+
Traffic data (#157) is opt-in: `traffic:` in kaloko.config.yml or the
|
|
163
|
+
scenario, with `file` (a Search Console or GA4 CSV export, English or Czech
|
|
164
|
+
headers), `gsc: { property }` and `ga4: { property }` (APIs through a service
|
|
165
|
+
account key named by `credentials_env`, default
|
|
166
|
+
GOOGLE_APPLICATION_CREDENTIALS; nothing is requested without them; answers
|
|
167
|
+
cached per run in <output>/.cache/traffic). Each page in `items.json` then has
|
|
168
|
+
`traffic: { clicks, impressions, ctr, position, sessions, conversions }`, the
|
|
169
|
+
node `stack.traffic` counts the pages with data, `kaloko export findings`
|
|
170
|
+
adds the numbers and `impact` (weight 3 for errors and failed acceptance, 1
|
|
171
|
+
for warnings, × impressions + 10 × (clicks + sessions) + 100 × conversions)
|
|
172
|
+
and sorts by it, and the `seo` pack adds HC127 (CTR below half the site's
|
|
173
|
+
median at similar positions, pages with 100+ impressions). Sort a fix list
|
|
174
|
+
by impact before handing it over; never put the key in the scenario.
|
|
175
|
+
|
|
158
176
|
A step `execution.script` still works. It is called with `item` in the
|
|
159
177
|
context, opens `item.url` itself and may call `capture()`. Without a script,
|
|
160
178
|
the walk opens each page and captures it.
|
|
161
179
|
|
|
180
|
+
## One page in every language (`variants: hreflang`)
|
|
181
|
+
|
|
182
|
+
One item is one page; its locales are variants of it. Without more setup,
|
|
183
|
+
each locale reads its own list, so a Czech product and its Slovak
|
|
184
|
+
translation become two items with different ids, the locales sample
|
|
185
|
+
different pages and a missing translation is invisible. For a multilingual
|
|
186
|
+
stack, set:
|
|
187
|
+
|
|
188
|
+
```yaml
|
|
189
|
+
items:
|
|
190
|
+
robots: true
|
|
191
|
+
match: '^/eshop/'
|
|
192
|
+
variants: hreflang
|
|
193
|
+
source_locale: cs # default: the scenario's first locale
|
|
194
|
+
limit: 100
|
|
195
|
+
rotate: week
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- The list, `match`/`exclude`, `limit` and `rotate` are taken in
|
|
199
|
+
`source_locale` (its base URL, `{locale}` = the source locale) for every
|
|
200
|
+
locale, so all locales walk the same window. Item ids come from the
|
|
201
|
+
source pages; `--items` takes an id or a source path.
|
|
202
|
+
- In another locale each chosen page's alternate for that locale is walked:
|
|
203
|
+
`<link rel="alternate" hreflang>` in the raw HTML head (no JavaScript),
|
|
204
|
+
else the `Link` header, else the sitemap's `xhtml:link`. Matching: the
|
|
205
|
+
same tag (`en-us` for `en_US`), else the bare language for a locale with a
|
|
206
|
+
region, else the first regional tag for a bare locale (`sk-sk` for `sk`).
|
|
207
|
+
`x-default` never stands for a locale. The page is read with the
|
|
208
|
+
environment's gate (headers, HTTP Basic, client certificates) and the
|
|
209
|
+
walk's cookies, on the environment's own origins only; 15 s and 1 MB at
|
|
210
|
+
most, stopping at `</head>`; a timeout or 5xx is tried once more. When no
|
|
211
|
+
alternate is on the host the page was read from (staging naming
|
|
212
|
+
production, also one domain per language), every alternate opens with its
|
|
213
|
+
path on the walked locale's origin.
|
|
214
|
+
- A page with no alternate for the locale, only `x-default`, an alternate
|
|
215
|
+
that points back to the page itself (another language), or a page that
|
|
216
|
+
cannot be read is not walked in that locale and is reported: the walk
|
|
217
|
+
says how many, `steps/<step>/variants.<locale>.json` keeps the list,
|
|
218
|
+
`items.json` marks the item (`missing: [sk]`) and lists every page under
|
|
219
|
+
`variants.locales`, the node gets `stack.variants`, HC1005 counts them
|
|
220
|
+
(advisory, also without the `seo` pack), and `kaloko export findings`
|
|
221
|
+
gives one row per page and locale at the source URL. Several source pages
|
|
222
|
+
leading to one alternate (a fallback to a listing or home) also count in
|
|
223
|
+
HC1005; they are walked. A translation that answers 4xx/5xx is walked
|
|
224
|
+
and also counts as missing (`error 404`). Reciprocity is HC125 (`seo` pack).
|
|
225
|
+
- The source page is read once per walk, whichever locale needs it. Each
|
|
226
|
+
capture of a variant keeps its own `path` in `items.json`; the item's
|
|
227
|
+
`path` and id are the source page's, and its `url`, title and fields too
|
|
228
|
+
when the run walks the source locale (else those of the first walked
|
|
229
|
+
locale).
|
|
230
|
+
- A script that lists pages per locale instead: give each page the same
|
|
231
|
+
`key` in every locale (for example the source path), so they are one item.
|
|
232
|
+
- Hosted runs (`kaloko schedule add`) do not group variants yet: there each
|
|
233
|
+
locale reads its own list and HC1005 is not reported (`kaloko validate`
|
|
234
|
+
warns). Use `kaloko walk` or the scheduled GitHub workflow.
|
|
235
|
+
|
|
162
236
|
## Cost and size: choose deliberately
|
|
163
237
|
|
|
164
238
|
- Deterministic criteria and packs run on every page. They are cheap.
|
|
@@ -213,8 +287,9 @@ the walk opens each page and captures it.
|
|
|
213
287
|
|
|
214
288
|
Say how many pages were walked, how many fail and on which criteria (with
|
|
215
289
|
two or three example paths), whether the limit cut the list, and that
|
|
216
|
-
semantic criteria covered only the sample.
|
|
217
|
-
|
|
290
|
+
semantic criteria covered only the sample. With `variants: hreflang`, also
|
|
291
|
+
say per locale how many source pages have no version there (HC1005). Put the
|
|
292
|
+
full list in the CSV or the MCP result rather than in the issue.
|
|
218
293
|
|
|
219
294
|
## Design environments
|
|
220
295
|
|
package/src/cli.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { newContext, accountFor, gateHeaders, gateOrigins, originMatcher, gateFo
|
|
|
18
18
|
import { configureGate, disposeShared } from './lib/linkcheck.mjs'
|
|
19
19
|
import { rememberEnvironment } from './lib/secrets.mjs'
|
|
20
20
|
import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
|
|
21
|
-
import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
|
|
21
|
+
import { buildManifest, writePreview, attentionLines, runTraffic } from './lib/build.mjs'
|
|
22
22
|
import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
|
|
23
23
|
import { signoffCmd, evidenceCmd } from './lib/signoff.mjs'
|
|
24
24
|
import { localized, readJson, writeJson, UserError, UsageError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG, EXIT, parseDuration } from './lib/util.mjs'
|
|
@@ -33,7 +33,7 @@ import { detectProject, initScenario, skeletonScenario, scenarioHeader, formatDe
|
|
|
33
33
|
import { checkSlug, checkName, setConfigKeys, createProject, renameProject, syncProject, shareProjectNote } from './lib/projects-cli.mjs'
|
|
34
34
|
import { recheckCaptures } from './lib/recheck.mjs'
|
|
35
35
|
import { runFindings, findingsText } from './lib/findings.mjs'
|
|
36
|
-
import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage } from './lib/stack.mjs'
|
|
36
|
+
import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage, sourceLocaleOf, variantReader } from './lib/stack.mjs'
|
|
37
37
|
import { walkSiteChecks, siteChecksOn } from './lib/site.mjs'
|
|
38
38
|
import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
|
|
39
39
|
import { expandPacks } from './lib/packs.mjs'
|
|
@@ -319,7 +319,9 @@ Results
|
|
|
319
319
|
export findings [<run>] [--format csv|json] [--locale l] [--step id] [--out file]
|
|
320
320
|
The fix list of a local run: every failing criterion and health check per page and
|
|
321
321
|
locale with its evidence, the page's edit_url and content_ref (from the item script
|
|
322
|
-
or items.resolve) and the walk that checks it again (kaloko walk --steps s --items p)
|
|
322
|
+
or items.resolve) and the walk that checks it again (kaloko walk --steps s --items p);
|
|
323
|
+
with traffic: in the config, also clicks, impressions, sessions and an impact
|
|
324
|
+
score, sorted by it
|
|
323
325
|
public [--run id] [--off] Make a shared run readable without sign-in (admin; testers may refresh an already public scenario)
|
|
324
326
|
stability [--last 4 | --runs a,b] [--same-commit] [--apply] [--json]
|
|
325
327
|
Regions that change on every run or move while the page is idle (clocks, ads,
|
|
@@ -807,11 +809,31 @@ async function exportScenario ({ args, config, root, client }) {
|
|
|
807
809
|
* Walks a stack step: resolves its pages, then opens and captures each one with the step's criteria and index
|
|
808
810
|
* fields (HTTP status of the main document included). A step script, when given, runs per page with `item`.
|
|
809
811
|
*/
|
|
810
|
-
async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, args, keys = null, tag = '' }) {
|
|
812
|
+
async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir, config, consoleFile, args, keys = null, tag = '', cache = null, gate = null }) {
|
|
811
813
|
// a rotating sample (items.rotate) remembers between runs how many runs walked it and when each page was checked
|
|
812
|
-
const
|
|
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
|
+
// locale variants read the source pages for their hreflang with the environment's access (variantReader)
|
|
822
|
+
const reader = node.items?.variants ? await variantReader({ page, gate, origins: envOrigins(env, scenario, locale) }) : null
|
|
823
|
+
let resolved
|
|
824
|
+
try { resolved = await resolveItems({ node, base: baseUrl(env, locale), sourceBase: baseUrl(env, sourceLocale), page, scenarioDir, env, locale, scenario, limit: Number(args.limit) || undefined, rotation: { run: round }, checked: state?.checked || null, only: list(args.items), cache, readPage: reader?.read || null }) } finally { await reader?.dispose() }
|
|
825
|
+
const { items, total, truncated, all, rotation, partial, variants, resolve_failed: unresolved, resolve_error: unresolvedWhy } = resolved
|
|
814
826
|
if (unresolved) say(`! ${node.id} ${locale}${tag}: items.resolve failed for ${unresolved} page(s), walked without an edit link (${unresolvedWhy})`)
|
|
827
|
+
if (variants) {
|
|
828
|
+
// the build marks the pages without a version in this locale; a partial walk (--items) updates its pages only
|
|
829
|
+
const file = join(run.dir, 'steps', node.id, `variants.${locale}.json`)
|
|
830
|
+
const before = partial ? await readJson(file, null) : null
|
|
831
|
+
const asked = new Set([...items.map((it) => it.id), ...variants.missing.map((m) => m.id)])
|
|
832
|
+
const keep = (list) => (before?.[list] || []).filter((m) => !asked.has(m.id) && !(m.ids || []).some((id) => asked.has(id)))
|
|
833
|
+
await writeJson(file, before ? { ...variants, pages: before.pages, missing: [...keep('missing'), ...variants.missing], shared: [...keep('shared'), ...variants.shared] } : variants)
|
|
834
|
+
if (variants.missing.length) say(`! ${node.id} ${locale}${tag}: ${variants.missing.length} of ${variants.pages} ${variants.source_locale} page(s) have no ${locale} version (hreflang), e.g. ${variants.missing[0].path}: ${variants.missing[0].reason}`)
|
|
835
|
+
if (variants.shared.length) say(`! ${node.id} ${locale}${tag}: ${variants.shared.length} ${locale} page(s) stand for several ${variants.source_locale} pages, e.g. ${variants.shared[0].url}`)
|
|
836
|
+
}
|
|
815
837
|
// --items picked the pages from the whole list (resolveItems only); a partial walk leaves the rotation state alone
|
|
816
838
|
const chosen = items
|
|
817
839
|
const passKeys = keys || engineKeys(run.meta.scope, 'chromium')
|
|
@@ -820,7 +842,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
|
|
|
820
842
|
const passBases = baseViewports({ ...run.meta.scope, viewports: Object.fromEntries(passKeys.map((k) => [k, run.meta.scope.viewports[k]])) })
|
|
821
843
|
const viewports = scopeKeys(run.meta.scope, passKeys, vpNames.length ? vpNames : [passBases[0] || bases[0]])
|
|
822
844
|
if (!Object.keys(viewports).length) { say(`▦ ${node.id} ${locale}${tag}: no viewport of this step in this pass`); return [] }
|
|
823
|
-
if (rotation) say(`▦ ${node.id} ${locale}${tag}: rotating sample, ${rotation.week ? `week ${rotation.week}` : `run ${rotation.run}`} — pages ${rotation.offset + 1}…${((rotation.offset + items.length - 1) % Math.max(1, total)) + 1} of ${total}, all covered in ${rotation.rounds} ${rotation.week ? 'weeks' : 'runs'}`)
|
|
845
|
+
if (rotation) say(`▦ ${node.id} ${locale}${tag}: rotating sample, ${rotation.week ? `week ${rotation.week}` : `run ${rotation.run}`} — pages ${rotation.offset + 1}…${((rotation.offset + (variants?.pages ?? items.length) - 1) % Math.max(1, total)) + 1} of ${total}, all covered in ${rotation.rounds} ${rotation.week ? 'weeks' : 'runs'}`)
|
|
824
846
|
say(`▦ ${node.id} ${locale}${tag}: ${chosen.length} page(s)${rotation ? '' : truncated ? ` — limit ${Number(args.limit) || node.items.limit || DEFAULT_LIMIT} reached (${total}+ found; raise items.limit or pass --limit)` : ''}`)
|
|
825
847
|
const fields = fieldsOf(node)
|
|
826
848
|
const script = node.execution?.script ? await import(pathToFileURL(resolve(scenarioDir, node.execution.script)).href) : null
|
|
@@ -841,7 +863,7 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
|
|
|
841
863
|
if (script) await script.default({ page, item, url: () => item.url, locale, env, run: run.meta, capture, acceptDialogs: () => acceptDialogs(page) })
|
|
842
864
|
else await page.goto(item.url, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
|
843
865
|
if (!captured) await capture()
|
|
844
|
-
ok++; streak.ok('stack'); checkedNow.push(item.path)
|
|
866
|
+
ok++; streak.ok('stack'); checkedNow.push(item.source_path || item.path)
|
|
845
867
|
} catch (error) {
|
|
846
868
|
failed.push(`${item.id} (${item.path}): ${error.message.split('\n')[0]}`)
|
|
847
869
|
if (streak.record('stack', error.message)) say(` stopping ${node.id} (${locale}${tag}): the last 5 pages failed with the same error`)
|
|
@@ -853,12 +875,16 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
|
|
|
853
875
|
// the rotation of this locale goes into the run (the stack index shows it) and the pages checked into the state
|
|
854
876
|
if (state && rotation) {
|
|
855
877
|
const checked = await state.save(checkedNow, all)
|
|
878
|
+
if (counter) await counter.save([], [])
|
|
856
879
|
await writeJson(join(run.dir, 'steps', node.id, `rotation.${locale}.json`), { ...rotation, ...rotationCoverage(all, checked) })
|
|
857
880
|
}
|
|
858
881
|
say(`${failed.length ? '✗' : '✓'} ${node.id} ${locale}${tag}: ${ok}/${chosen.length} page(s) captured`)
|
|
859
882
|
return failed
|
|
860
883
|
}
|
|
861
884
|
|
|
885
|
+
/** The origins of the environment for the scenario's locales: where the gate and the walk's cookies may go. */
|
|
886
|
+
const envOrigins = (env, scenario, locale) => [...new Set([...(scenario.locales || []), locale].map((l) => { try { return new URL(baseUrl(env, l)).origin } catch { return null } }).filter(Boolean))]
|
|
887
|
+
|
|
862
888
|
/** Deep link of an app step: a URL scheme as written, a path under the environment's base URL (universal links). */
|
|
863
889
|
function linkFor (env, node, locale) {
|
|
864
890
|
const raw = typeof node.path === 'string' ? node.path : (node.path?.[locale] ?? node.path?.default)
|
|
@@ -1107,16 +1133,17 @@ async function exportFindings (args) {
|
|
|
1107
1133
|
const { config, root, run, scenario } = await runContext(args)
|
|
1108
1134
|
// read-only: the manifest and the stack indexes are assembled in memory from the captures, nothing is written
|
|
1109
1135
|
const indexes = new Map()
|
|
1110
|
-
const
|
|
1136
|
+
const traffic = await runTraffic({ run, scenario, config, root })
|
|
1137
|
+
const manifest = await buildManifest({ run, scenario, config, root, catalog: [], indexes, traffic })
|
|
1111
1138
|
const step = typeof args.step === 'string' ? args.step : null
|
|
1112
1139
|
if (step && !(manifest.nodes || []).some((n) => n.id === step)) throw new UsageError(`No step ${step} in this run. Steps: ${(manifest.nodes || []).filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}.`)
|
|
1113
|
-
const rows = await runFindings(manifest, { stackIndex: (n) => indexes.get(n.id) || null, locale: typeof args.locale === 'string' ? args.locale : null, step })
|
|
1140
|
+
const rows = await runFindings(manifest, { stackIndex: (n) => indexes.get(n.id) || null, locale: typeof args.locale === 'string' ? args.locale : null, step, traffic })
|
|
1114
1141
|
const format = typeof args.format === 'string' ? args.format : args.json ? 'json' : 'csv'
|
|
1115
1142
|
const text = findingsText(rows, format, { run: manifest.id, scenario: manifest.scenario?.id || null })
|
|
1116
1143
|
if (typeof args.out === 'string') {
|
|
1117
1144
|
await writeFile(resolve(args.out), text)
|
|
1118
1145
|
const pages = new Set(rows.map((r) => `${r.step}|${r.page || ''}|${r.locale}`)).size
|
|
1119
|
-
say(args.json ? JSON.stringify({ file: resolve(args.out), count: rows.length, pages }) : `${rows.length} finding(s) on ${pages} page(s) → ${relative(process.cwd(), resolve(args.out)) || args.out}${rows.some((r) => r.edit_url) ? ' (with edit links)' : ''}`)
|
|
1146
|
+
say(args.json ? JSON.stringify({ file: resolve(args.out), count: rows.length, pages }) : `${rows.length} finding(s) on ${pages} page(s) → ${relative(process.cwd(), resolve(args.out)) || args.out}${rows.some((r) => r.edit_url) ? ' (with edit links)' : ''}${rows.some((r) => r.impact) ? ', sorted by impact (search and GA4 traffic of the page)' : ''}`)
|
|
1120
1147
|
} else process.stdout.write(format === 'json' ? `${text}\n` : text)
|
|
1121
1148
|
return { count: rows.length }
|
|
1122
1149
|
}
|
|
@@ -1581,6 +1608,9 @@ const commands = {
|
|
|
1581
1608
|
return
|
|
1582
1609
|
}
|
|
1583
1610
|
const pick = list(args.steps)
|
|
1611
|
+
// stacks with locale variants read their source list and each page's hreflang once per walk, for every locale
|
|
1612
|
+
const stackCache = new Map()
|
|
1613
|
+
const stackGate = await gateForRequests(env, { root }) // the gate for requests outside the browser: link checks, hreflang of stack pages
|
|
1584
1614
|
const locales = list(args.locales) || run.meta.scope.locales
|
|
1585
1615
|
const scope = run.meta.scope
|
|
1586
1616
|
// browser engines and device profiles (batch E): one pass each, capturing only its own keys; a run without
|
|
@@ -1590,7 +1620,7 @@ const commands = {
|
|
|
1590
1620
|
if (!passes.length) throw new UserError(`This run has no captures in ${only.join(', ')} (its browsers: ${enginesOf(scope).join(', ')}).`)
|
|
1591
1621
|
const needChromium = passes.some((p) => p.engine === 'chromium')
|
|
1592
1622
|
// --ephemeral: an own browser (own cookies and tabs) even when the shared one runs, so runs can walk in parallel
|
|
1593
|
-
configureGate(
|
|
1623
|
+
configureGate(stackGate)
|
|
1594
1624
|
const conn = args.ephemeral || !needChromium ? null : await connectBrowser({ root, config, env })
|
|
1595
1625
|
const releaseLock = conn ? await acquireWalkLock({ root, config, env, run: run.meta.id }).catch(async (e) => { await conn.browser.close(); throw e }) : null
|
|
1596
1626
|
const ephemeral = conn || !needChromium ? null : await launchEphemeral(config)
|
|
@@ -1686,7 +1716,7 @@ const commands = {
|
|
|
1686
1716
|
const consoleFile = shared && !sess ? consoleLog(root, config.output, env.name) : null
|
|
1687
1717
|
if (node.kind === 'stack') {
|
|
1688
1718
|
try {
|
|
1689
|
-
const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, args, keys: pass.keys, tag })
|
|
1719
|
+
const bad = await walkStack({ node, locale, page: sPage, env, run, scenario, scenarioDir, config, consoleFile, args, keys: pass.keys, tag, cache: stackCache, gate: stackGate })
|
|
1690
1720
|
for (const b of bad) failed.push(`${node.id} (${locale}${tag}) ${b}`)
|
|
1691
1721
|
} catch (error) {
|
|
1692
1722
|
failed.push(`${node.id} (${locale}${tag}): ${error.message.split('\n')[0]}`)
|
package/src/lib/build.mjs
CHANGED
|
@@ -9,15 +9,18 @@ import { layout } from './scenario.mjs'
|
|
|
9
9
|
import { assembleNode, assembleStack, summarize, runPii } from './manifest-core.mjs'
|
|
10
10
|
import { packageRoot } from './schema.mjs'
|
|
11
11
|
import { variantOf } from './dimensions.mjs'
|
|
12
|
+
import { loadTraffic } from './traffic.mjs'
|
|
12
13
|
|
|
13
14
|
export { targetOf, statusOf } from './manifest-core.mjs'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* The run manifest from the captures. `indexes` (a Map) makes it read-only: stack indexes go into the map by step id
|
|
17
|
-
* instead of steps/<step>/items.json and step specs are not written (kaloko export findings).
|
|
18
|
+
* instead of steps/<step>/items.json and step specs are not written (kaloko export findings). `traffic` is the run's
|
|
19
|
+
* traffic data (traffic.mjs loadTraffic); left out, it is loaded when the config or the scenario has a traffic block.
|
|
18
20
|
*/
|
|
19
|
-
export async function buildManifest ({ run, scenario, config, root, catalog = [], indexes = null }) {
|
|
21
|
+
export async function buildManifest ({ run, scenario, config, root, catalog = [], indexes = null, traffic }) {
|
|
20
22
|
const meta = run.meta
|
|
23
|
+
if (traffic === undefined) traffic = await runTraffic({ run, scenario, config, root })
|
|
21
24
|
const entries = await readCaptures(run.dir)
|
|
22
25
|
const captures = latestCaptures(entries)
|
|
23
26
|
const attachments = entries.filter((e) => e.type === 'attachment')
|
|
@@ -29,7 +32,7 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
|
|
|
29
32
|
const mailKeys = viewportNames.filter((k) => variantOf(meta.scope, k).browser === 'chromium')
|
|
30
33
|
const nodes = []
|
|
31
34
|
for (const node of scenario.nodes) {
|
|
32
|
-
if (node.kind === 'stack') { const p = pos.get(node.id); nodes.push(await buildStack({ run, node, base: { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: p.col, lane: p.lane, ...(node.refs?.length ? { refs: node.refs } : {}) }, captures, attachments, indexes })); continue }
|
|
35
|
+
if (node.kind === 'stack') { const p = pos.get(node.id); nodes.push(await buildStack({ run, node, base: { id: node.id, kind: node.kind, title: node.title, path: node.path ?? null, purpose: node.purpose ?? '', col: p.col, lane: p.lane, ...(node.refs?.length ? { refs: node.refs } : {}) }, captures, attachments, indexes, traffic })); continue }
|
|
33
36
|
const caps = []
|
|
34
37
|
// pages of a stack live in its items index; normal steps take their own captures only
|
|
35
38
|
for (const c of captures.filter((x) => x.node === node.id && !x.item)) {
|
|
@@ -102,6 +105,19 @@ export async function buildManifest ({ run, scenario, config, root, catalog = []
|
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The traffic data of a run (#157) when the config or the scenario asks for it; a source that cannot be read is said
|
|
110
|
+
* on stderr (tokens redacted) and left out, so the run still builds.
|
|
111
|
+
*/
|
|
112
|
+
export async function runTraffic ({ run, scenario, config, root }) {
|
|
113
|
+
// a scenario's traffic.file is relative to the scenario file; a run without one (older runs) uses the project root
|
|
114
|
+
const scenarioDir = run.meta.scenario_file ? resolve(root, run.meta.scenario_file, '..') : root
|
|
115
|
+
const started = run.meta.started_at ? new Date(run.meta.started_at) : new Date()
|
|
116
|
+
const traffic = await loadTraffic({ config, scenario, root, scenarioDir, runId: run.meta.id, now: Number.isNaN(started.getTime()) ? new Date() : started })
|
|
117
|
+
for (const p of traffic?.problems || []) process.stderr.write(`! ${p}\n`)
|
|
118
|
+
return traffic
|
|
119
|
+
}
|
|
120
|
+
|
|
105
121
|
/** The tools of a run: kaloko and its version, Node.js, the OS, and every browser version a capture reports. */
|
|
106
122
|
export function toolsOf (meta, nodes) {
|
|
107
123
|
const browsers = new Set()
|
|
@@ -165,7 +181,7 @@ async function runMedia ({ run, entries }) {
|
|
|
165
181
|
* A stack step: its pages go to steps/<node>/items.json (qawalk.stack.v1: fields, criteria, and per page the path,
|
|
166
182
|
* status, index fields, capture files and compact results); the node keeps counts, a visual sample and the path.
|
|
167
183
|
*/
|
|
168
|
-
async function buildStack ({ run, node, base, captures, attachments, indexes = null }) {
|
|
184
|
+
async function buildStack ({ run, node, base, captures, attachments, indexes = null, traffic = null }) {
|
|
169
185
|
const paths = runPaths(run.dir)
|
|
170
186
|
const list = []
|
|
171
187
|
for (const c of captures.filter((x) => x.node === node.id && x.item)) {
|
|
@@ -178,7 +194,10 @@ async function buildStack ({ run, node, base, captures, attachments, indexes = n
|
|
|
178
194
|
// rotating samples: steps/<node>/rotation.<locale>.json, written by the walk
|
|
179
195
|
const rotation = {}
|
|
180
196
|
for (const l of run.meta?.scope?.locales || []) { const r = await readJson(join(run.dir, 'steps', node.id, `rotation.${l}.json`), null); if (r) rotation[l] = r }
|
|
181
|
-
|
|
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
|
}
|