testem 3.19.1 → 3.20.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.
@@ -1,51 +1,125 @@
1
+ const EventEmitter = require('events').EventEmitter;
1
2
 
3
+ const Impl = require('./file_watcher_impl');
2
4
 
3
- const fireworm = require('fireworm');
4
- const EventEmitter = require('events').EventEmitter;
5
+ const kCompose = Symbol('FileWatcher.compose');
5
6
 
6
- module.exports = class FileWatcher extends EventEmitter {
7
- constructor(config) {
7
+ /**
8
+ * Coordinates file watching for Testem: it observes the current working directory (and any
9
+ * non-glob paths from config that live outside it), applies the same include / ignore rules as
10
+ * the rest of the app (`src_files`, `src_files_ignore`, `watch_files`, etc.), and reports when
11
+ * paths that matter for the test run change.
12
+ *
13
+ * Listen for {@link FileWatcher#event:fileChanged} to react to changes, and optionally for
14
+ * {@link FileWatcher#event:EMFILE} when the process hits open-file limits.
15
+ *
16
+ * ### Construction
17
+ *
18
+ * **Do not call `new FileWatcher(...)`.** The constructor is not part of the public API; it
19
+ * exists only for internal wiring and will always throw if you invoke it from outside this
20
+ * module. Obtain instances exclusively via {@link FileWatcher.create} with a config object that
21
+ * exposes `get(key)` and `isCwdMode()` (the same shape as Testem’s `Config`).
22
+ *
23
+ * ### Async vs synchronous
24
+ *
25
+ * **Return a `Promise` — use `await`, `.then()`, or `.catch()`:**
26
+ * {@link FileWatcher.create}, {@link FileWatcher#add}, {@link FileWatcher#close}.
27
+ *
28
+ * **Synchronous:** {@link FileWatcher#onFileChanged}, {@link FileWatcher#onEMFILE}, and
29
+ * inherited `EventEmitter` methods (`on`, `once`, `emit`, `removeListener`, …).
30
+ *
31
+ * @hideconstructor
32
+ * @fires FileWatcher#event:fileChanged
33
+ * @fires FileWatcher#event:EMFILE
34
+ */
35
+ class FileWatcher extends EventEmitter {
36
+ /**
37
+ * Not for public use. Application code must use {@link FileWatcher.create} instead; calling
38
+ * `new FileWatcher` directly always throws.
39
+ *
40
+ * @private
41
+ * @param {*} impl Internal implementation instance.
42
+ * @param {symbol} token Internal composition token (not exposed).
43
+ */
44
+ constructor(impl, token) {
8
45
  super();
46
+ if (token !== kCompose) {
47
+ throw new TypeError(
48
+ 'FileWatcher is not constructable; use FileWatcher.create(config)',
49
+ );
50
+ }
51
+ this._impl = impl;
52
+ impl.on('fileChanged', (filePath) => this.emit('fileChanged', filePath));
53
+ impl.on('EMFILE', () => this.emit('EMFILE'));
54
+ }
9
55
 
10
- this.fileWatcher = fireworm('./', {
11
- ignoreInitial: true,
12
- skipDirEntryPatterns: []
56
+ /**
57
+ * Create a watcher, wait until the underlying engine has finished its initial setup, and
58
+ * return a ready-to-use `FileWatcher`. This is the **only** supported way to obtain an
59
+ * instance.
60
+ *
61
+ * @param {{ get: function(string): *, isCwdMode: function(): boolean }} config Testem-style
62
+ * config (typically a `Config` instance): `get` reads keys such as `src_files`,
63
+ * `src_files_ignore`, `watch_files`, `file`, `disable_watching`; `isCwdMode` reflects cwd
64
+ * mode.
65
+ * @returns {Promise<FileWatcher>} Resolves with a connected watcher once startup is complete.
66
+ * **Await** this promise; if the engine fails during startup, the promise rejects.
67
+ */
68
+ static async create(config) {
69
+ const impl = await Impl.create(config);
70
+ return new FileWatcher(impl, kCompose);
71
+ }
72
+
73
+ /**
74
+ * Request that a **concrete** filesystem path be watched in addition to the configured
75
+ * patterns (for example, a file the dev server started serving after startup). Glob or
76
+ * minimatch strings are not accepted here — only real paths; pattern-based includes stay in
77
+ * config.
78
+ *
79
+ * @param {string} file Path relative to the current working directory or absolute, as
80
+ * accepted by the engine.
81
+ * @returns {Promise<void>} Settles when the engine has finished the add request. **Await**
82
+ * this promise (or attach `.catch()`) so validation errors, engine errors, and internal
83
+ * teardown on failure are observed.
84
+ */
85
+ add(file) {
86
+ return this._impl.add(file);
87
+ }
88
+
89
+ /**
90
+ * Tear down the watcher: close the engine, release OS watch handles, and remove listeners on
91
+ * this `FileWatcher`. Safe to call more than once; subsequent calls resolve promptly.
92
+ *
93
+ * @returns {Promise<void>} Resolves when shutdown is complete. **Await** if later code must
94
+ * run only after handles are released.
95
+ */
96
+ close() {
97
+ return Promise.resolve(this._impl.close()).then(() => {
98
+ this.removeAllListeners();
13
99
  });
14
- let onFileChanged = this.onFileChanged.bind(this);
15
- this.fileWatcher.on('change', onFileChanged);
16
- this.fileWatcher.on('add', onFileChanged);
17
- this.fileWatcher.on('remove', onFileChanged);
18
- this.fileWatcher.on('emfile', this.onEMFILE.bind(this));
19
-
20
- let watchFiles = config.get('watch_files');
21
- this.fileWatcher.clear();
22
- let confFile = config.get('file');
23
- if (confFile) {
24
- this.fileWatcher.add(confFile);
25
- }
26
- if (config.isCwdMode()) {
27
- this.fileWatcher.add('*.js');
28
- }
29
- if (watchFiles) {
30
- this.fileWatcher.add(watchFiles);
31
- }
32
- let srcFiles = config.get('src_files') || '*.js';
33
- this.fileWatcher.add(srcFiles);
34
- let ignoreFiles = config.get('src_files_ignore');
35
- if (ignoreFiles) {
36
- this.fileWatcher.ignore(ignoreFiles);
37
- }
38
100
  }
39
101
 
102
+ /**
103
+ * Manually signal that a path changed, for the same listeners as a real `fileChanged` event.
104
+ * Used in tests and narrow control flows; normal file edits are reported automatically by the
105
+ * engine.
106
+ *
107
+ * @param {string} filePath Path that should be treated as changed.
108
+ * @returns {void}
109
+ */
40
110
  onFileChanged(filePath) {
41
- this.emit('fileChanged', filePath);
111
+ return this._impl.onFileChanged(filePath);
42
112
  }
43
113
 
114
+ /**
115
+ * Manually emit the `EMFILE` condition (too many open files) for listeners that show user
116
+ * guidance. The real engine also surfaces this when appropriate.
117
+ *
118
+ * @returns {void}
119
+ */
44
120
  onEMFILE() {
45
- this.emit('EMFILE');
121
+ return this._impl.onEMFILE();
46
122
  }
123
+ }
47
124
 
48
- add(file) {
49
- this.fileWatcher.add(file);
50
- }
51
- };
125
+ module.exports = FileWatcher;
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Internal `FileWatcher` implementation (see {@link module:lib/file_watcher} for the public API).
3
+ *
4
+ * **Third-party pieces in use today:** `chokidar` (filesystem watch engine), `glob` (`hasMagic`
5
+ * for validating concrete `add` paths), and `minimatch` (include-pattern magic checks). Policy
6
+ * lists and matching live largely in {@link module:lib/utils/file_watch_glob_policy}.
7
+ *
8
+ * Nothing here assumes a single vendor forever: the engine is created only through
9
+ * {@link FileWatcherImpl.createWatcher} (with `chokidar` loaded lazily there), and glob/minimatch
10
+ * are confined to small helpers—so swapping the watcher, glob detection, or minimatch usage is
11
+ * meant to be localized work rather than a rewrite.
12
+ *
13
+ * @module lib/file_watcher_impl
14
+ */
15
+ const path = require('path');
16
+ const EventEmitter = require('events').EventEmitter;
17
+ const { hasMagic } = require('glob');
18
+ const { Minimatch } = require('minimatch');
19
+
20
+ const isEmfileError = require('./utils/is_emfile_error');
21
+ const { convertToPosix } = require('./utils/posix');
22
+ const {
23
+ buildWatchGlobPolicy,
24
+ pathMatchesWatchTarget,
25
+ } = require('./utils/file_watch_glob_policy');
26
+
27
+ /** @private Only {@link FileWatcherImpl.create} may construct instances. */
28
+ const kInternal = Symbol('FileWatcherImpl.internal');
29
+
30
+ function includePatternHasGlobMagic(pattern) {
31
+ return new Minimatch(pattern, { dot: true }).hasMagic();
32
+ }
33
+
34
+ function resolvedPathIsUnderCwd(resolved) {
35
+ const rel = path.relative(process.cwd(), resolved);
36
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
37
+ }
38
+
39
+ /**
40
+ * Whether the watch engine's `all` event (add/addDir) refers to the same path as `file` /
41
+ * `absTarget`.
42
+ */
43
+ function dynamicAddEventMatchesPath(watchedPath, file, absTarget) {
44
+ const absWatched = path.resolve(watchedPath);
45
+ const relWatched = convertToPosix(
46
+ path.relative(process.cwd(), absWatched),
47
+ );
48
+ const relTarget = convertToPosix(
49
+ path.relative(process.cwd(), absTarget),
50
+ );
51
+ return (
52
+ absWatched === absTarget ||
53
+ relWatched === relTarget ||
54
+ watchedPath === file
55
+ );
56
+ }
57
+
58
+ class FileWatcherImpl extends EventEmitter {
59
+ /**
60
+ * @param {{ get: function(string): *, isCwdMode: function(): boolean }} config
61
+ * @param {symbol} internalToken
62
+ */
63
+ constructor(config, internalToken) {
64
+ super();
65
+
66
+ if (internalToken !== kInternal) {
67
+ throw new TypeError(
68
+ 'FileWatcherImpl is not constructable; use FileWatcherImpl.create(config)',
69
+ );
70
+ }
71
+
72
+ const policy = buildWatchGlobPolicy(config);
73
+ this._watchPolicy = policy;
74
+ this._explicitWatchRelPaths = new Set();
75
+ const opts = { ignoreInitial: true };
76
+ if (policy.ignorePatterns.length) {
77
+ opts.ignored = policy.ignorePatterns;
78
+ }
79
+
80
+ this.fileWatcher = FileWatcherImpl.createWatcher('.', opts);
81
+
82
+ let readySettled = false;
83
+ this._readyPromise = new Promise((resolve, reject) => {
84
+ this.fileWatcher.once('ready', () => {
85
+ readySettled = true;
86
+ resolve();
87
+ });
88
+ this.fileWatcher.once('error', (err) => {
89
+ if (readySettled || isEmfileError(err)) {
90
+ return;
91
+ }
92
+ readySettled = true;
93
+ reject(err);
94
+ });
95
+ });
96
+
97
+ for (let i = 0; i < policy.includePatterns.length; i++) {
98
+ const pattern = policy.includePatterns[i];
99
+ if (includePatternHasGlobMagic(pattern)) {
100
+ continue;
101
+ }
102
+ const resolved = path.resolve(process.cwd(), pattern);
103
+ if (!resolvedPathIsUnderCwd(resolved)) {
104
+ this.fileWatcher.add(path.normalize(pattern));
105
+ }
106
+ }
107
+
108
+ const onEngineFsEvent = this.onEngineFsEvent.bind(this);
109
+ this.fileWatcher.on('change', onEngineFsEvent);
110
+ this.fileWatcher.on('add', onEngineFsEvent);
111
+ this.fileWatcher.on('unlink', onEngineFsEvent);
112
+ this.fileWatcher.on('unlinkDir', onEngineFsEvent);
113
+ this.fileWatcher.on('error', (err) => {
114
+ if (isEmfileError(err)) {
115
+ this.onEMFILE();
116
+ }
117
+ });
118
+ }
119
+
120
+ /**
121
+ * @param {{ get: function(string): *, isCwdMode: function(): boolean }} config
122
+ * @returns {Promise<FileWatcherImpl>}
123
+ */
124
+ static async create(config) {
125
+ const fw = new FileWatcherImpl(config, kInternal);
126
+ await fw._readyPromise;
127
+ return fw;
128
+ }
129
+
130
+ onEngineFsEvent(filePath) {
131
+ const resolved = path.resolve(filePath);
132
+ const posixRel = convertToPosix(
133
+ path.relative(process.cwd(), resolved),
134
+ );
135
+ if (
136
+ pathMatchesWatchTarget(posixRel, this._watchPolicy) ||
137
+ this._explicitWatchRelPaths.has(posixRel)
138
+ ) {
139
+ this.onFileChanged(filePath);
140
+ }
141
+ }
142
+
143
+ onFileChanged(filePath) {
144
+ this.emit('fileChanged', filePath);
145
+ }
146
+
147
+ onEMFILE() {
148
+ this.emit('EMFILE');
149
+ }
150
+
151
+ /**
152
+ * @param {Error} err
153
+ * @returns {Promise<never>}
154
+ */
155
+ async _closeAfterFailedAdd(err) {
156
+ try {
157
+ await this.close();
158
+ } catch {
159
+ // ignore close errors; primary error is `err`
160
+ }
161
+ throw err;
162
+ }
163
+
164
+ /**
165
+ * @param {string} file
166
+ * @returns {Promise<void>}
167
+ */
168
+ async _validateConcreteAddPath(file) {
169
+ if (typeof file !== 'string') {
170
+ await this._closeAfterFailedAdd(
171
+ new TypeError('FileWatcher.add expects a string path'),
172
+ );
173
+ }
174
+ if (file.length === 0) {
175
+ await this._closeAfterFailedAdd(
176
+ new TypeError('FileWatcher.add expects a non-empty path'),
177
+ );
178
+ }
179
+ if (hasMagic(file, { magicalBraces: true })) {
180
+ await this._closeAfterFailedAdd(
181
+ new TypeError(
182
+ 'FileWatcher.add does not accept glob patterns; pass a concrete file path: ' +
183
+ file,
184
+ ),
185
+ );
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Wait for dynamic `add` on the engine to settle (`all` add/addDir, `error`, or timeout).
191
+ *
192
+ * @param {string} file
193
+ * @returns {Promise<void>}
194
+ */
195
+ async _awaitEngineDynamicAdd(file) {
196
+ const engine = this.fileWatcher;
197
+ const absTarget = path.resolve(process.cwd(), file);
198
+ let onError;
199
+ let onAll;
200
+ let timeoutId;
201
+
202
+ const cleanup = () => {
203
+ if (onError) {
204
+ engine.removeListener('error', onError);
205
+ }
206
+ if (onAll) {
207
+ engine.removeListener('all', onAll);
208
+ }
209
+ if (timeoutId !== undefined) {
210
+ clearTimeout(timeoutId);
211
+ }
212
+ };
213
+
214
+ await new Promise((resolve, reject) => {
215
+ let settled = false;
216
+
217
+ const finish = (err) => {
218
+ if (settled) {
219
+ return;
220
+ }
221
+ settled = true;
222
+ cleanup();
223
+ if (err) {
224
+ reject(err);
225
+ } else {
226
+ resolve();
227
+ }
228
+ };
229
+
230
+ onError = (err) => {
231
+ finish(err);
232
+ };
233
+ engine.prependOnceListener('error', onError);
234
+
235
+ onAll = (event, watchedPath) => {
236
+ if (event === 'add' || event === 'addDir') {
237
+ if (dynamicAddEventMatchesPath(watchedPath, file, absTarget)) {
238
+ finish();
239
+ }
240
+ }
241
+ };
242
+ engine.on('all', onAll);
243
+
244
+ timeoutId = setTimeout(() => finish(), 1000);
245
+
246
+ try {
247
+ engine.add(file);
248
+ } catch (err) {
249
+ finish(err);
250
+ }
251
+ });
252
+ }
253
+
254
+ /**
255
+ * @param {string} file
256
+ * @returns {Promise<void>}
257
+ */
258
+ async add(file) {
259
+ await this._validateConcreteAddPath(file);
260
+ const rel = convertToPosix(
261
+ path.relative(
262
+ process.cwd(),
263
+ path.resolve(process.cwd(), file),
264
+ ),
265
+ );
266
+ this._explicitWatchRelPaths.add(rel);
267
+ try {
268
+ await this._awaitEngineDynamicAdd(file);
269
+ } catch (err) {
270
+ await this._closeAfterFailedAdd(err);
271
+ }
272
+ }
273
+
274
+ /**
275
+ * @returns {Promise<void>}
276
+ */
277
+ close() {
278
+ if (this.fileWatcher) {
279
+ const engine = this.fileWatcher;
280
+ this.fileWatcher = null;
281
+ return Promise.resolve(engine.close()).then(() => {
282
+ this.removeAllListeners();
283
+ });
284
+ }
285
+ this.removeAllListeners();
286
+ return Promise.resolve();
287
+ }
288
+ }
289
+
290
+ /** Lazy `require` keeps chokidar off the load path until a real watcher is built (e.g. tests stub this). */
291
+ FileWatcherImpl.createWatcher = function createWatcher(rootPath, opts) {
292
+ return require('chokidar').watch(rootPath, opts);
293
+ };
294
+
295
+ module.exports = FileWatcherImpl;
package/lib/launcher.js CHANGED
@@ -1,16 +1,12 @@
1
-
2
-
3
1
  const log = require('./log');
4
- const tmp = require('tmp');
2
+ const fs = require('fs');
5
3
  const path = require('path');
6
4
  const { fromCallback } = require('./utils/promises');
5
+ const { registerCleanup } = require('./utils/tmp-cleanup');
7
6
 
8
7
  const template = require('./utils/strutils').template;
9
8
  const ProcessCtl = require('./process-ctl');
10
9
 
11
- // setup graceful cleanup: removes the created directories when an uncaught exception occurs.
12
- tmp.setGracefulCleanup();
13
-
14
10
  module.exports = class Launcher {
15
11
  constructor(name, settings, config) {
16
12
  this.name = name;
@@ -126,12 +122,11 @@ module.exports = class Launcher {
126
122
 
127
123
  setupBrowserTmpDir() {
128
124
  const userDataDir = this.config.getUserDataDir();
129
- const tmpPath = path.join(userDataDir, 'testem-' + this.id);
125
+ const tmpPath = path.join(userDataDir, 'testem-' + this.id + '-');
130
126
 
131
- this.browserTmpDirectory = tmp.dirSync({
132
- template: `${tmpPath}-XXXXXX`,
133
- unsafeCleanup: true
134
- });
127
+ const dir = fs.mkdtempSync(tmpPath);
128
+ registerCleanup(dir);
129
+ this.browserTmpDirectory = dir;
135
130
  }
136
131
 
137
132
  browserTmpDir() {
@@ -139,6 +134,6 @@ module.exports = class Launcher {
139
134
  this.setupBrowserTmpDir();
140
135
  }
141
136
 
142
- return this.browserTmpDirectory.name;
137
+ return this.browserTmpDirectory;
143
138
  }
144
139
  };