testem 3.19.1 → 3.20.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.
@@ -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
  };
@@ -205,7 +205,6 @@ module.exports = View.extend({
205
205
  var runner = this.get('runner');
206
206
  var results = runner.get('results');
207
207
  var topLevelError = results ? results.get('topLevelError') : null;
208
- var tests = null;
209
208
  var out = '';
210
209
  var pendingOut = '';
211
210
 
@@ -213,6 +212,7 @@ module.exports = View.extend({
213
212
  out += 'Top Level:\n' + indent(topLevelError) + '\n\n';
214
213
  }
215
214
 
215
+ var tests;
216
216
  if (results && (tests = results.get('tests')) && !topLevelError) {
217
217
  var total = results.get('total');
218
218
  var pending = results.get('pending');
@@ -40,7 +40,9 @@ module.exports = class BrowserTestRunner {
40
40
  } else {
41
41
  this.launcher.start().then(browserProcess => {
42
42
  this.process = browserProcess;
43
- this.process.on('processExit', this.onProcessExit.bind(this));
43
+ if (!this.launcher.settings.ignoreProcessExit) {
44
+ this.process.on('processExit', this.onProcessExit.bind(this));
45
+ }
44
46
  this.process.on('processError', this.onProcessError.bind(this));
45
47
  this.setupStartTimer();
46
48
  }).catch(err => {
@@ -55,7 +55,6 @@ module.exports = class HookRunner {
55
55
  } else if (exe) {
56
56
  args = this.varsub(args || []);
57
57
  hookProcessPromise = this.processCtl.spawn(exe, args, options);
58
- command = exe + ' ' + args.join(' ');
59
58
  } else {
60
59
  return Promise.reject(new Error('No command or exe/args specified for hook ' + hook));
61
60
  }
@@ -103,8 +103,10 @@ class Server extends EventEmitter {
103
103
  })
104
104
  );
105
105
 
106
+ // In Express 5 (path-to-regexp v8), wildcard params like /*file are captured
107
+ // as an array of path segments, so we rejoin them with '/' to reconstruct the path.
106
108
  var serveStaticFile = (req, res) => {
107
- this.serveStaticFile(req.params[0], req, res);
109
+ this.serveStaticFile('/' + req.params.file.join('/'), req, res);
108
110
  };
109
111
 
110
112
  if (this.config.get('key') || this.config.get('pfx')) {
@@ -142,16 +144,31 @@ class Server extends EventEmitter {
142
144
  res.redirect(`/${String(Math.floor(Math.random() * 10000))}`);
143
145
  });
144
146
 
145
- app.get(/\/(-?[0-9]+)$/, (req, res) => {
146
- this.serveHomePage(req, res);
147
- });
148
-
149
147
  app.get('/testem.js', (req, res) => {
150
148
  this.serveTestemClientJs(req, res);
151
149
  });
152
150
 
153
- app.all(/^\/(?:-?[0-9]+)(\/.+)$/, serveStaticFile);
154
- app.all(/^(.+)$/, serveStaticFile);
151
+ // Express 5 dropped regex-in-param syntax (e.g. /:id(-?\d+)), so we match
152
+ // any single-segment :id and validate numerically in the handler.
153
+ app.get('/:id', (req, res, next) => {
154
+ if (/^-?\d+$/.test(req.params.id)) {
155
+ this.serveHomePage(req, res);
156
+ } else {
157
+ next();
158
+ }
159
+ });
160
+
161
+ // Serve static files prefixed with a numeric browser id (e.g. /1234/web/test.js).
162
+ // The numeric guard prevents /:id from greedily swallowing non-numeric paths.
163
+ app.all('/:id/*file', (req, res, next) => {
164
+ if (/^-?\d+$/.test(req.params.id)) {
165
+ serveStaticFile(req, res);
166
+ } else {
167
+ next();
168
+ }
169
+ });
170
+ // Catch-all: serve any other path as a static file.
171
+ app.all('/*file', serveStaticFile);
155
172
 
156
173
  app.use((err, req, res, next) => {
157
174
  if (err) {
@@ -229,7 +246,13 @@ class Server extends EventEmitter {
229
246
  });
230
247
  return;
231
248
  }
232
- app.all(`${url}*`, (req, res, next) => {
249
+ // Express 5 no longer accepts bare wildcard segments (e.g. /api*). We
250
+ // replace each * in the user-supplied proxy URL with a named param, then
251
+ // append an optional catch-all for trailing path segments.
252
+ let wIdx = 0;
253
+ const proxyPath =
254
+ url.replace(/\*/g, () => `:_proxyW${wIdx++}`).replace(/\/$/, '') + '{/*_proxyRest}';
255
+ app.all(proxyPath, (req, res, next) => {
233
256
  var opts = proxies[url];
234
257
  if (this.shouldProxy(req, opts)) {
235
258
  if (opts.host) {
@@ -3,40 +3,92 @@
3
3
  const yaml = require('js-yaml');
4
4
  const extend = Object.assign;
5
5
  const EventEmitter = require('events').EventEmitter;
6
- const TapParser = require('tap-parser');
6
+ const { Parser: TapParser } = require('tap-parser');
7
7
  const log = require('./log');
8
8
 
9
9
  module.exports = class TapConsumer extends EventEmitter {
10
10
  constructor() {
11
11
  super();
12
12
 
13
+ this.namePrefix = '';
14
+ this.prefixStack = [];
15
+ this.childParsers = [];
16
+
13
17
  this.stream = new TapParser();
14
- this.stream.on('assert', this.onTapAssert.bind(this));
15
- this.stream.on('extra', this.onTapExtra.bind(this));
18
+ this.attachParser(this.stream, true);
19
+ }
16
20
 
17
- this.stream.on('complete', this.onTapEnd.bind(this));
18
- this.stream.on('bailout', this.onTapError.bind(this));
21
+ attachParser(parser, isRoot) {
22
+ parser.on('assert', this.onTapAssert.bind(this));
23
+ parser.on('extra', this.onTapExtra.bind(this));
24
+ parser.on('child', this.onChildParser.bind(this));
25
+ parser.on('complete', this.onParserComplete.bind(this, parser));
26
+ if (isRoot) {
27
+ parser.on('bailout', this.onTapError.bind(this));
28
+ }
19
29
  }
20
30
 
21
- onTapAssert(data) {
22
- log.info(data);
23
- if (data.skip) {
31
+ onChildParser(childParser) {
32
+ this.prefixStack.push(this.namePrefix);
33
+ const parentPrefix = this.namePrefix;
34
+ const segment = (childParser.name && childParser.name.trim()) || '';
35
+ if (parentPrefix && segment) {
36
+ this.namePrefix = `${parentPrefix} > ${segment}`;
37
+ } else if (segment) {
38
+ this.namePrefix = segment;
39
+ } else if (parentPrefix) {
40
+ this.namePrefix = parentPrefix;
41
+ } else {
42
+ this.namePrefix = '';
43
+ }
44
+
45
+ this.childParsers.push(childParser);
46
+ this.attachParser(childParser, false);
47
+ }
48
+
49
+ onParserComplete(parser) {
50
+ if (parser === this.stream) {
51
+ this.onTapEnd();
24
52
  return;
25
53
  }
54
+ this.namePrefix = this.prefixStack.pop();
55
+ const idx = this.childParsers.indexOf(parser);
56
+ if (idx !== -1) {
57
+ this.childParsers.splice(idx, 1);
58
+ }
59
+ }
60
+
61
+ onTapAssert(data) {
62
+ log.verbose(JSON.stringify(data, null, 2));
26
63
 
27
64
  if (data.id === undefined) {
28
65
  return;
29
66
  }
30
67
 
68
+ const baseName = data.name ? data.name.trim() : '';
69
+ const qualifiedName = this.namePrefix
70
+ ? `${this.namePrefix} > ${baseName}`
71
+ : baseName;
72
+
31
73
  let test = {
32
74
  passed: 0,
33
75
  failed: 0,
34
76
  total: 1,
35
77
  id: data.id,
36
- name: data.name ? data.name.trim() : '',
78
+ name: qualifiedName,
37
79
  items: []
38
80
  };
39
81
 
82
+ if (data.skip) {
83
+ test.skipped = true;
84
+ this.emit('test-result', test);
85
+ return;
86
+ }
87
+
88
+ if (data.todo) {
89
+ test.todo = true;
90
+ }
91
+
40
92
  if (!data.ok) {
41
93
  let stack;
42
94
  if (data.diag) {
@@ -71,6 +123,16 @@ module.exports = class TapConsumer extends EventEmitter {
71
123
  }
72
124
  }
73
125
 
126
+ detachAllListeners() {
127
+ this.stream.removeAllListeners();
128
+ for (const p of this.childParsers) {
129
+ p.removeAllListeners();
130
+ }
131
+ this.childParsers = [];
132
+ this.prefixStack = [];
133
+ this.namePrefix = '';
134
+ }
135
+
74
136
  onTapError(reason) {
75
137
  let test = {
76
138
  failed: 1,
@@ -81,13 +143,13 @@ module.exports = class TapConsumer extends EventEmitter {
81
143
  }
82
144
  };
83
145
 
84
- this.stream.removeAllListeners();
146
+ this.detachAllListeners();
85
147
  this.emit('test-result', test);
86
148
  this.emit('all-test-results');
87
149
  }
88
150
 
89
151
  onTapEnd() {
90
- this.stream.removeAllListeners();
152
+ this.detachAllListeners();
91
153
  this.emit('all-test-results');
92
154
  }
93
155
  };
@@ -2,7 +2,7 @@
2
2
  * Shared esbuild config for bundling Node.js-dependent test code to run in a
3
3
  * browser via testem.
4
4
  *
5
- * esbuild's `@esbuild-plugins/node-modules-polyfill` stores the `process`
5
+ * `esbuild-plugins-node-modules-polyfill` stores the `process`
6
6
  * polyfill as a scoped variable inside the IIFE it emits, but CJS module
7
7
  * factories (wrapped in `__commonJS`) reference `process`, `__dirname`, and
8
8
  * `setImmediate` as free globals — causing ReferenceErrors at runtime.
@@ -1,5 +1,5 @@
1
1
  // esbuild inject shim: exposes Buffer as a global for browser bundles.
2
- // The @esbuild-plugins/node-modules-polyfill plugin resolves `require('buffer')`
2
+ // The esbuild-plugins-node-modules-polyfill plugin resolves `require('buffer')`
3
3
  // to a browser polyfill, but does not assign it to globalThis automatically.
4
4
  import { Buffer } from 'buffer';
5
5
  if (typeof globalThis !== 'undefined') { globalThis.Buffer = Buffer; }
@@ -0,0 +1,19 @@
1
+ const { glob } = require('glob');
2
+
3
+ /**
4
+ * Expand one resolved pattern to matching file paths using the project glob backend.
5
+ *
6
+ * Callers pass POSIX-style paths (see `convertToPosix`); this keeps options aligned
7
+ * with `glob` today and allows swapping the implementation later without touching Config.
8
+ *
9
+ * @param {string} resolvedPatternPosix - Single pattern (already resolved to absolute or cwd-relative form, forward slashes).
10
+ * @param {string[]} [ignorePatternsPosix] - Ignore globs in POSIX form; empty entries are dropped.
11
+ * @returns {Promise<string[]>} Matching paths, sorted lexicographically (same ordering as before extraction).
12
+ */
13
+ async function expandGlobPattern(resolvedPatternPosix, ignorePatternsPosix) {
14
+ const ignore = (ignorePatternsPosix || []).filter(Boolean);
15
+ const files = await glob(resolvedPatternPosix, { ignore });
16
+ return files.slice().sort();
17
+ }
18
+
19
+ module.exports = expandGlobPattern;