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.
- package/README.md +2 -0
- package/bin/codecept.js +11 -0
- package/docs/agents.md +26 -1
- package/docs/ai.md +57 -0
- package/docs/alternative-browsers.md +175 -148
- package/docs/assertions.md +74 -0
- package/docs/bootstrap.md +2 -0
- package/docs/commands.md +45 -0
- package/docs/configuration.md +37 -0
- package/docs/debugging.md +1 -1
- package/docs/helpers/ApiDataFactory.md +20 -20
- package/docs/helpers/Appium.md +1615 -670
- package/docs/helpers/Decision.md +146 -0
- package/docs/helpers/GraphQL.md +31 -31
- package/docs/helpers/GraphQLDataFactory.md +20 -20
- package/docs/helpers/Kitesurf.md +1808 -25
- package/docs/helpers/Lightpanda.md +139 -0
- package/docs/helpers/Obscura.md +1799 -74
- package/docs/helpers/Playwright.md +490 -428
- package/docs/helpers/Puppeteer.md +317 -255
- package/docs/helpers/REST.md +43 -43
- package/docs/helpers/WebDriver.md +237 -175
- package/docs/installation.md +16 -0
- package/docs/mcp.md +7 -1
- package/docs/parallel.md +35 -1
- package/docs/plugins/pause.md +2 -1
- package/docs/plugins/sitemap.md +48 -0
- package/docs/plugins.md +4 -0
- package/lib/ai.js +81 -2
- package/lib/codecept.js +32 -2
- package/lib/command/init.js +22 -0
- package/lib/command/initAgent.js +78 -0
- package/lib/command/run-multiple.js +3 -0
- package/lib/command/run-workers.js +10 -3
- package/lib/command/run.js +3 -2
- package/lib/command/utils.js +15 -0
- package/lib/command/workers/runTests.js +10 -10
- package/lib/container.js +4 -2
- package/lib/effects.js +19 -1
- package/lib/helper/CDPBrowser.js +349 -1384
- package/lib/helper/Decision.js +256 -0
- package/lib/helper/Kitesurf.js +3 -21
- package/lib/helper/Lightpanda.js +137 -0
- package/lib/helper/Obscura.js +21 -238
- package/lib/helper/Playwright.js +6 -4
- package/lib/helper/Puppeteer.js +12 -7
- package/lib/helper/WebDriver.js +2 -1
- package/lib/listener/result.js +4 -0
- package/lib/mocha/bdd.js +19 -1
- package/lib/plugin/junitReporter.js +4 -1
- package/lib/plugin/pause.js +4 -1
- package/lib/plugin/sitemap.js +228 -0
- package/lib/recorder.js +19 -18
- package/lib/result.js +16 -0
- package/lib/step/base.js +12 -0
- package/lib/utils.js +2 -10
- package/lib/webServer.js +193 -0
- package/lib/workers.js +118 -31
- package/package.json +4 -2
- package/typings/index.d.ts +60 -7
- package/typings/promiseBasedTypes.d.ts +772 -824
- package/typings/types.d.ts +866 -833
- 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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/lib/webServer.js
ADDED
|
@@ -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
|