ava 3.12.0 → 3.15.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.
@@ -1,27 +1,48 @@
1
1
  'use strict';
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
+ const url = require('url');
4
5
  const vm = require('vm');
5
- const isPlainObject = require('is-plain-object');
6
+ const {isPlainObject} = require('is-plain-object');
6
7
  const pkgConf = require('pkg-conf');
7
8
 
8
9
  const NO_SUCH_FILE = Symbol('no ava.config.js file');
9
10
  const MISSING_DEFAULT_EXPORT = Symbol('missing default export');
10
- const EXPERIMENTS = new Set(['configurableModuleFormat', 'disableSnapshotsInHooks', 'reverseTeardowns']);
11
+ const EXPERIMENTS = new Set([
12
+ 'configurableModuleFormat',
13
+ 'disableNullExpectations',
14
+ 'disableSnapshotsInHooks',
15
+ 'nextGenConfig',
16
+ 'reverseTeardowns',
17
+ 'sharedWorkers'
18
+ ]);
11
19
 
12
20
  // *Very* rudimentary support for loading ava.config.js files containing an `export default` statement.
13
- const evaluateJsConfig = configFile => {
14
- const contents = fs.readFileSync(configFile, 'utf8');
15
- const script = new vm.Script(`'use strict';(()=>{let __export__;\n${contents.replace(/export default/g, '__export__ =')};return __export__;})()`, {
21
+ const evaluateJsConfig = (contents, configFile) => {
22
+ const script = new vm.Script(`'use strict';(()=>{let __export__;\n${contents.toString('utf8').replace(/export default/g, '__export__ =')};return __export__;})()`, {
16
23
  filename: configFile,
17
24
  lineOffset: -1
18
25
  });
19
- return {
20
- default: script.runInThisContext()
21
- };
26
+ return script.runInThisContext();
22
27
  };
23
28
 
24
- const loadJsConfig = ({projectDir, configFile = path.join(projectDir, 'ava.config.js')}) => {
29
+ const importConfig = async ({configFile, fileForErrorMessage}) => {
30
+ let module;
31
+ try {
32
+ module = await import(url.pathToFileURL(configFile)); // eslint-disable-line node/no-unsupported-features/es-syntax
33
+ } catch (error) {
34
+ throw Object.assign(new Error(`Error loading ${fileForErrorMessage}: ${error.message}`), {parent: error});
35
+ }
36
+
37
+ const {default: config = MISSING_DEFAULT_EXPORT} = module;
38
+ if (config === MISSING_DEFAULT_EXPORT) {
39
+ throw new Error(`${fileForErrorMessage} must have a default export`);
40
+ }
41
+
42
+ return config;
43
+ };
44
+
45
+ const loadJsConfig = ({projectDir, configFile = path.join(projectDir, 'ava.config.js')}, useImport = false) => {
25
46
  if (!configFile.endsWith('.js')) {
26
47
  return null;
27
48
  }
@@ -30,7 +51,10 @@ const loadJsConfig = ({projectDir, configFile = path.join(projectDir, 'ava.confi
30
51
 
31
52
  let config;
32
53
  try {
33
- ({default: config = MISSING_DEFAULT_EXPORT} = evaluateJsConfig(configFile));
54
+ const contents = fs.readFileSync(configFile);
55
+ config = useImport && contents.includes('nonSemVerExperiments') && contents.includes('nextGenConfig') ?
56
+ importConfig({configFile, fileForErrorMessage}) :
57
+ evaluateJsConfig(contents, configFile) || MISSING_DEFAULT_EXPORT;
34
58
  } catch (error) {
35
59
  if (error.code === 'ENOENT') {
36
60
  return null;
@@ -63,14 +87,17 @@ const loadCjsConfig = ({projectDir, configFile = path.join(projectDir, 'ava.conf
63
87
  }
64
88
  };
65
89
 
66
- const loadMjsConfig = ({projectDir, configFile = path.join(projectDir, 'ava.config.mjs')}) => {
90
+ const loadMjsConfig = ({projectDir, configFile = path.join(projectDir, 'ava.config.mjs')}, experimentally = false) => {
67
91
  if (!configFile.endsWith('.mjs')) {
68
92
  return null;
69
93
  }
70
94
 
71
95
  const fileForErrorMessage = path.relative(projectDir, configFile);
72
96
  try {
73
- fs.readFileSync(configFile);
97
+ const contents = fs.readFileSync(configFile);
98
+ if (experimentally && contents.includes('nonSemVerExperiments') && contents.includes('nextGenConfig')) {
99
+ return {config: importConfig({configFile, fileForErrorMessage}), fileForErrorMessage};
100
+ }
74
101
  } catch (error) {
75
102
  if (error.code === 'ENOENT') {
76
103
  return null;
@@ -82,11 +109,7 @@ const loadMjsConfig = ({projectDir, configFile = path.join(projectDir, 'ava.conf
82
109
  throw new Error(`AVA cannot yet load ${fileForErrorMessage} files`);
83
110
  };
84
111
 
85
- function loadConfig({configFile, resolveFrom = process.cwd(), defaults = {}} = {}) { // eslint-disable-line complexity
86
- let packageConf = pkgConf.sync('ava', {cwd: resolveFrom});
87
- const filepath = pkgConf.filepath(packageConf);
88
- const projectDir = filepath === null ? resolveFrom : path.dirname(filepath);
89
-
112
+ function resolveConfigFile(projectDir, configFile) {
90
113
  if (configFile) {
91
114
  configFile = path.resolve(configFile); // Relative to CWD
92
115
  if (path.basename(configFile) !== path.relative(projectDir, configFile)) {
@@ -98,6 +121,15 @@ function loadConfig({configFile, resolveFrom = process.cwd(), defaults = {}} = {
98
121
  }
99
122
  }
100
123
 
124
+ return configFile;
125
+ }
126
+
127
+ function loadConfigSync({configFile, resolveFrom = process.cwd(), defaults = {}} = {}) {
128
+ let packageConf = pkgConf.sync('ava', {cwd: resolveFrom});
129
+ const filepath = pkgConf.filepath(packageConf);
130
+ const projectDir = filepath === null ? resolveFrom : path.dirname(filepath);
131
+
132
+ configFile = resolveConfigFile(projectDir, configFile);
101
133
  const allowConflictWithPackageJson = Boolean(configFile);
102
134
 
103
135
  let [{config: fileConf, fileForErrorMessage} = {config: NO_SUCH_FILE, fileForErrorMessage: undefined}, ...conflicting] = [
@@ -157,4 +189,79 @@ function loadConfig({configFile, resolveFrom = process.cwd(), defaults = {}} = {
157
189
  return config;
158
190
  }
159
191
 
160
- module.exports = loadConfig;
192
+ exports.loadConfigSync = loadConfigSync;
193
+
194
+ async function loadConfig({configFile, resolveFrom = process.cwd(), defaults = {}} = {}) {
195
+ let packageConf = await pkgConf('ava', {cwd: resolveFrom});
196
+ const filepath = pkgConf.filepath(packageConf);
197
+ const projectDir = filepath === null ? resolveFrom : path.dirname(filepath);
198
+
199
+ configFile = resolveConfigFile(projectDir, configFile);
200
+ const allowConflictWithPackageJson = Boolean(configFile);
201
+
202
+ // TODO: Refactor resolution logic to implement https://github.com/avajs/ava/issues/2285.
203
+ let [{config: fileConf, fileForErrorMessage} = {config: NO_SUCH_FILE, fileForErrorMessage: undefined}, ...conflicting] = [
204
+ loadJsConfig({projectDir, configFile}, true),
205
+ loadCjsConfig({projectDir, configFile}),
206
+ loadMjsConfig({projectDir, configFile}, true)
207
+ ].filter(result => result !== null);
208
+
209
+ if (conflicting.length > 0) {
210
+ throw new Error(`Conflicting configuration in ${fileForErrorMessage} and ${conflicting.map(({fileForErrorMessage}) => fileForErrorMessage).join(' & ')}`);
211
+ }
212
+
213
+ let sawPromise = false;
214
+ if (fileConf !== NO_SUCH_FILE) {
215
+ if (allowConflictWithPackageJson) {
216
+ packageConf = {};
217
+ } else if (Object.keys(packageConf).length > 0) {
218
+ throw new Error(`Conflicting configuration in ${fileForErrorMessage} and package.json`);
219
+ }
220
+
221
+ if (fileConf && typeof fileConf.then === 'function') { // eslint-disable-line promise/prefer-await-to-then
222
+ sawPromise = true;
223
+ fileConf = await fileConf;
224
+ }
225
+
226
+ if (!isPlainObject(fileConf) && typeof fileConf !== 'function') {
227
+ throw new TypeError(`${fileForErrorMessage} must export a plain object or factory function`);
228
+ }
229
+
230
+ if (typeof fileConf === 'function') {
231
+ fileConf = fileConf({projectDir});
232
+ if (fileConf && typeof fileConf.then === 'function') { // eslint-disable-line promise/prefer-await-to-then
233
+ sawPromise = true;
234
+ fileConf = await fileConf;
235
+ }
236
+
237
+ if (!isPlainObject(fileConf)) {
238
+ throw new TypeError(`Factory method exported by ${fileForErrorMessage} must return a plain object`);
239
+ }
240
+ }
241
+
242
+ if ('ava' in fileConf) {
243
+ throw new Error(`Encountered ’ava’ property in ${fileForErrorMessage}; avoid wrapping the configuration`);
244
+ }
245
+ }
246
+
247
+ const config = {...defaults, nonSemVerExperiments: {}, ...fileConf, ...packageConf, projectDir};
248
+
249
+ const {nonSemVerExperiments: experiments} = config;
250
+ if (!isPlainObject(experiments)) {
251
+ throw new Error(`nonSemVerExperiments from ${fileForErrorMessage} must be an object`);
252
+ }
253
+
254
+ for (const key of Object.keys(experiments)) {
255
+ if (!EXPERIMENTS.has(key)) {
256
+ throw new Error(`nonSemVerExperiments.${key} from ${fileForErrorMessage} is not a supported experiment`);
257
+ }
258
+ }
259
+
260
+ if (sawPromise && experiments.nextGenConfig !== true) {
261
+ throw new Error(`${fileForErrorMessage} exported a promise or an asynchronous factory function. You must enable the ’asyncConfigurationLoading’ experiment for this to work.`);
262
+ }
263
+
264
+ return config;
265
+ }
266
+
267
+ exports.loadConfig = loadConfig;
@@ -0,0 +1,252 @@
1
+ const {EventEmitter, on} = require('events');
2
+ const v8 = require('v8');
3
+ const {workerData, parentPort} = require('worker_threads');
4
+ const pkg = require('../../package.json');
5
+
6
+ // Used to forward messages received over the `parentPort`. Every subscription
7
+ // adds a listener, so do not enforce any maximums.
8
+ const events = new EventEmitter().setMaxListeners(0);
9
+
10
+ // Map of active test workers, used in receiveMessages() to get a reference to
11
+ // the TestWorker instance, and relevant release functions.
12
+ const activeTestWorkers = new Map();
13
+
14
+ class TestWorker {
15
+ constructor(id, file) {
16
+ this.id = id;
17
+ this.file = file;
18
+ }
19
+
20
+ teardown(fn) {
21
+ let done = false;
22
+ const teardownFn = async () => {
23
+ if (done) {
24
+ return;
25
+ }
26
+
27
+ done = true;
28
+ if (activeTestWorkers.has(this.id)) {
29
+ activeTestWorkers.get(this.id).teardownFns.delete(teardownFn);
30
+ }
31
+
32
+ await fn();
33
+ };
34
+
35
+ activeTestWorkers.get(this.id).teardownFns.add(teardownFn);
36
+
37
+ return teardownFn;
38
+ }
39
+
40
+ publish(data) {
41
+ return publishMessage(this, data);
42
+ }
43
+
44
+ async * subscribe() {
45
+ yield * receiveMessages(this);
46
+ }
47
+ }
48
+
49
+ class ReceivedMessage {
50
+ constructor(testWorker, id, serializedData) {
51
+ this.testWorker = testWorker;
52
+ this.id = id;
53
+ this.data = v8.deserialize(new Uint8Array(serializedData));
54
+ }
55
+
56
+ reply(data) {
57
+ return publishMessage(this.testWorker, data, this.id);
58
+ }
59
+ }
60
+
61
+ // Ensure that, no matter how often it's received, we have a stable message
62
+ // object.
63
+ const messageCache = new WeakMap();
64
+
65
+ async function * receiveMessages(fromTestWorker, replyTo) {
66
+ for await (const [message] of on(events, 'message')) {
67
+ if (fromTestWorker !== undefined) {
68
+ if (message.type === 'deregister-test-worker' && message.id === fromTestWorker.id) {
69
+ return;
70
+ }
71
+
72
+ if (message.type === 'message' && message.testWorkerId !== fromTestWorker.id) {
73
+ continue;
74
+ }
75
+ }
76
+
77
+ if (message.type !== 'message') {
78
+ continue;
79
+ }
80
+
81
+ if (replyTo === undefined && message.replyTo !== undefined) {
82
+ continue;
83
+ }
84
+
85
+ if (replyTo !== undefined && message.replyTo !== replyTo) {
86
+ continue;
87
+ }
88
+
89
+ const active = activeTestWorkers.get(message.testWorkerId);
90
+ // It is possible for a message to have been buffering for so long — perhaps
91
+ // due to the caller waiting before iterating to the next message — that the
92
+ // test worker has been deregistered. Ignore such messages.
93
+ //
94
+ // (This is really hard to write a test for, however!)
95
+ if (active === undefined) {
96
+ continue;
97
+ }
98
+
99
+ let received = messageCache.get(message);
100
+ if (received === undefined) {
101
+ received = new ReceivedMessage(active.instance, message.messageId, message.serializedData);
102
+ messageCache.set(message, received);
103
+ }
104
+
105
+ yield received;
106
+ }
107
+ }
108
+
109
+ let messageCounter = 0;
110
+ const messageIdPrefix = `${workerData.id}/message`;
111
+ const nextMessageId = () => `${messageIdPrefix}/${++messageCounter}`;
112
+
113
+ function publishMessage(testWorker, data, replyTo) {
114
+ const id = nextMessageId();
115
+ parentPort.postMessage({
116
+ type: 'message',
117
+ messageId: id,
118
+ testWorkerId: testWorker.id,
119
+ serializedData: [...v8.serialize(data)],
120
+ replyTo
121
+ });
122
+
123
+ return {
124
+ id,
125
+ async * replies() {
126
+ yield * receiveMessages(testWorker, id);
127
+ }
128
+ };
129
+ }
130
+
131
+ function broadcastMessage(data) {
132
+ const id = nextMessageId();
133
+ parentPort.postMessage({
134
+ type: 'broadcast',
135
+ messageId: id,
136
+ serializedData: [...v8.serialize(data)]
137
+ });
138
+
139
+ return {
140
+ id,
141
+ async * replies() {
142
+ yield * receiveMessages(undefined, id);
143
+ }
144
+ };
145
+ }
146
+
147
+ async function loadFactory() {
148
+ try {
149
+ const mod = require(workerData.filename);
150
+ if (typeof mod === 'function') {
151
+ return mod;
152
+ }
153
+
154
+ return mod.default;
155
+ } catch (error) {
156
+ if (error && (error.code === 'ERR_REQUIRE_ESM' || (error.code === 'MODULE_NOT_FOUND' && workerData.filename.startsWith('file://')))) {
157
+ const {default: factory} = await import(workerData.filename); // eslint-disable-line node/no-unsupported-features/es-syntax
158
+ return factory;
159
+ }
160
+
161
+ throw error;
162
+ }
163
+ }
164
+
165
+ let signalAvailable = () => {
166
+ parentPort.postMessage({type: 'available'});
167
+ signalAvailable = () => {};
168
+ };
169
+
170
+ let fatal;
171
+ loadFactory(workerData.filename).then(factory => {
172
+ if (typeof factory !== 'function') {
173
+ throw new TypeError(`Missing default factory function export for shared worker plugin at ${workerData.filename}`);
174
+ }
175
+
176
+ factory({
177
+ negotiateProtocol(supported) {
178
+ if (!supported.includes('experimental')) {
179
+ fatal = new Error(`This version of AVA (${pkg.version}) is not compatible with shared worker plugin at ${workerData.filename}`);
180
+ throw fatal;
181
+ }
182
+
183
+ const produceTestWorker = instance => events.emit('testWorker', instance);
184
+
185
+ parentPort.on('message', async message => {
186
+ if (message.type === 'register-test-worker') {
187
+ const {id, file} = message;
188
+ const instance = new TestWorker(id, file);
189
+
190
+ activeTestWorkers.set(id, {instance, teardownFns: new Set()});
191
+
192
+ produceTestWorker(instance);
193
+ }
194
+
195
+ if (message.type === 'deregister-test-worker') {
196
+ const {id} = message;
197
+ const {teardownFns} = activeTestWorkers.get(id);
198
+ activeTestWorkers.delete(id);
199
+
200
+ // Run possibly asynchronous release functions serially, in reverse
201
+ // order. Any error will crash the worker.
202
+ for await (const fn of [...teardownFns].reverse()) {
203
+ await fn();
204
+ }
205
+
206
+ parentPort.postMessage({
207
+ type: 'deregistered-test-worker',
208
+ id
209
+ });
210
+ }
211
+
212
+ // Wait for a turn of the event loop, to allow new subscriptions to be
213
+ // set up in response to the previous message.
214
+ setImmediate(() => events.emit('message', message));
215
+ });
216
+
217
+ return {
218
+ initialData: workerData.initialData,
219
+ protocol: 'experimental',
220
+
221
+ ready() {
222
+ signalAvailable();
223
+ return this;
224
+ },
225
+
226
+ broadcast(data) {
227
+ return broadcastMessage(data);
228
+ },
229
+
230
+ async * subscribe() {
231
+ yield * receiveMessages();
232
+ },
233
+
234
+ async * testWorkers() {
235
+ for await (const [worker] of on(events, 'testWorker')) {
236
+ yield worker;
237
+ }
238
+ }
239
+ };
240
+ }
241
+ });
242
+ }).catch(error => {
243
+ if (fatal === undefined) {
244
+ fatal = error;
245
+ }
246
+ }).finally(() => {
247
+ if (fatal !== undefined) {
248
+ process.nextTick(() => {
249
+ throw fatal;
250
+ });
251
+ }
252
+ });
@@ -0,0 +1,140 @@
1
+ const events = require('events');
2
+ const serializeError = require('../serialize-error');
3
+
4
+ let Worker;
5
+ try {
6
+ ({Worker} = require('worker_threads'));
7
+ } catch {}
8
+
9
+ const LOADER = require.resolve('./shared-worker-loader');
10
+
11
+ let sharedWorkerCounter = 0;
12
+ const launchedWorkers = new Map();
13
+
14
+ const waitForAvailable = async worker => {
15
+ for await (const [message] of events.on(worker, 'message')) {
16
+ if (message.type === 'available') {
17
+ return;
18
+ }
19
+ }
20
+ };
21
+
22
+ function launchWorker({filename, initialData}) {
23
+ if (launchedWorkers.has(filename)) {
24
+ return launchedWorkers.get(filename);
25
+ }
26
+
27
+ const id = `shared-worker/${++sharedWorkerCounter}`;
28
+ const worker = new Worker(LOADER, {
29
+ // Ensure the worker crashes for unhandled rejections, rather than allowing undefined behavior.
30
+ execArgv: ['--unhandled-rejections=strict'],
31
+ workerData: {
32
+ filename,
33
+ id,
34
+ initialData
35
+ }
36
+ });
37
+ worker.setMaxListeners(0);
38
+
39
+ const launched = {
40
+ statePromises: {
41
+ available: waitForAvailable(worker),
42
+ error: events.once(worker, 'error').then(([error]) => error) // eslint-disable-line promise/prefer-await-to-then
43
+ },
44
+ exited: false,
45
+ worker
46
+ };
47
+
48
+ launchedWorkers.set(filename, launched);
49
+ worker.once('exit', () => {
50
+ launched.exited = true;
51
+ });
52
+
53
+ return launched;
54
+ }
55
+
56
+ async function observeWorkerProcess(fork, runStatus) {
57
+ let registrationCount = 0;
58
+ let signalDeregistered;
59
+ const deregistered = new Promise(resolve => {
60
+ signalDeregistered = resolve;
61
+ });
62
+
63
+ fork.promise.finally(() => {
64
+ if (registrationCount === 0) {
65
+ signalDeregistered();
66
+ }
67
+ });
68
+
69
+ fork.onConnectSharedWorker(async channel => {
70
+ const launched = launchWorker(channel);
71
+
72
+ const handleChannelMessage = ({messageId, replyTo, serializedData}) => {
73
+ launched.worker.postMessage({
74
+ type: 'message',
75
+ testWorkerId: fork.forkId,
76
+ messageId,
77
+ replyTo,
78
+ serializedData
79
+ });
80
+ };
81
+
82
+ const handleWorkerMessage = async message => {
83
+ if (message.type === 'broadcast' || (message.type === 'message' && message.testWorkerId === fork.forkId)) {
84
+ const {messageId, replyTo, serializedData} = message;
85
+ channel.forwardMessageToFork({messageId, replyTo, serializedData});
86
+ }
87
+
88
+ if (message.type === 'deregistered-test-worker' && message.id === fork.forkId) {
89
+ launched.worker.off('message', handleWorkerMessage);
90
+
91
+ registrationCount--;
92
+ if (registrationCount === 0) {
93
+ signalDeregistered();
94
+ }
95
+ }
96
+ };
97
+
98
+ launched.statePromises.error.then(error => { // eslint-disable-line promise/prefer-await-to-then
99
+ signalDeregistered();
100
+ launched.worker.off('message', handleWorkerMessage);
101
+ runStatus.emitStateChange({type: 'shared-worker-error', err: serializeError('Shared worker error', true, error)});
102
+ channel.signalError();
103
+ });
104
+
105
+ try {
106
+ await launched.statePromises.available;
107
+
108
+ registrationCount++;
109
+ launched.worker.postMessage({
110
+ type: 'register-test-worker',
111
+ id: fork.forkId,
112
+ file: fork.file
113
+ });
114
+
115
+ fork.promise.finally(() => {
116
+ launched.worker.postMessage({
117
+ type: 'deregister-test-worker',
118
+ id: fork.forkId
119
+ });
120
+
121
+ channel.off('message', handleChannelMessage);
122
+ });
123
+
124
+ launched.worker.on('message', handleWorkerMessage);
125
+ channel.on('message', handleChannelMessage);
126
+ channel.signalReady();
127
+ } catch {
128
+ return;
129
+ } finally {
130
+ // Attaching listeners has the side-effect of referencing the worker.
131
+ // Explicitly unreference it now so it does not prevent the main process
132
+ // from exiting.
133
+ launched.worker.unref();
134
+ }
135
+ });
136
+
137
+ return deregistered;
138
+ }
139
+
140
+ exports.observeWorkerProcess = observeWorkerProcess;
@@ -195,6 +195,7 @@ class Reporter {
195
195
  this.internalErrors = [];
196
196
  this.knownFailures = [];
197
197
  this.lineNumberErrors = [];
198
+ this.sharedWorkerErrors = [];
198
199
  this.uncaughtExceptions = [];
199
200
  this.unhandledRejections = [];
200
201
  this.unsavedSnapshots = [];
@@ -340,6 +341,19 @@ class Reporter {
340
341
  break;
341
342
  }
342
343
 
344
+ case 'shared-worker-error': {
345
+ this.sharedWorkerErrors.push(event);
346
+
347
+ if (this.verbose) {
348
+ this.lineWriter.ensureEmptyLine();
349
+ this.lineWriter.writeLine(colors.error(`${figures.cross} Error in shared worker`));
350
+ this.lineWriter.writeLine();
351
+ this.writeErr(event);
352
+ }
353
+
354
+ break;
355
+ }
356
+
343
357
  case 'snapshot-error':
344
358
  this.unsavedSnapshots.push(event);
345
359
  break;
@@ -710,7 +724,7 @@ class Reporter {
710
724
  }
711
725
 
712
726
  if (this.failures.length > 0) {
713
- const writeTrailingLines = this.internalErrors.length > 0 || this.uncaughtExceptions.length > 0 || this.unhandledRejections.length > 0;
727
+ const writeTrailingLines = this.internalErrors.length > 0 || this.sharedWorkerErrors.length > 0 || this.uncaughtExceptions.length > 0 || this.unhandledRejections.length > 0;
714
728
 
715
729
  const lastFailure = this.failures[this.failures.length - 1];
716
730
  for (const event of this.failures) {
@@ -734,7 +748,7 @@ class Reporter {
734
748
 
735
749
  if (!this.verbose) {
736
750
  if (this.internalErrors.length > 0) {
737
- const writeTrailingLines = this.uncaughtExceptions.length > 0 || this.unhandledRejections.length > 0;
751
+ const writeTrailingLines = this.sharedWorkerErrors.length > 0 || this.uncaughtExceptions.length > 0 || this.unhandledRejections.length > 0;
738
752
 
739
753
  const last = this.internalErrors[this.internalErrors.length - 1];
740
754
  for (const event of this.internalErrors) {
@@ -756,6 +770,23 @@ class Reporter {
756
770
  }
757
771
  }
758
772
 
773
+ if (this.sharedWorkerErrors.length > 0) {
774
+ const writeTrailingLines = this.uncaughtExceptions.length > 0 || this.unhandledRejections.length > 0;
775
+
776
+ const last = this.sharedWorkerErrors[this.sharedWorkerErrors.length - 1];
777
+ for (const evt of this.sharedWorkerErrors) {
778
+ this.lineWriter.writeLine(colors.error(`${figures.cross} Error in shared worker`));
779
+ this.lineWriter.writeLine();
780
+ this.writeErr(evt.err);
781
+ if (evt !== last || writeTrailingLines) {
782
+ this.lineWriter.writeLine();
783
+ this.lineWriter.writeLine();
784
+ }
785
+
786
+ wroteSomething = true;
787
+ }
788
+ }
789
+
759
790
  if (this.uncaughtExceptions.length > 0) {
760
791
  const writeTrailingLines = this.unhandledRejections.length > 0;
761
792
 
@@ -125,12 +125,22 @@ class TapReporter {
125
125
  this.reportStream.write(`# ${stripAnsi(title)}${os.EOL}`);
126
126
  if (evt.logs) {
127
127
  for (const log of evt.logs) {
128
- const logLines = indentString(log, 4).replace(/^ {4}/, ' # ');
128
+ const logLines = indentString(log, 4).replace(/^ {4}/gm, '# ');
129
129
  this.reportStream.write(`${logLines}${os.EOL}`);
130
130
  }
131
131
  }
132
132
  }
133
133
 
134
+ writeTimeout(evt) {
135
+ const err = new Error(`Exited because no new tests completed within the last ${evt.period}ms of inactivity`);
136
+
137
+ for (const [testFile, tests] of evt.pendingTests) {
138
+ for (const title of tests) {
139
+ this.writeTest({testFile, title, err}, {passed: false, todo: false, skip: false});
140
+ }
141
+ }
142
+ }
143
+
134
144
  consumeStateChange(evt) { // eslint-disable-line complexity
135
145
  const fileStats = this.stats && evt.testFile ? this.stats.byFile.get(evt.testFile) : null;
136
146
 
@@ -172,7 +182,7 @@ class TapReporter {
172
182
  this.writeTest(evt, {passed: true, todo: false, skip: false});
173
183
  break;
174
184
  case 'timeout':
175
- this.writeCrash(evt, `Exited because no new tests completed within the last ${evt.period}ms of inactivity`);
185
+ this.writeTimeout(evt);
176
186
  break;
177
187
  case 'uncaught-exception':
178
188
  this.writeCrash(evt);