@koenvanbelle/cypress-soft-assertions 2.3.2 → 2.4.3

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
@@ -130,6 +130,23 @@ describe('Test Suite', () => {
130
130
  });
131
131
  ```
132
132
 
133
+ ### `soft_it.expectFailure`
134
+
135
+ Use `soft_it.expectFailure()` for browser behavior specs that intentionally trigger
136
+ soft failures but should still finish green overall.
137
+
138
+ ```typescript
139
+ soft_it.expectFailure('captures a known soft failure without failing the spec', () => {
140
+ cy.get('#title').should('have.text', 'Wrong Title');
141
+ cy.get('#status').should('have.text', 'Ready');
142
+ });
143
+ ```
144
+
145
+ This mode still verifies that a final `SoftAssertionError` was produced. If the
146
+ test completes without any soft failure, the test fails.
147
+
148
+ `soft_it.expectFailure.only(...)` is also available.
149
+
133
150
  ## How it works
134
151
 
135
152
  1. `soft_it()` patches Chai's assertion mechanism for the duration of the test.
@@ -158,7 +175,7 @@ SOFT ASSERTION FAILURES (3 failed):
158
175
  - **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
176
  - **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
177
  - **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:
178
+ - **Timeout-aware retries**: The plugin respects Cypress's `defaultCommandTimeout` and per-command `{ timeout }` overrides. Retriable assertions (`.should()`, `.and()`) are retried until just before the effective timeout expires, using `max(timeout - 100ms, timeout * 0.9)` as the swallow threshold. For slow applications, increase the timeout to give assertions more time to pass:
162
179
  ```typescript
163
180
  // Global: set in cypress.config.ts
164
181
  e2e: { defaultCommandTimeout: 10000 }
@@ -249,36 +266,6 @@ soft_it('works with automatic translation checks', () => {
249
266
  - Both plugins register hooks/events, but they can run together safely with the setup above.
250
267
  - Keep translation-checker in non-invasive mode (`failOnError: false` in auto mode) so functional assertions remain the source of test pass/fail behavior.
251
268
 
252
- ## Force-Fail Hook (strict mode)
253
-
254
- If your project (or another plugin) mutates test state in hooks (for example in `test:after:run`) and soft-failed tests appear as passed, enable strict mode per test:
255
-
256
- ```typescript
257
- soft_it.strict('fails hard when soft assertions are captured', () => {
258
- cy.visit('/');
259
- cy.get('#title').should('have.text', 'Wrong Title');
260
- cy.get('#status').should('have.text', 'Ready');
261
- });
262
- ```
263
-
264
- You can also use `soft_it.strict.only(...)` and `soft_it.strict.skip(...)`.
265
-
266
- To force strict mode for all soft tests in a run, use env flags:
267
-
268
- ```bash
269
- cypress run --env softAssertForceFail=true
270
- ```
271
-
272
- You can also use:
273
-
274
- ```bash
275
- cypress run --env SOFT_ASSERT_FORCE_FAIL=true
276
- ```
277
-
278
- When strict mode is enabled (per test or via env), the plugin throws the final `SoftAssertionError` from `afterEach` on the last attempt, preventing downstream hook-based recovery from turning the test green.
279
-
280
- Note: strict mode may abort remaining tests in the same suite after the first forced soft failure (standard Cypress hook-failure behavior).
281
-
282
269
  ## License
283
270
 
284
271
  MIT
package/dist/index.d.ts CHANGED
@@ -40,14 +40,12 @@ declare global {
40
40
  */
41
41
  function only(title: string, fn: Mocha.Func | Mocha.AsyncFunc): Mocha.Test;
42
42
  /**
43
- * Run this soft test in strict mode (force unrecoverable failure)
43
+ * Run a soft test that is expected to finish with a SoftAssertionError.
44
44
  */
45
- function strict(title: string, fn: Mocha.Func | Mocha.AsyncFunc): Mocha.Test;
46
- namespace strict {
47
- /** Run only this strict soft test */
45
+ function expectFailure(title: string, fn: Mocha.Func | Mocha.AsyncFunc): Mocha.Test;
46
+ namespace expectFailure {
47
+ /** Run only this expected-failure soft test */
48
48
  function only(title: string, fn: Mocha.Func | Mocha.AsyncFunc): Mocha.Test;
49
- /** Skip this strict soft test */
50
- function skip(title: string, fn: Mocha.Func | Mocha.AsyncFunc): void;
51
49
  }
52
50
  /**
53
51
  * Skip this test (like it.skip)
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ let softAssertionErrors = [];
20
20
  let retryAssertionFailures = new Map();
21
21
  let retryFirstSeen = new Map();
22
22
  let isInSoftTest = false;
23
- let forceFailCurrentSoftTest = false;
23
+ let expectSoftFailureCurrentSoftTest = false;
24
24
  let activeFailHandler = null;
25
25
  let originalChaiAssert = null;
26
26
  // ---------------------------------------------------------------------------
@@ -42,26 +42,6 @@ function getEffectiveTimeout() {
42
42
  return 4000;
43
43
  }
44
44
  }
45
- function isTruthy(value) {
46
- if (typeof value === 'boolean')
47
- return value;
48
- if (typeof value === 'number')
49
- return value !== 0;
50
- const normalized = String(value !== null && value !== void 0 ? value : '').trim().toLowerCase();
51
- return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
52
- }
53
- function shouldForceFailSoftAssertions() {
54
- var _a;
55
- try {
56
- const env = (_a = Cypress.env) === null || _a === void 0 ? void 0 : _a.bind(Cypress);
57
- if (!env)
58
- return false;
59
- return isTruthy(env('softAssertForceFail')) || isTruthy(env('SOFT_ASSERT_FORCE_FAIL'));
60
- }
61
- catch (_b) {
62
- return false;
63
- }
64
- }
65
45
  // ---------------------------------------------------------------------------
66
46
  // Internal helpers (Cypress-dependent — not unit-testable in isolation)
67
47
  // ---------------------------------------------------------------------------
@@ -181,6 +161,12 @@ function setupSoftAssertions() {
181
161
  // Final aggregated error must propagate to fail the test.
182
162
  if (String((error === null || error === void 0 ? void 0 : error.name) || '') === 'SoftAssertionError')
183
163
  throw error;
164
+ const runnable = cy.state('runnable');
165
+ const retryStatusInfo = getRetryStatusInfo(runnable);
166
+ if (retryStatusInfo.shouldAttemptsContinue) {
167
+ abortSoftTest();
168
+ throw error;
169
+ }
184
170
  // Check if this is an assertion error that was already handled by
185
171
  // patchedAssertionAssert (swallowed after timeout). In that case
186
172
  // the fail handler should NOT fire. But for non-assertion command
@@ -229,28 +215,70 @@ function buildSoftAssertionError() {
229
215
  }
230
216
  function finalizeSoftTest() {
231
217
  isInSoftTest = false;
232
- forceFailCurrentSoftTest = false;
218
+ expectSoftFailureCurrentSoftTest = false;
233
219
  restoreAssertions();
234
220
  return buildSoftAssertionError();
235
221
  }
236
222
  function abortSoftTest() {
237
223
  isInSoftTest = false;
238
- forceFailCurrentSoftTest = false;
224
+ expectSoftFailureCurrentSoftTest = false;
239
225
  restoreAssertions();
240
226
  retryAssertionFailures.clear();
241
227
  retryFirstSeen.clear();
242
228
  }
229
+ function getRetryStatusInfo(test) {
230
+ var _a;
231
+ const currentRetry = typeof (test === null || test === void 0 ? void 0 : test.currentRetry) === 'function'
232
+ ? test.currentRetry()
233
+ : typeof Cypress.currentRetry === 'number'
234
+ ? Cypress.currentRetry
235
+ : 0;
236
+ const maxRetries = typeof (test === null || test === void 0 ? void 0 : test.retries) === 'function'
237
+ ? test.retries()
238
+ : typeof (test === null || test === void 0 ? void 0 : test._retries) === 'number'
239
+ ? test._retries
240
+ : typeof Cypress.getTestRetries === 'function'
241
+ ? (_a = Cypress.getTestRetries()) !== null && _a !== void 0 ? _a : 0
242
+ : 0;
243
+ return {
244
+ attempts: currentRetry + 1,
245
+ shouldAttemptsContinue: currentRetry < maxRetries,
246
+ };
247
+ }
248
+ function finalizeSoftTestInQueue(expectsSoftFailure) {
249
+ return cy.then(() => {
250
+ if (!isInSoftTest)
251
+ return;
252
+ const finalError = finalizeSoftTest();
253
+ if (expectsSoftFailure) {
254
+ if (!finalError) {
255
+ throw new Error('Expected SoftAssertionError but soft_it.expectFailure test completed without one.');
256
+ }
257
+ return;
258
+ }
259
+ if (finalError) {
260
+ throw finalError;
261
+ }
262
+ });
263
+ }
243
264
  function createSoftIt(baseIt, options) {
244
265
  return function (title, fn) {
245
266
  return baseIt(title, function () {
246
267
  isInSoftTest = true;
247
- forceFailCurrentSoftTest = Boolean(options === null || options === void 0 ? void 0 : options.strict) || shouldForceFailSoftAssertions();
268
+ expectSoftFailureCurrentSoftTest = Boolean(options === null || options === void 0 ? void 0 : options.expectFailure);
248
269
  softAssertionErrors = [];
249
270
  retryAssertionFailures.clear();
250
271
  retryFirstSeen.clear();
251
272
  setupSoftAssertions();
252
273
  try {
253
- return fn.call(this);
274
+ const result = fn.call(this);
275
+ if (result && typeof result.then === 'function' && !Cypress.isCy(result)) {
276
+ return Cypress.Promise.resolve(result).then(() => {
277
+ finalizeSoftTestInQueue(expectSoftFailureCurrentSoftTest);
278
+ });
279
+ }
280
+ finalizeSoftTestInQueue(expectSoftFailureCurrentSoftTest);
281
+ return result;
254
282
  }
255
283
  catch (error) {
256
284
  abortSoftTest();
@@ -282,57 +310,54 @@ globalThis.soft_it = createSoftIt(it);
282
310
  */
283
311
  globalThis.soft_it.only = createSoftIt(it.only);
284
312
  /**
285
- * soft_it.strict - Run this soft test in strict mode.
286
- *
287
- * Strict mode throws the final SoftAssertionError from afterEach on the
288
- * last attempt, making the failure unrecoverable by downstream hooks.
313
+ * soft_it.expectFailure - Run a soft test that is expected to aggregate
314
+ * into a final SoftAssertionError without failing the enclosing behavior spec.
289
315
  */
290
- globalThis.soft_it.strict = createSoftIt(it, { strict: true });
316
+ globalThis.soft_it.expectFailure = createSoftIt(it, { expectFailure: true });
291
317
  /**
292
- * soft_it.strict.only - Run only this strict soft test
318
+ * soft_it.expectFailure.only - Run only this expected-failure soft test.
293
319
  */
294
- globalThis.soft_it.strict.only = createSoftIt(it.only, { strict: true });
320
+ globalThis.soft_it.expectFailure.only = createSoftIt(it.only, { expectFailure: true });
295
321
  /**
296
322
  * soft_it.skip - Skip this soft test
297
323
  */
298
324
  globalThis.soft_it.skip = it.skip;
299
- /**
300
- * soft_it.strict.skip - Skip this strict soft test
301
- */
302
- globalThis.soft_it.strict.skip = it.skip;
303
325
  // Global afterEach hook: finalize soft assertions after each test.
304
326
  // Registered at the root level so it applies to all suites.
305
327
  // For non-soft tests (isInSoftTest === false), this is a no-op.
306
328
  afterEach(function () {
307
- var _a, _b, _c;
308
329
  if (!isInSoftTest)
309
330
  return;
331
+ const expectsSoftFailure = expectSoftFailureCurrentSoftTest;
310
332
  const finalError = finalizeSoftTest();
333
+ if (expectsSoftFailure) {
334
+ if (!finalError) {
335
+ throw new Error('Expected SoftAssertionError but soft_it.expectFailure test completed without one.');
336
+ }
337
+ return;
338
+ }
311
339
  if (finalError) {
312
340
  const test = this.currentTest;
313
- const currentRetry = (_a = test === null || test === void 0 ? void 0 : test._currentRetry) !== null && _a !== void 0 ? _a : 0;
314
- const maxRetries = (_b = test === null || test === void 0 ? void 0 : test._retries) !== null && _b !== void 0 ? _b : 0;
315
- if (currentRetry < maxRetries) {
316
- // Intermediate retry attempt — throw so Cypress triggers the next retry.
317
- // Cypress's retry machinery intercepts hook failures during non-final
318
- // attempts and does NOT abort the suite, so this is safe here.
319
- throw finalError;
320
- }
321
- // Last (or only) attempt — use runner.fail() to mark the TEST as failed
322
- // rather than the hook. Throwing from afterEach on the final attempt
323
- // would skip remaining tests in the suite.
324
- if (forceFailCurrentSoftTest) {
325
- // Strict mode: force an unrecoverable hook failure.
326
- // This guarantees a failed result even when other plugins mutate
327
- // test state in test:after:run.
328
- throw finalError;
329
- }
330
- const runner = (_c = Cypress.mocha) === null || _c === void 0 ? void 0 : _c.getRunner();
331
- if (runner && test) {
332
- runner.fail(test, finalError);
333
- }
334
- else {
335
- throw finalError;
341
+ if (test) {
342
+ const retryStatusInfo = getRetryStatusInfo(test);
343
+ if (retryStatusInfo.shouldAttemptsContinue) {
344
+ throw finalError;
345
+ }
346
+ test.err = finalError;
347
+ test._cypressTestStatusInfo = {
348
+ outerStatus: 'failed',
349
+ shouldAttemptsContinue: retryStatusInfo.shouldAttemptsContinue,
350
+ attempts: retryStatusInfo.attempts,
351
+ strategy: 'detect-flake-and-pass-on-threshold',
352
+ };
353
+ const prevAttempts = Array.isArray(test.prevAttempts) ? test.prevAttempts : [];
354
+ if (!prevAttempts.some((attempt) => (attempt === null || attempt === void 0 ? void 0 : attempt.state) === 'failed' && (attempt === null || attempt === void 0 ? void 0 : attempt.err))) {
355
+ prevAttempts.unshift({
356
+ state: 'failed',
357
+ err: finalError,
358
+ });
359
+ }
360
+ test.prevAttempts = prevAttempts;
336
361
  }
337
362
  }
338
363
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koenvanbelle/cypress-soft-assertions",
3
- "version": "2.3.2",
3
+ "version": "2.4.3",
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",