@jsenv/core 31.2.0 → 32.0.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.
@@ -5,14 +5,13 @@ import wrapAnsi from "wrap-ansi"
5
5
  import stripAnsi from "strip-ansi"
6
6
 
7
7
  import { urlToFileSystemPath } from "@jsenv/urls"
8
- import { createDetailedMessage, createLog, startSpinner } from "@jsenv/log"
8
+ import { createLog, startSpinner } from "@jsenv/log"
9
9
  import { Abort, raceProcessTeardownEvents } from "@jsenv/abort"
10
10
  import { ensureEmptyDirectory, writeFileSync } from "@jsenv/filesystem"
11
11
 
12
12
  import { reportToCoverage } from "./coverage/report_to_coverage.js"
13
13
  import { run } from "@jsenv/core/src/execute/run.js"
14
14
 
15
- import { pingServer } from "../ping_server.js"
16
15
  import { ensureGlobalGc } from "./gc.js"
17
16
  import { generateExecutionSteps } from "./execution_steps.js"
18
17
  import { createExecutionLog, createSummaryLog } from "./logs_file_execution.js"
@@ -48,7 +47,7 @@ export const executePlan = async (
48
47
  coverageMethodForBrowsers,
49
48
  coverageMethodForNodeJs,
50
49
  coverageV8ConflictWarning,
51
- coverageTempDirectoryRelativeUrl,
50
+ coverageTempDirectoryUrl,
52
51
 
53
52
  beforeExecutionCallback = () => {},
54
53
  afterExecutionCallback = () => {},
@@ -59,30 +58,6 @@ export const executePlan = async (
59
58
  const callbacks = []
60
59
  const stopAfterAllSignal = { notify: () => {} }
61
60
 
62
- let someNeedsServer = false
63
- let someNodeRuntime = false
64
- const runtimes = {}
65
- Object.keys(plan).forEach((filePattern) => {
66
- const filePlan = plan[filePattern]
67
- Object.keys(filePlan).forEach((executionName) => {
68
- const executionConfig = filePlan[executionName]
69
- const { runtime } = executionConfig
70
- if (runtime) {
71
- runtimes[runtime.name] = runtime.version
72
- if (runtime.type === "browser") {
73
- someNeedsServer = true
74
- }
75
- if (runtime.type === "node") {
76
- someNodeRuntime = true
77
- }
78
- }
79
- })
80
- })
81
- logger.debug(
82
- createDetailedMessage(`Prepare executing plan`, {
83
- runtimes: JSON.stringify(runtimes, null, " "),
84
- }),
85
- )
86
61
  const multipleExecutionsOperation = Abort.startOperation()
87
62
  multipleExecutionsOperation.addAbortSignal(signal)
88
63
  if (handleSIGINT) {
@@ -104,32 +79,6 @@ export const executePlan = async (
104
79
  }
105
80
 
106
81
  try {
107
- const coverageTempDirectoryUrl = new URL(
108
- coverageTempDirectoryRelativeUrl,
109
- rootDirectoryUrl,
110
- ).href
111
- if (
112
- someNodeRuntime &&
113
- coverageEnabled &&
114
- coverageMethodForNodeJs === "NODE_V8_COVERAGE"
115
- ) {
116
- if (process.env.NODE_V8_COVERAGE) {
117
- // when runned multiple times, we don't want to keep previous files in this directory
118
- await ensureEmptyDirectory(process.env.NODE_V8_COVERAGE)
119
- } else {
120
- coverageMethodForNodeJs = "Profiler"
121
- logger.warn(
122
- createDetailedMessage(
123
- `process.env.NODE_V8_COVERAGE is required to generate coverage for Node.js subprocesses`,
124
- {
125
- "suggestion": `set process.env.NODE_V8_COVERAGE`,
126
- "suggestion 2": `use coverageMethodForNodeJs: "Profiler". But it means coverage for child_process and worker_thread cannot be collected`,
127
- },
128
- ),
129
- )
130
- }
131
- }
132
-
133
82
  if (gcBetweenExecutions) {
134
83
  ensureGlobalGc()
135
84
  }
@@ -177,19 +126,6 @@ export const executePlan = async (
177
126
  coverageMethodForNodeJs,
178
127
  stopAfterAllSignal,
179
128
  }
180
- if (someNeedsServer) {
181
- if (!devServerOrigin) {
182
- throw new TypeError(
183
- `devServerOrigin is required when running tests on browser(s)`,
184
- )
185
- }
186
- const devServerStarted = await pingServer(devServerOrigin)
187
- if (!devServerStarted) {
188
- throw new Error(
189
- `dev server not started at ${devServerOrigin}. It is required to run tests`,
190
- )
191
- }
192
- }
193
129
 
194
130
  logger.debug(`Generate executions`)
195
131
  const executionSteps = await getExecutionAsSteps({
@@ -292,7 +228,10 @@ export const executePlan = async (
292
228
  executionResult = await run({
293
229
  signal: multipleExecutionsOperation.signal,
294
230
  logger,
295
- allocatedMs: executionParams.allocatedMs,
231
+ allocatedMs:
232
+ typeof executionParams.allocatedMs === "function"
233
+ ? executionParams.allocatedMs(beforeExecutionInfo)
234
+ : executionParams.allocatedMs,
296
235
  keepRunning,
297
236
  mirrorConsole: false, // file are executed in parallel, log would be a mess to read
298
237
  collectConsole: executionParams.collectConsole,
@@ -1,13 +1,20 @@
1
+ import { existsSync } from "node:fs"
1
2
  import { URL_META } from "@jsenv/url-meta"
2
3
  import {
3
4
  urlToFileSystemPath,
4
- resolveDirectoryUrl,
5
- urlIsInsideOf,
6
5
  urlToRelativeUrl,
6
+ urlIsInsideOf,
7
7
  } from "@jsenv/urls"
8
- import { ensureEmptyDirectory, validateDirectoryUrl } from "@jsenv/filesystem"
8
+ import {
9
+ ensureEmptyDirectory,
10
+ assertAndNormalizeDirectoryUrl,
11
+ assertAndNormalizeFileUrl,
12
+ } from "@jsenv/filesystem"
9
13
  import { createLogger, createDetailedMessage } from "@jsenv/log"
10
14
 
15
+ import { lookupPackageDirectory } from "../lookup_package_directory.js"
16
+ import { pingServer } from "../ping_server.js"
17
+ import { basicFetch } from "../basic_fetch.js"
11
18
  import { generateCoverageJsonFile } from "./coverage/coverage_reporter_json_file.js"
12
19
  import { generateCoverageHtmlDirectory } from "./coverage/coverage_reporter_html_directory.js"
13
20
  import { generateCoverageTextLog } from "./coverage/coverage_reporter_text_log.js"
@@ -16,8 +23,8 @@ import { executePlan } from "./execute_plan.js"
16
23
  /**
17
24
  * Execute a list of files and log how it goes.
18
25
  * @param {Object} testPlanParameters
19
- * @param {string|url} testPlanParameters.rootDirectoryUrl Root directory of the project
20
- * @param {string|url} [testPlanParameters.serverOrigin=undefined] Jsenv dev server origin; required when executing test on browsers
26
+ * @param {string|url} testPlanParameters.testDirectoryUrl Directory containing test files
27
+ * @param {string|url} [testPlanParameters.devServerOrigin=undefined] Jsenv dev server origin; required when executing test on browsers
21
28
  * @param {Object} testPlanParameters.testPlan Object associating patterns leading to files to runtimes where they should be executed
22
29
  * @param {boolean} [testPlanParameters.completedExecutionLogAbbreviation=false] Abbreviate completed execution information to shorten terminal output
23
30
  * @param {boolean} [testPlanParameters.completedExecutionLogMerging=false] Merge completed execution logs to shorten terminal output
@@ -43,7 +50,8 @@ export const executeTestPlan = async ({
43
50
  logFileRelativeUrl = ".jsenv/test_plan_debug.txt",
44
51
  completedExecutionLogAbbreviation = false,
45
52
  completedExecutionLogMerging = false,
46
- rootDirectoryUrl,
53
+ testDirectoryUrl,
54
+ devServerModuleUrl,
47
55
  devServerOrigin,
48
56
 
49
57
  testPlan,
@@ -61,9 +69,7 @@ export const executeTestPlan = async ({
61
69
  gcBetweenExecutions = logMemoryHeapUsage,
62
70
 
63
71
  coverageEnabled = process.argv.includes("--coverage"),
64
- coverageConfig = {
65
- "./src/": true,
66
- },
72
+ coverageConfig = { "./**/*": true },
67
73
  coverageIncludeMissing = true,
68
74
  coverageAndExecutionAllowed = false,
69
75
  coverageMethodForNodeJs = process.env.NODE_V8_COVERAGE
@@ -71,16 +77,23 @@ export const executeTestPlan = async ({
71
77
  : "Profiler",
72
78
  coverageMethodForBrowsers = "playwright_api", // "istanbul" also accepted
73
79
  coverageV8ConflictWarning = true,
74
- coverageTempDirectoryRelativeUrl = "./.coverage/tmp/",
80
+ coverageTempDirectoryUrl,
81
+ coverageReportRootDirectoryUrl,
75
82
  // skip empty means empty files won't appear in the coverage reports (json and html)
76
83
  coverageReportSkipEmpty = false,
77
84
  // skip full means file with 100% coverage won't appear in coverage reports (json and html)
78
85
  coverageReportSkipFull = false,
79
86
  coverageReportTextLog = true,
80
- coverageReportJsonFile = process.env.CI ? null : "./.coverage/coverage.json",
81
- coverageReportHtmlDirectory = process.env.CI ? "./.coverage/" : null,
87
+ coverageReportJson = process.env.CI,
88
+ coverageReportJsonFileUrl,
89
+ coverageReportHtml = !process.env.CI,
90
+ coverageReportHtmlDirectoryUrl,
82
91
  ...rest
83
92
  }) => {
93
+ let someNeedsServer = false
94
+ let someNodeRuntime = false
95
+ let stopDevServerNeeded = false
96
+ const runtimes = {}
84
97
  // param validation
85
98
  {
86
99
  const unexpectedParamNames = Object.keys(rest)
@@ -89,16 +102,81 @@ export const executeTestPlan = async ({
89
102
  `${unexpectedParamNames.join(",")}: there is no such param`,
90
103
  )
91
104
  }
92
- const rootDirectoryUrlValidation = validateDirectoryUrl(rootDirectoryUrl)
93
- if (!rootDirectoryUrlValidation.valid) {
94
- throw new TypeError(
95
- `rootDirectoryUrl ${rootDirectoryUrlValidation.message}, got ${rootDirectoryUrl}`,
96
- )
105
+ testDirectoryUrl = assertAndNormalizeDirectoryUrl(
106
+ testDirectoryUrl,
107
+ "testDirectoryUrl",
108
+ )
109
+ if (!existsSync(new URL(testDirectoryUrl))) {
110
+ throw new Error(`ENOENT on testDirectoryUrl at ${testDirectoryUrl}`)
97
111
  }
98
- rootDirectoryUrl = rootDirectoryUrlValidation.value
99
112
  if (typeof testPlan !== "object") {
100
113
  throw new Error(`testPlan must be an object, got ${testPlan}`)
101
114
  }
115
+
116
+ Object.keys(testPlan).forEach((filePattern) => {
117
+ const filePlan = testPlan[filePattern]
118
+ if (!filePlan) return
119
+ Object.keys(filePlan).forEach((executionName) => {
120
+ const executionConfig = filePlan[executionName]
121
+ const { runtime } = executionConfig
122
+ if (runtime) {
123
+ runtimes[runtime.name] = runtime.version
124
+ if (runtime.type === "browser") {
125
+ someNeedsServer = true
126
+ }
127
+ if (runtime.type === "node") {
128
+ someNodeRuntime = true
129
+ }
130
+ }
131
+ })
132
+ })
133
+
134
+ if (someNeedsServer) {
135
+ if (!devServerOrigin) {
136
+ throw new TypeError(
137
+ `devServerOrigin is required when running tests on browser(s)`,
138
+ )
139
+ }
140
+ let devServerStarted = await pingServer(devServerOrigin)
141
+ if (!devServerStarted) {
142
+ if (!devServerModuleUrl) {
143
+ throw new TypeError(
144
+ `devServerModuleUrl is required when dev server is not started in order to run tests on browser(s)`,
145
+ )
146
+ }
147
+ try {
148
+ process.env.IMPORTED_BY_TEST_PLAN = "1"
149
+ await import(devServerModuleUrl)
150
+ delete process.env.IMPORTED_BY_TEST_PLAN
151
+ } catch (e) {
152
+ if (e.code === "MODULE_NOT_FOUND") {
153
+ throw new Error(
154
+ `Cannot find file responsible to start dev server at "${devServerModuleUrl}"`,
155
+ )
156
+ }
157
+ throw e
158
+ }
159
+ devServerStarted = await pingServer(devServerOrigin)
160
+ if (!devServerStarted) {
161
+ throw new Error(
162
+ `dev server not started after importing "${devServerModuleUrl}", ensure this module file is starting a server at "${devServerOrigin}"`,
163
+ )
164
+ }
165
+ stopDevServerNeeded = true
166
+ }
167
+ const { sourceDirectoryUrl } = await basicFetch(
168
+ `${devServerOrigin}/__server_params__.json`,
169
+ )
170
+ if (
171
+ testDirectoryUrl !== sourceDirectoryUrl &&
172
+ !urlIsInsideOf(testDirectoryUrl, sourceDirectoryUrl)
173
+ ) {
174
+ throw new Error(
175
+ `testDirectoryUrl must be inside sourceDirectoryUrl when running tests on browser(s)`,
176
+ )
177
+ }
178
+ }
179
+
102
180
  if (coverageEnabled) {
103
181
  if (typeof coverageConfig !== "object") {
104
182
  throw new TypeError(
@@ -135,16 +213,96 @@ export const executeTestPlan = async ({
135
213
  )
136
214
  }
137
215
  }
216
+ if (coverageReportRootDirectoryUrl === undefined) {
217
+ coverageReportRootDirectoryUrl =
218
+ lookupPackageDirectory(testDirectoryUrl)
219
+ } else {
220
+ coverageReportRootDirectoryUrl = assertAndNormalizeDirectoryUrl(
221
+ coverageReportRootDirectoryUrl,
222
+ "coverageReportRootDirectoryUrl",
223
+ )
224
+ }
225
+ if (coverageTempDirectoryUrl === undefined) {
226
+ coverageTempDirectoryUrl = new URL(
227
+ "./.coverage/tmp/",
228
+ coverageReportRootDirectoryUrl,
229
+ )
230
+ } else {
231
+ coverageTempDirectoryUrl = assertAndNormalizeDirectoryUrl(
232
+ coverageTempDirectoryUrl,
233
+ "coverageTempDirectoryUrl",
234
+ )
235
+ }
236
+ if (coverageReportJson) {
237
+ if (coverageReportJsonFileUrl === undefined) {
238
+ coverageReportJsonFileUrl = new URL(
239
+ "./.coverage/coverage.json",
240
+ coverageReportRootDirectoryUrl,
241
+ )
242
+ } else {
243
+ coverageReportJsonFileUrl = assertAndNormalizeFileUrl(
244
+ coverageReportJsonFileUrl,
245
+ "coverageReportJsonFileUrl",
246
+ )
247
+ }
248
+ }
249
+ if (coverageReportHtml) {
250
+ if (coverageReportHtmlDirectoryUrl === undefined) {
251
+ coverageReportHtmlDirectoryUrl = new URL(
252
+ "./.coverage/",
253
+ coverageReportRootDirectoryUrl,
254
+ )
255
+ } else {
256
+ coverageReportHtmlDirectoryUrl = assertAndNormalizeDirectoryUrl(
257
+ coverageReportHtmlDirectoryUrl,
258
+ "coverageReportHtmlDirectoryUrl",
259
+ )
260
+ }
261
+ }
138
262
  }
139
263
  }
140
264
 
141
265
  const logger = createLogger({ logLevel })
142
- if (Object.keys(coverageConfig).length === 0) {
143
- logger.warn(
144
- `coverageConfig is an empty object. Nothing will be instrumented for coverage so your coverage will be empty`,
145
- )
266
+ logger.debug(
267
+ createDetailedMessage(`Prepare executing plan`, {
268
+ runtimes: JSON.stringify(runtimes, null, " "),
269
+ }),
270
+ )
271
+
272
+ // param normalization
273
+ {
274
+ if (coverageEnabled) {
275
+ if (Object.keys(coverageConfig).length === 0) {
276
+ logger.warn(
277
+ `coverageConfig is an empty object. Nothing will be instrumented for coverage so your coverage will be empty`,
278
+ )
279
+ }
280
+ if (
281
+ someNodeRuntime &&
282
+ coverageEnabled &&
283
+ coverageMethodForNodeJs === "NODE_V8_COVERAGE"
284
+ ) {
285
+ if (process.env.NODE_V8_COVERAGE) {
286
+ // when runned multiple times, we don't want to keep previous files in this directory
287
+ await ensureEmptyDirectory(process.env.NODE_V8_COVERAGE)
288
+ } else {
289
+ coverageMethodForNodeJs = "Profiler"
290
+ logger.warn(
291
+ createDetailedMessage(
292
+ `process.env.NODE_V8_COVERAGE is required to generate coverage for Node.js subprocesses`,
293
+ {
294
+ "suggestion": `set process.env.NODE_V8_COVERAGE`,
295
+ "suggestion 2": `use coverageMethodForNodeJs: "Profiler". But it means coverage for child_process and worker_thread cannot be collected`,
296
+ },
297
+ ),
298
+ )
299
+ }
300
+ }
301
+ }
146
302
  }
147
303
 
304
+ testPlan = { ...testPlan, "**/.jsenv/": null }
305
+
148
306
  const result = await executePlan(testPlan, {
149
307
  signal,
150
308
  handleSIGINT,
@@ -158,7 +316,7 @@ export const executeTestPlan = async ({
158
316
  logFileRelativeUrl,
159
317
  completedExecutionLogMerging,
160
318
  completedExecutionLogAbbreviation,
161
- rootDirectoryUrl,
319
+ rootDirectoryUrl: testDirectoryUrl,
162
320
  devServerOrigin,
163
321
 
164
322
  maxExecutionsInParallel,
@@ -174,8 +332,17 @@ export const executeTestPlan = async ({
174
332
  coverageMethodForBrowsers,
175
333
  coverageMethodForNodeJs,
176
334
  coverageV8ConflictWarning,
177
- coverageTempDirectoryRelativeUrl,
335
+ coverageTempDirectoryUrl,
178
336
  })
337
+ if (stopDevServerNeeded) {
338
+ // we are expecting ECONNRESET because server will be stopped by the request
339
+ basicFetch(`${devServerOrigin}/__stop__`).catch((e) => {
340
+ if (e.code === "ECONNRESET") {
341
+ return
342
+ }
343
+ throw e
344
+ })
345
+ }
179
346
  if (
180
347
  updateProcessExitCode &&
181
348
  result.planSummary.counters.total !== result.planSummary.counters.completed
@@ -189,42 +356,29 @@ export const executeTestPlan = async ({
189
356
  // keep this one first because it does ensureEmptyDirectory
190
357
  // and in case coverage json file gets written in the same directory
191
358
  // it must be done before
192
- if (coverageEnabled && coverageReportHtmlDirectory) {
193
- const coverageHtmlDirectoryUrl = resolveDirectoryUrl(
194
- coverageReportHtmlDirectory,
195
- rootDirectoryUrl,
196
- )
197
- if (!urlIsInsideOf(coverageHtmlDirectoryUrl, rootDirectoryUrl)) {
198
- throw new Error(
199
- `coverageReportHtmlDirectory must be inside rootDirectoryUrl`,
200
- )
201
- }
202
- await ensureEmptyDirectory(coverageHtmlDirectoryUrl)
203
- const htmlCoverageDirectoryIndexFileUrl = `${coverageHtmlDirectoryUrl}index.html`
359
+ if (coverageEnabled && coverageReportHtml) {
360
+ await ensureEmptyDirectory(coverageReportHtmlDirectoryUrl)
361
+ const htmlCoverageDirectoryIndexFileUrl = `${coverageReportHtmlDirectoryUrl}index.html`
204
362
  logger.info(
205
363
  `-> ${urlToFileSystemPath(htmlCoverageDirectoryIndexFileUrl)}`,
206
364
  )
207
365
  promises.push(
208
366
  generateCoverageHtmlDirectory(planCoverage, {
209
- rootDirectoryUrl,
367
+ rootDirectoryUrl: coverageReportRootDirectoryUrl,
210
368
  coverageHtmlDirectoryRelativeUrl: urlToRelativeUrl(
211
- coverageHtmlDirectoryUrl,
212
- rootDirectoryUrl,
369
+ coverageReportHtmlDirectoryUrl,
370
+ coverageReportRootDirectoryUrl,
213
371
  ),
214
372
  coverageReportSkipEmpty,
215
373
  coverageReportSkipFull,
216
374
  }),
217
375
  )
218
376
  }
219
- if (coverageEnabled && coverageReportJsonFile) {
220
- const coverageJsonFileUrl = new URL(
221
- coverageReportJsonFile,
222
- rootDirectoryUrl,
223
- ).href
377
+ if (coverageEnabled && coverageReportJson) {
224
378
  promises.push(
225
379
  generateCoverageJsonFile({
226
380
  coverage: result.planCoverage,
227
- coverageJsonFileUrl,
381
+ coverageJsonFileUrl: coverageReportJsonFileUrl,
228
382
  logger,
229
383
  }),
230
384
  )
@@ -0,0 +1,50 @@
1
+ import { registerDirectoryLifecycle } from "@jsenv/filesystem"
2
+
3
+ export const watchSourceFiles = (
4
+ sourceDirectoryUrl,
5
+ callback,
6
+ { sourceFileConfig = {}, keepProcessAlive, cooldownBetweenFileEvents },
7
+ ) => {
8
+ // Project should use a dedicated directory (usually "src/")
9
+ // passed to the dev server via "sourceDirectoryUrl" param
10
+ // In that case all files inside the source directory should be watched
11
+ // But some project might want to use their root directory as source directory
12
+ // In that case source directory might contain files matching "node_modules/*" or ".git/*"
13
+ // And jsenv should not consider these as source files and watch them (to not hurt performances)
14
+ const watchPatterns = {
15
+ "**/*": true, // by default watch everything inside the source directory
16
+ "**/.*": false, // file starting with a dot -> do not watch
17
+ "**/.*/": false, // directory starting with a dot -> do not watch
18
+ "**/node_modules/": false, // node_modules directory -> do not watch
19
+ ...sourceFileConfig,
20
+ }
21
+ const stopWatchingSourceFiles = registerDirectoryLifecycle(
22
+ sourceDirectoryUrl,
23
+ {
24
+ watchPatterns,
25
+ cooldownBetweenFileEvents,
26
+ keepProcessAlive,
27
+ recursive: true,
28
+ added: ({ relativeUrl }) => {
29
+ callback({
30
+ url: new URL(relativeUrl, sourceDirectoryUrl).href,
31
+ event: "added",
32
+ })
33
+ },
34
+ updated: ({ relativeUrl }) => {
35
+ callback({
36
+ url: new URL(relativeUrl, sourceDirectoryUrl).href,
37
+ event: "modified",
38
+ })
39
+ },
40
+ removed: ({ relativeUrl }) => {
41
+ callback({
42
+ url: new URL(relativeUrl, sourceDirectoryUrl).href,
43
+ event: "removed",
44
+ })
45
+ },
46
+ },
47
+ )
48
+ stopWatchingSourceFiles.watchPatterns = watchPatterns
49
+ return stopWatchingSourceFiles
50
+ }