@koenvanbelle/cypress-soft-assertions 2.1.1 → 2.2.1

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/dist/index.d.ts CHANGED
@@ -4,6 +4,14 @@
4
4
  * Provides soft_it() function that wraps Cypress tests to make all assertions soft.
5
5
  * Assertions don't stop execution on failure - they continue and all failures are
6
6
  * aggregated and reported at the end of the test.
7
+ *
8
+ * Architecture:
9
+ * - Chai's Assertion.prototype.assert is patched to intercept assertion failures.
10
+ * - During retries, failures are rethrown so Cypress can retry.
11
+ * - Once the retry window expires, the error is swallowed (not rethrown).
12
+ * This lets Cypress consider the command "passed" and continue the queue.
13
+ * - A fail handler catches non-assertion errors (e.g. element-not-found timeouts).
14
+ * - An afterEach hook finalizes: aggregates all failures and reports them.
7
15
  */
8
16
  declare global {
9
17
  /**
package/dist/index.js CHANGED
@@ -5,6 +5,14 @@
5
5
  * Provides soft_it() function that wraps Cypress tests to make all assertions soft.
6
6
  * Assertions don't stop execution on failure - they continue and all failures are
7
7
  * aggregated and reported at the end of the test.
8
+ *
9
+ * Architecture:
10
+ * - Chai's Assertion.prototype.assert is patched to intercept assertion failures.
11
+ * - During retries, failures are rethrown so Cypress can retry.
12
+ * - Once the retry window expires, the error is swallowed (not rethrown).
13
+ * This lets Cypress consider the command "passed" and continue the queue.
14
+ * - A fail handler catches non-assertion errors (e.g. element-not-found timeouts).
15
+ * - An afterEach hook finalizes: aggregates all failures and reports them.
8
16
  */
9
17
  Object.defineProperty(exports, "__esModule", { value: true });
10
18
  let softAssertionErrors = [];
@@ -70,10 +78,6 @@ function getRetryableCommandId() {
70
78
  const current = cy.state('current');
71
79
  if (!current)
72
80
  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
81
  const assertionCmd = (_a = current.get) === null || _a === void 0 ? void 0 : _a.call(current, 'currentAssertionCommand');
78
82
  if (assertionCmd) {
79
83
  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 : '';
@@ -105,6 +109,15 @@ function patchChaiAssertions() {
105
109
  return;
106
110
  assertionProto.assert = patchedAssertionAssert;
107
111
  }
112
+ function resolveStableToken(assertionContext, args) {
113
+ const token = getAssertionToken(assertionContext, args);
114
+ if (token)
115
+ return token;
116
+ const retryableCid = getRetryableCommandId();
117
+ if (retryableCid)
118
+ return `__cmd__|${retryableCid}|${toTokenPart(args === null || args === void 0 ? void 0 : args[3])}`;
119
+ return '';
120
+ }
108
121
  function patchedAssertionAssert(...args) {
109
122
  if (!originalChaiAssert)
110
123
  return;
@@ -112,62 +125,55 @@ function patchedAssertionAssert(...args) {
112
125
  const result = originalChaiAssert.apply(this, args);
113
126
  // Assertion passed — clear any staged failure for this token.
114
127
  if (isInSoftTest) {
115
- const token = getAssertionToken(this, args);
128
+ const token = resolveStableToken(this, args);
116
129
  if (token) {
117
130
  retryAssertionFailures.delete(token);
118
131
  retryFirstSeen.delete(token);
119
132
  }
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);
126
- }
127
133
  }
128
134
  return result;
129
135
  }
130
136
  catch (error) {
131
137
  if (!isInSoftTest)
132
138
  throw error;
133
- const token = getAssertionToken(this, args);
139
+ const errorEntry = {
140
+ message: String((error === null || error === void 0 ? void 0 : error.message) || error),
141
+ stack: error === null || error === void 0 ? void 0 : error.stack,
142
+ };
143
+ const token = resolveStableToken(this, args);
134
144
  if (token) {
135
- // Stage the failure under a stable token so it can be cleared if
136
- // a later retry succeeds.
137
- retryAssertionFailures.set(token, {
138
- message: String((error === null || error === void 0 ? void 0 : error.message) || error),
139
- stack: error === null || error === void 0 ? void 0 : error.stack,
140
- });
145
+ // Track when we first saw this failure.
141
146
  if (!retryFirstSeen.has(token)) {
142
147
  retryFirstSeen.set(token, Date.now());
143
148
  }
144
- // Always rethrow to let Cypress use its full retry/timeout window.
145
- // When the timeout expires, Cypress fires the fail event; our fail
146
- // handler captures it and clears the staged entry to avoid duplicates.
147
- throw error;
148
- }
149
- // No identifiable subject from the DOM. If we're inside a retryable
150
- // command (.should() / .and()), derive a token from the command ID so the
151
- // retry-window logic still applies (e.g. window property assertions).
152
- const retryableCid = getRetryableCommandId();
153
- if (retryableCid) {
154
- const fallbackToken = `__cmd__|${retryableCid}|${toTokenPart(args === null || args === void 0 ? void 0 : args[3])}`;
155
- retryAssertionFailures.set(fallbackToken, {
156
- message: String((error === null || error === void 0 ? void 0 : error.message) || error),
157
- stack: error === null || error === void 0 ? void 0 : error.stack,
158
- });
159
- if (!retryFirstSeen.has(fallbackToken)) {
160
- retryFirstSeen.set(fallbackToken, Date.now());
149
+ // Stage the failure so it can be cleared if a later retry succeeds.
150
+ retryAssertionFailures.set(token, errorEntry);
151
+ // Check if the retry window has expired. Swallow slightly before
152
+ // Cypress's own timeout to prevent it from firing the fail event.
153
+ // Cypress retries every ~50ms, so subtracting 100ms ensures we
154
+ // catch at least 1-2 more retries before the deadline.
155
+ const elapsed = Date.now() - retryFirstSeen.get(token);
156
+ const timeout = getEffectiveTimeout();
157
+ const swallowAt = Math.max(timeout - 100, timeout * 0.9);
158
+ if (elapsed < swallowAt) {
159
+ // Still within the retry window rethrow so Cypress retries.
160
+ throw error;
161
161
  }
162
- // Always rethrow to let Cypress use its full retry/timeout window.
163
- throw error;
162
+ // Retry window expired. Swallow the error: Cypress considers the
163
+ // assertion "passed", the command resolves, and the queue continues.
164
+ // The failure is already staged in retryAssertionFailures and will
165
+ // be promoted to softAssertionErrors during finalization.
166
+ return;
164
167
  }
165
- // Bare expect() in .then() callbacks — capture directly.
168
+ // No stable token (e.g. bare expect() in .then() callbacks).
169
+ // Capture directly and swallow so the queue continues.
166
170
  captureSoftAssertion(error);
167
171
  }
168
172
  }
169
173
  function setupSoftAssertions() {
170
174
  patchChaiAssertions();
175
+ // Fail handler for non-assertion errors (e.g. element-not-found after
176
+ // cy.get timeout). These don't go through Chai's assert at all.
171
177
  if (!activeFailHandler) {
172
178
  activeFailHandler = (error) => {
173
179
  if (!isInSoftTest)
@@ -175,11 +181,12 @@ function setupSoftAssertions() {
175
181
  // Final aggregated error must propagate to fail the test.
176
182
  if (String((error === null || error === void 0 ? void 0 : error.name) || '') === 'SoftAssertionError')
177
183
  throw error;
178
- // Non-assertion command failures (e.g. element not found timeouts)
179
- // are captured as soft failures.
184
+ // Check if this is an assertion error that was already handled by
185
+ // patchedAssertionAssert (swallowed after timeout). In that case
186
+ // the fail handler should NOT fire. But for non-assertion command
187
+ // failures (e.g. cy.get can't find element), capture as soft failure.
180
188
  captureSoftAssertion(error);
181
- // Clear any matching retry-tracked entry to prevent double-counting
182
- // at finalization (the fail handler is now the authoritative capture).
189
+ // Clear any matching retry-tracked entry to prevent double-counting.
183
190
  const errorMsg = (error === null || error === void 0 ? void 0 : error.message) || String(error);
184
191
  for (const [token, entry] of retryAssertionFailures.entries()) {
185
192
  if (entry.message === errorMsg) {
@@ -255,31 +262,13 @@ function createSoftIt(baseIt) {
255
262
  retryAssertionFailures.clear();
256
263
  retryFirstSeen.clear();
257
264
  setupSoftAssertions();
258
- let result;
259
265
  try {
260
- result = fn.call(this);
266
+ return fn.call(this);
261
267
  }
262
268
  catch (error) {
263
269
  abortSoftTest();
264
270
  throw error;
265
271
  }
266
- // Finalize from inside the test chain (not from hooks) so Cypress counts
267
- // the failure on the test itself.
268
- return cy.then(() => {
269
- return Cypress.Promise.resolve(result)
270
- .catch((error) => {
271
- if (isInSoftTest) {
272
- abortSoftTest();
273
- }
274
- throw error;
275
- })
276
- .then(() => {
277
- const finalError = finalizeSoftTest();
278
- if (finalError) {
279
- throw finalError;
280
- }
281
- });
282
- });
283
272
  });
284
273
  };
285
274
  }
@@ -309,3 +298,24 @@ globalThis.soft_it.only = createSoftIt(it.only);
309
298
  * soft_it.skip - Skip this soft test
310
299
  */
311
300
  globalThis.soft_it.skip = it.skip;
301
+ // Global afterEach hook: finalize soft assertions after each test.
302
+ // Registered at the root level so it applies to all suites.
303
+ // For non-soft tests (isInSoftTest === false), this is a no-op.
304
+ afterEach(function () {
305
+ var _a;
306
+ if (!isInSoftTest)
307
+ return;
308
+ const finalError = finalizeSoftTest();
309
+ if (finalError) {
310
+ // Use runner.fail() to mark the TEST as failed rather than the hook.
311
+ // Throwing from afterEach would skip remaining tests in the suite.
312
+ const runner = (_a = Cypress.mocha) === null || _a === void 0 ? void 0 : _a.getRunner();
313
+ const test = this.currentTest;
314
+ if (runner && test) {
315
+ runner.fail(test, finalError);
316
+ }
317
+ else {
318
+ throw finalError;
319
+ }
320
+ }
321
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koenvanbelle/cypress-soft-assertions",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
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",
@@ -9,7 +9,8 @@
9
9
  "prepublishOnly": "npm run build",
10
10
  "cy:open": "cypress open",
11
11
  "cy:run": "cypress run",
12
- "test": "npm run build && cypress run"
12
+ "test": "npm run build && cypress run && npm run test:runner",
13
+ "test:runner": "node --test test/runner.test.mjs"
13
14
  },
14
15
  "keywords": [
15
16
  "cypress",