mocha-distributed 0.9.6 → 0.9.8

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
@@ -173,7 +173,7 @@ Keep in mind that:
173
173
  You might have a look at list-tests-from-redis.js for an example on how to
174
174
  query redis and list all tests.
175
175
 
176
- ### Mark tests to run serially
176
+ ## Run tests serially
177
177
 
178
178
  If you'd like some of your tests to run serially you can use a magic string with
179
179
  this framework.
@@ -0,0 +1,25 @@
1
+ const util = require('./util.js');
2
+ const { expect } = require('chai');
3
+
4
+ // require('mocha-distributed')
5
+ require('../index.js');
6
+
7
+ describe ('suite-7', async function () {
8
+ this.timeout (10*1000);
9
+ this.retries(3);
10
+
11
+ // this test is created to make sure that
12
+ for (let i = 0; i < 10; i++) {
13
+ it ('test-7.1-duplicated-title', async function() {
14
+ const retryCount = this.test.currentRetry();
15
+ const id = `${i}`.padStart(2, '0');
16
+ console.log (`[id=${id}] test-7.1-duplicated-title (retry ${retryCount})`);
17
+ await util.sleep(0.1 + 0.5*Math.random());
18
+
19
+ // make this test flacky to make sure that it is retried
20
+ if (retryCount < 2) {
21
+ expect (false).to.be.true;
22
+ }
23
+ })
24
+ }
25
+ })
package/index.js CHANGED
@@ -34,6 +34,16 @@ let g_redis = null;
34
34
 
35
35
  let g_capture = { stdout: null, stderr: null };
36
36
 
37
+ // Cache errors from intermediate retry attempts. Mocha only sets test.err via
38
+ // the reporter on the final EVENT_TEST_FAIL; for non-final retries it emits
39
+ // EVENT_TEST_RETRY instead and never stores the error on the test object.
40
+ const g_retryErrors = new Map();
41
+
42
+ // Map to keep track of the number of times a test key full path has been seen,
43
+ // to add a suffix and parallelize duplicated test titles in multiple runners
44
+ const g_duplicateTestKeyFullPathCount = new Map();
45
+ let g_lastTestKeyFullPath = null;
46
+
37
47
  // -----------------------------------------------------------------------------
38
48
  // getTestPath
39
49
  //
@@ -108,6 +118,11 @@ function captureStream(stream) {
108
118
  // Initialize redis once before the tests
109
119
  // -----------------------------------------------------------------------------
110
120
  exports.mochaGlobalSetup = async function () {
121
+ // `this` is the Mocha Runner — store errors from non-final retry attempts
122
+ // so afterEach can record them (Mocha never sets test.err for those).
123
+ this.on('retry', (test, err) => {
124
+ g_retryErrors.set(test.fullTitle(), err);
125
+ });
111
126
  if (g_mochaVerbose) {
112
127
  const redisNoCredentials = g_redisAddress.replace(
113
128
  /\/\/[^@]*@/,
@@ -160,9 +175,26 @@ exports.mochaGlobalTeardown = async function () {
160
175
  exports.mochaHooks = {
161
176
  beforeEach: async function () {
162
177
  const testPath = getTestPath(this.currentTest);
163
- const testKeyFullPath = `${g_testExecutionId}:${getSerialGranularity(testPath.join(":"))}`;
178
+ let testKeyFullPath = `${g_testExecutionId}:${getSerialGranularity(testPath.join(":"))}`;
164
179
  const testKeySuite = `${g_testExecutionId}:${getSerialGranularity(testPath[0])}`;
165
180
 
181
+ // if this is the first attempt, we need to put a suffix to be able to
182
+ // parallelize duplicates in multiple runners
183
+ if ((this.currentTest._currentRetry || 0) === 0) {
184
+ g_duplicateTestKeyFullPathCount.set(
185
+ testKeyFullPath,
186
+ (g_duplicateTestKeyFullPathCount.get(testKeyFullPath) || 0) + 1
187
+ );
188
+ testKeyFullPath += `:dup-${g_duplicateTestKeyFullPathCount.get(testKeyFullPath)}`;
189
+ g_lastTestKeyFullPath = testKeyFullPath;
190
+ }
191
+ else {
192
+ // ensure we use the same key for retries as the original attempt, otherwise
193
+ // we will screw up the distribution of tests; we use the last generated key
194
+ // since retries are executed sequentially
195
+ testKeyFullPath = g_lastTestKeyFullPath;
196
+ }
197
+
166
198
  const testKey =
167
199
  g_granularity === GRANULARITY.TEST ? testKeyFullPath : testKeySuite;
168
200
 
@@ -183,7 +215,7 @@ exports.mochaHooks = {
183
215
  }
184
216
  },
185
217
 
186
- afterEach(done) {
218
+ afterEach: async function () {
187
219
  const SKIPPED = "pending";
188
220
  const FAILED = "failed";
189
221
  const PASSED = "passed";
@@ -227,7 +259,10 @@ exports.mochaHooks = {
227
259
  // Error objects cannot be properly serialized with stringify, thus
228
260
  // we need to use this hack to make it look like a normal object.
229
261
  // Hopefully this should work as well with other sort of objects
230
- const err = this.currentTest.err || null;
262
+ const err = this.currentTest.err
263
+ || g_retryErrors.get(this.currentTest.fullTitle())
264
+ || null;
265
+ g_retryErrors.delete(this.currentTest.fullTitle());
231
266
  const errObj = JSON.parse(
232
267
  JSON.stringify(err, Object.getOwnPropertyNames(err || {}))
233
268
  );
@@ -252,16 +287,16 @@ exports.mochaHooks = {
252
287
  };
253
288
 
254
289
  // save results as single line on purpose
255
- const key = `${g_testExecutionId}:test_result`;
256
- g_redis.rPush(key, JSON.stringify(testResult));
257
- g_redis.expire(key, g_expirationTime);
258
-
259
- // increment passed_count/failed_count & set expiry time
290
+ const resultKey = `${g_testExecutionId}:test_result`;
260
291
  const countKey = `${g_testExecutionId}:${stateFixed}_count`;
261
- g_redis.incr(countKey);
262
- g_redis.expire(countKey, g_expirationTime);
263
- }
264
292
 
265
- done();
293
+ await g_redis
294
+ .multi()
295
+ .rPush(resultKey, JSON.stringify(testResult))
296
+ .expire(resultKey, g_expirationTime)
297
+ .incr(countKey)
298
+ .expire(countKey, g_expirationTime)
299
+ .exec();
300
+ }
266
301
  },
267
302
  };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "mocha-distributed",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Run multiple mocha suites and tests in parallel, from different processes and different machines. Results available on a redis database.",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
7
+ "test": "mocha test/test-retry.js"
8
8
  },
9
9
  "keywords": [
10
10
  "mocha",
@@ -0,0 +1,142 @@
1
+ // -----------------------------------------------------------------------------
2
+ // test/test-retry.js
3
+ //
4
+ // Mocha test suite verifying that mocha-distributed records err, stdout and
5
+ // stderr on ALL retry attempts, not just the last one.
6
+ //
7
+ // Run with: mocha test/test-retry.js
8
+ // -----------------------------------------------------------------------------
9
+ 'use strict';
10
+
11
+ const assert = require('assert');
12
+ const Mocha = require('mocha/lib/mocha');
13
+ const Suite = require('mocha/lib/suite');
14
+ const Test = require('mocha/lib/test');
15
+
16
+ // -----------------------------------------------------------------------------
17
+ // Mock redis helpers
18
+ // -----------------------------------------------------------------------------
19
+ const redisResolved = require.resolve('redis');
20
+
21
+ const mockMulti = (writtenResults) => () => {
22
+ const cmds = [];
23
+ const chain = {
24
+ set: (...a) => { cmds.push(['set', ...a]); return chain; },
25
+ get: (...a) => { cmds.push(['get', ...a]); return chain; },
26
+ rPush: (...a) => {
27
+ cmds.push(['rPush', ...a]);
28
+ writtenResults.push(JSON.parse(a[1]));
29
+ return chain;
30
+ },
31
+ expire: (...a) => { cmds.push(['expire', ...a]); return chain; },
32
+ incr: (...a) => { cmds.push(['incr', ...a]); return chain; },
33
+ exec: async () => {
34
+ // beforeEach pipeline: SET NX + GET — this runner always wins ownership
35
+ if (cmds[0] && cmds[0][0] === 'set') {
36
+ return [null, process.env.MOCHA_DISTRIBUTED_RUNNER_ID];
37
+ }
38
+ // afterEach pipeline: rPush + expire + incr + expire
39
+ return [1, 1, 1, 1];
40
+ }
41
+ };
42
+ return chain;
43
+ };
44
+
45
+ function injectMockRedis(writtenResults) {
46
+ require.cache[redisResolved] = {
47
+ id: redisResolved,
48
+ filename: redisResolved,
49
+ loaded: true,
50
+ exports: {
51
+ createClient: () => ({
52
+ on: () => {},
53
+ connect: async () => {},
54
+ quit: async () => {},
55
+ multi: mockMulti(writtenResults),
56
+ })
57
+ },
58
+ };
59
+ }
60
+
61
+ function restoreRedis() {
62
+ delete require.cache[redisResolved];
63
+ }
64
+
65
+ function loadFreshLib() {
66
+ const libPath = require.resolve('../index.js');
67
+ delete require.cache[libPath];
68
+ return require('../index.js');
69
+ }
70
+
71
+ // -----------------------------------------------------------------------------
72
+ // Tests
73
+ // -----------------------------------------------------------------------------
74
+ describe('mocha-distributed', function () {
75
+
76
+ describe('retry attempt recording', function () {
77
+ let writtenResults;
78
+ let lib;
79
+
80
+ before(function () {
81
+ writtenResults = [];
82
+ injectMockRedis(writtenResults);
83
+
84
+ process.env.MOCHA_DISTRIBUTED = 'redis://mock';
85
+ process.env.MOCHA_DISTRIBUTED_EXECUTION_ID = 'test-exec-retry';
86
+ process.env.MOCHA_DISTRIBUTED_RUNNER_ID = 'runner-test';
87
+
88
+ lib = loadFreshLib();
89
+ });
90
+
91
+ after(function () {
92
+ restoreRedis();
93
+ delete require.cache[require.resolve('../index.js')];
94
+ });
95
+
96
+ it('records err, stdout and stderr on every retry attempt', async function () {
97
+ this.timeout(10000);
98
+
99
+ // Build the inner mocha instance with a flaky test that fails on
100
+ // attempts 0 and 1, and passes on attempt 2
101
+ const m = new Mocha({ reporter: 'min' });
102
+ m.rootHooks(lib.mochaHooks);
103
+ m.globalSetup([lib.mochaGlobalSetup]);
104
+ m.globalTeardown([lib.mochaGlobalTeardown]);
105
+
106
+ const suite = Suite.create(m.suite, 'retry-suite');
107
+ suite.retries(2);
108
+
109
+ let attempt = 0;
110
+ suite.addTest(new Test('flaky-test', function () {
111
+ attempt++;
112
+ console.log('stdout from attempt ' + attempt);
113
+ console.error('stderr from attempt ' + attempt);
114
+ if (attempt < 3) throw new Error('intentional failure on attempt ' + attempt);
115
+ }));
116
+
117
+ await new Promise(resolve => m.run(resolve));
118
+
119
+ // Sort by retryAttempt for stable assertions
120
+ const results = writtenResults.slice().sort((a, b) => a.retryAttempt - b.retryAttempt);
121
+
122
+ assert.strictEqual(results.length, 3, '3 results written to Redis (one per attempt)');
123
+
124
+ // Attempts 0 and 1: failed, err populated, stdout and stderr present
125
+ for (const i of [0, 1]) {
126
+ const r = results[i];
127
+ assert.strictEqual(r.retryAttempt, i, `attempt ${i}: retryAttempt`);
128
+ assert.strictEqual(r.state, 'failed', `attempt ${i}: state`);
129
+ assert.ok(r.err && r.err.message, `attempt ${i}: err.message should be set`);
130
+ assert.ok(r.stdout.includes(`attempt ${i + 1}`), `attempt ${i}: stdout`);
131
+ assert.ok(r.stderr.includes(`attempt ${i + 1}`), `attempt ${i}: stderr`);
132
+ }
133
+
134
+ // Attempt 2: passed, stdout and stderr present
135
+ const r2 = results[2];
136
+ assert.strictEqual(r2.retryAttempt, 2, 'attempt 2: retryAttempt');
137
+ assert.strictEqual(r2.state, 'passed', 'attempt 2: state');
138
+ assert.ok(r2.stdout.includes('attempt 3'), 'attempt 2: stdout');
139
+ assert.ok(r2.stderr.includes('attempt 3'), 'attempt 2: stderr');
140
+ });
141
+ });
142
+ });
@@ -5,10 +5,10 @@ export MOCHA_DISTRIBUTED="redis://127.0.0.1"
5
5
  npm install > /dev/null 2>&1
6
6
 
7
7
  N=$1
8
- COMMAND="mocha --require ./index.js example/**/*.js"
8
+ COMMAND="npx mocha --require ./index.js example/**/*.js"
9
9
 
10
10
  # cleanup tmp files
11
- rm tmp-*
11
+ rm -f tmp-*
12
12
 
13
13
  echo "Spawning $N commands in parallel with Execution ID: $MOCHA_DISTRIBUTED_EXECUTION_ID"
14
14