@koenvanbelle/cypress-soft-assertions 2.0.0 → 2.1.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.
Files changed (3) hide show
  1. package/README.md +9 -0
  2. package/dist/index.js +89 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -158,6 +158,15 @@ SOFT ASSERTION FAILURES (3 failed):
158
158
  - **Retriable assertions are retried**: `.should()` and `.and()` assertions are retried by Cypress before being captured as soft failures. Assertions that eventually pass are not reported.
159
159
  - **Non-assertion errors still stop execution**: Network errors, visit timeouts, and other command errors are captured as soft failures but may prevent subsequent commands from running.
160
160
  - **State resets between tests**: Each `soft_it()` test starts with a clean slate — failures from one test never leak into another.
161
+ - **Timeout-aware retries**: The plugin respects Cypress's `defaultCommandTimeout` and per-command `{ timeout }` overrides. Retriable assertions (`.should()`, `.and()`) are retried for up to 75% of the effective timeout before being captured as soft failures. For slow applications, increase the timeout to give assertions more time to pass:
162
+ ```typescript
163
+ // Global: set in cypress.config.ts
164
+ e2e: { defaultCommandTimeout: 10000 }
165
+
166
+ // Per-command: override on individual commands
167
+ cy.get('.slow-element', { timeout: 15000 }).should('be.visible');
168
+ ```
169
+ - **Cypress Studio / Command Log**: When a soft assertion fails definitively, the plugin swallows the error so the test can continue. Cypress treats the command as resolved, which means it may not appear as a failed step in the Cypress Studio command log or may look like the assertion was skipped. The assertions **do** execute and failures **are** captured — they just don't show visually in the command log. Check the final `SoftAssertionError` report for the complete list of failures.
161
170
 
162
171
  ## License
163
172
 
package/dist/index.js CHANGED
@@ -9,15 +9,26 @@
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  let softAssertionErrors = [];
11
11
  let retryAssertionFailures = new Map();
12
- let retryAttemptCount = new Map();
12
+ let retryFirstSeen = new Map();
13
13
  let isInSoftTest = false;
14
14
  let activeFailHandler = null;
15
15
  let originalChaiAssert = null;
16
- // After this many consecutive assertion failures on the same token, stop
17
- // rethrowing (which would cause Cypress to retry) and swallow instead.
18
- // This gives Cypress enough retry cycles for assertions that will eventually
19
- // pass, while bounding the time spent on definitively failing assertions.
20
- const MAX_RETHROWS = 10;
16
+ function getEffectiveTimeout() {
17
+ var _a;
18
+ try {
19
+ const current = cy.state('current');
20
+ const perCommand = (_a = current === null || current === void 0 ? void 0 : current.get) === null || _a === void 0 ? void 0 : _a.call(current, 'timeout');
21
+ if (typeof perCommand === 'number')
22
+ return perCommand;
23
+ }
24
+ catch ( /* ignore */_b) { /* ignore */ }
25
+ try {
26
+ return Cypress.config('defaultCommandTimeout') || 4000;
27
+ }
28
+ catch (_c) {
29
+ return 4000;
30
+ }
31
+ }
21
32
  function captureSoftAssertion(error) {
22
33
  const message = (error === null || error === void 0 ? void 0 : error.message) || String(error);
23
34
  const stack = error === null || error === void 0 ? void 0 : error.stack;
@@ -53,6 +64,29 @@ function toTokenPart(value) {
53
64
  return String(value);
54
65
  }
55
66
  }
67
+ function getRetryableCommandId() {
68
+ var _a, _b, _c, _d, _e, _f, _g;
69
+ try {
70
+ const current = cy.state('current');
71
+ if (!current)
72
+ return '';
73
+ // In Cypress 15, inside a .should()/.and() callback, cy.state('current')
74
+ // points to the parent command (e.g. 'wrap', 'window', 'get'). The
75
+ // 'followedByShouldCallback' attribute is set to true, and
76
+ // 'currentAssertionCommand' points to the should/and command object.
77
+ const assertionCmd = (_a = current.get) === null || _a === void 0 ? void 0 : _a.call(current, 'currentAssertionCommand');
78
+ if (assertionCmd) {
79
+ const id = (_d = (_c = (_b = assertionCmd.get) === null || _b === void 0 ? void 0 : _b.call(assertionCmd, 'id')) !== null && _c !== void 0 ? _c : assertionCmd.id) !== null && _d !== void 0 ? _d : '';
80
+ return id ? String(id) : '';
81
+ }
82
+ if ((_e = current.get) === null || _e === void 0 ? void 0 : _e.call(current, 'followedByShouldCallback')) {
83
+ const id = (_g = (_f = current.get) === null || _f === void 0 ? void 0 : _f.call(current, 'id')) !== null && _g !== void 0 ? _g : '';
84
+ return id ? String(id) : '';
85
+ }
86
+ }
87
+ catch ( /* ignore */_h) { /* ignore */ }
88
+ return '';
89
+ }
56
90
  function getAssertionToken(assertionContext, args) {
57
91
  const subjectKey = getSubjectKey(assertionContext);
58
92
  if (!subjectKey)
@@ -81,7 +115,14 @@ function patchedAssertionAssert(...args) {
81
115
  const token = getAssertionToken(this, args);
82
116
  if (token) {
83
117
  retryAssertionFailures.delete(token);
84
- retryAttemptCount.delete(token);
118
+ retryFirstSeen.delete(token);
119
+ }
120
+ // Also clear command-based fallback tokens for this command
121
+ const retryableCid = getRetryableCommandId();
122
+ if (retryableCid) {
123
+ const fallbackToken = `__cmd__|${retryableCid}|${toTokenPart(args === null || args === void 0 ? void 0 : args[3])}`;
124
+ retryAssertionFailures.delete(fallbackToken);
125
+ retryFirstSeen.delete(fallbackToken);
85
126
  }
86
127
  }
87
128
  return result;
@@ -97,21 +138,48 @@ function patchedAssertionAssert(...args) {
97
138
  message: String((error === null || error === void 0 ? void 0 : error.message) || error),
98
139
  stack: error === null || error === void 0 ? void 0 : error.stack,
99
140
  });
100
- const attempts = (retryAttemptCount.get(token) || 0) + 1;
101
- retryAttemptCount.set(token, attempts);
102
- if (attempts <= MAX_RETHROWS) {
103
- // Rethrow to let Cypress retry the assertion. This gives retriable
104
- // commands (should/and, retried from get/contains/etc.) a window
105
- // to eventually pass.
141
+ const now = Date.now();
142
+ if (!retryFirstSeen.has(token)) {
143
+ retryFirstSeen.set(token, now);
144
+ }
145
+ const elapsed = now - retryFirstSeen.get(token);
146
+ const timeout = getEffectiveTimeout();
147
+ // Use 75% of the timeout as the retry window. This ensures the plugin
148
+ // swallows the error before Cypress's own timeout fires (which would
149
+ // route through the fail handler and prevent finalization).
150
+ if (elapsed < timeout * 0.75) {
151
+ // Still within the command timeout window — rethrow to let Cypress
152
+ // retry the assertion. If it eventually passes, the token is cleared.
153
+ throw error;
154
+ }
155
+ // Past the timeout budget — swallow so the command "succeeds" and
156
+ // Cypress moves on to the next queued command. The token stays in the
157
+ // Map and will be promoted to softAssertionErrors at finalization.
158
+ return;
159
+ }
160
+ // No identifiable subject from the DOM. If we're inside a retryable
161
+ // command (.should() / .and()), derive a token from the command ID so the
162
+ // retry-window logic still applies (e.g. window property assertions).
163
+ const retryableCid = getRetryableCommandId();
164
+ if (retryableCid) {
165
+ const fallbackToken = `__cmd__|${retryableCid}|${toTokenPart(args === null || args === void 0 ? void 0 : args[3])}`;
166
+ retryAssertionFailures.set(fallbackToken, {
167
+ message: String((error === null || error === void 0 ? void 0 : error.message) || error),
168
+ stack: error === null || error === void 0 ? void 0 : error.stack,
169
+ });
170
+ const now = Date.now();
171
+ if (!retryFirstSeen.has(fallbackToken)) {
172
+ retryFirstSeen.set(fallbackToken, now);
173
+ }
174
+ const elapsed = now - retryFirstSeen.get(fallbackToken);
175
+ const timeout = getEffectiveTimeout();
176
+ if (elapsed < timeout * 0.75) {
106
177
  throw error;
107
178
  }
108
- // Past the retry budget — swallow so the command "succeeds" and Cypress
109
- // moves on to the next queued command. The token stays in the Map and
110
- // will be promoted to softAssertionErrors at finalization.
179
+ // Past retry budget — swallow and let Cypress move on.
111
180
  return;
112
181
  }
113
- // No identifiable subject — capture directly (e.g. bare expect() calls
114
- // in .then() callbacks).
182
+ // Bare expect() in .then() callbacks — capture directly.
115
183
  captureSoftAssertion(error);
116
184
  }
117
185
  }
@@ -150,7 +218,7 @@ function buildSoftAssertionError() {
150
218
  captureSoftAssertion(entry);
151
219
  }
152
220
  retryAssertionFailures.clear();
153
- retryAttemptCount.clear();
221
+ retryFirstSeen.clear();
154
222
  if (softAssertionErrors.length > 0) {
155
223
  const errorMessages = softAssertionErrors
156
224
  .map((entry, index) => ` ${index + 1}. ${entry.message}`)
@@ -180,7 +248,7 @@ function abortSoftTest() {
180
248
  isInSoftTest = false;
181
249
  restoreAssertions();
182
250
  retryAssertionFailures.clear();
183
- retryAttemptCount.clear();
251
+ retryFirstSeen.clear();
184
252
  }
185
253
  function createSoftIt(baseIt) {
186
254
  return function (title, fn) {
@@ -188,7 +256,7 @@ function createSoftIt(baseIt) {
188
256
  isInSoftTest = true;
189
257
  softAssertionErrors = [];
190
258
  retryAssertionFailures.clear();
191
- retryAttemptCount.clear();
259
+ retryFirstSeen.clear();
192
260
  setupSoftAssertions();
193
261
  let result;
194
262
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koenvanbelle/cypress-soft-assertions",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "A Cypress plugin that provides soft_it() for soft assertions - all assertions continue on failure and are reported together",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",