@geonosis/walk 1.0.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/LICENSE +202 -0
- package/README.md +125 -0
- package/bin/geonosis-walk.mjs +4 -0
- package/dist/chunk-GA522TKR.js +743 -0
- package/dist/index.d.ts +137 -0
- package/dist/index.js +26 -0
- package/dist/walk-cli.js +78 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @geonosis/walk
|
|
2
|
+
|
|
3
|
+
The customer walk as a gate. Nine computed-DOM probes over a list of URLs, one JSON report, and an
|
|
4
|
+
exit code that tells a run that could not happen from a page that was clean.
|
|
5
|
+
|
|
6
|
+
It exists because a screenshot lies. A cream chip on a cream page photographs exactly like a chip
|
|
7
|
+
that is there; thirteen copies of one placeholder SVG photograph as thirteen icons; a full product
|
|
8
|
+
page for a handle no backend has photographs as a product. The DOM does not lie, so every probe here
|
|
9
|
+
reads computed styles, box geometry, asset signatures, rendered text and HTTP statuses — never
|
|
10
|
+
pixels.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm i -D @geonosis/walk playwright
|
|
14
|
+
npx geonosis-walk https://example.test/ https://example.test/products/x --report walk.json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
0 the walk ran and found nothing
|
|
19
|
+
1 the walk ran and found something
|
|
20
|
+
2 the walk did not run — no browser, a URL that did not answer, a probe with no list to look for
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Two is why there are three. A tool that answered "no findings" for a browser it could not launch
|
|
24
|
+
would pass hardest exactly when it was broken.
|
|
25
|
+
|
|
26
|
+
## The nine probes
|
|
27
|
+
|
|
28
|
+
| id | What it reads | Needs |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `light-on-light` | An element with an opaque background of its own, no background image, no border, no shadow, no outline, whose box does not simply repaint the one behind it — and whose WCAG contrast against the painted ancestor background is under the floor. | nothing (`minContrast` defaults to 1.05) |
|
|
31
|
+
| `placeholder-asset` | Asset signatures: an image's resolved `src`, an SVG's own shape geometry. Fires on a signature used in more slots than allowed, and on any signature or `alt`/`aria-label`/`title` matching the placeholder list. | nothing (`maxRepeats` defaults to 2) |
|
|
32
|
+
| `link-integrity` | `href` absent, empty, `#` or `javascript:` — and, for same-origin links, a HEAD (GET on 405/501) that answers 400 or worse. | nothing |
|
|
33
|
+
| `fabricated-claim` | The page's rendered text, against a list of claim patterns. Not the markup: a claim in a comment is a claim nobody was shown. | **`claims`** |
|
|
34
|
+
| `stub-only-entity` | An element naming an entity, offering a way to buy it, whose id is in no authoritative set. | **`known`** |
|
|
35
|
+
| `fake-session` | Anything that means "signed in", on a page loaded with no cookies and no storage. | **`selectors` or `text`** |
|
|
36
|
+
| `ops-leakage` | The rendered text of a scope, against patterns that name internal choices. Scope is what separates a leak from a footnote. | **`patterns`** |
|
|
37
|
+
| `buy-box-above-fold` | `getBoundingClientRect()` at the walk's viewport: the buy box below the element it must precede, or below the fold. | **`selector`** |
|
|
38
|
+
| `error-page-status` | A page whose rendered text matches an error-surface pattern, served with a healthy status. Every curl-style health check, uptime monitor and load balancer reads such a page as fine. | **`patterns`** |
|
|
39
|
+
|
|
40
|
+
The six in bold **refuse the run** when the option is missing, naming it (D-031). None of the six
|
|
41
|
+
can have a default that is true of a repo which has not configured it — what a company can stand
|
|
42
|
+
behind, which entities exist, what its signed-in header looks like, which words mean "operations",
|
|
43
|
+
where its buy box is, what its error surface says — and a probe that ran with an empty list would
|
|
44
|
+
report a clean page for every repo that never set one up.
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
Everything lives in the `walk` block of `geonosis.json`; the command line wins where they overlap.
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"walk": {
|
|
53
|
+
"urls": ["http://localhost:3000/", "http://localhost:3000/products/kraft-mailer-box"],
|
|
54
|
+
"viewport": { "width": 1440, "height": 900 },
|
|
55
|
+
"report": ".geonosis/walk-report.json",
|
|
56
|
+
"probes": ["light-on-light", "placeholder-asset", "link-integrity", "fabricated-claim"],
|
|
57
|
+
"options": {
|
|
58
|
+
"fabricated-claim": { "claims": ["\\d\\.\\d/5", "\\d[\\d,]*\\+ brands", "\\d+M\\+"] },
|
|
59
|
+
"ops-leakage": { "scope": "[data-testid=delivery]", "patterns": ["Consolidated Sea"] },
|
|
60
|
+
"buy-box-above-fold": { "selector": "[data-testid=buy-box]", "above": "[data-testid=preview]" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
geonosis-walk [url…] [--report <path>] [--config <path>] [--viewport WIDTHxHEIGHT] [--probe <id>]…
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
With no `probes` the walk runs the three that need no list. A mobile pass is the same command with
|
|
71
|
+
`--viewport 390x844`.
|
|
72
|
+
|
|
73
|
+
## The report
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"startedAt": "…", "finishedAt": "…",
|
|
78
|
+
"viewport": { "width": 390, "height": 844 },
|
|
79
|
+
"probes": ["buy-box-above-fold"],
|
|
80
|
+
"counts": { "buy-box-above-fold": 1 },
|
|
81
|
+
"pages": [{ "url": "…", "findings": [
|
|
82
|
+
{ "class": "buy-box-above-fold", "severity": "major", "url": "…",
|
|
83
|
+
"selector": "[data-testid=\"buy-box\"]",
|
|
84
|
+
"evidence": "the buy box starts at 900px, below the fold at 844px" }
|
|
85
|
+
]}]
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`counts` is keyed by the classes that RAN, so a zero means "probed for, found none" and an absent
|
|
90
|
+
class was never looked for. `@geonosis/ratchet`'s `walkFindings` counter reads this file and refuses
|
|
91
|
+
when it is missing or unparsable, which is the same rule as the exit code: a gate that cannot
|
|
92
|
+
measure has not passed.
|
|
93
|
+
|
|
94
|
+
## Driving your own page
|
|
95
|
+
|
|
96
|
+
`walk()` gives every URL a fresh, storage-free context — that is what makes `fake-session` mean
|
|
97
|
+
anything. A surface behind a flow (a checkout with a cart in it) is not that shape, so drive your
|
|
98
|
+
own page and call the probe:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { probeById } from '@geonosis/walk'
|
|
102
|
+
|
|
103
|
+
const response = await page.goto(url)
|
|
104
|
+
|
|
105
|
+
const findings = await probeById('ops-leakage').run(page, {
|
|
106
|
+
memo: new Map(),
|
|
107
|
+
options: { patterns: ['Maersk Ocean'], scope: '[data-testid=delivery]' },
|
|
108
|
+
status: response?.status() ?? 0,
|
|
109
|
+
url: page.url(),
|
|
110
|
+
viewport: { width: 1440, height: 900 },
|
|
111
|
+
})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`status` is the page's own response status, not a second request: `error-page-status` asks whether
|
|
115
|
+
the status agrees with the body, and asking the server again asks a different question.
|
|
116
|
+
|
|
117
|
+
## What this is not
|
|
118
|
+
|
|
119
|
+
A green report is not a clean surface. Eight of the nine came from an audit that held 36 defects;
|
|
120
|
+
four are reproduced by a probe alone, fourteen partially, and eighteen — five product tables that
|
|
121
|
+
disagree, a cart that re-prices what the product page quoted, copy promising a control nobody built,
|
|
122
|
+
four levels of taxonomy for fourteen products — are invisible to any browser probe there could be.
|
|
123
|
+
Those stay the walker's judgement. The ninth came from a production incident instead, which is how
|
|
124
|
+
the list is meant to grow. The reasoning, defect by defect, is in
|
|
125
|
+
[`docs/walk-review-inventory-2026-08-30.md`](../../docs/walk-review-inventory-2026-08-30.md).
|
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var WalkError = class extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "WalkError";
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// src/browser.ts
|
|
10
|
+
var inPage = (job) => {
|
|
11
|
+
const dom = {
|
|
12
|
+
/** The deepest element that still contains the match — the one a reader would point at. */
|
|
13
|
+
deepest: (root, needle) => {
|
|
14
|
+
let node = root;
|
|
15
|
+
let moved = true;
|
|
16
|
+
while (moved) {
|
|
17
|
+
moved = false;
|
|
18
|
+
for (const child of node.children) {
|
|
19
|
+
if (dom.textOf(child).includes(needle)) {
|
|
20
|
+
node = child;
|
|
21
|
+
moved = true;
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return node;
|
|
27
|
+
},
|
|
28
|
+
rectOf: (element) => {
|
|
29
|
+
const box = element.getBoundingClientRect();
|
|
30
|
+
return { height: box.height, left: box.left, top: box.top, width: box.width };
|
|
31
|
+
},
|
|
32
|
+
selectorOf: (element) => {
|
|
33
|
+
if (element.id !== "") return `#${element.id}`;
|
|
34
|
+
const testId = element.getAttribute("data-testid");
|
|
35
|
+
if (testId !== null && testId !== "") return `[data-testid="${testId}"]`;
|
|
36
|
+
const parts = [];
|
|
37
|
+
let node = element;
|
|
38
|
+
while (node !== null && parts.length < 5 && node.tagName !== "BODY") {
|
|
39
|
+
const parent = node.parentElement;
|
|
40
|
+
if (parent === null) break;
|
|
41
|
+
const current = node;
|
|
42
|
+
const siblings = [...parent.children].filter((one) => one.tagName === current.tagName);
|
|
43
|
+
const nth = siblings.indexOf(current) + 1;
|
|
44
|
+
const tag = current.tagName.toLowerCase();
|
|
45
|
+
parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${nth})` : tag);
|
|
46
|
+
node = parent;
|
|
47
|
+
}
|
|
48
|
+
return parts.join(" > ");
|
|
49
|
+
},
|
|
50
|
+
shown: (element) => {
|
|
51
|
+
const box = element.getBoundingClientRect();
|
|
52
|
+
return box.width > 0 && box.height > 0 && getComputedStyle(element).visibility !== "hidden";
|
|
53
|
+
},
|
|
54
|
+
textOf: (element) => element.innerText ?? element.textContent ?? ""
|
|
55
|
+
};
|
|
56
|
+
if (job.kind === "contrast") {
|
|
57
|
+
const rgb = {
|
|
58
|
+
bordered: (style) => style.borderStyle !== "none" && [
|
|
59
|
+
style.borderTopWidth,
|
|
60
|
+
style.borderRightWidth,
|
|
61
|
+
style.borderBottomWidth,
|
|
62
|
+
style.borderLeftWidth
|
|
63
|
+
].some((width) => Number.parseFloat(width) > 0),
|
|
64
|
+
channel: (value) => value / 255 <= 0.03928 ? value / 255 / 12.92 : ((value / 255 + 0.055) / 1.055) ** 2.4,
|
|
65
|
+
luminance: ({ b, g, r }) => 0.2126 * rgb.channel(r) + 0.7152 * rgb.channel(g) + 0.0722 * rgb.channel(b),
|
|
66
|
+
parse: (value) => {
|
|
67
|
+
const inside = /rgba?\(([^)]+)\)/.exec(value)?.[1];
|
|
68
|
+
if (inside === void 0) return null;
|
|
69
|
+
const numbers = inside.split(/[,\s/]+/).filter((one) => one !== "").map(Number);
|
|
70
|
+
const [r, g, b, a] = numbers;
|
|
71
|
+
if (r === void 0 || g === void 0 || b === void 0) return null;
|
|
72
|
+
return { a: a ?? 1, b, g, r };
|
|
73
|
+
},
|
|
74
|
+
ratio: (one, other) => {
|
|
75
|
+
const first = rgb.luminance(one);
|
|
76
|
+
const second = rgb.luminance(other);
|
|
77
|
+
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const hits = [];
|
|
81
|
+
const white = { a: 1, b: 255, g: 255, r: 255 };
|
|
82
|
+
for (const element of document.querySelectorAll(job.selector)) {
|
|
83
|
+
const tag = element.tagName;
|
|
84
|
+
if (tag === "HTML" || tag === "BODY") continue;
|
|
85
|
+
if (job.ignore.some((one) => element.matches(one))) continue;
|
|
86
|
+
const style = getComputedStyle(element);
|
|
87
|
+
const own = rgb.parse(style.backgroundColor);
|
|
88
|
+
if (own === null || own.a !== 1) continue;
|
|
89
|
+
if (style.backgroundImage !== "none" || style.boxShadow !== "none") continue;
|
|
90
|
+
if (style.outlineStyle !== "none" || rgb.bordered(style)) continue;
|
|
91
|
+
if (!dom.shown(element)) continue;
|
|
92
|
+
const box = element.getBoundingClientRect();
|
|
93
|
+
let node = element.parentElement;
|
|
94
|
+
let behind = null;
|
|
95
|
+
let fullBleed = false;
|
|
96
|
+
let behindSelector = "the canvas";
|
|
97
|
+
let behindValue = "rgb(255, 255, 255)";
|
|
98
|
+
while (node !== null) {
|
|
99
|
+
const parentStyle = getComputedStyle(node);
|
|
100
|
+
const colour = rgb.parse(parentStyle.backgroundColor);
|
|
101
|
+
if (colour !== null && colour.a === 1) {
|
|
102
|
+
const parentBox = node.getBoundingClientRect();
|
|
103
|
+
fullBleed = parentBox.width <= box.width && parentBox.height <= box.height;
|
|
104
|
+
behind = colour;
|
|
105
|
+
behindSelector = node.tagName === "BODY" ? "body" : dom.selectorOf(node);
|
|
106
|
+
behindValue = parentStyle.backgroundColor;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
node = node.parentElement;
|
|
110
|
+
}
|
|
111
|
+
if (fullBleed) continue;
|
|
112
|
+
const contrast = rgb.ratio(own, behind ?? white);
|
|
113
|
+
if (contrast >= job.minContrast) continue;
|
|
114
|
+
hits.push({
|
|
115
|
+
background: style.backgroundColor,
|
|
116
|
+
container: behindValue,
|
|
117
|
+
containerSelector: behindSelector,
|
|
118
|
+
contrast: Math.round(contrast * 100) / 100,
|
|
119
|
+
selector: dom.selectorOf(element)
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return hits;
|
|
123
|
+
}
|
|
124
|
+
if (job.kind === "assets") {
|
|
125
|
+
const geometry = ["d", "points", "cx", "cy", "r", "x", "y", "x1", "y1", "x2", "y2", "href"];
|
|
126
|
+
const scope = document.querySelector(job.scope) ?? document.body;
|
|
127
|
+
const hits = [];
|
|
128
|
+
for (const element of scope.querySelectorAll(job.assetSelector)) {
|
|
129
|
+
if (!dom.shown(element)) continue;
|
|
130
|
+
const tag = element.tagName.toLowerCase();
|
|
131
|
+
const signature = tag === "img" ? `src:${element.src ?? element.getAttribute("src") ?? ""}` : `svg:${[
|
|
132
|
+
...element.querySelectorAll("path,circle,rect,ellipse,line,polygon,polyline,use")
|
|
133
|
+
].map(
|
|
134
|
+
(shape) => `${shape.tagName.toLowerCase()}(${geometry.map((name) => shape.getAttribute(name) ?? "").join(",")})`
|
|
135
|
+
).join("|")}`;
|
|
136
|
+
hits.push({
|
|
137
|
+
label: [
|
|
138
|
+
element.getAttribute("alt") ?? "",
|
|
139
|
+
element.getAttribute("aria-label") ?? "",
|
|
140
|
+
element.getAttribute("title") ?? ""
|
|
141
|
+
].filter((one) => one !== "").join(" "),
|
|
142
|
+
selector: dom.selectorOf(element),
|
|
143
|
+
signature
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return hits;
|
|
147
|
+
}
|
|
148
|
+
if (job.kind === "links") {
|
|
149
|
+
return [...document.querySelectorAll(job.selector)].map((element) => ({
|
|
150
|
+
href: element.getAttribute("href"),
|
|
151
|
+
resolved: element.href ?? "",
|
|
152
|
+
selector: dom.selectorOf(element),
|
|
153
|
+
text: dom.textOf(element).trim().slice(0, 80)
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
if (job.kind === "text") {
|
|
157
|
+
const roots = job.scope === "" ? [document.body] : [...document.querySelectorAll(job.scope)];
|
|
158
|
+
const hits = [];
|
|
159
|
+
for (const root of roots) {
|
|
160
|
+
const text = dom.textOf(root);
|
|
161
|
+
for (const pattern of job.patterns) {
|
|
162
|
+
const matcher = new RegExp(pattern, `${job.flags}g`);
|
|
163
|
+
let found = matcher.exec(text);
|
|
164
|
+
while (found !== null) {
|
|
165
|
+
const matched = found[0];
|
|
166
|
+
if (matched === "") break;
|
|
167
|
+
hits.push({
|
|
168
|
+
pattern,
|
|
169
|
+
selector: dom.selectorOf(dom.deepest(root, matched)),
|
|
170
|
+
text: matched
|
|
171
|
+
});
|
|
172
|
+
found = matcher.exec(text);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return hits;
|
|
177
|
+
}
|
|
178
|
+
if (job.kind === "entities") {
|
|
179
|
+
return [...document.querySelectorAll(job.selector)].map((element) => ({
|
|
180
|
+
affordance: job.affordance === "" || element.querySelector(job.affordance) !== null,
|
|
181
|
+
id: element.getAttribute(job.attribute) ?? "",
|
|
182
|
+
selector: dom.selectorOf(element)
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
if (job.kind === "present") {
|
|
186
|
+
return job.selectors.map((selector) => ({
|
|
187
|
+
present: document.querySelector(selector) !== null,
|
|
188
|
+
selector
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
rects: job.selectors.map((selector) => {
|
|
193
|
+
const element = document.querySelector(selector);
|
|
194
|
+
return { rect: element === null ? null : dom.rectOf(element), selector };
|
|
195
|
+
}),
|
|
196
|
+
viewportHeight: innerHeight
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
var collect = async (page, job) => await page.evaluate(inPage, job);
|
|
200
|
+
|
|
201
|
+
// src/options.ts
|
|
202
|
+
var stringOption = (probe, options, name, fallback) => {
|
|
203
|
+
const value = options[name];
|
|
204
|
+
if (typeof value === "string" && value !== "") return value;
|
|
205
|
+
if (fallback !== void 0) return fallback;
|
|
206
|
+
throw new WalkError(`${probe}: needs a "${name}" in the walk options`);
|
|
207
|
+
};
|
|
208
|
+
var stringsOption = (options, name, fallback) => {
|
|
209
|
+
const value = options[name];
|
|
210
|
+
if (!Array.isArray(value)) return fallback;
|
|
211
|
+
return value.filter((one) => typeof one === "string");
|
|
212
|
+
};
|
|
213
|
+
var numberOption = (options, name, fallback) => {
|
|
214
|
+
const value = options[name];
|
|
215
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
216
|
+
};
|
|
217
|
+
var flagOption = (options, name, fallback) => typeof options[name] === "boolean" ? options[name] : fallback;
|
|
218
|
+
var requireStrings = (probe, options, name) => {
|
|
219
|
+
const value = stringsOption(options, name, []);
|
|
220
|
+
if (value.length === 0) {
|
|
221
|
+
throw new WalkError(`${probe}: needs a non-empty "${name}" in the walk options`);
|
|
222
|
+
}
|
|
223
|
+
return value;
|
|
224
|
+
};
|
|
225
|
+
var ALLOWED_FLAGS = /* @__PURE__ */ new Set(["i", "m", "s", "u"]);
|
|
226
|
+
var patternFlags = (probe, options) => {
|
|
227
|
+
const flags = stringOption(probe, options, "flags", "");
|
|
228
|
+
for (const flag of flags) {
|
|
229
|
+
if (!ALLOWED_FLAGS.has(flag)) {
|
|
230
|
+
throw new WalkError(`${probe}: "${flags}" is not a set of regex flags (i, m, s, u)`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return flags;
|
|
234
|
+
};
|
|
235
|
+
var assertPatterns = (probe, patterns, flags) => {
|
|
236
|
+
for (const pattern of patterns) {
|
|
237
|
+
try {
|
|
238
|
+
RegExp(pattern, `${flags}g`);
|
|
239
|
+
} catch {
|
|
240
|
+
throw new WalkError(`${probe}: "${pattern}" is not a regular expression`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// src/probes/buy-box-above-fold.ts
|
|
246
|
+
var buyBoxAboveFold = {
|
|
247
|
+
class: "buy-box-above-fold",
|
|
248
|
+
id: "buy-box-above-fold",
|
|
249
|
+
needs: (options) => {
|
|
250
|
+
stringOption("buy-box-above-fold", options, "selector");
|
|
251
|
+
},
|
|
252
|
+
run: async (page, { options, url }) => {
|
|
253
|
+
const selector = stringOption("buy-box-above-fold", options, "selector");
|
|
254
|
+
const above = stringOption("buy-box-above-fold", options, "above", "");
|
|
255
|
+
const { rects, viewportHeight } = await collect(page, {
|
|
256
|
+
kind: "geometry",
|
|
257
|
+
selectors: above === "" ? [selector] : [selector, above]
|
|
258
|
+
});
|
|
259
|
+
const buyBox = rects.find((one) => one.selector === selector)?.rect ?? null;
|
|
260
|
+
if (buyBox === null) {
|
|
261
|
+
return flagOption(options, "required", false) ? [
|
|
262
|
+
{
|
|
263
|
+
class: "buy-box-above-fold",
|
|
264
|
+
evidence: `no element matches "${selector}" on a page the walk requires one on`,
|
|
265
|
+
severity: "major",
|
|
266
|
+
url
|
|
267
|
+
}
|
|
268
|
+
] : [];
|
|
269
|
+
}
|
|
270
|
+
const fold = numberOption(options, "maxTop", viewportHeight);
|
|
271
|
+
const other = above === "" ? null : rects.find((one) => one.selector === above)?.rect ?? null;
|
|
272
|
+
const found = [];
|
|
273
|
+
if (other !== null && buyBox.top > other.top) {
|
|
274
|
+
found.push({
|
|
275
|
+
class: "buy-box-above-fold",
|
|
276
|
+
evidence: `the buy box starts at ${Math.round(buyBox.top)}px, below "${above}" at ${Math.round(other.top)}px`,
|
|
277
|
+
selector,
|
|
278
|
+
severity: "major",
|
|
279
|
+
url
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (buyBox.top >= fold) {
|
|
283
|
+
found.push({
|
|
284
|
+
class: "buy-box-above-fold",
|
|
285
|
+
evidence: `the buy box starts at ${Math.round(buyBox.top)}px, below the fold at ${Math.round(fold)}px`,
|
|
286
|
+
selector,
|
|
287
|
+
severity: "major",
|
|
288
|
+
url
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return found;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// src/probes/error-page-status.ts
|
|
296
|
+
var errorPageStatus = {
|
|
297
|
+
class: "error-page-status",
|
|
298
|
+
id: "error-page-status",
|
|
299
|
+
needs: (options) => {
|
|
300
|
+
const patterns = requireStrings("error-page-status", options, "patterns");
|
|
301
|
+
assertPatterns("error-page-status", patterns, patternFlags("error-page-status", options));
|
|
302
|
+
},
|
|
303
|
+
run: async (page, { options, status, url }) => {
|
|
304
|
+
if (status >= numberOption(options, "honestFrom", 400)) return [];
|
|
305
|
+
const hits = await collect(page, {
|
|
306
|
+
flags: patternFlags("error-page-status", options),
|
|
307
|
+
kind: "text",
|
|
308
|
+
patterns: requireStrings("error-page-status", options, "patterns"),
|
|
309
|
+
scope: stringOption("error-page-status", options, "scope", "")
|
|
310
|
+
});
|
|
311
|
+
return hits.map((hit) => ({
|
|
312
|
+
class: "error-page-status",
|
|
313
|
+
evidence: `"${hit.text}" is on a page that answered ${status} \u2014 every health check reads that as healthy`,
|
|
314
|
+
selector: hit.selector,
|
|
315
|
+
severity: "blocking",
|
|
316
|
+
url
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// src/probes/fabricated-claim.ts
|
|
322
|
+
var fabricatedClaim = {
|
|
323
|
+
class: "fabricated-claim",
|
|
324
|
+
id: "fabricated-claim",
|
|
325
|
+
needs: (options) => {
|
|
326
|
+
const claims = requireStrings("fabricated-claim", options, "claims");
|
|
327
|
+
assertPatterns("fabricated-claim", claims, patternFlags("fabricated-claim", options));
|
|
328
|
+
},
|
|
329
|
+
run: async (page, { options, url }) => {
|
|
330
|
+
const hits = await collect(page, {
|
|
331
|
+
flags: patternFlags("fabricated-claim", options),
|
|
332
|
+
kind: "text",
|
|
333
|
+
patterns: requireStrings("fabricated-claim", options, "claims"),
|
|
334
|
+
scope: stringOption("fabricated-claim", options, "scope", "")
|
|
335
|
+
});
|
|
336
|
+
return hits.map((hit) => ({
|
|
337
|
+
class: "fabricated-claim",
|
|
338
|
+
evidence: `"${hit.text}" is on the page, matching /${hit.pattern}/`,
|
|
339
|
+
selector: hit.selector,
|
|
340
|
+
severity: "blocking",
|
|
341
|
+
url
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// src/probes/fake-session.ts
|
|
347
|
+
var escapeForRegex = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
348
|
+
var fakeSession = {
|
|
349
|
+
class: "fake-session",
|
|
350
|
+
id: "fake-session",
|
|
351
|
+
needs: (options) => {
|
|
352
|
+
const selectors = stringsOption(options, "selectors", []);
|
|
353
|
+
const text = stringsOption(options, "text", []);
|
|
354
|
+
if (selectors.length === 0 && text.length === 0) {
|
|
355
|
+
throw new WalkError(
|
|
356
|
+
'fake-session: needs "selectors" or "text" \u2014 what a signed-in page looks like here'
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
run: async (page, { options, url }) => {
|
|
361
|
+
const selectors = stringsOption(options, "selectors", []);
|
|
362
|
+
const text = stringsOption(options, "text", []);
|
|
363
|
+
const present = selectors.length === 0 ? [] : await collect(page, { kind: "present", selectors });
|
|
364
|
+
const said = text.length === 0 ? [] : await collect(page, {
|
|
365
|
+
flags: "",
|
|
366
|
+
kind: "text",
|
|
367
|
+
patterns: text.map(escapeForRegex),
|
|
368
|
+
scope: ""
|
|
369
|
+
});
|
|
370
|
+
const bySelector = present.filter((hit) => hit.present).map((hit) => ({
|
|
371
|
+
class: "fake-session",
|
|
372
|
+
evidence: `a context nobody signed in to renders ${hit.selector}`,
|
|
373
|
+
selector: hit.selector,
|
|
374
|
+
severity: "blocking",
|
|
375
|
+
url
|
|
376
|
+
}));
|
|
377
|
+
const byText = said.map((hit) => ({
|
|
378
|
+
class: "fake-session",
|
|
379
|
+
evidence: `a context nobody signed in to reads "${hit.text}"`,
|
|
380
|
+
selector: hit.selector,
|
|
381
|
+
severity: "blocking",
|
|
382
|
+
url
|
|
383
|
+
}));
|
|
384
|
+
return [...bySelector, ...byText];
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// src/probes/light-on-light.ts
|
|
389
|
+
var lightOnLight = {
|
|
390
|
+
class: "light-on-light",
|
|
391
|
+
id: "light-on-light",
|
|
392
|
+
run: async (page, { options, url }) => {
|
|
393
|
+
const minContrast = numberOption(options, "minContrast", 1.05);
|
|
394
|
+
const hits = await collect(page, {
|
|
395
|
+
ignore: stringsOption(options, "ignore", []),
|
|
396
|
+
kind: "contrast",
|
|
397
|
+
minContrast,
|
|
398
|
+
selector: stringOption("light-on-light", options, "selector", "*")
|
|
399
|
+
});
|
|
400
|
+
return hits.map((hit) => ({
|
|
401
|
+
class: "light-on-light",
|
|
402
|
+
evidence: `${hit.background} on ${hit.container} (${hit.containerSelector}) \u2014 contrast ${hit.contrast.toFixed(2)}, floor ${minContrast}`,
|
|
403
|
+
selector: hit.selector,
|
|
404
|
+
severity: "major",
|
|
405
|
+
url
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// src/probes/link-integrity.ts
|
|
411
|
+
var DEAD = ["", "#"];
|
|
412
|
+
var linkIntegrity = {
|
|
413
|
+
class: "link-integrity",
|
|
414
|
+
id: "link-integrity",
|
|
415
|
+
run: async (page, { memo, options, url }) => {
|
|
416
|
+
const dead = stringsOption(options, "deadHrefs", DEAD);
|
|
417
|
+
const ignore = stringsOption(options, "ignore", []);
|
|
418
|
+
const checkExternal = flagOption(options, "checkExternal", false);
|
|
419
|
+
const origin = new URL(url).origin;
|
|
420
|
+
const hits = await collect(page, {
|
|
421
|
+
kind: "links",
|
|
422
|
+
selector: stringOption("link-integrity", options, "selector", 'a,[role="link"]')
|
|
423
|
+
});
|
|
424
|
+
const found = [];
|
|
425
|
+
for (const hit of hits) {
|
|
426
|
+
const href = hit.href;
|
|
427
|
+
if (href === null || dead.includes(href.trim()) || href.trim().startsWith("javascript:")) {
|
|
428
|
+
found.push({
|
|
429
|
+
class: "link-integrity",
|
|
430
|
+
evidence: `dead control: href ${href === null ? "absent" : `"${href}"`} on "${hit.text}"`,
|
|
431
|
+
selector: hit.selector,
|
|
432
|
+
severity: "major",
|
|
433
|
+
url
|
|
434
|
+
});
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const target = hit.resolved;
|
|
438
|
+
if (!target.startsWith("http")) continue;
|
|
439
|
+
if (ignore.some((pattern) => new RegExp(pattern).test(target))) continue;
|
|
440
|
+
if (!checkExternal && new URL(target).origin !== origin) continue;
|
|
441
|
+
const status = await statusOf(page, memo, target);
|
|
442
|
+
if (status < 400) continue;
|
|
443
|
+
found.push({
|
|
444
|
+
class: "link-integrity",
|
|
445
|
+
evidence: `${target} answered ${status}`,
|
|
446
|
+
selector: hit.selector,
|
|
447
|
+
severity: "blocking",
|
|
448
|
+
url
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
return found;
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
var statusOf = async (page, memo, target) => {
|
|
455
|
+
const key = `link-integrity:${target}`;
|
|
456
|
+
const seen = memo.get(key);
|
|
457
|
+
if (typeof seen === "number") return seen;
|
|
458
|
+
let status;
|
|
459
|
+
try {
|
|
460
|
+
const head = await page.request.head(target, { failOnStatusCode: false });
|
|
461
|
+
status = head.status() === 405 || head.status() === 501 ? (await page.request.get(target, { failOnStatusCode: false })).status() : head.status();
|
|
462
|
+
} catch (error) {
|
|
463
|
+
status = 599;
|
|
464
|
+
memo.set(`${key}:why`, error.message);
|
|
465
|
+
}
|
|
466
|
+
memo.set(key, status);
|
|
467
|
+
return status;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// src/probes/ops-leakage.ts
|
|
471
|
+
var opsLeakage = {
|
|
472
|
+
class: "ops-leakage",
|
|
473
|
+
id: "ops-leakage",
|
|
474
|
+
needs: (options) => {
|
|
475
|
+
const patterns = requireStrings("ops-leakage", options, "patterns");
|
|
476
|
+
assertPatterns("ops-leakage", patterns, patternFlags("ops-leakage", options));
|
|
477
|
+
},
|
|
478
|
+
run: async (page, { options, url }) => {
|
|
479
|
+
const hits = await collect(page, {
|
|
480
|
+
flags: patternFlags("ops-leakage", options),
|
|
481
|
+
kind: "text",
|
|
482
|
+
patterns: requireStrings("ops-leakage", options, "patterns"),
|
|
483
|
+
scope: stringOption("ops-leakage", options, "scope", "")
|
|
484
|
+
});
|
|
485
|
+
return hits.map((hit) => ({
|
|
486
|
+
class: "ops-leakage",
|
|
487
|
+
evidence: `"${hit.text}" is offered to the customer, matching /${hit.pattern}/`,
|
|
488
|
+
selector: hit.selector,
|
|
489
|
+
severity: "blocking",
|
|
490
|
+
url
|
|
491
|
+
}));
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// src/probes/placeholder-asset.ts
|
|
496
|
+
var placeholderAsset = {
|
|
497
|
+
class: "placeholder-asset",
|
|
498
|
+
id: "placeholder-asset",
|
|
499
|
+
run: async (page, { options, url }) => {
|
|
500
|
+
const maxRepeats = numberOption(options, "maxRepeats", 2);
|
|
501
|
+
const signatures = stringsOption(options, "signatures", []);
|
|
502
|
+
const flags = patternFlags("placeholder-asset", options);
|
|
503
|
+
assertPatterns("placeholder-asset", signatures, flags);
|
|
504
|
+
const hits = await collect(page, {
|
|
505
|
+
assetSelector: stringOption("placeholder-asset", options, "assets", "img,svg"),
|
|
506
|
+
kind: "assets",
|
|
507
|
+
scope: stringOption("placeholder-asset", options, "scope", "body")
|
|
508
|
+
});
|
|
509
|
+
const named = hits.filter(
|
|
510
|
+
(hit) => signatures.some(
|
|
511
|
+
(pattern) => new RegExp(pattern, flags).test(`${hit.signature} ${hit.label}`)
|
|
512
|
+
)
|
|
513
|
+
).map((hit) => ({
|
|
514
|
+
class: "placeholder-asset",
|
|
515
|
+
evidence: `asset matches a placeholder signature: ${hit.signature.slice(0, 120)}`,
|
|
516
|
+
selector: hit.selector,
|
|
517
|
+
severity: "major",
|
|
518
|
+
url
|
|
519
|
+
}));
|
|
520
|
+
const bySignature = /* @__PURE__ */ new Map();
|
|
521
|
+
for (const hit of hits) {
|
|
522
|
+
bySignature.set(hit.signature, [...bySignature.get(hit.signature) ?? [], hit.selector]);
|
|
523
|
+
}
|
|
524
|
+
const repeated = [...bySignature.entries()].filter(([, selectors]) => selectors.length > maxRepeats).map(([signature, selectors]) => ({
|
|
525
|
+
class: "placeholder-asset",
|
|
526
|
+
evidence: `the same asset in ${selectors.length} slots, more than the ${maxRepeats} allowed \u2014 ${signature.slice(0, 120)}`,
|
|
527
|
+
selector: selectors[0],
|
|
528
|
+
severity: "major",
|
|
529
|
+
url
|
|
530
|
+
}));
|
|
531
|
+
return [...named, ...repeated];
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// src/probes/stub-only-entity.ts
|
|
536
|
+
var stubOnlyEntity = {
|
|
537
|
+
class: "stub-only-entity",
|
|
538
|
+
id: "stub-only-entity",
|
|
539
|
+
needs: (options) => {
|
|
540
|
+
requireStrings("stub-only-entity", options, "known");
|
|
541
|
+
},
|
|
542
|
+
run: async (page, { options, url }) => {
|
|
543
|
+
const known = new Set(requireStrings("stub-only-entity", options, "known"));
|
|
544
|
+
const attribute = stringOption("stub-only-entity", options, "attribute", "data-entity-id");
|
|
545
|
+
const hits = await collect(page, {
|
|
546
|
+
affordance: stringOption("stub-only-entity", options, "affordance", ""),
|
|
547
|
+
attribute,
|
|
548
|
+
kind: "entities",
|
|
549
|
+
selector: stringOption("stub-only-entity", options, "selector", `[${attribute}]`)
|
|
550
|
+
});
|
|
551
|
+
return hits.filter((hit) => hit.id !== "" && hit.affordance && !known.has(hit.id)).map((hit) => ({
|
|
552
|
+
class: "stub-only-entity",
|
|
553
|
+
evidence: `"${hit.id}" can be bought here and is in no authoritative set of ${known.size} entities`,
|
|
554
|
+
selector: hit.selector,
|
|
555
|
+
severity: "blocking",
|
|
556
|
+
url
|
|
557
|
+
}));
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// src/probes/index.ts
|
|
562
|
+
var PROBES = [
|
|
563
|
+
buyBoxAboveFold,
|
|
564
|
+
errorPageStatus,
|
|
565
|
+
fabricatedClaim,
|
|
566
|
+
fakeSession,
|
|
567
|
+
lightOnLight,
|
|
568
|
+
linkIntegrity,
|
|
569
|
+
opsLeakage,
|
|
570
|
+
placeholderAsset,
|
|
571
|
+
stubOnlyEntity
|
|
572
|
+
];
|
|
573
|
+
var probeById = (id) => {
|
|
574
|
+
const probe = PROBES.find((one) => one.id === id);
|
|
575
|
+
if (probe === void 0) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`no probe called "${id}" \u2014 known probes: ${PROBES.map((one) => one.id).join(", ")}`
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
return probe;
|
|
581
|
+
};
|
|
582
|
+
var DEFAULT_PROBES = PROBES.filter((one) => one.needs === void 0);
|
|
583
|
+
|
|
584
|
+
// src/config.ts
|
|
585
|
+
import { existsSync, readFileSync } from "fs";
|
|
586
|
+
import { resolve } from "path";
|
|
587
|
+
var VIEWPORT = { height: 900, width: 1440 };
|
|
588
|
+
var WALK_REPORT = ".geonosis/walk-report.json";
|
|
589
|
+
var WALK_DEFAULT = {
|
|
590
|
+
options: {},
|
|
591
|
+
probes: DEFAULT_PROBES.map((one) => one.id),
|
|
592
|
+
report: WALK_REPORT,
|
|
593
|
+
urls: [],
|
|
594
|
+
viewport: VIEWPORT
|
|
595
|
+
};
|
|
596
|
+
var KNOWN = /* @__PURE__ */ new Set(["options", "probes", "report", "urls", "viewport"]);
|
|
597
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
598
|
+
var readViewport = (value) => {
|
|
599
|
+
if (value === void 0) return VIEWPORT;
|
|
600
|
+
if (!isRecord(value) || typeof value.height !== "number" || typeof value.width !== "number") {
|
|
601
|
+
throw new WalkError('walk.viewport must be { "width": <number>, "height": <number> }');
|
|
602
|
+
}
|
|
603
|
+
return { height: value.height, width: value.width };
|
|
604
|
+
};
|
|
605
|
+
var parseViewport = (value) => {
|
|
606
|
+
const found = /^(\d+)x(\d+)$/.exec(value.trim());
|
|
607
|
+
if (found?.[1] === void 0 || found[2] === void 0) {
|
|
608
|
+
throw new WalkError(`"${value}" is not a viewport \u2014 write it as WIDTHxHEIGHT, e.g. 390x844`);
|
|
609
|
+
}
|
|
610
|
+
return { height: Number(found[2]), width: Number(found[1]) };
|
|
611
|
+
};
|
|
612
|
+
var readStrings = (value, name, fallback) => {
|
|
613
|
+
if (value === void 0) return fallback;
|
|
614
|
+
if (!Array.isArray(value) || value.some((one) => typeof one !== "string")) {
|
|
615
|
+
throw new WalkError(`walk.${name} must be a list of strings`);
|
|
616
|
+
}
|
|
617
|
+
return value;
|
|
618
|
+
};
|
|
619
|
+
var loadWalkConfig = (file) => {
|
|
620
|
+
if (!existsSync(file)) return WALK_DEFAULT;
|
|
621
|
+
let parsed;
|
|
622
|
+
try {
|
|
623
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
624
|
+
} catch (error) {
|
|
625
|
+
throw new WalkError(`${file} is not valid JSON: ${error.message}`);
|
|
626
|
+
}
|
|
627
|
+
if (!isRecord(parsed)) throw new WalkError(`${file} must hold an object`);
|
|
628
|
+
const block = parsed.walk;
|
|
629
|
+
if (block === void 0) return WALK_DEFAULT;
|
|
630
|
+
if (!isRecord(block)) throw new WalkError("walk must be an object");
|
|
631
|
+
for (const key of Object.keys(block)) {
|
|
632
|
+
if (!KNOWN.has(key)) throw new WalkError(`walk.${key} is not a key of the walk block`);
|
|
633
|
+
}
|
|
634
|
+
const options = block.options ?? {};
|
|
635
|
+
if (!isRecord(options)) throw new WalkError("walk.options must be an object keyed by probe id");
|
|
636
|
+
for (const [id, one] of Object.entries(options)) {
|
|
637
|
+
if (!isRecord(one)) throw new WalkError(`walk.options.${id} must be an object`);
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
options,
|
|
641
|
+
probes: readStrings(block.probes, "probes", WALK_DEFAULT.probes),
|
|
642
|
+
report: typeof block.report === "string" ? block.report : WALK_REPORT,
|
|
643
|
+
urls: readStrings(block.urls, "urls", []),
|
|
644
|
+
viewport: readViewport(block.viewport)
|
|
645
|
+
};
|
|
646
|
+
};
|
|
647
|
+
var configPath = (cwd, given) => resolve(cwd, given ?? "geonosis.json");
|
|
648
|
+
|
|
649
|
+
// src/walk.ts
|
|
650
|
+
var launch = async () => {
|
|
651
|
+
let chromium;
|
|
652
|
+
try {
|
|
653
|
+
;
|
|
654
|
+
({ chromium } = await import("playwright"));
|
|
655
|
+
} catch (error) {
|
|
656
|
+
throw new WalkError(
|
|
657
|
+
`playwright is a peer dependency of @geonosis/walk and is not installed: ${error.message}`
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
return await chromium.launch({ headless: true });
|
|
662
|
+
} catch (error) {
|
|
663
|
+
throw new WalkError(
|
|
664
|
+
`chromium would not launch \u2014 \`npx playwright install chromium\`: ${error.message}`
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
var walk = async ({
|
|
669
|
+
browser,
|
|
670
|
+
options = {},
|
|
671
|
+
probes,
|
|
672
|
+
urls,
|
|
673
|
+
viewport
|
|
674
|
+
}) => {
|
|
675
|
+
if (urls.length === 0) throw new WalkError("no URL to walk");
|
|
676
|
+
for (const probe of probes) probe.needs?.(options[probe.id] ?? {});
|
|
677
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
678
|
+
const own = browser ?? await launch();
|
|
679
|
+
const memo = /* @__PURE__ */ new Map();
|
|
680
|
+
const pages = [];
|
|
681
|
+
try {
|
|
682
|
+
for (const url of urls) {
|
|
683
|
+
const context = await own.newContext({ viewport });
|
|
684
|
+
try {
|
|
685
|
+
const page = await context.newPage();
|
|
686
|
+
const response = await page.goto(url).catch((error) => {
|
|
687
|
+
throw new WalkError(`${url} could not be opened: ${error.message}`);
|
|
688
|
+
});
|
|
689
|
+
if (response === null) throw new WalkError(`${url} answered nothing`);
|
|
690
|
+
if (response.status() >= 400) {
|
|
691
|
+
throw new WalkError(`${url} answered ${response.status()}`);
|
|
692
|
+
}
|
|
693
|
+
const findings = [];
|
|
694
|
+
for (const probe of probes) {
|
|
695
|
+
findings.push(
|
|
696
|
+
...await probe.run(page, {
|
|
697
|
+
memo,
|
|
698
|
+
options: options[probe.id] ?? {},
|
|
699
|
+
status: response.status(),
|
|
700
|
+
url,
|
|
701
|
+
viewport
|
|
702
|
+
})
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
pages.push({ findings, url });
|
|
706
|
+
} finally {
|
|
707
|
+
await context.close();
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
} finally {
|
|
711
|
+
if (browser === void 0) await own.close();
|
|
712
|
+
}
|
|
713
|
+
const counts = {};
|
|
714
|
+
for (const probe of probes) counts[probe.class] = 0;
|
|
715
|
+
for (const page of pages) {
|
|
716
|
+
for (const finding of page.findings) {
|
|
717
|
+
counts[finding.class] = (counts[finding.class] ?? 0) + 1;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
counts,
|
|
722
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
723
|
+
pages,
|
|
724
|
+
probes: probes.map((one) => one.id),
|
|
725
|
+
startedAt,
|
|
726
|
+
viewport
|
|
727
|
+
};
|
|
728
|
+
};
|
|
729
|
+
var findingsIn = (report) => report.pages.reduce((total, page) => total + page.findings.length, 0);
|
|
730
|
+
|
|
731
|
+
export {
|
|
732
|
+
WalkError,
|
|
733
|
+
PROBES,
|
|
734
|
+
probeById,
|
|
735
|
+
DEFAULT_PROBES,
|
|
736
|
+
WALK_REPORT,
|
|
737
|
+
WALK_DEFAULT,
|
|
738
|
+
parseViewport,
|
|
739
|
+
loadWalkConfig,
|
|
740
|
+
configPath,
|
|
741
|
+
walk,
|
|
742
|
+
findingsIn
|
|
743
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Page, Browser } from 'playwright';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The nine defect classes the walk is mechanical about.
|
|
5
|
+
*
|
|
6
|
+
* Six are the dielime CX audit's own groups; `light-on-light` is dielime's G3 browser-proof probe
|
|
7
|
+
* (a cream chip on a cream page reads as broken, and the screenshot says it is fine),
|
|
8
|
+
* `buy-box-above-fold` is that audit's G1 promoted to a class, and `error-page-status` came from a
|
|
9
|
+
* production incident: an error surface served with a 200, which every health check read as
|
|
10
|
+
* healthy. What the nine do NOT cover — a number that disagrees with a number on another page, copy
|
|
11
|
+
* that promises a control nobody built, four levels of taxonomy for fourteen products — is in
|
|
12
|
+
* `docs/walk-review-inventory-2026-08-30.md`, and stays the walker's judgement. A green walk report
|
|
13
|
+
* is not a clean surface.
|
|
14
|
+
*/
|
|
15
|
+
type DefectClass = 'buy-box-above-fold' | 'error-page-status' | 'fabricated-claim' | 'fake-session' | 'light-on-light' | 'link-integrity' | 'ops-leakage' | 'placeholder-asset' | 'stub-only-entity';
|
|
16
|
+
/** `blocking` breaks trust or the buy path; `major` confuses the buyer; `minor` is polish. */
|
|
17
|
+
type Severity = 'blocking' | 'major' | 'minor';
|
|
18
|
+
/**
|
|
19
|
+
* One defect, as the build plan's contract states it. `evidence` is the measured value — the two
|
|
20
|
+
* colours and the ratio, the status code, the matched text — never a restatement of the class,
|
|
21
|
+
* because the reader's next question is always "what did you actually see".
|
|
22
|
+
*/
|
|
23
|
+
type Finding = {
|
|
24
|
+
class: DefectClass;
|
|
25
|
+
evidence: string;
|
|
26
|
+
selector?: string;
|
|
27
|
+
severity: Severity;
|
|
28
|
+
url: string;
|
|
29
|
+
};
|
|
30
|
+
type Viewport = {
|
|
31
|
+
height: number;
|
|
32
|
+
width: number;
|
|
33
|
+
};
|
|
34
|
+
type ProbeContext = {
|
|
35
|
+
/**
|
|
36
|
+
* One walk, one memo. `link-integrity` would otherwise send a HEAD request for the same footer
|
|
37
|
+
* link once per page, which is forty requests for one answer on a forty-page walk.
|
|
38
|
+
*/
|
|
39
|
+
memo: Map<string, unknown>;
|
|
40
|
+
/** This probe's own block of the `options` map, already narrowed by id. */
|
|
41
|
+
options: Record<string, unknown>;
|
|
42
|
+
/**
|
|
43
|
+
* The status the page's OWN response carried. Read from `page.goto`, because nothing on a loaded
|
|
44
|
+
* page remembers it and a second request for it would ask a different question of a server that
|
|
45
|
+
* may not answer twice the same way.
|
|
46
|
+
*/
|
|
47
|
+
status: number;
|
|
48
|
+
url: string;
|
|
49
|
+
viewport: Viewport;
|
|
50
|
+
};
|
|
51
|
+
type Probe = {
|
|
52
|
+
class: DefectClass;
|
|
53
|
+
id: string;
|
|
54
|
+
/**
|
|
55
|
+
* D-031, per probe: a probe that cannot mean anything without an option refuses the run when it
|
|
56
|
+
* is missing, naming the option. Five of the eight have one — a claim list, an authoritative id
|
|
57
|
+
* set, what "signed in" looks like, the operations patterns, the buy-box selector — because none
|
|
58
|
+
* of those five can have a default that is true of a repo that has not configured it. A probe
|
|
59
|
+
* that ran and reported nothing because nobody told it what to look for is indistinguishable
|
|
60
|
+
* from a clean page.
|
|
61
|
+
*/
|
|
62
|
+
needs?: (options: Record<string, unknown>) => void;
|
|
63
|
+
run: (page: Page, context: ProbeContext) => Promise<Finding[]>;
|
|
64
|
+
};
|
|
65
|
+
type PageReport = {
|
|
66
|
+
findings: Finding[];
|
|
67
|
+
url: string;
|
|
68
|
+
};
|
|
69
|
+
type WalkReport = {
|
|
70
|
+
/**
|
|
71
|
+
* Keyed by the classes that RAN. A zero therefore means "probed for, none found"; a class that is
|
|
72
|
+
* absent was never looked for. All eight keys with five zeros would read as five clean gates
|
|
73
|
+
* nobody enabled.
|
|
74
|
+
*/
|
|
75
|
+
counts: Partial<Record<DefectClass, number>>;
|
|
76
|
+
finishedAt: string;
|
|
77
|
+
pages: PageReport[];
|
|
78
|
+
probes: string[];
|
|
79
|
+
startedAt: string;
|
|
80
|
+
viewport: Viewport;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* The walk could not run: no browser, no URL, a page that did not answer, a probe with no option it
|
|
84
|
+
* needs. Distinct from "the walk ran and found nothing", which is a report with zeros in it — the
|
|
85
|
+
* CLI turns this into exit 2 and that into exit 0.
|
|
86
|
+
*/
|
|
87
|
+
declare class WalkError extends Error {
|
|
88
|
+
constructor(message: string);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type WalkConfig = {
|
|
92
|
+
options: Record<string, Record<string, unknown>>;
|
|
93
|
+
probes: string[];
|
|
94
|
+
report: string;
|
|
95
|
+
urls: string[];
|
|
96
|
+
viewport: Viewport;
|
|
97
|
+
};
|
|
98
|
+
declare const WALK_REPORT = ".geonosis/walk-report.json";
|
|
99
|
+
declare const WALK_DEFAULT: WalkConfig;
|
|
100
|
+
/** `WxH`, the shape a viewport is written in everywhere else — 1440x900, 390x844. */
|
|
101
|
+
declare const parseViewport: (value: string) => Viewport;
|
|
102
|
+
/** The `walk` block of `geonosis.json`, every key optional, every default a contract. */
|
|
103
|
+
declare const loadWalkConfig: (file: string) => WalkConfig;
|
|
104
|
+
declare const configPath: (cwd: string, given?: string) => string;
|
|
105
|
+
|
|
106
|
+
declare const PROBES: Probe[];
|
|
107
|
+
declare const probeById: (id: string) => Probe;
|
|
108
|
+
/**
|
|
109
|
+
* The probes a walk runs when nobody named any: the ones whose reading is a property of any page.
|
|
110
|
+
* The other five need a repo's own list to mean anything (D-031) and would refuse the run, so a
|
|
111
|
+
* default set that included them would make `geonosis-walk <url>` fail on every repo.
|
|
112
|
+
*/
|
|
113
|
+
declare const DEFAULT_PROBES: Probe[];
|
|
114
|
+
|
|
115
|
+
type WalkInput = {
|
|
116
|
+
/** An already-open browser, for a walker driving a flow. Absent means launch one and close it. */
|
|
117
|
+
browser?: Browser;
|
|
118
|
+
/** Each probe's options, keyed by probe id. */
|
|
119
|
+
options?: Record<string, Record<string, unknown>>;
|
|
120
|
+
probes: Probe[];
|
|
121
|
+
urls: string[];
|
|
122
|
+
viewport: Viewport;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Every URL, every probe, one report.
|
|
126
|
+
*
|
|
127
|
+
* Each page gets its OWN context, with no cookies and no storage. That is what makes `fake-session`
|
|
128
|
+
* mean anything, and it is why `walk` is the stateless surface: a walker that has to reach a
|
|
129
|
+
* checkout with a cart in it drives its own page and calls `probe.run(page, context)` directly.
|
|
130
|
+
*
|
|
131
|
+
* Refusals come BEFORE the browser opens, and a page that did not answer stops the walk. A report
|
|
132
|
+
* with no findings in it must only ever mean the probes looked and found none.
|
|
133
|
+
*/
|
|
134
|
+
declare const walk: ({ browser, options, probes, urls, viewport, }: WalkInput) => Promise<WalkReport>;
|
|
135
|
+
declare const findingsIn: (report: WalkReport) => number;
|
|
136
|
+
|
|
137
|
+
export { DEFAULT_PROBES, type DefectClass, type Finding, PROBES, type PageReport, type Probe, type ProbeContext, type Severity, type Viewport, WALK_DEFAULT, WALK_REPORT, type WalkConfig, WalkError, type WalkInput, type WalkReport, configPath, findingsIn, loadWalkConfig, parseViewport, probeById, walk };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_PROBES,
|
|
3
|
+
PROBES,
|
|
4
|
+
WALK_DEFAULT,
|
|
5
|
+
WALK_REPORT,
|
|
6
|
+
WalkError,
|
|
7
|
+
configPath,
|
|
8
|
+
findingsIn,
|
|
9
|
+
loadWalkConfig,
|
|
10
|
+
parseViewport,
|
|
11
|
+
probeById,
|
|
12
|
+
walk
|
|
13
|
+
} from "./chunk-GA522TKR.js";
|
|
14
|
+
export {
|
|
15
|
+
DEFAULT_PROBES,
|
|
16
|
+
PROBES,
|
|
17
|
+
WALK_DEFAULT,
|
|
18
|
+
WALK_REPORT,
|
|
19
|
+
WalkError,
|
|
20
|
+
configPath,
|
|
21
|
+
findingsIn,
|
|
22
|
+
loadWalkConfig,
|
|
23
|
+
parseViewport,
|
|
24
|
+
probeById,
|
|
25
|
+
walk
|
|
26
|
+
};
|
package/dist/walk-cli.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WalkError,
|
|
3
|
+
configPath,
|
|
4
|
+
findingsIn,
|
|
5
|
+
loadWalkConfig,
|
|
6
|
+
parseViewport,
|
|
7
|
+
probeById,
|
|
8
|
+
walk
|
|
9
|
+
} from "./chunk-GA522TKR.js";
|
|
10
|
+
|
|
11
|
+
// src/walk-cli.ts
|
|
12
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
13
|
+
import { dirname, resolve } from "path";
|
|
14
|
+
var parse = (argv) => {
|
|
15
|
+
const out = { probes: [], urls: [] };
|
|
16
|
+
for (let at = 0; at < argv.length; at += 1) {
|
|
17
|
+
const arg = argv[at];
|
|
18
|
+
if (arg === void 0) continue;
|
|
19
|
+
const takes = (name) => {
|
|
20
|
+
const value = argv[at + 1];
|
|
21
|
+
if (value === void 0) throw new WalkError(`${name} needs a value`);
|
|
22
|
+
at += 1;
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
if (arg === "--config") out.config = takes("--config");
|
|
26
|
+
else if (arg === "--report") out.report = takes("--report");
|
|
27
|
+
else if (arg === "--viewport") out.viewport = takes("--viewport");
|
|
28
|
+
else if (arg === "--probe") out.probes.push(takes("--probe"));
|
|
29
|
+
else if (arg.startsWith("-")) throw new WalkError(`unknown option ${arg}`);
|
|
30
|
+
else out.urls.push(arg);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
};
|
|
34
|
+
var line = (finding) => `${finding.severity} ${finding.class} ${finding.url}${finding.selector === void 0 ? "" : ` ${finding.selector}`}
|
|
35
|
+
${finding.evidence}`;
|
|
36
|
+
var summarise = (report) => [
|
|
37
|
+
...report.pages.flatMap((page) => page.findings.map(line)),
|
|
38
|
+
"",
|
|
39
|
+
...Object.entries(report.counts).map(([name, count]) => `${count} ${name}`),
|
|
40
|
+
`${report.pages.length} page(s), ${findingsIn(report)} finding(s)`
|
|
41
|
+
].join("\n");
|
|
42
|
+
var main = async () => {
|
|
43
|
+
const argv = parse(process.argv.slice(2));
|
|
44
|
+
const cwd = process.cwd();
|
|
45
|
+
const config = loadWalkConfig(configPath(cwd, argv.config));
|
|
46
|
+
const ids = argv.probes.length > 0 ? argv.probes : config.probes;
|
|
47
|
+
const probes = ids.map((id) => {
|
|
48
|
+
try {
|
|
49
|
+
return probeById(id);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new WalkError(error.message);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
const report = await walk({
|
|
55
|
+
options: config.options,
|
|
56
|
+
probes,
|
|
57
|
+
urls: argv.urls.length > 0 ? argv.urls : config.urls,
|
|
58
|
+
viewport: argv.viewport === void 0 ? config.viewport : parseViewport(argv.viewport)
|
|
59
|
+
});
|
|
60
|
+
const at = resolve(cwd, argv.report ?? config.report);
|
|
61
|
+
mkdirSync(dirname(at), { recursive: true });
|
|
62
|
+
writeFileSync(at, `${JSON.stringify(report, null, 2)}
|
|
63
|
+
`);
|
|
64
|
+
process.stdout.write(`${summarise(report)}
|
|
65
|
+
${at}
|
|
66
|
+
`);
|
|
67
|
+
return findingsIn(report) > 0 ? 1 : 0;
|
|
68
|
+
};
|
|
69
|
+
main().then(
|
|
70
|
+
(code) => {
|
|
71
|
+
process.exitCode = code;
|
|
72
|
+
},
|
|
73
|
+
(error) => {
|
|
74
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
75
|
+
`);
|
|
76
|
+
process.exitCode = 2;
|
|
77
|
+
}
|
|
78
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@geonosis/walk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The customer walk as a gate: eight computed-DOM probes over a list of URLs, one JSON report, and an exit code that tells a could-not-run from a clean page.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"playwright",
|
|
7
|
+
"audit",
|
|
8
|
+
"accessibility",
|
|
9
|
+
"contrast",
|
|
10
|
+
"probe",
|
|
11
|
+
"gate",
|
|
12
|
+
"ci"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/microcompanies/geonosis/tree/main/packages/walk",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/microcompanies/geonosis.git",
|
|
18
|
+
"directory": "packages/walk"
|
|
19
|
+
},
|
|
20
|
+
"license": "Apache-2.0",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "dist/index.js",
|
|
23
|
+
"bin": {
|
|
24
|
+
"geonosis-walk": "bin/geonosis-walk.mjs"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"playwright": ">=1.40.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"playwright": "1.60.0"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsup",
|
|
47
|
+
"typecheck": "tsc --noEmit"
|
|
48
|
+
}
|
|
49
|
+
}
|