codeceptjs 4.2.0 → 4.2.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +2 -0
  2. package/bin/codecept.js +11 -0
  3. package/docs/agents.md +26 -1
  4. package/docs/ai.md +57 -0
  5. package/docs/alternative-browsers.md +175 -148
  6. package/docs/assertions.md +74 -0
  7. package/docs/bootstrap.md +2 -0
  8. package/docs/commands.md +45 -0
  9. package/docs/configuration.md +37 -0
  10. package/docs/debugging.md +1 -1
  11. package/docs/helpers/ApiDataFactory.md +20 -20
  12. package/docs/helpers/Appium.md +1615 -670
  13. package/docs/helpers/Decision.md +146 -0
  14. package/docs/helpers/GraphQL.md +31 -31
  15. package/docs/helpers/GraphQLDataFactory.md +20 -20
  16. package/docs/helpers/Kitesurf.md +1808 -25
  17. package/docs/helpers/Lightpanda.md +139 -0
  18. package/docs/helpers/Obscura.md +1799 -74
  19. package/docs/helpers/Playwright.md +490 -428
  20. package/docs/helpers/Puppeteer.md +317 -255
  21. package/docs/helpers/REST.md +43 -43
  22. package/docs/helpers/WebDriver.md +237 -175
  23. package/docs/installation.md +16 -0
  24. package/docs/mcp.md +7 -1
  25. package/docs/parallel.md +35 -1
  26. package/docs/plugins/pause.md +2 -1
  27. package/docs/plugins/sitemap.md +48 -0
  28. package/docs/plugins.md +4 -0
  29. package/lib/ai.js +81 -2
  30. package/lib/codecept.js +32 -2
  31. package/lib/command/init.js +22 -0
  32. package/lib/command/initAgent.js +78 -0
  33. package/lib/command/run-multiple.js +3 -0
  34. package/lib/command/run-workers.js +10 -3
  35. package/lib/command/run.js +3 -2
  36. package/lib/command/utils.js +15 -0
  37. package/lib/command/workers/runTests.js +10 -10
  38. package/lib/container.js +4 -2
  39. package/lib/effects.js +19 -1
  40. package/lib/helper/CDPBrowser.js +349 -1384
  41. package/lib/helper/Decision.js +256 -0
  42. package/lib/helper/Kitesurf.js +3 -21
  43. package/lib/helper/Lightpanda.js +137 -0
  44. package/lib/helper/Obscura.js +21 -238
  45. package/lib/helper/Playwright.js +6 -4
  46. package/lib/helper/Puppeteer.js +12 -7
  47. package/lib/helper/WebDriver.js +2 -1
  48. package/lib/listener/result.js +4 -0
  49. package/lib/mocha/bdd.js +19 -1
  50. package/lib/plugin/junitReporter.js +4 -1
  51. package/lib/plugin/pause.js +4 -1
  52. package/lib/plugin/sitemap.js +228 -0
  53. package/lib/recorder.js +19 -18
  54. package/lib/result.js +16 -0
  55. package/lib/step/base.js +12 -0
  56. package/lib/utils.js +2 -10
  57. package/lib/webServer.js +193 -0
  58. package/lib/workers.js +118 -31
  59. package/package.json +4 -2
  60. package/typings/index.d.ts +60 -7
  61. package/typings/promiseBasedTypes.d.ts +772 -824
  62. package/typings/types.d.ts +866 -833
  63. package/docs/helpers/CDPBrowser.md +0 -2138
@@ -0,0 +1,228 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { isMainThread, threadId } from 'worker_threads'
4
+ import { mkdirp } from 'mkdirp'
5
+ import { DOMImplementation, XMLSerializer } from '@xmldom/xmldom'
6
+
7
+ import Container from '../container.js'
8
+ import event from '../event.js'
9
+ import recorder from '../recorder.js'
10
+ import store from '../store.js'
11
+ import output from '../output.js'
12
+ import { parsePluginArgs } from '../utils/pluginParser.js'
13
+
14
+ const defaultConfig = {
15
+ file: 'sitemap.xml',
16
+ stripQuery: true,
17
+ }
18
+
19
+ const SITEMAP_NS = 'http://www.sitemaps.org/schemas/sitemap/0.9'
20
+ const PARTIAL_PREFIX = '.sitemap.'
21
+
22
+ /**
23
+ * Collects all pages visited while tests are running and saves them as a [sitemap](https://www.sitemaps.org/protocol.html) XML file.
24
+ *
25
+ * The plugin listens to browser navigation events, so it doesn't slow down test steps.
26
+ * Page loads and SPA route changes (`history.pushState`) of the top-level page are recorded, iframes are ignored.
27
+ * Every unique `http(s)` URL is added to the sitemap. URL fragments (`#...`) are dropped.
28
+ *
29
+ * Supported helpers: Playwright, Puppeteer, and WebDriver with BiDi protocol enabled (default).
30
+ * The sitemap is written once all tests finish.
31
+ *
32
+ * #### Configuration
33
+ *
34
+ * ```js
35
+ * "plugins": {
36
+ * "sitemap": {
37
+ * "enabled": true
38
+ * }
39
+ * }
40
+ * ```
41
+ *
42
+ * Possible config options:
43
+ *
44
+ * * `file`: path to the sitemap file, relative to the output directory. Default: `sitemap.xml`, saved as `output/sitemap.xml`.
45
+ * * `stripQuery`: remove query strings so `/posts?page=1` and `/posts?page=2` become one `/posts` entry. Set to `false` to keep them. Default: true.
46
+ *
47
+ * CLI examples:
48
+ *
49
+ * ```
50
+ * npx codeceptjs run -p sitemap
51
+ * npx codeceptjs run -p sitemap:file=visited.xml
52
+ * npx codeceptjs run -p sitemap:stripQuery=false
53
+ * ```
54
+ *
55
+ * Works with `run-workers`: URLs from all workers are merged into a single sitemap.
56
+ *
57
+ * @param {*} config
58
+ */
59
+ export default function (config = {}) {
60
+ config = Object.assign({}, defaultConfig, config, parsePluginArgs(config._args))
61
+
62
+ const urls = new Set()
63
+
64
+ const outputDir = () => store.outputDir || process.cwd()
65
+
66
+ const addUrl = url => {
67
+ if (!url) return
68
+ let parsed
69
+ try {
70
+ parsed = new URL(url)
71
+ } catch {
72
+ return
73
+ }
74
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return
75
+ parsed.hash = ''
76
+ if (config.stripQuery) parsed.search = ''
77
+ urls.add(parsed.toString())
78
+ }
79
+
80
+ const writeSitemap = list => {
81
+ if (!list.length) return
82
+
83
+ const file = path.resolve(outputDir(), config.file)
84
+ mkdirp.sync(path.dirname(file))
85
+ fs.writeFileSync(file, buildXml(list.sort()))
86
+ output.plugin('sitemap', `Sitemap with ${list.length} pages saved to ${file}`)
87
+ }
88
+
89
+ const attached = new WeakSet()
90
+
91
+ const onLoad = page => addUrl(page.url())
92
+
93
+ const onFrameNavigated = frame => {
94
+ if (frame.parentFrame()) return
95
+ addUrl(frame.url())
96
+ }
97
+
98
+ const attachPage = page => {
99
+ if (!page || attached.has(page)) return
100
+ attached.add(page)
101
+ addUrl(page.url())
102
+ page.on('load', onLoad)
103
+ page.on('framenavigated', onFrameNavigated)
104
+ }
105
+
106
+ const attachContext = context => {
107
+ attached.add(context)
108
+ context.on('page', attachPage)
109
+ context.pages().forEach(attachPage)
110
+ }
111
+
112
+ const attachPlaywright = browser => {
113
+ if (!browser.contexts) return attachContext(browser)
114
+ attached.add(browser)
115
+ browser.on('context', attachContext)
116
+ browser.contexts().forEach(attachContext)
117
+ }
118
+
119
+ const attachPuppeteer = async browser => {
120
+ attached.add(browser)
121
+ browser.on('targetcreated', async target => {
122
+ if (target.type() !== 'page') return
123
+ attachPage(await target.page())
124
+ })
125
+ for (const page of await browser.pages()) attachPage(page)
126
+ }
127
+
128
+ const subscribeBidi = async browser => {
129
+ attached.add(browser)
130
+ const frames = new Set()
131
+ const onNavigation = params => {
132
+ if (frames.has(params.context)) return
133
+ addUrl(params.url)
134
+ }
135
+ browser.on('browsingContext.contextCreated', params => {
136
+ if (params.parent) frames.add(params.context)
137
+ })
138
+ browser.on('browsingContext.load', onNavigation)
139
+ browser.on('browsingContext.historyUpdated', onNavigation)
140
+ try {
141
+ await browser.sessionSubscribe({ events: ['browsingContext.contextCreated', 'browsingContext.load'] })
142
+ } catch (err) {
143
+ output.plugin('sitemap', `Can't subscribe to navigation events: ${err.message}`)
144
+ }
145
+ try {
146
+ await browser.sessionSubscribe({ events: ['browsingContext.historyUpdated'] })
147
+ } catch {}
148
+ }
149
+
150
+ event.dispatcher.on(event.test.started, () => {
151
+ const helpers = Container.helpers()
152
+
153
+ if (helpers.Playwright) {
154
+ const browser = helpers.Playwright.browser
155
+ if (browser && !attached.has(browser)) attachPlaywright(browser)
156
+ return
157
+ }
158
+
159
+ if (helpers.Puppeteer) {
160
+ const browser = helpers.Puppeteer.browser
161
+ if (!browser || attached.has(browser)) return
162
+ recorder.add('sitemap: listen to navigation', () => attachPuppeteer(browser), true)
163
+ return
164
+ }
165
+
166
+ if (helpers.WebDriver) {
167
+ const browser = helpers.WebDriver.browser
168
+ if (!browser || attached.has(browser)) return
169
+ if (!browser.capabilities || !browser.capabilities.webSocketUrl) {
170
+ attached.add(browser)
171
+ output.plugin('sitemap', 'WebDriver BiDi protocol is disabled, visited pages are not collected')
172
+ return
173
+ }
174
+ recorder.add('sitemap: subscribe to navigation', () => subscribeBidi(browser), true)
175
+ }
176
+ })
177
+
178
+ const partialFiles = dir => {
179
+ if (!fs.existsSync(dir)) return []
180
+ return fs
181
+ .readdirSync(dir)
182
+ .filter(name => name.startsWith(PARTIAL_PREFIX) && name.endsWith('.json'))
183
+ .map(name => path.join(dir, name))
184
+ }
185
+
186
+ event.dispatcher.on(event.all.result, () => {
187
+ if (isMainThread) {
188
+ if (store.workerMode) return
189
+ writeSitemap([...urls])
190
+ return
191
+ }
192
+ if (!urls.size) return
193
+ const dir = outputDir()
194
+ mkdirp.sync(dir)
195
+ fs.writeFileSync(path.join(dir, `${PARTIAL_PREFIX}${process.pid}.${threadId}.json`), JSON.stringify([...urls]))
196
+ })
197
+
198
+ event.dispatcher.on(event.workers.before, () => {
199
+ for (const partial of partialFiles(outputDir())) fs.unlinkSync(partial)
200
+ })
201
+
202
+ event.dispatcher.on(event.workers.result, () => {
203
+ for (const partial of partialFiles(outputDir())) {
204
+ try {
205
+ for (const url of JSON.parse(fs.readFileSync(partial, 'utf8'))) urls.add(url)
206
+ } catch {}
207
+ fs.unlinkSync(partial)
208
+ }
209
+ writeSitemap([...urls])
210
+ })
211
+ }
212
+
213
+ function buildXml(list) {
214
+ const doc = new DOMImplementation().createDocument(SITEMAP_NS, 'urlset', null)
215
+ const urlset = doc.documentElement
216
+ for (const url of list) {
217
+ const urlEl = doc.createElementNS(SITEMAP_NS, 'url')
218
+ const locEl = doc.createElementNS(SITEMAP_NS, 'loc')
219
+ locEl.appendChild(doc.createTextNode(url))
220
+ urlEl.appendChild(doc.createTextNode('\n '))
221
+ urlEl.appendChild(locEl)
222
+ urlEl.appendChild(doc.createTextNode('\n '))
223
+ urlset.appendChild(doc.createTextNode('\n '))
224
+ urlset.appendChild(urlEl)
225
+ }
226
+ urlset.appendChild(doc.createTextNode('\n'))
227
+ return `<?xml version="1.0" encoding="UTF-8"?>\n${new XMLSerializer().serializeToString(doc)}\n`
228
+ }
package/lib/recorder.js CHANGED
@@ -202,34 +202,35 @@ export default {
202
202
  debug(chalk.gray(`${currentQueue()} Queued | ${taskName}`))
203
203
 
204
204
  return (promise = Promise.resolve(promise).then(res => {
205
- // prefer options for non-conditional retries
206
- const retryOpts = this.retries
207
- .sort((r1, r2) => r1.when && !r2.when)
208
- .slice(-1)
209
- .pop()
210
- // no retries or unnamed tasks
211
205
  debug(`${currentQueue()} Running | ${taskName} | Timeout: ${timeout || 'None'}`)
212
- if (retryOpts) debug(`${currentQueue()} Retry opts`, JSON.stringify(retryOpts))
213
206
 
214
- if (!retryOpts || !taskName || !retry) {
207
+ const runTask = () => {
215
208
  const [promise, timer] = getTimeoutPromise(timeout, taskName)
216
209
  return Promise.race([promise, Promise.resolve(res).then(fn)]).finally(() => clearTimeout(timer))
217
210
  }
218
211
 
212
+ // no retries or unnamed tasks
213
+ if (!this.retries.length || !taskName || !retry) return runTask()
214
+
219
215
  const retryRules = this.retries.slice().reverse()
220
- return promiseRetry(Object.assign({}, defaultRetryOptions, retryOpts), (retry, number) => {
221
- if (number > 1) output.log(`${currentQueue()}Retrying... Attempt #${number}`)
222
- const [promise, timer] = getTimeoutPromise(timeout, taskName)
223
- return Promise.race([promise, Promise.resolve(res).then(fn)])
224
- .finally(() => clearTimeout(timer))
225
- .catch(err => {
216
+ // prefer options for non-conditional retries, otherwise use the latest rule matching the error
217
+ const findRetryOpts = err => retryRules.find(r => !r.when) || retryRules.find(r => r.when(err))
218
+
219
+ return runTask().catch(firstErr => {
220
+ if (ignoredErrs.includes(firstErr)) return
221
+ const retryOpts = findRetryOpts(firstErr)
222
+ if (!retryOpts) throw firstErr
223
+ debug(`${currentQueue()} Retry opts`, JSON.stringify(retryOpts))
224
+
225
+ return promiseRetry(Object.assign({}, defaultRetryOptions, retryOpts), (retry, number) => {
226
+ if (number === 1) return retry(firstErr)
227
+ output.log(`${currentQueue()}Retrying... Attempt #${number}`)
228
+ return runTask().catch(err => {
226
229
  if (ignoredErrs.includes(err)) return
227
- for (const retryObj of retryRules) {
228
- if (!retryObj.when) return retry(err)
229
- if (retryObj.when && retryObj.when(err)) return retry(err)
230
- }
230
+ if (findRetryOpts(err)) return retry(err)
231
231
  throw err
232
232
  })
233
+ })
233
234
  })
234
235
  }))
235
236
  },
package/lib/result.js CHANGED
@@ -216,6 +216,22 @@ class Result {
216
216
  fs.writeFileSync(path.join(store.outputDir, fileName), JSON.stringify(this.simplify(), null, 2))
217
217
  }
218
218
 
219
+ /**
220
+ * Loads tests that failed in a result saved by a previous run.
221
+ *
222
+ * @param {string} [outputDir] Directory containing the saved result. Defaults to `output_dir`.
223
+ * @param {string} [fileName] Path to the JSON file, relative to `outputDir`. Defaults to "result.json".
224
+ * @returns {Array<{uid: string, title: string, state: string}>} Serialized failed tests.
225
+ */
226
+ static loadFailedTests(outputDir = store.outputDir, fileName = 'result.json') {
227
+ const resultFile = path.join(outputDir, fileName)
228
+ if (!fs.existsSync(resultFile)) {
229
+ throw new Error(`No previous run found (${resultFile} does not exist), run tests once first`)
230
+ }
231
+ const { tests = [] } = JSON.parse(fs.readFileSync(resultFile, 'utf8'))
232
+ return tests.filter(test => test.state === 'failed')
233
+ }
234
+
219
235
  /**
220
236
  * Adds stats to this result.
221
237
  *
package/lib/step/base.js CHANGED
@@ -52,6 +52,18 @@ class Step {
52
52
  this.setTrace()
53
53
  }
54
54
 
55
+ /**
56
+ * Alias of `title`, the name of the helper method this step executes
57
+ * @type {string}
58
+ */
59
+ get name() {
60
+ return this.title
61
+ }
62
+
63
+ set name(name) {
64
+ this.title = name
65
+ }
66
+
55
67
  setMetaStep(metaStep) {
56
68
  this.metaStep = metaStep
57
69
  }
package/lib/utils.js CHANGED
@@ -372,11 +372,7 @@ function shouldAppendBaseUrl(url) {
372
372
  }
373
373
 
374
374
  function trimUrl(url) {
375
- const firstChar = url.substr(1)
376
- if (firstChar === '/') {
377
- url = url.slice(1)
378
- }
379
- return url
375
+ return url.replace(/^\/+/, '')
380
376
  }
381
377
 
382
378
  function joinUrl(baseUrl, url) {
@@ -390,11 +386,7 @@ export const appendBaseUrl = function (baseUrl = '', oneOrMoreUrls) {
390
386
  if (!(typeof oneOrMoreUrls === 'string' || Array.isArray(oneOrMoreUrls))) {
391
387
  throw new Error(`Expected type of Urls is 'string' or 'array', Found '${typeof oneOrMoreUrls}'.`)
392
388
  }
393
- // Remove '/' if it's at the end of baseUrl
394
- const lastChar = baseUrl.substr(-1)
395
- if (lastChar === '/') {
396
- baseUrl = baseUrl.slice(0, -1)
397
- }
389
+ baseUrl = baseUrl.replace(/\/+$/, '')
398
390
 
399
391
  if (!Array.isArray(oneOrMoreUrls)) {
400
392
  return joinUrl(baseUrl, oneOrMoreUrls)
@@ -0,0 +1,193 @@
1
+ import { spawn, spawnSync } from 'child_process'
2
+ import path from 'path'
3
+ import output from './output.js'
4
+ import store from './store.js'
5
+ import { isWindows } from './utils.js'
6
+
7
+ const TAIL_SIZE = 20
8
+ const KILL_TIMEOUT = 5000
9
+ const SIGNALS = ['SIGINT', 'SIGTERM']
10
+
11
+ class WebServer {
12
+ static running = []
13
+
14
+ static signalHandlers = Object.fromEntries(SIGNALS.map(signal => [signal, () => WebServer.onSignal(signal)]))
15
+
16
+ constructor(config) {
17
+ const { command, url, enabled = true, timeout = 60000, cwd = '.', env = {} } = config
18
+ if (!command) throw new Error('webServer: "command" is required')
19
+ if (!url) throw new Error(`webServer: "url" is required for "${command}"`)
20
+ this.command = command
21
+ this.url = url
22
+ this.enabled = enabled
23
+ this.timeout = timeout
24
+ this.cwd = cwd
25
+ this.env = env
26
+ this.tail = []
27
+ this.proc = null
28
+ }
29
+
30
+ static async startAll(config) {
31
+ if (!config) return
32
+ const configs = Array.isArray(config) ? config : [config]
33
+ for (const serverConfig of configs) {
34
+ await new WebServer(serverConfig).start()
35
+ }
36
+ }
37
+
38
+ static async stopAll() {
39
+ while (WebServer.running.length) {
40
+ await WebServer.running[WebServer.running.length - 1].stop()
41
+ WebServer.running.pop()
42
+ }
43
+ WebServer.removeListeners()
44
+ }
45
+
46
+ static killAllSync() {
47
+ for (const server of WebServer.running) server.kill('SIGKILL')
48
+ }
49
+
50
+ static onSignal(signal) {
51
+ WebServer.killAllSync()
52
+ WebServer.removeListeners()
53
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal)
54
+ }
55
+
56
+ static addListeners() {
57
+ if (WebServer.running.length) return
58
+ process.on('exit', WebServer.killAllSync)
59
+ for (const signal of SIGNALS) process.on(signal, WebServer.signalHandlers[signal])
60
+ }
61
+
62
+ static removeListeners() {
63
+ process.removeListener('exit', WebServer.killAllSync)
64
+ for (const signal of SIGNALS) process.removeListener(signal, WebServer.signalHandlers[signal])
65
+ }
66
+
67
+ async isUp() {
68
+ try {
69
+ const res = await fetch(this.url, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(1000) })
70
+ await res.body?.cancel().catch(() => {})
71
+ return true
72
+ } catch (err) {
73
+ return false
74
+ }
75
+ }
76
+
77
+ async start() {
78
+ if (!this.enabled) return
79
+
80
+ if (await this.isUp()) {
81
+ output.print(`Reusing web server at ${this.url}`)
82
+ return
83
+ }
84
+
85
+ output.print(`Starting web server: ${this.command}`)
86
+
87
+ this.proc = spawn(this.command, {
88
+ shell: true,
89
+ detached: !isWindows(),
90
+ cwd: path.resolve(store.codeceptDir || process.cwd(), this.cwd),
91
+ env: { ...process.env, ...this.env },
92
+ stdio: ['ignore', 'pipe', 'pipe'],
93
+ })
94
+
95
+ this.collect(this.proc.stdout)
96
+ this.collect(this.proc.stderr)
97
+
98
+ WebServer.addListeners()
99
+ WebServer.running.push(this)
100
+
101
+ let exited = null
102
+ const closed = new Promise(resolve => this.proc.once('close', resolve))
103
+ this.proc.once('error', err => {
104
+ exited = `failed to start: ${err.message}`
105
+ })
106
+ this.proc.once('exit', (code, signal) => {
107
+ exited = `exited with ${signal ? `signal ${signal}` : `code ${code}`}`
108
+ })
109
+
110
+ const deadline = Date.now() + this.timeout
111
+ let delay = 100
112
+ while (true) {
113
+ if (exited) {
114
+ await Promise.race([closed, new Promise(resolve => setTimeout(resolve, 500))])
115
+ await WebServer.stopAll()
116
+ throw new Error(`webServer "${this.command}" ${exited} before ${this.url} was ready.${this.formatTail()}`)
117
+ }
118
+ if (await this.isUp()) break
119
+ if (Date.now() >= deadline) {
120
+ await WebServer.stopAll()
121
+ throw new Error(`webServer "${this.command}" did not respond at ${this.url} within ${this.timeout}ms.${this.formatTail()}`)
122
+ }
123
+ await new Promise(resolve => setTimeout(resolve, delay))
124
+ delay = Math.min(delay * 2, 1000)
125
+ }
126
+ }
127
+
128
+ async stop() {
129
+ if (!this.proc) return
130
+ this.kill('SIGTERM')
131
+ await this.waitForExit()
132
+ this.proc.stdout?.destroy()
133
+ this.proc.stderr?.destroy()
134
+ }
135
+
136
+ kill(signal) {
137
+ const { proc } = this
138
+ if (!proc?.pid) return
139
+ try {
140
+ if (isWindows()) {
141
+ if (proc.exitCode !== null) return
142
+ spawnSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'])
143
+ } else {
144
+ process.kill(-proc.pid, signal)
145
+ }
146
+ } catch (err) {
147
+ try {
148
+ proc.kill(signal)
149
+ } catch (e) {}
150
+ }
151
+ }
152
+
153
+ waitForExit() {
154
+ const { proc } = this
155
+ return new Promise(resolve => {
156
+ if (proc.exitCode !== null || proc.signalCode !== null) return resolve()
157
+ let killTimer
158
+ let safetyTimer
159
+ const finish = () => {
160
+ clearTimeout(killTimer)
161
+ clearTimeout(safetyTimer)
162
+ resolve()
163
+ }
164
+ proc.once('exit', finish)
165
+ killTimer = setTimeout(() => this.kill('SIGKILL'), KILL_TIMEOUT)
166
+ safetyTimer = setTimeout(finish, KILL_TIMEOUT + 500)
167
+ })
168
+ }
169
+
170
+ collect(stream) {
171
+ let rest = ''
172
+ stream.on('data', chunk => {
173
+ const lines = (rest + chunk.toString()).split(/\r?\n/)
174
+ rest = lines.pop()
175
+ for (const line of lines) this.addLine(line)
176
+ })
177
+ stream.on('end', () => this.addLine(rest))
178
+ }
179
+
180
+ addLine(line) {
181
+ if (!line.trim()) return
182
+ this.tail.push(line)
183
+ if (this.tail.length > TAIL_SIZE) this.tail.shift()
184
+ if (store.debugMode || output.level() >= 2) output.print(output.styles.debug(`[webServer] ${line}`))
185
+ }
186
+
187
+ formatTail() {
188
+ if (!this.tail.length) return '\n(no output)'
189
+ return `\nLast output:\n${this.tail.join('\n')}`
190
+ }
191
+ }
192
+
193
+ export default WebServer