@koenvanbelle/cypress-soft-assertions 2.1.0 → 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 +8 -0
- package/dist/index.js +84 -77
- package/package.json +3 -2
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,79 +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 =
|
|
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
|
|
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
|
-
//
|
|
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
|
-
});
|
|
141
|
-
const now = Date.now();
|
|
145
|
+
// Track when we first saw this failure.
|
|
142
146
|
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);
|
|
147
|
+
retryFirstSeen.set(token, Date.now());
|
|
173
148
|
}
|
|
174
|
-
|
|
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);
|
|
175
156
|
const timeout = getEffectiveTimeout();
|
|
176
|
-
|
|
157
|
+
const swallowAt = Math.max(timeout - 100, timeout * 0.9);
|
|
158
|
+
if (elapsed < swallowAt) {
|
|
159
|
+
// Still within the retry window — rethrow so Cypress retries.
|
|
177
160
|
throw error;
|
|
178
161
|
}
|
|
179
|
-
//
|
|
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.
|
|
180
166
|
return;
|
|
181
167
|
}
|
|
182
|
-
//
|
|
168
|
+
// No stable token (e.g. bare expect() in .then() callbacks).
|
|
169
|
+
// Capture directly and swallow so the queue continues.
|
|
183
170
|
captureSoftAssertion(error);
|
|
184
171
|
}
|
|
185
172
|
}
|
|
186
173
|
function setupSoftAssertions() {
|
|
187
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.
|
|
188
177
|
if (!activeFailHandler) {
|
|
189
178
|
activeFailHandler = (error) => {
|
|
190
179
|
if (!isInSoftTest)
|
|
@@ -192,9 +181,20 @@ function setupSoftAssertions() {
|
|
|
192
181
|
// Final aggregated error must propagate to fail the test.
|
|
193
182
|
if (String((error === null || error === void 0 ? void 0 : error.name) || '') === 'SoftAssertionError')
|
|
194
183
|
throw error;
|
|
195
|
-
//
|
|
196
|
-
//
|
|
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.
|
|
197
188
|
captureSoftAssertion(error);
|
|
189
|
+
// Clear any matching retry-tracked entry to prevent double-counting.
|
|
190
|
+
const errorMsg = (error === null || error === void 0 ? void 0 : error.message) || String(error);
|
|
191
|
+
for (const [token, entry] of retryAssertionFailures.entries()) {
|
|
192
|
+
if (entry.message === errorMsg) {
|
|
193
|
+
retryAssertionFailures.delete(token);
|
|
194
|
+
retryFirstSeen.delete(token);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
198
|
return false;
|
|
199
199
|
};
|
|
200
200
|
Cypress.on('fail', activeFailHandler);
|
|
@@ -213,9 +213,13 @@ function restoreAssertions() {
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
function buildSoftAssertionError() {
|
|
216
|
-
// Promote any remaining retry-tracked failures
|
|
216
|
+
// Promote any remaining retry-tracked failures that weren't already
|
|
217
|
+
// captured by the fail handler (dedup by message).
|
|
217
218
|
for (const entry of retryAssertionFailures.values()) {
|
|
218
|
-
|
|
219
|
+
const isDuplicate = softAssertionErrors.some(e => e.message === entry.message);
|
|
220
|
+
if (!isDuplicate) {
|
|
221
|
+
softAssertionErrors.push(entry);
|
|
222
|
+
}
|
|
219
223
|
}
|
|
220
224
|
retryAssertionFailures.clear();
|
|
221
225
|
retryFirstSeen.clear();
|
|
@@ -258,31 +262,13 @@ function createSoftIt(baseIt) {
|
|
|
258
262
|
retryAssertionFailures.clear();
|
|
259
263
|
retryFirstSeen.clear();
|
|
260
264
|
setupSoftAssertions();
|
|
261
|
-
let result;
|
|
262
265
|
try {
|
|
263
|
-
|
|
266
|
+
return fn.call(this);
|
|
264
267
|
}
|
|
265
268
|
catch (error) {
|
|
266
269
|
abortSoftTest();
|
|
267
270
|
throw error;
|
|
268
271
|
}
|
|
269
|
-
// Finalize from inside the test chain (not from hooks) so Cypress counts
|
|
270
|
-
// the failure on the test itself.
|
|
271
|
-
return cy.then(() => {
|
|
272
|
-
return Cypress.Promise.resolve(result)
|
|
273
|
-
.catch((error) => {
|
|
274
|
-
if (isInSoftTest) {
|
|
275
|
-
abortSoftTest();
|
|
276
|
-
}
|
|
277
|
-
throw error;
|
|
278
|
-
})
|
|
279
|
-
.then(() => {
|
|
280
|
-
const finalError = finalizeSoftTest();
|
|
281
|
-
if (finalError) {
|
|
282
|
-
throw finalError;
|
|
283
|
-
}
|
|
284
|
-
});
|
|
285
|
-
});
|
|
286
272
|
});
|
|
287
273
|
};
|
|
288
274
|
}
|
|
@@ -312,3 +298,24 @@ globalThis.soft_it.only = createSoftIt(it.only);
|
|
|
312
298
|
* soft_it.skip - Skip this soft test
|
|
313
299
|
*/
|
|
314
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
|
|
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",
|