@jsenv/test 1.5.1 → 1.6.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,4 +1,4 @@
1
- import { chmod, stat, lstat, readdir, promises, unlink, openSync, closeSync, rmdir, readFile as readFile$1, writeFile as writeFile$1, writeFileSync as writeFileSync$1, mkdirSync, readFileSync, readdirSync, existsSync } from "node:fs";
1
+ import { chmod, stat, lstat, readdir, promises, unlink, openSync, closeSync, rmdir, readFile as readFile$1, writeFile as writeFile$1, writeFileSync as writeFileSync$1, mkdirSync, readFileSync, existsSync, readdirSync } from "node:fs";
2
2
  import { URL_META, filterV8Coverage } from "./js/v8_coverage.js";
3
3
  import { pathToFileURL, fileURLToPath } from "node:url";
4
4
  import "node:crypto";
@@ -8,7 +8,9 @@ import os from "node:os";
8
8
  import tty from "node:tty";
9
9
  import stringWidth from "string-width";
10
10
  import { createRequire } from "node:module";
11
+ import { Worker } from "node:worker_threads";
11
12
  import { createServer } from "node:net";
13
+ import { spawn, spawnSync, fork } from "node:child_process";
12
14
  import v8, { takeCoverage } from "node:v8";
13
15
  import stripAnsi from "strip-ansi";
14
16
  import { applyBabelPlugins } from "@jsenv/ast";
@@ -17,1068 +19,1066 @@ import { runInNewContext } from "node:vm";
17
19
  import wrapAnsi from "wrap-ansi";
18
20
  import { injectSupervisorIntoHTML, supervisorFileUrl } from "@jsenv/plugin-supervisor";
19
21
  import { SOURCEMAP, generateSourcemapDataUrl } from "@jsenv/sourcemap";
20
- import { fork } from "node:child_process";
21
22
  import { findFreePort } from "@jsenv/server";
22
- import { Worker } from "node:worker_threads";
23
23
 
24
- const urlToScheme = (url) => {
25
- const urlString = String(url);
26
- const colonIndex = urlString.indexOf(":");
27
- if (colonIndex === -1) {
28
- return "";
29
- }
24
+ /*
25
+ * See callback_race.md
26
+ */
30
27
 
31
- const scheme = urlString.slice(0, colonIndex);
32
- return scheme;
33
- };
28
+ const raceCallbacks = (raceDescription, winnerCallback) => {
29
+ let cleanCallbacks = [];
30
+ let status = "racing";
34
31
 
35
- const urlToResource = (url) => {
36
- const scheme = urlToScheme(url);
32
+ const clean = () => {
33
+ cleanCallbacks.forEach((clean) => {
34
+ clean();
35
+ });
36
+ cleanCallbacks = null;
37
+ };
37
38
 
38
- if (scheme === "file") {
39
- const urlAsStringWithoutFileProtocol = String(url).slice("file://".length);
40
- return urlAsStringWithoutFileProtocol;
41
- }
39
+ const cancel = () => {
40
+ if (status !== "racing") {
41
+ return;
42
+ }
43
+ status = "cancelled";
44
+ clean();
45
+ };
42
46
 
43
- if (scheme === "https" || scheme === "http") {
44
- // remove origin
45
- const afterProtocol = String(url).slice(scheme.length + "://".length);
46
- const pathnameSlashIndex = afterProtocol.indexOf("/", "://".length);
47
- const urlAsStringWithoutOrigin = afterProtocol.slice(pathnameSlashIndex);
48
- return urlAsStringWithoutOrigin;
49
- }
47
+ Object.keys(raceDescription).forEach((candidateName) => {
48
+ const register = raceDescription[candidateName];
49
+ const returnValue = register((data) => {
50
+ if (status !== "racing") {
51
+ return;
52
+ }
53
+ status = "done";
54
+ clean();
55
+ winnerCallback({
56
+ name: candidateName,
57
+ data,
58
+ });
59
+ });
60
+ if (typeof returnValue === "function") {
61
+ cleanCallbacks.push(returnValue);
62
+ }
63
+ });
50
64
 
51
- const urlAsStringWithoutProtocol = String(url).slice(scheme.length + 1);
52
- return urlAsStringWithoutProtocol;
65
+ return cancel;
53
66
  };
54
67
 
55
- const urlToPathname = (url) => {
56
- const resource = urlToResource(url);
57
- const pathname = resourceToPathname(resource);
58
- return pathname;
59
- };
68
+ const createCallbackListNotifiedOnce = () => {
69
+ let callbacks = [];
70
+ let status = "waiting";
71
+ let currentCallbackIndex = -1;
60
72
 
61
- const resourceToPathname = (resource) => {
62
- const searchSeparatorIndex = resource.indexOf("?");
63
- if (searchSeparatorIndex > -1) {
64
- return resource.slice(0, searchSeparatorIndex);
65
- }
66
- const hashIndex = resource.indexOf("#");
67
- if (hashIndex > -1) {
68
- return resource.slice(0, hashIndex);
69
- }
70
- return resource;
71
- };
73
+ const callbackListOnce = {};
72
74
 
73
- const urlToExtension = (url) => {
74
- const pathname = urlToPathname(url);
75
- return pathnameToExtension(pathname);
76
- };
75
+ const add = (callback) => {
76
+ if (status !== "waiting") {
77
+ emitUnexpectedActionWarning({ action: "add", status });
78
+ return removeNoop;
79
+ }
77
80
 
78
- const pathnameToExtension = (pathname) => {
79
- const slashLastIndex = pathname.lastIndexOf("/");
80
- if (slashLastIndex !== -1) {
81
- pathname = pathname.slice(slashLastIndex + 1);
82
- }
81
+ if (typeof callback !== "function") {
82
+ throw new Error(`callback must be a function, got ${callback}`);
83
+ }
83
84
 
84
- const dotLastIndex = pathname.lastIndexOf(".");
85
- if (dotLastIndex === -1) return "";
86
- // if (dotLastIndex === pathname.length - 1) return ""
87
- const extension = pathname.slice(dotLastIndex);
88
- return extension;
89
- };
85
+ // don't register twice
86
+ const existingCallback = callbacks.find((callbackCandidate) => {
87
+ return callbackCandidate === callback;
88
+ });
89
+ if (existingCallback) {
90
+ emitCallbackDuplicationWarning();
91
+ return removeNoop;
92
+ }
90
93
 
91
- const ensurePathnameTrailingSlash = (url) => {
92
- const urlObject = new URL(url);
93
- const { pathname } = urlObject;
94
- if (pathname.endsWith("/")) {
95
- return url;
96
- }
97
- let { origin } = urlObject;
98
- // origin is "null" for "file://" urls with Node.js
99
- if (origin === "null" && urlObject.href.startsWith("file:")) {
100
- origin = "file://";
101
- }
102
- const { search, hash } = urlObject;
103
- return `${origin}${pathname}/${search}${hash}`;
94
+ callbacks.push(callback);
95
+ return () => {
96
+ if (status === "notified") {
97
+ // once called removing does nothing
98
+ // as the callbacks array is frozen to null
99
+ return;
100
+ }
101
+
102
+ const index = callbacks.indexOf(callback);
103
+ if (index === -1) {
104
+ return;
105
+ }
106
+
107
+ if (status === "looping") {
108
+ if (index <= currentCallbackIndex) {
109
+ // The callback was already called (or is the current callback)
110
+ // We don't want to mutate the callbacks array
111
+ // or it would alter the looping done in "call" and the next callback
112
+ // would be skipped
113
+ return;
114
+ }
115
+
116
+ // Callback is part of the next callback to call,
117
+ // we mutate the callbacks array to prevent this callback to be called
118
+ }
119
+
120
+ callbacks.splice(index, 1);
121
+ };
122
+ };
123
+
124
+ const notify = (param) => {
125
+ if (status !== "waiting") {
126
+ emitUnexpectedActionWarning({ action: "call", status });
127
+ return [];
128
+ }
129
+ status = "looping";
130
+ const values = callbacks.map((callback, index) => {
131
+ currentCallbackIndex = index;
132
+ return callback(param);
133
+ });
134
+ callbackListOnce.notified = true;
135
+ status = "notified";
136
+ // we reset callbacks to null after looping
137
+ // so that it's possible to remove during the loop
138
+ callbacks = null;
139
+ currentCallbackIndex = -1;
140
+
141
+ return values;
142
+ };
143
+
144
+ callbackListOnce.notified = false;
145
+ callbackListOnce.add = add;
146
+ callbackListOnce.notify = notify;
147
+
148
+ return callbackListOnce;
104
149
  };
105
150
 
106
- const isFileSystemPath = (value) => {
107
- if (typeof value !== "string") {
108
- throw new TypeError(
109
- `isFileSystemPath first arg must be a string, got ${value}`,
151
+ const emitUnexpectedActionWarning = ({ action, status }) => {
152
+ if (typeof process.emitWarning === "function") {
153
+ process.emitWarning(
154
+ `"${action}" should not happen when callback list is ${status}`,
155
+ {
156
+ CODE: "UNEXPECTED_ACTION_ON_CALLBACK_LIST",
157
+ detail: `Code is potentially executed when it should not`,
158
+ },
159
+ );
160
+ } else {
161
+ console.warn(
162
+ `"${action}" should not happen when callback list is ${status}`,
110
163
  );
111
164
  }
112
- if (value[0] === "/") {
113
- return true;
165
+ };
166
+
167
+ const emitCallbackDuplicationWarning = () => {
168
+ if (typeof process.emitWarning === "function") {
169
+ process.emitWarning(`Trying to add a callback already in the list`, {
170
+ CODE: "CALLBACK_DUPLICATION",
171
+ detail: `Code is potentially executed more than it should`,
172
+ });
173
+ } else {
174
+ console.warn(`Trying to add same callback twice`);
114
175
  }
115
- return startsWithWindowsDriveLetter(value);
116
176
  };
117
177
 
118
- const startsWithWindowsDriveLetter = (string) => {
119
- const firstChar = string[0];
120
- if (!/[a-zA-Z]/.test(firstChar)) return false;
178
+ const removeNoop = () => {};
121
179
 
122
- const secondChar = string[1];
123
- if (secondChar !== ":") return false;
180
+ /*
181
+ * https://github.com/whatwg/dom/issues/920
182
+ */
124
183
 
125
- return true;
126
- };
127
184
 
128
- const fileSystemPathToUrl = (value) => {
129
- if (!isFileSystemPath(value)) {
130
- throw new Error(`value must be a filesystem path, got ${value}`);
131
- }
132
- return String(pathToFileURL(value));
133
- };
185
+ const Abort = {
186
+ isAbortError: (error) => {
187
+ return error && error.name === "AbortError";
188
+ },
134
189
 
135
- const resolveUrl = (specifier, baseUrl) => {
136
- if (typeof baseUrl === "undefined") {
137
- throw new TypeError(`baseUrl missing to resolve ${specifier}`);
138
- }
139
- return String(new URL(specifier, baseUrl));
190
+ startOperation: () => {
191
+ return createOperation();
192
+ },
193
+
194
+ throwIfAborted: (signal) => {
195
+ if (signal.aborted) {
196
+ const error = new Error(`The operation was aborted`);
197
+ error.name = "AbortError";
198
+ error.type = "aborted";
199
+ throw error;
200
+ }
201
+ },
140
202
  };
141
203
 
142
- const getCommonPathname = (pathname, otherPathname) => {
143
- if (pathname === otherPathname) {
144
- return pathname;
145
- }
146
- let commonPart = "";
147
- let commonPathname = "";
148
- let i = 0;
149
- const length = pathname.length;
150
- const otherLength = otherPathname.length;
151
- while (i < length) {
152
- const char = pathname.charAt(i);
153
- const otherChar = otherPathname.charAt(i);
154
- i++;
155
- if (char === otherChar) {
156
- if (char === "/") {
157
- commonPart += "/";
158
- commonPathname += commonPart;
159
- commonPart = "";
160
- } else {
161
- commonPart += char;
162
- }
163
- } else {
164
- if (char === "/" && i - 1 === otherLength) {
165
- commonPart += "/";
166
- commonPathname += commonPart;
167
- }
168
- return commonPathname;
169
- }
170
- }
171
- if (length === otherLength) {
172
- commonPathname += commonPart;
173
- } else if (otherPathname.charAt(i) === "/") {
174
- commonPathname += commonPart;
175
- }
176
- return commonPathname;
177
- };
178
-
179
- const urlToRelativeUrl = (url, baseUrl) => {
180
- const urlObject = new URL(url);
181
- const baseUrlObject = new URL(baseUrl);
182
-
183
- if (urlObject.protocol !== baseUrlObject.protocol) {
184
- const urlAsString = String(url);
185
- return urlAsString;
186
- }
187
-
188
- if (
189
- urlObject.username !== baseUrlObject.username ||
190
- urlObject.password !== baseUrlObject.password ||
191
- urlObject.host !== baseUrlObject.host
192
- ) {
193
- const afterUrlScheme = String(url).slice(urlObject.protocol.length);
194
- return afterUrlScheme;
195
- }
196
-
197
- const { pathname, hash, search } = urlObject;
198
- if (pathname === "/") {
199
- const baseUrlResourceWithoutLeadingSlash = baseUrlObject.pathname.slice(1);
200
- return baseUrlResourceWithoutLeadingSlash;
201
- }
202
-
203
- const basePathname = baseUrlObject.pathname;
204
- const commonPathname = getCommonPathname(pathname, basePathname);
205
- if (!commonPathname) {
206
- const urlAsString = String(url);
207
- return urlAsString;
208
- }
209
- const specificPathname = pathname.slice(commonPathname.length);
210
- const baseSpecificPathname = basePathname.slice(commonPathname.length);
211
- if (baseSpecificPathname.includes("/")) {
212
- const baseSpecificParentPathname =
213
- pathnameToParentPathname(baseSpecificPathname);
214
- const relativeDirectoriesNotation = baseSpecificParentPathname.replace(
215
- /.*?\//g,
216
- "../",
217
- );
218
- const relativeUrl = `${relativeDirectoriesNotation}${specificPathname}${search}${hash}`;
219
- return relativeUrl;
220
- }
221
-
222
- const relativeUrl = `${specificPathname}${search}${hash}`;
223
- return relativeUrl;
224
- };
204
+ const createOperation = () => {
205
+ const operationAbortController = new AbortController();
206
+ // const abortOperation = (value) => abortController.abort(value)
207
+ const operationSignal = operationAbortController.signal;
225
208
 
226
- const pathnameToParentPathname = (pathname) => {
227
- const slashLastIndex = pathname.lastIndexOf("/");
228
- if (slashLastIndex === -1) {
229
- return "/";
230
- }
231
- return pathname.slice(0, slashLastIndex + 1);
232
- };
209
+ // abortCallbackList is used to ignore the max listeners warning from Node.js
210
+ // this warning is useful but becomes problematic when it's expected
211
+ // (a function doing 20 http call in parallel)
212
+ // To be 100% sure we don't have memory leak, only Abortable.asyncCallback
213
+ // uses abortCallbackList to know when something is aborted
214
+ const abortCallbackList = createCallbackListNotifiedOnce();
215
+ const endCallbackList = createCallbackListNotifiedOnce();
233
216
 
234
- const moveUrl = ({ url, from, to, preferRelative }) => {
235
- let relativeUrl = urlToRelativeUrl(url, from);
236
- if (relativeUrl.slice(0, 2) === "//") {
237
- // restore the protocol
238
- relativeUrl = new URL(relativeUrl, url).href;
239
- }
240
- const absoluteUrl = new URL(relativeUrl, to).href;
241
- if (preferRelative) {
242
- return urlToRelativeUrl(absoluteUrl, to);
243
- }
244
- return absoluteUrl;
245
- };
217
+ let isAbortAfterEnd = false;
246
218
 
247
- const urlIsInsideOf = (url, otherUrl) => {
248
- const urlObject = new URL(url);
249
- const otherUrlObject = new URL(otherUrl);
219
+ operationSignal.onabort = () => {
220
+ operationSignal.onabort = null;
250
221
 
251
- if (urlObject.origin !== otherUrlObject.origin) {
252
- return false;
253
- }
222
+ const allAbortCallbacksPromise = Promise.all(abortCallbackList.notify());
223
+ if (!isAbortAfterEnd) {
224
+ addEndCallback(async () => {
225
+ await allAbortCallbacksPromise;
226
+ });
227
+ }
228
+ };
254
229
 
255
- const urlPathname = urlObject.pathname;
256
- const otherUrlPathname = otherUrlObject.pathname;
257
- if (urlPathname === otherUrlPathname) {
258
- return false;
259
- }
230
+ const throwIfAborted = () => {
231
+ Abort.throwIfAborted(operationSignal);
232
+ };
260
233
 
261
- const isInside = urlPathname.startsWith(otherUrlPathname);
262
- return isInside;
263
- };
234
+ // add a callback called on abort
235
+ // differences with signal.addEventListener('abort')
236
+ // - operation.end awaits the return value of this callback
237
+ // - It won't increase the count of listeners for "abort" that would
238
+ // trigger max listeners warning when count > 10
239
+ const addAbortCallback = (callback) => {
240
+ // It would be painful and not super redable to check if signal is aborted
241
+ // before deciding if it's an abort or end callback
242
+ // with pseudo-code below where we want to stop server either
243
+ // on abort or when ended because signal is aborted
244
+ // operation[operation.signal.aborted ? 'addAbortCallback': 'addEndCallback'](async () => {
245
+ // await server.stop()
246
+ // })
247
+ if (operationSignal.aborted) {
248
+ return addEndCallback(callback);
249
+ }
250
+ return abortCallbackList.add(callback);
251
+ };
264
252
 
265
- const urlToFileSystemPath = (url) => {
266
- let urlString = String(url);
267
- if (urlString[urlString.length - 1] === "/") {
268
- // remove trailing / so that nodejs path becomes predictable otherwise it logs
269
- // the trailing slash on linux but does not on windows
270
- urlString = urlString.slice(0, -1);
271
- }
272
- const fileSystemPath = fileURLToPath(urlString);
273
- return fileSystemPath;
274
- };
253
+ const addEndCallback = (callback) => {
254
+ return endCallbackList.add(callback);
255
+ };
275
256
 
276
- const validateDirectoryUrl = (value) => {
277
- let urlString;
257
+ const end = async ({ abortAfterEnd = false } = {}) => {
258
+ await Promise.all(endCallbackList.notify());
278
259
 
279
- if (value instanceof URL) {
280
- urlString = value.href;
281
- } else if (typeof value === "string") {
282
- if (isFileSystemPath(value)) {
283
- urlString = fileSystemPathToUrl(value);
284
- } else {
285
- try {
286
- urlString = String(new URL(value));
287
- } catch (e) {
288
- return {
289
- valid: false,
290
- value,
291
- message: `must be a valid url`,
292
- };
260
+ // "abortAfterEnd" can be handy to ensure "abort" callbacks
261
+ // added with { once: true } are removed
262
+ // It might also help garbage collection because
263
+ // runtime implementing AbortSignal (Node.js, browsers) can consider abortSignal
264
+ // as settled and clean up things
265
+ if (abortAfterEnd) {
266
+ // because of operationSignal.onabort = null
267
+ // + abortCallbackList.clear() this won't re-call
268
+ // callbacks
269
+ if (!operationSignal.aborted) {
270
+ isAbortAfterEnd = true;
271
+ operationAbortController.abort();
293
272
  }
294
273
  }
295
- } else {
296
- return {
297
- valid: false,
298
- value,
299
- message: `must be a string or an url`,
274
+ };
275
+
276
+ const addAbortSignal = (
277
+ signal,
278
+ { onAbort = callbackNoop, onRemove = callbackNoop } = {},
279
+ ) => {
280
+ const applyAbortEffects = () => {
281
+ const onAbortCallback = onAbort;
282
+ onAbort = callbackNoop;
283
+ onAbortCallback();
300
284
  };
301
- }
302
- if (!urlString.startsWith("file://")) {
303
- return {
304
- valid: false,
305
- value,
306
- message: 'must start with "file://"',
285
+ const applyRemoveEffects = () => {
286
+ const onRemoveCallback = onRemove;
287
+ onRemove = callbackNoop;
288
+ onAbort = callbackNoop;
289
+ onRemoveCallback();
307
290
  };
308
- }
309
- return {
310
- valid: true,
311
- value: ensurePathnameTrailingSlash(urlString),
312
- };
313
- };
314
291
 
315
- const assertAndNormalizeDirectoryUrl = (
316
- directoryUrl,
317
- name = "directoryUrl",
318
- ) => {
319
- const { valid, message, value } = validateDirectoryUrl(directoryUrl);
320
- if (!valid) {
321
- throw new TypeError(`${name} ${message}, got ${value}`);
322
- }
323
- return value;
324
- };
292
+ if (operationSignal.aborted) {
293
+ applyAbortEffects();
294
+ applyRemoveEffects();
295
+ return callbackNoop;
296
+ }
325
297
 
326
- const validateFileUrl = (value, baseUrl) => {
327
- let urlString;
298
+ if (signal.aborted) {
299
+ operationAbortController.abort();
300
+ applyAbortEffects();
301
+ applyRemoveEffects();
302
+ return callbackNoop;
303
+ }
328
304
 
329
- if (value instanceof URL) {
330
- urlString = value.href;
331
- } else if (typeof value === "string") {
332
- if (isFileSystemPath(value)) {
333
- urlString = fileSystemPathToUrl(value);
334
- } else {
335
- try {
336
- urlString = String(new URL(value, baseUrl));
337
- } catch (e) {
338
- return {
339
- valid: false,
340
- value,
341
- message: "must be a valid url",
305
+ const cancelRace = raceCallbacks(
306
+ {
307
+ operation_abort: (cb) => {
308
+ return addAbortCallback(cb);
309
+ },
310
+ operation_end: (cb) => {
311
+ return addEndCallback(cb);
312
+ },
313
+ child_abort: (cb) => {
314
+ return addEventListener(signal, "abort", cb);
315
+ },
316
+ },
317
+ (winner) => {
318
+ const raceEffects = {
319
+ // Both "operation_abort" and "operation_end"
320
+ // means we don't care anymore if the child aborts.
321
+ // So we can:
322
+ // - remove "abort" event listener on child (done by raceCallback)
323
+ // - remove abort callback on operation (done by raceCallback)
324
+ // - remove end callback on operation (done by raceCallback)
325
+ // - call any custom cancel function
326
+ operation_abort: () => {
327
+ applyAbortEffects();
328
+ applyRemoveEffects();
329
+ },
330
+ operation_end: () => {
331
+ // Exists to
332
+ // - remove abort callback on operation
333
+ // - remove "abort" event listener on child
334
+ // - call any custom cancel function
335
+ applyRemoveEffects();
336
+ },
337
+ child_abort: () => {
338
+ applyAbortEffects();
339
+ operationAbortController.abort();
340
+ },
342
341
  };
343
- }
344
- }
345
- } else {
346
- return {
347
- valid: false,
348
- value,
349
- message: "must be a string or an url",
350
- };
351
- }
342
+ raceEffects[winner.name](winner.value);
343
+ },
344
+ );
352
345
 
353
- if (!urlString.startsWith("file://")) {
354
- return {
355
- valid: false,
356
- value,
357
- message: 'must start with "file://"',
346
+ return () => {
347
+ cancelRace();
348
+ applyRemoveEffects();
358
349
  };
359
- }
360
-
361
- return {
362
- valid: true,
363
- value: urlString,
364
350
  };
365
- };
366
-
367
- const assertAndNormalizeFileUrl = (
368
- fileUrl,
369
- baseUrl,
370
- name = "fileUrl",
371
- ) => {
372
- const { valid, message, value } = validateFileUrl(fileUrl, baseUrl);
373
- if (!valid) {
374
- throw new TypeError(`${name} ${message}, got ${fileUrl}`);
375
- }
376
- return value;
377
- };
378
351
 
379
- const statsToType = (stats) => {
380
- if (stats.isFile()) return "file";
381
- if (stats.isDirectory()) return "directory";
382
- if (stats.isSymbolicLink()) return "symbolic-link";
383
- if (stats.isFIFO()) return "fifo";
384
- if (stats.isSocket()) return "socket";
385
- if (stats.isCharacterDevice()) return "character-device";
386
- if (stats.isBlockDevice()) return "block-device";
387
- return undefined;
388
- };
352
+ const addAbortSource = (abortSourceCallback) => {
353
+ const abortSource = {
354
+ cleaned: false,
355
+ signal: null,
356
+ remove: callbackNoop,
357
+ };
358
+ const abortSourceController = new AbortController();
359
+ const abortSourceSignal = abortSourceController.signal;
360
+ abortSource.signal = abortSourceSignal;
361
+ if (operationSignal.aborted) {
362
+ return abortSource;
363
+ }
364
+ const returnValue = abortSourceCallback((value) => {
365
+ abortSourceController.abort(value);
366
+ });
367
+ const removeAbortSignal = addAbortSignal(abortSourceSignal, {
368
+ onRemove: () => {
369
+ if (typeof returnValue === "function") {
370
+ returnValue();
371
+ }
372
+ abortSource.cleaned = true;
373
+ },
374
+ });
375
+ abortSource.remove = removeAbortSignal;
376
+ return abortSource;
377
+ };
389
378
 
390
- // https://github.com/coderaiser/cloudcmd/issues/63#issuecomment-195478143
391
- // https://nodejs.org/api/fs.html#fs_file_modes
392
- // https://github.com/TooTallNate/stat-mode
379
+ const timeout = (ms) => {
380
+ return addAbortSource((abort) => {
381
+ const timeoutId = setTimeout(abort, ms);
382
+ // an abort source return value is called when:
383
+ // - operation is aborted (by an other source)
384
+ // - operation ends
385
+ return () => {
386
+ clearTimeout(timeoutId);
387
+ };
388
+ });
389
+ };
393
390
 
394
- // cannot get from fs.constants because they are not available on windows
395
- const S_IRUSR = 256; /* 0000400 read permission, owner */
396
- const S_IWUSR = 128; /* 0000200 write permission, owner */
397
- const S_IXUSR = 64; /* 0000100 execute/search permission, owner */
398
- const S_IRGRP = 32; /* 0000040 read permission, group */
399
- const S_IWGRP = 16; /* 0000020 write permission, group */
400
- const S_IXGRP = 8; /* 0000010 execute/search permission, group */
401
- const S_IROTH = 4; /* 0000004 read permission, others */
402
- const S_IWOTH = 2; /* 0000002 write permission, others */
403
- const S_IXOTH = 1; /* 0000001 execute/search permission, others */
391
+ const withSignal = async (asyncCallback) => {
392
+ const abortController = new AbortController();
393
+ const signal = abortController.signal;
394
+ const removeAbortSignal = addAbortSignal(signal, {
395
+ onAbort: () => {
396
+ abortController.abort();
397
+ },
398
+ });
399
+ try {
400
+ const value = await asyncCallback(signal);
401
+ removeAbortSignal();
402
+ return value;
403
+ } catch (e) {
404
+ removeAbortSignal();
405
+ throw e;
406
+ }
407
+ };
404
408
 
405
- const permissionsToBinaryFlags = ({ owner, group, others }) => {
406
- let binaryFlags = 0;
409
+ const withSignalSync = (callback) => {
410
+ const abortController = new AbortController();
411
+ const signal = abortController.signal;
412
+ const removeAbortSignal = addAbortSignal(signal, {
413
+ onAbort: () => {
414
+ abortController.abort();
415
+ },
416
+ });
417
+ try {
418
+ const value = callback(signal);
419
+ removeAbortSignal();
420
+ return value;
421
+ } catch (e) {
422
+ removeAbortSignal();
423
+ throw e;
424
+ }
425
+ };
407
426
 
408
- if (owner.read) binaryFlags |= S_IRUSR;
409
- if (owner.write) binaryFlags |= S_IWUSR;
410
- if (owner.execute) binaryFlags |= S_IXUSR;
427
+ return {
428
+ // We could almost hide the operationSignal
429
+ // But it can be handy for 2 things:
430
+ // - know if operation is aborted (operation.signal.aborted)
431
+ // - forward the operation.signal directly (not using "withSignal" or "withSignalSync")
432
+ signal: operationSignal,
411
433
 
412
- if (group.read) binaryFlags |= S_IRGRP;
413
- if (group.write) binaryFlags |= S_IWGRP;
414
- if (group.execute) binaryFlags |= S_IXGRP;
434
+ throwIfAborted,
435
+ addAbortCallback,
436
+ addAbortSignal,
437
+ addAbortSource,
438
+ timeout,
439
+ withSignal,
440
+ withSignalSync,
441
+ addEndCallback,
442
+ end,
443
+ };
444
+ };
415
445
 
416
- if (others.read) binaryFlags |= S_IROTH;
417
- if (others.write) binaryFlags |= S_IWOTH;
418
- if (others.execute) binaryFlags |= S_IXOTH;
446
+ const callbackNoop = () => {};
419
447
 
420
- return binaryFlags;
448
+ const addEventListener = (target, eventName, cb) => {
449
+ target.addEventListener(eventName, cb);
450
+ return () => {
451
+ target.removeEventListener(eventName, cb);
452
+ };
421
453
  };
422
454
 
423
- const writeEntryPermissions = async (source, permissions) => {
424
- const sourceUrl = assertAndNormalizeFileUrl(source);
455
+ const raceProcessTeardownEvents = (processTeardownEvents, callback) => {
456
+ return raceCallbacks(
457
+ {
458
+ ...(processTeardownEvents.SIGHUP ? SIGHUP_CALLBACK : {}),
459
+ ...(processTeardownEvents.SIGTERM ? SIGTERM_CALLBACK : {}),
460
+ ...(processTeardownEvents.SIGINT ? SIGINT_CALLBACK : {}),
461
+ ...(processTeardownEvents.beforeExit ? BEFORE_EXIT_CALLBACK : {}),
462
+ ...(processTeardownEvents.exit ? EXIT_CALLBACK : {}),
463
+ },
464
+ callback,
465
+ );
466
+ };
425
467
 
426
- let binaryFlags;
427
- if (typeof permissions === "object") {
428
- permissions = {
429
- owner: {
430
- read: getPermissionOrComputeDefault("read", "owner", permissions),
431
- write: getPermissionOrComputeDefault("write", "owner", permissions),
432
- execute: getPermissionOrComputeDefault("execute", "owner", permissions),
433
- },
434
- group: {
435
- read: getPermissionOrComputeDefault("read", "group", permissions),
436
- write: getPermissionOrComputeDefault("write", "group", permissions),
437
- execute: getPermissionOrComputeDefault("execute", "group", permissions),
438
- },
439
- others: {
440
- read: getPermissionOrComputeDefault("read", "others", permissions),
441
- write: getPermissionOrComputeDefault("write", "others", permissions),
442
- execute: getPermissionOrComputeDefault(
443
- "execute",
444
- "others",
445
- permissions,
446
- ),
447
- },
468
+ const SIGHUP_CALLBACK = {
469
+ SIGHUP: (cb) => {
470
+ process.on("SIGHUP", cb);
471
+ return () => {
472
+ process.removeListener("SIGHUP", cb);
448
473
  };
449
- binaryFlags = permissionsToBinaryFlags(permissions);
450
- } else {
451
- binaryFlags = permissions;
452
- }
474
+ },
475
+ };
453
476
 
454
- return new Promise((resolve, reject) => {
455
- chmod(new URL(sourceUrl), binaryFlags, (error) => {
456
- if (error) {
457
- reject(error);
458
- } else {
459
- resolve();
460
- }
461
- });
462
- });
477
+ const SIGTERM_CALLBACK = {
478
+ SIGTERM: (cb) => {
479
+ process.on("SIGTERM", cb);
480
+ return () => {
481
+ process.removeListener("SIGTERM", cb);
482
+ };
483
+ },
463
484
  };
464
485
 
465
- const actionLevels = { read: 0, write: 1, execute: 2 };
466
- const subjectLevels = { others: 0, group: 1, owner: 2 };
486
+ const BEFORE_EXIT_CALLBACK = {
487
+ beforeExit: (cb) => {
488
+ process.on("beforeExit", cb);
489
+ return () => {
490
+ process.removeListener("beforeExit", cb);
491
+ };
492
+ },
493
+ };
467
494
 
468
- const getPermissionOrComputeDefault = (action, subject, permissions) => {
469
- if (subject in permissions) {
470
- const subjectPermissions = permissions[subject];
471
- if (action in subjectPermissions) {
472
- return subjectPermissions[action];
473
- }
495
+ const EXIT_CALLBACK = {
496
+ exit: (cb) => {
497
+ process.on("exit", cb);
498
+ return () => {
499
+ process.removeListener("exit", cb);
500
+ };
501
+ },
502
+ };
474
503
 
475
- const actionLevel = actionLevels[action];
476
- const actionFallback = Object.keys(actionLevels).find(
477
- (actionFallbackCandidate) =>
478
- actionLevels[actionFallbackCandidate] > actionLevel &&
479
- actionFallbackCandidate in subjectPermissions,
480
- );
481
- if (actionFallback) {
482
- return subjectPermissions[actionFallback];
483
- }
484
- }
504
+ const SIGINT_CALLBACK = {
505
+ SIGINT: (cb) => {
506
+ process.on("SIGINT", cb);
507
+ return () => {
508
+ process.removeListener("SIGINT", cb);
509
+ };
510
+ },
511
+ };
485
512
 
486
- const subjectLevel = subjectLevels[subject];
487
- // do we have a subject with a stronger level (group or owner)
488
- // where we could read the action permission ?
489
- const subjectFallback = Object.keys(subjectLevels).find(
490
- (subjectFallbackCandidate) =>
491
- subjectLevels[subjectFallbackCandidate] > subjectLevel &&
492
- subjectFallbackCandidate in permissions,
493
- );
494
- if (subjectFallback) {
495
- const subjectPermissions = permissions[subjectFallback];
496
- return action in subjectPermissions
497
- ? subjectPermissions[action]
498
- : getPermissionOrComputeDefault(action, subjectFallback, permissions);
513
+ const urlToScheme = (url) => {
514
+ const urlString = String(url);
515
+ const colonIndex = urlString.indexOf(":");
516
+ if (colonIndex === -1) {
517
+ return "";
499
518
  }
500
519
 
501
- return false;
520
+ const scheme = urlString.slice(0, colonIndex);
521
+ return scheme;
502
522
  };
503
523
 
504
- /*
505
- * - stats object documentation on Node.js
506
- * https://nodejs.org/docs/latest-v13.x/api/fs.html#fs_class_fs_stats
507
- */
508
-
509
-
510
- const isWindows$2 = process.platform === "win32";
511
-
512
- const readEntryStat = async (
513
- source,
514
- { nullIfNotFound = false, followLink = true } = {},
515
- ) => {
516
- let sourceUrl = assertAndNormalizeFileUrl(source);
517
- if (sourceUrl.endsWith("/")) sourceUrl = sourceUrl.slice(0, -1);
518
-
519
- const sourcePath = urlToFileSystemPath(sourceUrl);
524
+ const urlToResource = (url) => {
525
+ const scheme = urlToScheme(url);
520
526
 
521
- const handleNotFoundOption = nullIfNotFound
522
- ? {
523
- handleNotFoundError: () => null,
524
- }
525
- : {};
527
+ if (scheme === "file") {
528
+ const urlAsStringWithoutFileProtocol = String(url).slice("file://".length);
529
+ return urlAsStringWithoutFileProtocol;
530
+ }
526
531
 
527
- return readStat(sourcePath, {
528
- followLink,
529
- ...handleNotFoundOption,
530
- ...(isWindows$2
531
- ? {
532
- // Windows can EPERM on stat
533
- handlePermissionDeniedError: async (error) => {
534
- console.error(
535
- `trying to fix windows EPERM after stats on ${sourcePath}`,
536
- );
532
+ if (scheme === "https" || scheme === "http") {
533
+ // remove origin
534
+ const afterProtocol = String(url).slice(scheme.length + "://".length);
535
+ const pathnameSlashIndex = afterProtocol.indexOf("/", "://".length);
536
+ const urlAsStringWithoutOrigin = afterProtocol.slice(pathnameSlashIndex);
537
+ return urlAsStringWithoutOrigin;
538
+ }
537
539
 
538
- try {
539
- // unfortunately it means we mutate the permissions
540
- // without being able to restore them to the previous value
541
- // (because reading current permission would also throw)
542
- await writeEntryPermissions(sourceUrl, 0o666);
543
- const stats = await readStat(sourcePath, {
544
- followLink,
545
- ...handleNotFoundOption,
546
- // could not fix the permission error, give up and throw original error
547
- handlePermissionDeniedError: () => {
548
- console.error(`still got EPERM after stats on ${sourcePath}`);
549
- throw error;
550
- },
551
- });
552
- return stats;
553
- } catch (e) {
554
- console.error(
555
- `error while trying to fix windows EPERM after stats on ${sourcePath}: ${e.stack}`,
556
- );
557
- throw error;
558
- }
559
- },
560
- }
561
- : {}),
562
- });
540
+ const urlAsStringWithoutProtocol = String(url).slice(scheme.length + 1);
541
+ return urlAsStringWithoutProtocol;
563
542
  };
564
543
 
565
- const readStat = (
566
- sourcePath,
567
- {
568
- followLink,
569
- handleNotFoundError = null,
570
- handlePermissionDeniedError = null,
571
- } = {},
572
- ) => {
573
- const nodeMethod = followLink ? stat : lstat;
574
-
575
- return new Promise((resolve, reject) => {
576
- nodeMethod(sourcePath, (error, statsObject) => {
577
- if (error) {
578
- if (handleNotFoundError && error.code === "ENOENT") {
579
- resolve(handleNotFoundError(error));
580
- } else if (
581
- handlePermissionDeniedError &&
582
- (error.code === "EPERM" || error.code === "EACCES")
583
- ) {
584
- resolve(handlePermissionDeniedError(error));
585
- } else {
586
- reject(error);
587
- }
588
- } else {
589
- resolve(statsObject);
590
- }
591
- });
592
- });
544
+ const urlToPathname = (url) => {
545
+ const resource = urlToResource(url);
546
+ const pathname = resourceToPathname(resource);
547
+ return pathname;
593
548
  };
594
549
 
595
- /*
596
- * See callback_race.md
597
- */
550
+ const resourceToPathname = (resource) => {
551
+ const searchSeparatorIndex = resource.indexOf("?");
552
+ if (searchSeparatorIndex > -1) {
553
+ return resource.slice(0, searchSeparatorIndex);
554
+ }
555
+ const hashIndex = resource.indexOf("#");
556
+ if (hashIndex > -1) {
557
+ return resource.slice(0, hashIndex);
558
+ }
559
+ return resource;
560
+ };
598
561
 
599
- const raceCallbacks = (raceDescription, winnerCallback) => {
600
- let cleanCallbacks = [];
601
- let status = "racing";
562
+ const urlToExtension = (url) => {
563
+ const pathname = urlToPathname(url);
564
+ return pathnameToExtension(pathname);
565
+ };
602
566
 
603
- const clean = () => {
604
- cleanCallbacks.forEach((clean) => {
605
- clean();
606
- });
607
- cleanCallbacks = null;
608
- };
567
+ const pathnameToExtension = (pathname) => {
568
+ const slashLastIndex = pathname.lastIndexOf("/");
569
+ if (slashLastIndex !== -1) {
570
+ pathname = pathname.slice(slashLastIndex + 1);
571
+ }
609
572
 
610
- const cancel = () => {
611
- if (status !== "racing") {
612
- return;
613
- }
614
- status = "cancelled";
615
- clean();
616
- };
573
+ const dotLastIndex = pathname.lastIndexOf(".");
574
+ if (dotLastIndex === -1) return "";
575
+ // if (dotLastIndex === pathname.length - 1) return ""
576
+ const extension = pathname.slice(dotLastIndex);
577
+ return extension;
578
+ };
617
579
 
618
- Object.keys(raceDescription).forEach((candidateName) => {
619
- const register = raceDescription[candidateName];
620
- const returnValue = register((data) => {
621
- if (status !== "racing") {
622
- return;
623
- }
624
- status = "done";
625
- clean();
626
- winnerCallback({
627
- name: candidateName,
628
- data,
629
- });
630
- });
631
- if (typeof returnValue === "function") {
632
- cleanCallbacks.push(returnValue);
633
- }
634
- });
580
+ const ensurePathnameTrailingSlash = (url) => {
581
+ const urlObject = new URL(url);
582
+ const { pathname } = urlObject;
583
+ if (pathname.endsWith("/")) {
584
+ return url;
585
+ }
586
+ let { origin } = urlObject;
587
+ // origin is "null" for "file://" urls with Node.js
588
+ if (origin === "null" && urlObject.href.startsWith("file:")) {
589
+ origin = "file://";
590
+ }
591
+ const { search, hash } = urlObject;
592
+ return `${origin}${pathname}/${search}${hash}`;
593
+ };
635
594
 
636
- return cancel;
595
+ const isFileSystemPath = (value) => {
596
+ if (typeof value !== "string") {
597
+ throw new TypeError(
598
+ `isFileSystemPath first arg must be a string, got ${value}`,
599
+ );
600
+ }
601
+ if (value[0] === "/") {
602
+ return true;
603
+ }
604
+ return startsWithWindowsDriveLetter(value);
637
605
  };
638
606
 
639
- const createCallbackListNotifiedOnce = () => {
640
- let callbacks = [];
641
- let status = "waiting";
642
- let currentCallbackIndex = -1;
607
+ const startsWithWindowsDriveLetter = (string) => {
608
+ const firstChar = string[0];
609
+ if (!/[a-zA-Z]/.test(firstChar)) return false;
643
610
 
644
- const callbackListOnce = {};
611
+ const secondChar = string[1];
612
+ if (secondChar !== ":") return false;
645
613
 
646
- const add = (callback) => {
647
- if (status !== "waiting") {
648
- emitUnexpectedActionWarning({ action: "add", status });
649
- return removeNoop;
650
- }
614
+ return true;
615
+ };
651
616
 
652
- if (typeof callback !== "function") {
653
- throw new Error(`callback must be a function, got ${callback}`);
654
- }
617
+ const fileSystemPathToUrl = (value) => {
618
+ if (!isFileSystemPath(value)) {
619
+ throw new Error(`value must be a filesystem path, got ${value}`);
620
+ }
621
+ return String(pathToFileURL(value));
622
+ };
655
623
 
656
- // don't register twice
657
- const existingCallback = callbacks.find((callbackCandidate) => {
658
- return callbackCandidate === callback;
659
- });
660
- if (existingCallback) {
661
- emitCallbackDuplicationWarning();
662
- return removeNoop;
663
- }
624
+ const resolveUrl = (specifier, baseUrl) => {
625
+ if (typeof baseUrl === "undefined") {
626
+ throw new TypeError(`baseUrl missing to resolve ${specifier}`);
627
+ }
628
+ return String(new URL(specifier, baseUrl));
629
+ };
664
630
 
665
- callbacks.push(callback);
666
- return () => {
667
- if (status === "notified") {
668
- // once called removing does nothing
669
- // as the callbacks array is frozen to null
670
- return;
631
+ const getCommonPathname = (pathname, otherPathname) => {
632
+ if (pathname === otherPathname) {
633
+ return pathname;
634
+ }
635
+ let commonPart = "";
636
+ let commonPathname = "";
637
+ let i = 0;
638
+ const length = pathname.length;
639
+ const otherLength = otherPathname.length;
640
+ while (i < length) {
641
+ const char = pathname.charAt(i);
642
+ const otherChar = otherPathname.charAt(i);
643
+ i++;
644
+ if (char === otherChar) {
645
+ if (char === "/") {
646
+ commonPart += "/";
647
+ commonPathname += commonPart;
648
+ commonPart = "";
649
+ } else {
650
+ commonPart += char;
671
651
  }
672
-
673
- const index = callbacks.indexOf(callback);
674
- if (index === -1) {
675
- return;
652
+ } else {
653
+ if (char === "/" && i - 1 === otherLength) {
654
+ commonPart += "/";
655
+ commonPathname += commonPart;
676
656
  }
657
+ return commonPathname;
658
+ }
659
+ }
660
+ if (length === otherLength) {
661
+ commonPathname += commonPart;
662
+ } else if (otherPathname.charAt(i) === "/") {
663
+ commonPathname += commonPart;
664
+ }
665
+ return commonPathname;
666
+ };
677
667
 
678
- if (status === "looping") {
679
- if (index <= currentCallbackIndex) {
680
- // The callback was already called (or is the current callback)
681
- // We don't want to mutate the callbacks array
682
- // or it would alter the looping done in "call" and the next callback
683
- // would be skipped
684
- return;
685
- }
668
+ const urlToRelativeUrl = (url, baseUrl) => {
669
+ const urlObject = new URL(url);
670
+ const baseUrlObject = new URL(baseUrl);
686
671
 
687
- // Callback is part of the next callback to call,
688
- // we mutate the callbacks array to prevent this callback to be called
689
- }
672
+ if (urlObject.protocol !== baseUrlObject.protocol) {
673
+ const urlAsString = String(url);
674
+ return urlAsString;
675
+ }
690
676
 
691
- callbacks.splice(index, 1);
692
- };
693
- };
677
+ if (
678
+ urlObject.username !== baseUrlObject.username ||
679
+ urlObject.password !== baseUrlObject.password ||
680
+ urlObject.host !== baseUrlObject.host
681
+ ) {
682
+ const afterUrlScheme = String(url).slice(urlObject.protocol.length);
683
+ return afterUrlScheme;
684
+ }
694
685
 
695
- const notify = (param) => {
696
- if (status !== "waiting") {
697
- emitUnexpectedActionWarning({ action: "call", status });
698
- return [];
699
- }
700
- status = "looping";
701
- const values = callbacks.map((callback, index) => {
702
- currentCallbackIndex = index;
703
- return callback(param);
704
- });
705
- callbackListOnce.notified = true;
706
- status = "notified";
707
- // we reset callbacks to null after looping
708
- // so that it's possible to remove during the loop
709
- callbacks = null;
710
- currentCallbackIndex = -1;
686
+ const { pathname, hash, search } = urlObject;
687
+ if (pathname === "/") {
688
+ const baseUrlResourceWithoutLeadingSlash = baseUrlObject.pathname.slice(1);
689
+ return baseUrlResourceWithoutLeadingSlash;
690
+ }
711
691
 
712
- return values;
713
- };
692
+ const basePathname = baseUrlObject.pathname;
693
+ const commonPathname = getCommonPathname(pathname, basePathname);
694
+ if (!commonPathname) {
695
+ const urlAsString = String(url);
696
+ return urlAsString;
697
+ }
698
+ const specificPathname = pathname.slice(commonPathname.length);
699
+ const baseSpecificPathname = basePathname.slice(commonPathname.length);
700
+ if (baseSpecificPathname.includes("/")) {
701
+ const baseSpecificParentPathname =
702
+ pathnameToParentPathname(baseSpecificPathname);
703
+ const relativeDirectoriesNotation = baseSpecificParentPathname.replace(
704
+ /.*?\//g,
705
+ "../",
706
+ );
707
+ const relativeUrl = `${relativeDirectoriesNotation}${specificPathname}${search}${hash}`;
708
+ return relativeUrl;
709
+ }
714
710
 
715
- callbackListOnce.notified = false;
716
- callbackListOnce.add = add;
717
- callbackListOnce.notify = notify;
711
+ const relativeUrl = `${specificPathname}${search}${hash}`;
712
+ return relativeUrl;
713
+ };
718
714
 
719
- return callbackListOnce;
715
+ const pathnameToParentPathname = (pathname) => {
716
+ const slashLastIndex = pathname.lastIndexOf("/");
717
+ if (slashLastIndex === -1) {
718
+ return "/";
719
+ }
720
+ return pathname.slice(0, slashLastIndex + 1);
720
721
  };
721
722
 
722
- const emitUnexpectedActionWarning = ({ action, status }) => {
723
- if (typeof process.emitWarning === "function") {
724
- process.emitWarning(
725
- `"${action}" should not happen when callback list is ${status}`,
726
- {
727
- CODE: "UNEXPECTED_ACTION_ON_CALLBACK_LIST",
728
- detail: `Code is potentially executed when it should not`,
729
- },
730
- );
731
- } else {
732
- console.warn(
733
- `"${action}" should not happen when callback list is ${status}`,
734
- );
723
+ const moveUrl = ({ url, from, to, preferRelative }) => {
724
+ let relativeUrl = urlToRelativeUrl(url, from);
725
+ if (relativeUrl.slice(0, 2) === "//") {
726
+ // restore the protocol
727
+ relativeUrl = new URL(relativeUrl, url).href;
728
+ }
729
+ const absoluteUrl = new URL(relativeUrl, to).href;
730
+ if (preferRelative) {
731
+ return urlToRelativeUrl(absoluteUrl, to);
735
732
  }
733
+ return absoluteUrl;
736
734
  };
737
735
 
738
- const emitCallbackDuplicationWarning = () => {
739
- if (typeof process.emitWarning === "function") {
740
- process.emitWarning(`Trying to add a callback already in the list`, {
741
- CODE: "CALLBACK_DUPLICATION",
742
- detail: `Code is potentially executed more than it should`,
743
- });
744
- } else {
745
- console.warn(`Trying to add same callback twice`);
746
- }
747
- };
736
+ const urlIsInsideOf = (url, otherUrl) => {
737
+ const urlObject = new URL(url);
738
+ const otherUrlObject = new URL(otherUrl);
748
739
 
749
- const removeNoop = () => {};
740
+ if (urlObject.origin !== otherUrlObject.origin) {
741
+ return false;
742
+ }
750
743
 
751
- /*
752
- * https://github.com/whatwg/dom/issues/920
753
- */
744
+ const urlPathname = urlObject.pathname;
745
+ const otherUrlPathname = otherUrlObject.pathname;
746
+ if (urlPathname === otherUrlPathname) {
747
+ return false;
748
+ }
754
749
 
750
+ const isInside = urlPathname.startsWith(otherUrlPathname);
751
+ return isInside;
752
+ };
755
753
 
756
- const Abort = {
757
- isAbortError: (error) => {
758
- return error && error.name === "AbortError";
759
- },
754
+ const urlToFileSystemPath = (url) => {
755
+ let urlString = String(url);
756
+ if (urlString[urlString.length - 1] === "/") {
757
+ // remove trailing / so that nodejs path becomes predictable otherwise it logs
758
+ // the trailing slash on linux but does not on windows
759
+ urlString = urlString.slice(0, -1);
760
+ }
761
+ const fileSystemPath = fileURLToPath(urlString);
762
+ return fileSystemPath;
763
+ };
760
764
 
761
- startOperation: () => {
762
- return createOperation();
763
- },
765
+ const validateDirectoryUrl = (value) => {
766
+ let urlString;
764
767
 
765
- throwIfAborted: (signal) => {
766
- if (signal.aborted) {
767
- const error = new Error(`The operation was aborted`);
768
- error.name = "AbortError";
769
- error.type = "aborted";
770
- throw error;
768
+ if (value instanceof URL) {
769
+ urlString = value.href;
770
+ } else if (typeof value === "string") {
771
+ if (isFileSystemPath(value)) {
772
+ urlString = fileSystemPathToUrl(value);
773
+ } else {
774
+ try {
775
+ urlString = String(new URL(value));
776
+ } catch (e) {
777
+ return {
778
+ valid: false,
779
+ value,
780
+ message: `must be a valid url`,
781
+ };
782
+ }
771
783
  }
772
- },
784
+ } else {
785
+ return {
786
+ valid: false,
787
+ value,
788
+ message: `must be a string or an url`,
789
+ };
790
+ }
791
+ if (!urlString.startsWith("file://")) {
792
+ return {
793
+ valid: false,
794
+ value,
795
+ message: 'must start with "file://"',
796
+ };
797
+ }
798
+ return {
799
+ valid: true,
800
+ value: ensurePathnameTrailingSlash(urlString),
801
+ };
773
802
  };
774
803
 
775
- const createOperation = () => {
776
- const operationAbortController = new AbortController();
777
- // const abortOperation = (value) => abortController.abort(value)
778
- const operationSignal = operationAbortController.signal;
804
+ const assertAndNormalizeDirectoryUrl = (
805
+ directoryUrl,
806
+ name = "directoryUrl",
807
+ ) => {
808
+ const { valid, message, value } = validateDirectoryUrl(directoryUrl);
809
+ if (!valid) {
810
+ throw new TypeError(`${name} ${message}, got ${value}`);
811
+ }
812
+ return value;
813
+ };
779
814
 
780
- // abortCallbackList is used to ignore the max listeners warning from Node.js
781
- // this warning is useful but becomes problematic when it's expected
782
- // (a function doing 20 http call in parallel)
783
- // To be 100% sure we don't have memory leak, only Abortable.asyncCallback
784
- // uses abortCallbackList to know when something is aborted
785
- const abortCallbackList = createCallbackListNotifiedOnce();
786
- const endCallbackList = createCallbackListNotifiedOnce();
815
+ const validateFileUrl = (value, baseUrl) => {
816
+ let urlString;
787
817
 
788
- let isAbortAfterEnd = false;
818
+ if (value instanceof URL) {
819
+ urlString = value.href;
820
+ } else if (typeof value === "string") {
821
+ if (isFileSystemPath(value)) {
822
+ urlString = fileSystemPathToUrl(value);
823
+ } else {
824
+ try {
825
+ urlString = String(new URL(value, baseUrl));
826
+ } catch (e) {
827
+ return {
828
+ valid: false,
829
+ value,
830
+ message: "must be a valid url",
831
+ };
832
+ }
833
+ }
834
+ } else {
835
+ return {
836
+ valid: false,
837
+ value,
838
+ message: "must be a string or an url",
839
+ };
840
+ }
789
841
 
790
- operationSignal.onabort = () => {
791
- operationSignal.onabort = null;
842
+ if (!urlString.startsWith("file://")) {
843
+ return {
844
+ valid: false,
845
+ value,
846
+ message: 'must start with "file://"',
847
+ };
848
+ }
792
849
 
793
- const allAbortCallbacksPromise = Promise.all(abortCallbackList.notify());
794
- if (!isAbortAfterEnd) {
795
- addEndCallback(async () => {
796
- await allAbortCallbacksPromise;
797
- });
798
- }
850
+ return {
851
+ valid: true,
852
+ value: urlString,
799
853
  };
854
+ };
800
855
 
801
- const throwIfAborted = () => {
802
- Abort.throwIfAborted(operationSignal);
803
- };
856
+ const assertAndNormalizeFileUrl = (
857
+ fileUrl,
858
+ baseUrl,
859
+ name = "fileUrl",
860
+ ) => {
861
+ const { valid, message, value } = validateFileUrl(fileUrl, baseUrl);
862
+ if (!valid) {
863
+ throw new TypeError(`${name} ${message}, got ${fileUrl}`);
864
+ }
865
+ return value;
866
+ };
804
867
 
805
- // add a callback called on abort
806
- // differences with signal.addEventListener('abort')
807
- // - operation.end awaits the return value of this callback
808
- // - It won't increase the count of listeners for "abort" that would
809
- // trigger max listeners warning when count > 10
810
- const addAbortCallback = (callback) => {
811
- // It would be painful and not super redable to check if signal is aborted
812
- // before deciding if it's an abort or end callback
813
- // with pseudo-code below where we want to stop server either
814
- // on abort or when ended because signal is aborted
815
- // operation[operation.signal.aborted ? 'addAbortCallback': 'addEndCallback'](async () => {
816
- // await server.stop()
817
- // })
818
- if (operationSignal.aborted) {
819
- return addEndCallback(callback);
820
- }
821
- return abortCallbackList.add(callback);
822
- };
868
+ const statsToType = (stats) => {
869
+ if (stats.isFile()) return "file";
870
+ if (stats.isDirectory()) return "directory";
871
+ if (stats.isSymbolicLink()) return "symbolic-link";
872
+ if (stats.isFIFO()) return "fifo";
873
+ if (stats.isSocket()) return "socket";
874
+ if (stats.isCharacterDevice()) return "character-device";
875
+ if (stats.isBlockDevice()) return "block-device";
876
+ return undefined;
877
+ };
823
878
 
824
- const addEndCallback = (callback) => {
825
- return endCallbackList.add(callback);
826
- };
879
+ // https://github.com/coderaiser/cloudcmd/issues/63#issuecomment-195478143
880
+ // https://nodejs.org/api/fs.html#fs_file_modes
881
+ // https://github.com/TooTallNate/stat-mode
827
882
 
828
- const end = async ({ abortAfterEnd = false } = {}) => {
829
- await Promise.all(endCallbackList.notify());
883
+ // cannot get from fs.constants because they are not available on windows
884
+ const S_IRUSR = 256; /* 0000400 read permission, owner */
885
+ const S_IWUSR = 128; /* 0000200 write permission, owner */
886
+ const S_IXUSR = 64; /* 0000100 execute/search permission, owner */
887
+ const S_IRGRP = 32; /* 0000040 read permission, group */
888
+ const S_IWGRP = 16; /* 0000020 write permission, group */
889
+ const S_IXGRP = 8; /* 0000010 execute/search permission, group */
890
+ const S_IROTH = 4; /* 0000004 read permission, others */
891
+ const S_IWOTH = 2; /* 0000002 write permission, others */
892
+ const S_IXOTH = 1; /* 0000001 execute/search permission, others */
830
893
 
831
- // "abortAfterEnd" can be handy to ensure "abort" callbacks
832
- // added with { once: true } are removed
833
- // It might also help garbage collection because
834
- // runtime implementing AbortSignal (Node.js, browsers) can consider abortSignal
835
- // as settled and clean up things
836
- if (abortAfterEnd) {
837
- // because of operationSignal.onabort = null
838
- // + abortCallbackList.clear() this won't re-call
839
- // callbacks
840
- if (!operationSignal.aborted) {
841
- isAbortAfterEnd = true;
842
- operationAbortController.abort();
843
- }
844
- }
845
- };
894
+ const permissionsToBinaryFlags = ({ owner, group, others }) => {
895
+ let binaryFlags = 0;
846
896
 
847
- const addAbortSignal = (
848
- signal,
849
- { onAbort = callbackNoop, onRemove = callbackNoop } = {},
850
- ) => {
851
- const applyAbortEffects = () => {
852
- const onAbortCallback = onAbort;
853
- onAbort = callbackNoop;
854
- onAbortCallback();
855
- };
856
- const applyRemoveEffects = () => {
857
- const onRemoveCallback = onRemove;
858
- onRemove = callbackNoop;
859
- onAbort = callbackNoop;
860
- onRemoveCallback();
861
- };
897
+ if (owner.read) binaryFlags |= S_IRUSR;
898
+ if (owner.write) binaryFlags |= S_IWUSR;
899
+ if (owner.execute) binaryFlags |= S_IXUSR;
900
+
901
+ if (group.read) binaryFlags |= S_IRGRP;
902
+ if (group.write) binaryFlags |= S_IWGRP;
903
+ if (group.execute) binaryFlags |= S_IXGRP;
904
+
905
+ if (others.read) binaryFlags |= S_IROTH;
906
+ if (others.write) binaryFlags |= S_IWOTH;
907
+ if (others.execute) binaryFlags |= S_IXOTH;
862
908
 
863
- if (operationSignal.aborted) {
864
- applyAbortEffects();
865
- applyRemoveEffects();
866
- return callbackNoop;
867
- }
909
+ return binaryFlags;
910
+ };
868
911
 
869
- if (signal.aborted) {
870
- operationAbortController.abort();
871
- applyAbortEffects();
872
- applyRemoveEffects();
873
- return callbackNoop;
874
- }
912
+ const writeEntryPermissions = async (source, permissions) => {
913
+ const sourceUrl = assertAndNormalizeFileUrl(source);
875
914
 
876
- const cancelRace = raceCallbacks(
877
- {
878
- operation_abort: (cb) => {
879
- return addAbortCallback(cb);
880
- },
881
- operation_end: (cb) => {
882
- return addEndCallback(cb);
883
- },
884
- child_abort: (cb) => {
885
- return addEventListener(signal, "abort", cb);
886
- },
915
+ let binaryFlags;
916
+ if (typeof permissions === "object") {
917
+ permissions = {
918
+ owner: {
919
+ read: getPermissionOrComputeDefault("read", "owner", permissions),
920
+ write: getPermissionOrComputeDefault("write", "owner", permissions),
921
+ execute: getPermissionOrComputeDefault("execute", "owner", permissions),
887
922
  },
888
- (winner) => {
889
- const raceEffects = {
890
- // Both "operation_abort" and "operation_end"
891
- // means we don't care anymore if the child aborts.
892
- // So we can:
893
- // - remove "abort" event listener on child (done by raceCallback)
894
- // - remove abort callback on operation (done by raceCallback)
895
- // - remove end callback on operation (done by raceCallback)
896
- // - call any custom cancel function
897
- operation_abort: () => {
898
- applyAbortEffects();
899
- applyRemoveEffects();
900
- },
901
- operation_end: () => {
902
- // Exists to
903
- // - remove abort callback on operation
904
- // - remove "abort" event listener on child
905
- // - call any custom cancel function
906
- applyRemoveEffects();
907
- },
908
- child_abort: () => {
909
- applyAbortEffects();
910
- operationAbortController.abort();
911
- },
912
- };
913
- raceEffects[winner.name](winner.value);
923
+ group: {
924
+ read: getPermissionOrComputeDefault("read", "group", permissions),
925
+ write: getPermissionOrComputeDefault("write", "group", permissions),
926
+ execute: getPermissionOrComputeDefault("execute", "group", permissions),
927
+ },
928
+ others: {
929
+ read: getPermissionOrComputeDefault("read", "others", permissions),
930
+ write: getPermissionOrComputeDefault("write", "others", permissions),
931
+ execute: getPermissionOrComputeDefault(
932
+ "execute",
933
+ "others",
934
+ permissions,
935
+ ),
914
936
  },
915
- );
916
-
917
- return () => {
918
- cancelRace();
919
- applyRemoveEffects();
920
937
  };
921
- };
938
+ binaryFlags = permissionsToBinaryFlags(permissions);
939
+ } else {
940
+ binaryFlags = permissions;
941
+ }
922
942
 
923
- const addAbortSource = (abortSourceCallback) => {
924
- const abortSource = {
925
- cleaned: false,
926
- signal: null,
927
- remove: callbackNoop,
928
- };
929
- const abortSourceController = new AbortController();
930
- const abortSourceSignal = abortSourceController.signal;
931
- abortSource.signal = abortSourceSignal;
932
- if (operationSignal.aborted) {
933
- return abortSource;
934
- }
935
- const returnValue = abortSourceCallback((value) => {
936
- abortSourceController.abort(value);
937
- });
938
- const removeAbortSignal = addAbortSignal(abortSourceSignal, {
939
- onRemove: () => {
940
- if (typeof returnValue === "function") {
941
- returnValue();
942
- }
943
- abortSource.cleaned = true;
944
- },
943
+ return new Promise((resolve, reject) => {
944
+ chmod(new URL(sourceUrl), binaryFlags, (error) => {
945
+ if (error) {
946
+ reject(error);
947
+ } else {
948
+ resolve();
949
+ }
945
950
  });
946
- abortSource.remove = removeAbortSignal;
947
- return abortSource;
948
- };
951
+ });
952
+ };
949
953
 
950
- const timeout = (ms) => {
951
- return addAbortSource((abort) => {
952
- const timeoutId = setTimeout(abort, ms);
953
- // an abort source return value is called when:
954
- // - operation is aborted (by an other source)
955
- // - operation ends
956
- return () => {
957
- clearTimeout(timeoutId);
958
- };
959
- });
960
- };
954
+ const actionLevels = { read: 0, write: 1, execute: 2 };
955
+ const subjectLevels = { others: 0, group: 1, owner: 2 };
961
956
 
962
- const withSignal = async (asyncCallback) => {
963
- const abortController = new AbortController();
964
- const signal = abortController.signal;
965
- const removeAbortSignal = addAbortSignal(signal, {
966
- onAbort: () => {
967
- abortController.abort();
968
- },
969
- });
970
- try {
971
- const value = await asyncCallback(signal);
972
- removeAbortSignal();
973
- return value;
974
- } catch (e) {
975
- removeAbortSignal();
976
- throw e;
957
+ const getPermissionOrComputeDefault = (action, subject, permissions) => {
958
+ if (subject in permissions) {
959
+ const subjectPermissions = permissions[subject];
960
+ if (action in subjectPermissions) {
961
+ return subjectPermissions[action];
977
962
  }
978
- };
979
963
 
980
- const withSignalSync = (callback) => {
981
- const abortController = new AbortController();
982
- const signal = abortController.signal;
983
- const removeAbortSignal = addAbortSignal(signal, {
984
- onAbort: () => {
985
- abortController.abort();
986
- },
987
- });
988
- try {
989
- const value = callback(signal);
990
- removeAbortSignal();
991
- return value;
992
- } catch (e) {
993
- removeAbortSignal();
994
- throw e;
964
+ const actionLevel = actionLevels[action];
965
+ const actionFallback = Object.keys(actionLevels).find(
966
+ (actionFallbackCandidate) =>
967
+ actionLevels[actionFallbackCandidate] > actionLevel &&
968
+ actionFallbackCandidate in subjectPermissions,
969
+ );
970
+ if (actionFallback) {
971
+ return subjectPermissions[actionFallback];
995
972
  }
996
- };
973
+ }
997
974
 
998
- return {
999
- // We could almost hide the operationSignal
1000
- // But it can be handy for 2 things:
1001
- // - know if operation is aborted (operation.signal.aborted)
1002
- // - forward the operation.signal directly (not using "withSignal" or "withSignalSync")
1003
- signal: operationSignal,
975
+ const subjectLevel = subjectLevels[subject];
976
+ // do we have a subject with a stronger level (group or owner)
977
+ // where we could read the action permission ?
978
+ const subjectFallback = Object.keys(subjectLevels).find(
979
+ (subjectFallbackCandidate) =>
980
+ subjectLevels[subjectFallbackCandidate] > subjectLevel &&
981
+ subjectFallbackCandidate in permissions,
982
+ );
983
+ if (subjectFallback) {
984
+ const subjectPermissions = permissions[subjectFallback];
985
+ return action in subjectPermissions
986
+ ? subjectPermissions[action]
987
+ : getPermissionOrComputeDefault(action, subjectFallback, permissions);
988
+ }
1004
989
 
1005
- throwIfAborted,
1006
- addAbortCallback,
1007
- addAbortSignal,
1008
- addAbortSource,
1009
- timeout,
1010
- withSignal,
1011
- withSignalSync,
1012
- addEndCallback,
1013
- end,
1014
- };
990
+ return false;
1015
991
  };
1016
992
 
1017
- const callbackNoop = () => {};
993
+ /*
994
+ * - stats object documentation on Node.js
995
+ * https://nodejs.org/docs/latest-v13.x/api/fs.html#fs_class_fs_stats
996
+ */
1018
997
 
1019
- const addEventListener = (target, eventName, cb) => {
1020
- target.addEventListener(eventName, cb);
1021
- return () => {
1022
- target.removeEventListener(eventName, cb);
1023
- };
1024
- };
1025
998
 
1026
- const raceProcessTeardownEvents = (processTeardownEvents, callback) => {
1027
- return raceCallbacks(
1028
- {
1029
- ...(processTeardownEvents.SIGHUP ? SIGHUP_CALLBACK : {}),
1030
- ...(processTeardownEvents.SIGTERM ? SIGTERM_CALLBACK : {}),
1031
- ...(processTeardownEvents.SIGINT ? SIGINT_CALLBACK : {}),
1032
- ...(processTeardownEvents.beforeExit ? BEFORE_EXIT_CALLBACK : {}),
1033
- ...(processTeardownEvents.exit ? EXIT_CALLBACK : {}),
1034
- },
1035
- callback,
1036
- );
1037
- };
999
+ const isWindows$2 = process.platform === "win32";
1038
1000
 
1039
- const SIGHUP_CALLBACK = {
1040
- SIGHUP: (cb) => {
1041
- process.on("SIGHUP", cb);
1042
- return () => {
1043
- process.removeListener("SIGHUP", cb);
1044
- };
1045
- },
1046
- };
1001
+ const readEntryStat = async (
1002
+ source,
1003
+ { nullIfNotFound = false, followLink = true } = {},
1004
+ ) => {
1005
+ let sourceUrl = assertAndNormalizeFileUrl(source);
1006
+ if (sourceUrl.endsWith("/")) sourceUrl = sourceUrl.slice(0, -1);
1047
1007
 
1048
- const SIGTERM_CALLBACK = {
1049
- SIGTERM: (cb) => {
1050
- process.on("SIGTERM", cb);
1051
- return () => {
1052
- process.removeListener("SIGTERM", cb);
1053
- };
1054
- },
1055
- };
1008
+ const sourcePath = urlToFileSystemPath(sourceUrl);
1009
+
1010
+ const handleNotFoundOption = nullIfNotFound
1011
+ ? {
1012
+ handleNotFoundError: () => null,
1013
+ }
1014
+ : {};
1056
1015
 
1057
- const BEFORE_EXIT_CALLBACK = {
1058
- beforeExit: (cb) => {
1059
- process.on("beforeExit", cb);
1060
- return () => {
1061
- process.removeListener("beforeExit", cb);
1062
- };
1063
- },
1064
- };
1016
+ return readStat(sourcePath, {
1017
+ followLink,
1018
+ ...handleNotFoundOption,
1019
+ ...(isWindows$2
1020
+ ? {
1021
+ // Windows can EPERM on stat
1022
+ handlePermissionDeniedError: async (error) => {
1023
+ console.error(
1024
+ `trying to fix windows EPERM after stats on ${sourcePath}`,
1025
+ );
1065
1026
 
1066
- const EXIT_CALLBACK = {
1067
- exit: (cb) => {
1068
- process.on("exit", cb);
1069
- return () => {
1070
- process.removeListener("exit", cb);
1071
- };
1072
- },
1027
+ try {
1028
+ // unfortunately it means we mutate the permissions
1029
+ // without being able to restore them to the previous value
1030
+ // (because reading current permission would also throw)
1031
+ await writeEntryPermissions(sourceUrl, 0o666);
1032
+ const stats = await readStat(sourcePath, {
1033
+ followLink,
1034
+ ...handleNotFoundOption,
1035
+ // could not fix the permission error, give up and throw original error
1036
+ handlePermissionDeniedError: () => {
1037
+ console.error(`still got EPERM after stats on ${sourcePath}`);
1038
+ throw error;
1039
+ },
1040
+ });
1041
+ return stats;
1042
+ } catch (e) {
1043
+ console.error(
1044
+ `error while trying to fix windows EPERM after stats on ${sourcePath}: ${e.stack}`,
1045
+ );
1046
+ throw error;
1047
+ }
1048
+ },
1049
+ }
1050
+ : {}),
1051
+ });
1073
1052
  };
1074
1053
 
1075
- const SIGINT_CALLBACK = {
1076
- SIGINT: (cb) => {
1077
- process.on("SIGINT", cb);
1078
- return () => {
1079
- process.removeListener("SIGINT", cb);
1080
- };
1081
- },
1054
+ const readStat = (
1055
+ sourcePath,
1056
+ {
1057
+ followLink,
1058
+ handleNotFoundError = null,
1059
+ handlePermissionDeniedError = null,
1060
+ } = {},
1061
+ ) => {
1062
+ const nodeMethod = followLink ? stat : lstat;
1063
+
1064
+ return new Promise((resolve, reject) => {
1065
+ nodeMethod(sourcePath, (error, statsObject) => {
1066
+ if (error) {
1067
+ if (handleNotFoundError && error.code === "ENOENT") {
1068
+ resolve(handleNotFoundError(error));
1069
+ } else if (
1070
+ handlePermissionDeniedError &&
1071
+ (error.code === "EPERM" || error.code === "EACCES")
1072
+ ) {
1073
+ resolve(handlePermissionDeniedError(error));
1074
+ } else {
1075
+ reject(error);
1076
+ }
1077
+ } else {
1078
+ resolve(statsObject);
1079
+ }
1080
+ });
1081
+ });
1082
1082
  };
1083
1083
 
1084
1084
  const readDirectory = async (url, { emfileMaxWait = 1000 } = {}) => {
@@ -2708,6 +2708,22 @@ const startSpinner = ({
2708
2708
  return spinner;
2709
2709
  };
2710
2710
 
2711
+ const createTeardown = () => {
2712
+ const teardownCallbackSet = new Set();
2713
+ return {
2714
+ addCallback: (callback) => {
2715
+ teardownCallbackSet.add(callback);
2716
+ },
2717
+ trigger: async () => {
2718
+ await Promise.all(
2719
+ Array.from(teardownCallbackSet.values()).map(async (callback) => {
2720
+ await callback();
2721
+ }),
2722
+ );
2723
+ },
2724
+ };
2725
+ };
2726
+
2711
2727
  const generateCoverageJsonFile = async ({
2712
2728
  coverage,
2713
2729
  coverageJsonFileUrl,
@@ -2812,6 +2828,217 @@ const pingServer = async (url) => {
2812
2828
  return false;
2813
2829
  };
2814
2830
 
2831
+ const startServerUsingModuleUrl = async (
2832
+ webServer,
2833
+ { signal, teardown, logger, allocatedMs },
2834
+ ) => {
2835
+ if (!existsSync(new URL(webServer.moduleUrl))) {
2836
+ throw new Error(
2837
+ `webServer.moduleUrl does not lead to a file at "${webServer.moduleUrl}"`,
2838
+ );
2839
+ }
2840
+ const worker = new Worker(
2841
+ new URL(
2842
+ "./worker_importing_module_starting_web_server.mjs",
2843
+ import.meta.url,
2844
+ ),
2845
+ {
2846
+ workerData: {
2847
+ url: String(webServer.moduleUrl),
2848
+ },
2849
+ env: {
2850
+ IMPORTED_BY_TEST_PLAN: "1",
2851
+ },
2852
+ stdin: true,
2853
+ stdout: true,
2854
+ },
2855
+ );
2856
+ let errorReceived = false;
2857
+ const errorPromise = new Promise((resolve, reject) => {
2858
+ worker.on("error", (e) => {
2859
+ errorReceived = true;
2860
+ reject(e);
2861
+ });
2862
+ });
2863
+
2864
+ const killWorker = async () => {
2865
+ await worker.terminate();
2866
+ };
2867
+
2868
+ const startOperation = Abort.startOperation();
2869
+ startOperation.addAbortSignal(signal);
2870
+ const timeoutAbortSource = startOperation.timeout(allocatedMs);
2871
+ startOperation.addAbortCallback(killWorker);
2872
+ teardown.addCallback(killWorker);
2873
+
2874
+ const startedPromise = (async () => {
2875
+ const logScale = [100, 250, 500];
2876
+ // eslint-disable-next-line no-constant-condition
2877
+ while (true) {
2878
+ if (errorReceived) {
2879
+ break;
2880
+ }
2881
+ const connected = await pingServer(webServer.origin);
2882
+ if (connected) {
2883
+ break;
2884
+ }
2885
+
2886
+ startOperation.throwIfAborted();
2887
+ const delay = logScale.shift() || 1000;
2888
+ logger.debug(`Waiting ${delay}ms`);
2889
+ await new Promise((x) => setTimeout(x, delay));
2890
+ }
2891
+ })();
2892
+
2893
+ try {
2894
+ await Promise.race([errorPromise, startedPromise]);
2895
+ } catch (e) {
2896
+ if (Abort.isAbortError(e)) {
2897
+ if (timeoutAbortSource.signal.aborted) {
2898
+ // aborted by timeout
2899
+ throw new Error(
2900
+ `"${webServer.moduleUrl}" did not start a server in less than ${allocatedMs}ms (webServer.moduleUrl)`,
2901
+ );
2902
+ }
2903
+ if (signal.aborted) {
2904
+ // aborted from outside
2905
+ return;
2906
+ }
2907
+ }
2908
+ throw e;
2909
+ } finally {
2910
+ await startOperation.end();
2911
+ }
2912
+ };
2913
+
2914
+ const startServerUsingCommand = async (
2915
+ webServer,
2916
+ { signal, allocatedMs, logger, teardown },
2917
+ ) => {
2918
+ const spawnedProcess = spawn(webServer.command, [], {
2919
+ // On non-windows platforms, `detached: true` makes child process a leader of a new
2920
+ // process group, making it possible to kill child process tree with `.kill(-pid)` command.
2921
+ // @see https://nodejs.org/api/child_process.html#child_process_options_detached
2922
+ detached: process.platform !== "win32",
2923
+ stdio: ["pipe", "pipe", "pipe"],
2924
+ shell: true,
2925
+ cwd: webServer.cwd,
2926
+ });
2927
+ if (!spawnedProcess.pid) {
2928
+ await new Promise((resolve, reject) => {
2929
+ spawnedProcess.once("error", (error) => {
2930
+ reject(new Error(`Failed to launch: ${error}`));
2931
+ });
2932
+ });
2933
+ }
2934
+
2935
+ let errorReceived = false;
2936
+ const errorPromise = new Promise((resolve, reject) => {
2937
+ spawnedProcess.on("error", (e) => {
2938
+ errorReceived = true;
2939
+ reject(e);
2940
+ });
2941
+ });
2942
+
2943
+ // const stdout = readline.createInterface({ input: spawnedProcess.stdout });
2944
+ // stdout.on("line", () => {
2945
+ // logger.debug(`[pid=${spawnedProcess.pid}][out] ${data}`);
2946
+ // });
2947
+ // const stderr = readline.createInterface({ input: spawnedProcess.stderr });
2948
+ // stderr.on("line", (data) => {
2949
+ // logger.debug(`[pid=${spawnedProcess.pid}][err] ${data}`);
2950
+ // });
2951
+ let processClosed = false;
2952
+ const closedPromise = new Promise((resolve) => {
2953
+ spawnedProcess.once("exit", (exitCode, signal) => {
2954
+ logger.info(
2955
+ `[pid=${spawnedProcess.pid}] <process did exit: exitCode=${exitCode}, signal=${signal}>`,
2956
+ );
2957
+ processClosed = true;
2958
+ resolve();
2959
+ });
2960
+ });
2961
+ const killProcess = async () => {
2962
+ logger.info(`[pid=${spawnedProcess.pid}] <kill>`);
2963
+ if (!spawnedProcess.pid || spawnedProcess.killed || processClosed) {
2964
+ logger.info(
2965
+ `[pid=${spawnedProcess.pid}] <skipped force kill spawnedProcess.killed=${spawnedProcess.killed} processClosed=${processClosed}>`,
2966
+ );
2967
+ return;
2968
+ }
2969
+ logger.info(`[pid=${spawnedProcess.pid}] <will force kill>`);
2970
+ // Force kill the browser.
2971
+ try {
2972
+ if (process.platform === "win32") {
2973
+ const taskkillProcess = spawnSync(
2974
+ `taskkill /pid ${spawnedProcess.pid} /T /F`,
2975
+ { shell: true },
2976
+ );
2977
+ const [stdout, stderr] = [
2978
+ taskkillProcess.stdout.toString(),
2979
+ taskkillProcess.stderr.toString(),
2980
+ ];
2981
+ if (stdout)
2982
+ logger.info(`[pid=${spawnedProcess.pid}] taskkill stdout: ${stdout}`);
2983
+ if (stderr)
2984
+ logger.info(`[pid=${spawnedProcess.pid}] taskkill stderr: ${stderr}`);
2985
+ } else {
2986
+ process.kill(-spawnedProcess.pid, "SIGKILL");
2987
+ }
2988
+ } catch (e) {
2989
+ logger.info(
2990
+ `[pid=${spawnedProcess.pid}] exception while trying to kill process: ${e}`,
2991
+ );
2992
+ // the process might have already stopped
2993
+ }
2994
+ await closedPromise;
2995
+ };
2996
+
2997
+ const startOperation = Abort.startOperation();
2998
+ startOperation.addAbortSignal(signal);
2999
+ const timeoutAbortSource = startOperation.timeout(allocatedMs);
3000
+ startOperation.addAbortCallback(killProcess);
3001
+ teardown.addCallback(killProcess);
3002
+
3003
+ const startedPromise = (async () => {
3004
+ const logScale = [100, 250, 500];
3005
+ // eslint-disable-next-line no-constant-condition
3006
+ while (true) {
3007
+ if (errorReceived) {
3008
+ break;
3009
+ }
3010
+ const connected = await pingServer(webServer.origin);
3011
+ if (connected) {
3012
+ break;
3013
+ }
3014
+ startOperation.throwIfAborted();
3015
+ const delay = logScale.shift() || 1000;
3016
+ logger.debug(`Waiting ${delay}ms`);
3017
+ await new Promise((x) => setTimeout(x, delay));
3018
+ }
3019
+ })();
3020
+
3021
+ try {
3022
+ await Promise.race([errorPromise, startedPromise]);
3023
+ } catch (e) {
3024
+ if (Abort.isAbortError(e)) {
3025
+ if (timeoutAbortSource.signal.aborted) {
3026
+ // aborted by timeout
3027
+ throw new Error(
3028
+ `"${webServer.command}" command did not start a server in less than ${allocatedMs}ms (webServer.command)`,
3029
+ );
3030
+ }
3031
+ if (signal.aborted) {
3032
+ // aborted from outside
3033
+ return;
3034
+ }
3035
+ }
3036
+ throw e;
3037
+ } finally {
3038
+ await startOperation.end();
3039
+ }
3040
+ };
3041
+
2815
3042
  const basicFetch = async (
2816
3043
  url,
2817
3044
  { rejectUnauthorized = true, method = "GET", headers = {} } = {},
@@ -2866,50 +3093,39 @@ const basicFetch = async (
2866
3093
  });
2867
3094
  };
2868
3095
 
2869
- const assertAndNormalizeWebServer = async (webServer) => {
3096
+ const assertAndNormalizeWebServer = async (
3097
+ webServer,
3098
+ { signal, logger, teardown },
3099
+ ) => {
2870
3100
  if (!webServer) {
2871
3101
  throw new TypeError(
2872
3102
  `webServer is required when running tests on browser(s)`,
2873
3103
  );
2874
3104
  }
2875
3105
  const unexpectedParamNames = Object.keys(webServer).filter((key) => {
2876
- return !["origin", "moduleUrl", "rootDirectoryUrl"].includes(key);
3106
+ return ![
3107
+ "origin",
3108
+ "moduleUrl",
3109
+ "command",
3110
+ "cwd",
3111
+ "rootDirectoryUrl",
3112
+ ].includes(key);
2877
3113
  });
2878
3114
  if (unexpectedParamNames.length > 0) {
2879
3115
  throw new TypeError(
2880
3116
  `${unexpectedParamNames.join(",")}: there is no such param to webServer`,
2881
3117
  );
2882
3118
  }
2883
-
2884
- let aServerIsListening = await pingServer(webServer.origin);
2885
- if (!aServerIsListening) {
2886
- if (!webServer.moduleUrl) {
2887
- throw new TypeError(
2888
- `webServer.moduleUrl is required as there is no server listening "${webServer.origin}"`,
2889
- );
2890
- }
2891
- try {
2892
- process.env.IMPORTED_BY_TEST_PLAN = "1";
2893
- await import(webServer.moduleUrl);
2894
- delete process.env.IMPORTED_BY_TEST_PLAN;
2895
- } catch (e) {
2896
- if (
2897
- e.code === "ERR_MODULE_NOT_FOUND" &&
2898
- e.message.includes(fileURLToPath(webServer.moduleUrl))
2899
- ) {
2900
- throw new Error(
2901
- `webServer.moduleUrl does not lead to a file at "${webServer.moduleUrl}"`,
2902
- );
2903
- }
2904
- throw e;
2905
- }
2906
- aServerIsListening = await pingServer(webServer.origin);
2907
- if (!aServerIsListening) {
2908
- throw new Error(
2909
- `webServer.moduleUrl did not start a server listening at "${webServer.origin}", check file at "${webServer.moduleUrl}"`,
2910
- );
2911
- }
3119
+ if (typeof webServer.origin !== "string") {
3120
+ throw new TypeError(
3121
+ `webServer.origin must be a string, got ${webServer.origin}`,
3122
+ );
2912
3123
  }
3124
+ await ensureWebServerIsStarted(webServer, {
3125
+ signal,
3126
+ teardown,
3127
+ logger,
3128
+ });
2913
3129
  const { headers } = await basicFetch(webServer.origin, {
2914
3130
  method: "GET",
2915
3131
  rejectUnauthorized: false,
@@ -2939,6 +3155,37 @@ const assertAndNormalizeWebServer = async (webServer) => {
2939
3155
  }
2940
3156
  };
2941
3157
 
3158
+ const ensureWebServerIsStarted = async (
3159
+ webServer,
3160
+ { signal, teardown, logger, allocatedMs = 5_000 },
3161
+ ) => {
3162
+ const aServerIsListening = await pingServer(webServer.origin);
3163
+ if (aServerIsListening) {
3164
+ return;
3165
+ }
3166
+ if (webServer.moduleUrl) {
3167
+ await startServerUsingModuleUrl(webServer, {
3168
+ signal,
3169
+ allocatedMs,
3170
+ teardown,
3171
+ logger,
3172
+ });
3173
+ return;
3174
+ }
3175
+ if (webServer.command) {
3176
+ await startServerUsingCommand(webServer, {
3177
+ signal,
3178
+ allocatedMs,
3179
+ teardown,
3180
+ logger,
3181
+ });
3182
+ return;
3183
+ }
3184
+ throw new TypeError(
3185
+ `webServer.moduleUrl or webServer.command is required as there is no server listening "${webServer.origin}"`,
3186
+ );
3187
+ };
3188
+
2942
3189
  const executionStepsFromTestPlan = async ({
2943
3190
  signal,
2944
3191
  rootDirectoryUrl,
@@ -4209,7 +4456,7 @@ const executeSteps = async (
4209
4456
  executionSteps,
4210
4457
  {
4211
4458
  signal,
4212
- handleSIGINT,
4459
+ teardown,
4213
4460
  logger,
4214
4461
  logRefresh,
4215
4462
  logRuntime,
@@ -4249,23 +4496,9 @@ const executeSteps = async (
4249
4496
  const executePlanReturnValue = {};
4250
4497
  const report = {};
4251
4498
  const callbacks = [];
4252
- const stopAfterAllSignal = { notify: () => {} };
4253
4499
 
4254
4500
  const multipleExecutionsOperation = Abort.startOperation();
4255
4501
  multipleExecutionsOperation.addAbortSignal(signal);
4256
- if (handleSIGINT) {
4257
- multipleExecutionsOperation.addAbortSource((abort) => {
4258
- return raceProcessTeardownEvents(
4259
- {
4260
- SIGINT: true,
4261
- },
4262
- () => {
4263
- logger.debug(`SIGINT abort`);
4264
- abort();
4265
- },
4266
- );
4267
- });
4268
- }
4269
4502
  const failFastAbortController = new AbortController();
4270
4503
  if (failFast) {
4271
4504
  multipleExecutionsOperation.addAbortSignal(failFastAbortController.signal);
@@ -4318,7 +4551,8 @@ const executeSteps = async (
4318
4551
  coverageConfig,
4319
4552
  coverageMethodForBrowsers,
4320
4553
  coverageMethodForNodeJs,
4321
- stopAfterAllSignal,
4554
+ isTestPlan: true,
4555
+ teardown,
4322
4556
  };
4323
4557
 
4324
4558
  if (logMergeForCompletedExecutions && !process.stdout.isTTY) {
@@ -4516,8 +4750,8 @@ const executeSteps = async (
4516
4750
  },
4517
4751
  });
4518
4752
  if (!keepRunning) {
4519
- logger.debug("stopAfterAllSignal.notify()");
4520
- await stopAfterAllSignal.notify();
4753
+ logger.debug("trigger test plan teardown");
4754
+ await teardown.trigger();
4521
4755
  }
4522
4756
 
4523
4757
  counters.cancelled = counters.total - counters.done;
@@ -4539,10 +4773,9 @@ const executeSteps = async (
4539
4773
  executePlanReturnValue.aborted = multipleExecutionsOperation.signal.aborted;
4540
4774
  executePlanReturnValue.planSummary = summary;
4541
4775
  executePlanReturnValue.planReport = report;
4542
- await callbacks.reduce(async (previous, callback) => {
4543
- await previous;
4776
+ for (const callback of callbacks) {
4544
4777
  await callback();
4545
- }, Promise.resolve());
4778
+ }
4546
4779
  return executePlanReturnValue;
4547
4780
  } finally {
4548
4781
  await multipleExecutionsOperation.end();
@@ -4708,6 +4941,25 @@ const executeTestPlan = async ({
4708
4941
  coverageReportHtmlDirectoryUrl,
4709
4942
  ...rest
4710
4943
  }) => {
4944
+ const teardown = createTeardown();
4945
+
4946
+ const operation = Abort.startOperation();
4947
+ operation.addAbortSignal(signal);
4948
+ if (handleSIGINT) {
4949
+ operation.addAbortSource((abort) => {
4950
+ return raceProcessTeardownEvents(
4951
+ {
4952
+ SIGINT: true,
4953
+ },
4954
+ () => {
4955
+ logger.debug(`SIGINT abort`);
4956
+ abort();
4957
+ },
4958
+ );
4959
+ });
4960
+ }
4961
+
4962
+ let logger;
4711
4963
  let someNeedsServer = false;
4712
4964
  let someHasCoverageV8 = false;
4713
4965
  let someNodeRuntime = false;
@@ -4731,6 +4983,8 @@ const executeTestPlan = async ({
4731
4983
  throw new Error(`testPlan must be an object, got ${testPlan}`);
4732
4984
  }
4733
4985
 
4986
+ logger = createLogger({ logLevel });
4987
+
4734
4988
  Object.keys(testPlan).forEach((filePattern) => {
4735
4989
  const filePlan = testPlan[filePattern];
4736
4990
  if (!filePlan) return;
@@ -4753,7 +5007,11 @@ const executeTestPlan = async ({
4753
5007
  });
4754
5008
 
4755
5009
  if (someNeedsServer) {
4756
- await assertAndNormalizeWebServer(webServer);
5010
+ await assertAndNormalizeWebServer(webServer, {
5011
+ signal: operation.signal,
5012
+ teardown,
5013
+ logger,
5014
+ });
4757
5015
  }
4758
5016
 
4759
5017
  if (coverageEnabled) {
@@ -4838,7 +5096,6 @@ const executeTestPlan = async ({
4838
5096
  }
4839
5097
  }
4840
5098
 
4841
- const logger = createLogger({ logLevel });
4842
5099
  logger.debug(
4843
5100
  createDetailedMessage(`Prepare executing plan`, {
4844
5101
  runtimes: JSON.stringify(runtimes, null, " "),
@@ -4893,7 +5150,7 @@ const executeTestPlan = async ({
4893
5150
 
4894
5151
  const result = await executeSteps(executionSteps, {
4895
5152
  signal,
4896
- handleSIGINT,
5153
+ teardown,
4897
5154
  logger,
4898
5155
  logRefresh,
4899
5156
  logSummary,
@@ -5217,7 +5474,8 @@ const createRuntimeUsingPlaywright = ({
5217
5474
  coverageMethodForBrowsers,
5218
5475
  coverageFileUrl,
5219
5476
 
5220
- stopAfterAllSignal,
5477
+ teardown,
5478
+ isTestPlan,
5221
5479
  stopSignal,
5222
5480
  keepRunning,
5223
5481
  onConsole,
@@ -5236,7 +5494,7 @@ const createRuntimeUsingPlaywright = ({
5236
5494
  await cleanupCallbackList.notify({ reason });
5237
5495
  });
5238
5496
 
5239
- const isBrowserDedicatedToExecution = isolatedTab || !stopAfterAllSignal;
5497
+ const isBrowserDedicatedToExecution = isolatedTab || !isTestPlan;
5240
5498
  let browserAndContextPromise = isBrowserDedicatedToExecution
5241
5499
  ? null
5242
5500
  : browserPromiseCache.get(label);
@@ -5485,15 +5743,11 @@ const createRuntimeUsingPlaywright = ({
5485
5743
  cleanupCallbackList.add(() => {
5486
5744
  browser.removeListener("disconnected", disconnectedCallback);
5487
5745
  });
5488
- const notifyPrevious = stopAfterAllSignal.notify;
5489
- stopAfterAllSignal.notify = async () => {
5490
- await notifyPrevious();
5746
+ teardown.addCallback(async () => {
5491
5747
  browser.removeListener("disconnected", disconnectedCallback);
5492
- logger.debug(
5493
- `stopAfterAllSignal notified -> closing ${browserName}`,
5494
- );
5748
+ logger.debug(`testPlan teardown -> closing ${browserName}`);
5495
5749
  await closeBrowser();
5496
- };
5750
+ });
5497
5751
  }
5498
5752
  },
5499
5753
  response: async (cb) => {
@@ -6840,6 +7094,7 @@ const execute = async ({
6840
7094
  rootDirectoryUrl,
6841
7095
  "rootDirectoryUrl",
6842
7096
  );
7097
+ const teardown = createTeardown();
6843
7098
  const executeOperation = Abort.startOperation();
6844
7099
  executeOperation.addAbortSignal(signal);
6845
7100
  if (handleSIGINT) {
@@ -6854,7 +7109,7 @@ const execute = async ({
6854
7109
  }
6855
7110
 
6856
7111
  if (runtime.type === "browser") {
6857
- await assertAndNormalizeWebServer(webServer);
7112
+ await assertAndNormalizeWebServer(webServer, { signal, teardown, logger });
6858
7113
  }
6859
7114
 
6860
7115
  let resultTransformer = (result) => result;
@@ -6863,6 +7118,7 @@ const execute = async ({
6863
7118
  webServer,
6864
7119
  fileRelativeUrl,
6865
7120
  importMap,
7121
+ teardown,
6866
7122
  ...runtimeParams,
6867
7123
  };
6868
7124
 
@@ -6902,6 +7158,7 @@ const execute = async ({
6902
7158
  }
6903
7159
  return result;
6904
7160
  } finally {
7161
+ await teardown.trigger();
6905
7162
  await executeOperation.end();
6906
7163
  }
6907
7164
  };