@hs-web-team/eslint-config-node 5.1.1 → 5.2.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/README.md
CHANGED
|
@@ -180,6 +180,17 @@ This package includes a shared accessibility testing setup using [cypress-axe](h
|
|
|
180
180
|
|
|
181
181
|
`cy.checkAccessibility()` adds the `high-contrast` class to `body`, runs WCAG 2.2 Level AA rules only, and logs each violation with its id, help text, impact, element targets, and help URL. TypeScript types are included — no `tsconfig.json` changes required.
|
|
182
182
|
|
|
183
|
+
## Cloudflare 429 handling (optional, Cucumber suites)
|
|
184
|
+
|
|
185
|
+
For Cypress + Cucumber suites hitting Cloudflare-fronted endpoints, opt in to shared handling of Cloudflare `429` rate-limit responses: stop retrying a 429'd test, skip the rest of a Scenario Outline whose example 429'd, and tag the failure with a `[RATE-LIMIT-429]` marker. Add one import inside your `stepDefinitions` glob (not `support/e2e.js`):
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
// cypress/support/step_definitions/rate-limit-429.js (or .ts)
|
|
189
|
+
import '@hs-web-team/eslint-config-node/cypress/rate-limit-429';
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
For how it works and how to verify it with a mocked 429, see [examples/cypress-usage.md](./examples/cypress-usage.md#cloudflare-429-handling-cucumber-suites).
|
|
193
|
+
|
|
183
194
|
## Custom Rules
|
|
184
195
|
|
|
185
196
|
This package ships a `hs-web-team` ESLint plugin with custom rules that are active automatically when you use `wtConfig` or `wtBrowserConfig` — no extra setup needed.
|
|
@@ -200,6 +211,7 @@ This package provides multiple configurations:
|
|
|
200
211
|
- **Stylelint configuration** (`.stylelintrc.json` export): For SCSS/CSS linting
|
|
201
212
|
- **Cypress configuration** (`cypress.config` export): For E2E testing with Cypress
|
|
202
213
|
- **Accessibility testing** (`/cypress` export): Opt-in cypress-axe setup with WCAG 2.2 AA checks
|
|
214
|
+
- **Cloudflare 429 handling** (`/cypress/rate-limit-429` export): Opt-in Cucumber handling of rate-limit 429s
|
|
203
215
|
- **Prettier configuration** (`.prettierrc.json` export): For code formatting
|
|
204
216
|
|
|
205
217
|
Choose the appropriate configurations based on your project needs.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/// <reference types="cypress" />
|
|
2
|
+
|
|
3
|
+
// Side-effect-only module: importing it registers the Cloudflare-429 Cypress/Cucumber hooks.
|
|
4
|
+
// There are no exported bindings — this declaration just makes the bare import resolvable in
|
|
5
|
+
// TypeScript consumers:
|
|
6
|
+
//
|
|
7
|
+
// import '@hs-web-team/eslint-config-node/cypress/rate-limit-429';
|
|
8
|
+
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Before } from '@badeball/cypress-cucumber-preprocessor';
|
|
2
|
+
|
|
3
|
+
// All Cloudflare-429 handling lives in one file so the three hooks share plain module state.
|
|
4
|
+
// Usage & setup: see examples/cypress-usage.md. In short: import this (side-effect only) from a
|
|
5
|
+
// file in your `stepDefinitions` glob, NOT the support file — the cucumber Before() hook
|
|
6
|
+
// (outline-skip) only registers against the step-def bundle's global registry, so importing from
|
|
7
|
+
// support/e2e.js throws "Expected to find a global registry ...".
|
|
8
|
+
//
|
|
9
|
+
// Using Mocha's afterEach (NOT cucumber After()) is deliberate: the preprocessor's After() does
|
|
10
|
+
// NOT run when a scenario fails, and a 429 fails the scenario, so After() could never react to it.
|
|
11
|
+
// https://github.com/badeball/cypress-cucumber-preprocessor/blob/master/docs/cucumber-basics.md#hooks
|
|
12
|
+
|
|
13
|
+
/** @typedef {{ url: string, ray: string | string[], at: string }} RateLimit429Hit */
|
|
14
|
+
|
|
15
|
+
// Shared across a feature's scenarios; resets between spec files (so nothing leaks between features).
|
|
16
|
+
/** @type {RateLimit429Hit | null} */
|
|
17
|
+
let rateLimit429Hit = null;
|
|
18
|
+
/** @type {string | null} */
|
|
19
|
+
let currentOutline = null;
|
|
20
|
+
/** @type {string | null} */
|
|
21
|
+
let rateLimited429Outline = null;
|
|
22
|
+
|
|
23
|
+
// cy.state('runnable') is an internal API
|
|
24
|
+
const currentRunnable = () => cy.state('runnable');
|
|
25
|
+
|
|
26
|
+
// Cypress decides whether to requeue a failed test before afterEach runs, so a 429'd test's
|
|
27
|
+
// retries must be cancelled here, during the test.
|
|
28
|
+
const disableRetriesForCurrentTest = () => {
|
|
29
|
+
const runnable = currentRunnable();
|
|
30
|
+
if (!runnable) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
/* eslint-disable no-underscore-dangle */
|
|
34
|
+
const attempt = runnable._currentRetry ?? 0;
|
|
35
|
+
// Pin both; Cypress re-expands _retries from _maxRetries otherwise.
|
|
36
|
+
runnable._retries = attempt;
|
|
37
|
+
runnable._maxRetries = attempt;
|
|
38
|
+
/* eslint-enable no-underscore-dangle */
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
rateLimit429Hit = null; // to ensure each scenario starts clean, in case a previous afterEach was skipped
|
|
43
|
+
|
|
44
|
+
// Observe every response (middleware:true never modifies). Genuine Cloudflare 429s
|
|
45
|
+
// carry a cf-ray header; third-party 429s (Google c2dm, pixels, etc.) don't, so ignore
|
|
46
|
+
// those — otherwise we'd wrongly disable retries / skip an outline. Gate on the header,
|
|
47
|
+
// not the host, since the suite may hit several Cloudflare-fronted hosts.
|
|
48
|
+
cy.intercept({ url: '**/*', middleware: true }, req => {
|
|
49
|
+
req.on('response', res => {
|
|
50
|
+
const ray = res.headers['cf-ray'];
|
|
51
|
+
if (res.statusCode === 429 && ray) {
|
|
52
|
+
rateLimit429Hit = { url: req.url, ray, at: new Date().toISOString() };
|
|
53
|
+
disableRetriesForCurrentTest();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(function () {
|
|
60
|
+
const hit = rateLimit429Hit;
|
|
61
|
+
rateLimit429Hit = null;
|
|
62
|
+
if (!hit) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const test = this.currentTest;
|
|
67
|
+
|
|
68
|
+
// If this example failed on the 429, record its outline so the Before hook below skips the
|
|
69
|
+
// outline's remaining examples — they hit the same blocked endpoint and would 429 too.
|
|
70
|
+
if (test?.state === 'failed') {
|
|
71
|
+
rateLimited429Outline = currentOutline;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Don't throw here — an afterEach throw skips the rest of the spec.
|
|
75
|
+
const marker =
|
|
76
|
+
`[RATE-LIMIT-429] Cloudflare 429 rate limit (IP blocked ~10 min). ` +
|
|
77
|
+
`Retries disabled for this test. at=${hit.at} url=${hit.url} cf-ray=${hit.ray}`;
|
|
78
|
+
|
|
79
|
+
if (test?.err) {
|
|
80
|
+
test.err.message = `${marker}\n\n${test.err.message}`;
|
|
81
|
+
} else {
|
|
82
|
+
Cypress.log({ name: '429', message: marker });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// A 429 blocks the IP ~10 min, so every later example of the same Scenario Outline would 429
|
|
87
|
+
// too. Identify the outline by pickle.astNodeIds[0] (the outline node, identical across its
|
|
88
|
+
// rows — the title can't be used, it interpolates the example values). Record the 429'd
|
|
89
|
+
// example's outline, skip the rest; any other scenario clears it.
|
|
90
|
+
Before(({ pickle }) => {
|
|
91
|
+
currentOutline = pickle.astNodeIds?.[0] ?? null;
|
|
92
|
+
if (rateLimited429Outline) {
|
|
93
|
+
if (currentOutline === rateLimited429Outline) {
|
|
94
|
+
return 'skipped';
|
|
95
|
+
}
|
|
96
|
+
rateLimited429Outline = null;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
});
|
package/cypress.config.cjs
CHANGED
|
@@ -38,6 +38,9 @@ const getRootDir = currDir => {
|
|
|
38
38
|
* or `null` if this is not the dev environment or no such property exists.
|
|
39
39
|
*/
|
|
40
40
|
const getDevBaseUrl = () => {
|
|
41
|
+
// Skip the DEV portal lookup in CI; Jenkins runs as `jenkins_lt`.
|
|
42
|
+
if (process.env.LT_USERNAME === 'jenkins_lt') return null;
|
|
43
|
+
|
|
41
44
|
try {
|
|
42
45
|
const root = getRootDir(__dirname);
|
|
43
46
|
if (!root) return null;
|
|
@@ -115,6 +115,59 @@ regression:
|
|
|
115
115
|
url: https://prod.example.com
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
+
## Cloudflare 429 handling (Cucumber suites)
|
|
119
|
+
|
|
120
|
+
For Cypress + Cucumber suites that hit Cloudflare-fronted endpoints, this package ships an
|
|
121
|
+
opt-in handler for Cloudflare `429` rate-limit responses. A Cloudflare 429 is a ~10-minute
|
|
122
|
+
IP block, so a retry can't pass — it just burns the limited request budget. The handler:
|
|
123
|
+
|
|
124
|
+
- **Stops retrying** a test that fails on a Cloudflare 429 (it still fails once; every other
|
|
125
|
+
failure retries per your `retries` config, and sibling tests are unaffected).
|
|
126
|
+
- **Skips the remaining examples of a Scenario Outline** once one example hits a 429 — the
|
|
127
|
+
other rows target the same blocked endpoint, so they're marked pending instead of run.
|
|
128
|
+
- **Tags the failure** with a searchable `[RATE-LIMIT-429]` marker (timestamp, URL, `cf-ray`)
|
|
129
|
+
on the test error, without ever throwing from the hook.
|
|
130
|
+
|
|
131
|
+
Detection is gated on Cloudflare's `cf-ray` response header, so unrelated third-party 429s
|
|
132
|
+
(pixels, analytics, etc.) don't trigger it.
|
|
133
|
+
|
|
134
|
+
### Enabling it
|
|
135
|
+
|
|
136
|
+
Add **one file inside your `stepDefinitions` glob** (commonly
|
|
137
|
+
`cypress/support/step_definitions/`). JavaScript projects name it `.js`, TypeScript projects
|
|
138
|
+
name it `.ts` — the contents are identical:
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
// cypress/support/step_definitions/rate-limit-429.js (or .ts)
|
|
142
|
+
import '@hs-web-team/eslint-config-node/cypress/rate-limit-429';
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
That single side-effect import registers everything. No config, `setupNodeEvents`, or
|
|
146
|
+
dependency changes are needed — `@badeball/cypress-cucumber-preprocessor` is already a peer
|
|
147
|
+
dependency your suite satisfies.
|
|
148
|
+
|
|
149
|
+
> **It must live in a step-definitions file, not `cypress/support/e2e.js`.** The handler
|
|
150
|
+
> registers a Cucumber `Before` hook (for the outline-skip), and the preprocessor only exposes
|
|
151
|
+
> its hook registry while bundling step-definition files. Importing from the support file
|
|
152
|
+
> throws `Expected to find a global registry ...`.
|
|
153
|
+
|
|
154
|
+
### Verifying it with a mocked 429
|
|
155
|
+
|
|
156
|
+
Real Cloudflare 429s are intermittent, so to confirm the handler fires, force one in a
|
|
157
|
+
throwaway/smoke spec with a **dynamic-reply** intercept — point it at a request the page
|
|
158
|
+
actually makes (a browser `fetch`/XHR, not `cy.request`):
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
cy.intercept('GET', '**/your-endpoint', req =>
|
|
162
|
+
req.reply({ statusCode: 429, headers: { 'cf-ray': 'mock-cf-ray-123' } }),
|
|
163
|
+
);
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The handler's `**/*` middleware intercept runs first and observes the response, so the test
|
|
167
|
+
fails once (no retry), carries the `[RATE-LIMIT-429]` marker, and — in a Scenario Outline —
|
|
168
|
+
the remaining examples are skipped. Use a dynamic `req.reply(...)`, not a static stub object:
|
|
169
|
+
a static stub doesn't reliably drive the response lifecycle the middleware observes.
|
|
170
|
+
|
|
118
171
|
## Customizing Configuration
|
|
119
172
|
|
|
120
173
|
### Add Custom setupNodeEvents
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hs-web-team/eslint-config-node",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"description": "HubSpot Marketing WebTeam shared configurations for ESLint, Prettier, Stylelint, and Cypress",
|
|
5
5
|
"files": [
|
|
6
6
|
".prettierrc.json",
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
"types": "./cypress/index.d.ts",
|
|
34
34
|
"default": "./cypress/index.js"
|
|
35
35
|
},
|
|
36
|
+
"./cypress/rate-limit-429": {
|
|
37
|
+
"types": "./cypress/rate-limit-429.d.ts",
|
|
38
|
+
"default": "./cypress/rate-limit-429.js"
|
|
39
|
+
},
|
|
36
40
|
"./plugin": "./plugins/hs-web-team/index.js",
|
|
37
41
|
"./gherkin/.gplintrc": "./gherkin/.gplintrc",
|
|
38
42
|
"./gherkin/coverage-config": "./gherkin/coverage-config.js"
|
|
@@ -44,6 +48,9 @@
|
|
|
44
48
|
],
|
|
45
49
|
"cypress": [
|
|
46
50
|
"./cypress/index.d.ts"
|
|
51
|
+
],
|
|
52
|
+
"cypress/rate-limit-429": [
|
|
53
|
+
"./cypress/rate-limit-429.d.ts"
|
|
47
54
|
]
|
|
48
55
|
}
|
|
49
56
|
},
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
const DEFAULT_EXCEPTIONS = new Set(['e', '_']);
|
|
2
2
|
const DEFAULT_MIN_LENGTH = 2;
|
|
3
|
+
const DEFAULT_DISALLOWED_NAMES = new Set([
|
|
4
|
+
'cfg',
|
|
5
|
+
'btn',
|
|
6
|
+
'msg',
|
|
7
|
+
'tmp',
|
|
8
|
+
'idx',
|
|
9
|
+
'val',
|
|
10
|
+
'str',
|
|
11
|
+
'obj',
|
|
12
|
+
'arr',
|
|
13
|
+
'evt',
|
|
14
|
+
]);
|
|
3
15
|
|
|
4
16
|
const SORT_METHODS = new Set(['sort', 'toSorted']);
|
|
5
17
|
const ARRAY_ITERATION_METHODS = new Set([
|
|
@@ -66,6 +78,7 @@ export const noAbbreviations = {
|
|
|
66
78
|
messages: {
|
|
67
79
|
tooShort:
|
|
68
80
|
'Identifier "{{name}}" is too short ({{length}} < {{min}}). Use a descriptive name.',
|
|
81
|
+
disallowed: 'Identifier "{{name}}" is a known abbreviation. Use a descriptive name.',
|
|
69
82
|
},
|
|
70
83
|
schema: [
|
|
71
84
|
{
|
|
@@ -73,6 +86,7 @@ export const noAbbreviations = {
|
|
|
73
86
|
properties: {
|
|
74
87
|
exceptions: { type: 'array', items: { type: 'string' }, uniqueItems: true },
|
|
75
88
|
minLength: { type: 'integer', minimum: 1 },
|
|
89
|
+
disallowedNames: { type: 'array', items: { type: 'string' }, uniqueItems: true },
|
|
76
90
|
},
|
|
77
91
|
additionalProperties: false,
|
|
78
92
|
},
|
|
@@ -83,13 +97,17 @@ export const noAbbreviations = {
|
|
|
83
97
|
const options = context.options[0] ?? {};
|
|
84
98
|
const exceptions = new Set([...DEFAULT_EXCEPTIONS, ...(options.exceptions ?? [])]);
|
|
85
99
|
const minLength = options.minLength ?? DEFAULT_MIN_LENGTH;
|
|
100
|
+
const disallowedNames = new Set([
|
|
101
|
+
...DEFAULT_DISALLOWED_NAMES,
|
|
102
|
+
...(options.disallowedNames ?? []),
|
|
103
|
+
]);
|
|
86
104
|
|
|
87
105
|
return {
|
|
88
106
|
Identifier(node) {
|
|
89
107
|
const { name, parent } = node;
|
|
90
108
|
|
|
91
|
-
if (name.length >= minLength) return;
|
|
92
109
|
if (exceptions.has(name)) return;
|
|
110
|
+
if ((name.length >= minLength) && !disallowedNames.has(name)) return;
|
|
93
111
|
|
|
94
112
|
// Only flag declaration sites — not usage sites.
|
|
95
113
|
//
|
|
@@ -124,11 +142,16 @@ export const noAbbreviations = {
|
|
|
124
142
|
isCallbackToMethod(parent, ARRAY_ITERATION_METHODS)
|
|
125
143
|
) return;
|
|
126
144
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
145
|
+
if (name.length < minLength) {
|
|
146
|
+
context.report({
|
|
147
|
+
node,
|
|
148
|
+
messageId: 'tooShort',
|
|
149
|
+
data: { name, length: name.length, min: minLength },
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
context.report({ node, messageId: 'disallowed', data: { name } });
|
|
132
155
|
},
|
|
133
156
|
};
|
|
134
157
|
},
|