@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
+ });
@@ -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.1.1",
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
- context.report({
128
- node,
129
- messageId: 'tooShort',
130
- data: { name, length: name.length, min: minLength },
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
  },