Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ you can use the `bootstrap` and `teardown` config. Use it to start and stop a we

When using the [parallel execution](/parallel) mode, there are two additional hooks available; `bootstrapAll` and `teardownAll`. See [bootstrapAll & teardownAll](#bootstrapall-teardownall) for more information.

> To start and stop the application under test, use the [`webServer`](/configuration#web-server) option instead of writing bootstrap code.


### Example: Bootstrap & Teardown

Expand Down
36 changes: 36 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const config = {

- `bootstrap` / `teardown` — run code before / after the whole run; an async function or a path to a JS module. See [Bootstrap](/bootstrap).
- `bootstrapAll` / `teardownAll` — run once around a parallel run (before any worker starts / after all finish). See [bootstrapAll / teardownAll](/bootstrap#bootstrapall-teardownall).
- `webServer` — start the application under test before the run and stop it after. See [Web Server](#web-server).

**Test runner**

Expand Down Expand Up @@ -89,6 +90,41 @@ require: ['tsx/esm', 'should', './lib/testSetup']

The config file itself (`codecept.conf.ts`) and helpers are transpiled automatically — only test files need the loader. See [TypeScript](/typescript) for the full setup.

## Web Server

`webServer` starts your application before tests run and stops it when they finish, so you don't need a `bootstrap` script for it:

```js
export const config = {
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
enabled: !process.env.CI,
},
// ...
}
```

- `command` — shell command that starts the app.
- `url` — CodeceptJS sends GET requests here until it gets any HTTP response, then starts the tests. If `url` already responds before `command` is run, the running server is reused.
- `enabled` — set to `false` to skip starting the server, e.g. `enabled: !process.env.CI` when CI provides its own. Default `true`.
- `timeout` — milliseconds to wait for `url`. Default `60000`.
- `cwd` — working directory for `command`, relative to the config file. Default is the config directory.
- `env` — extra environment variables for `command`.

If the server exits early or doesn't respond within `timeout`, the run fails and prints the last 20 lines of its output. Run with `--debug` to see all of its output.

To start several services, pass an array. They start in order and stop in reverse order:

```js
webServer: [
{ command: 'npm run api', url: 'http://localhost:4000/health' },
{ command: 'npm run start', url: 'http://localhost:3000' },
],
```

The server is started as part of bootstrap, right before the `bootstrap` hook (or `bootstrapAll` for `run-workers` and `run-multiple`), and stopped after `teardown` (`teardownAll`). It starts once in the main process; workers and child processes reuse it. The interactive `shell` starts it too, `dry-run` and `check` only with `--bootstrap`. CodeceptJS stops the command and every process it spawned, including when the run is interrupted with Ctrl+C.

## Dynamic configuration

A JS/TS config file is plain code, so you can read environment variables and build the config at runtime:
Expand Down
9 changes: 1 addition & 8 deletions docs/plugins/aiTrace.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
---
permalink: /plugins/aiTrace
editLink: false
sidebar: auto
title: aiTrace
---

<!-- Generated by documentation.js. Update this documentation by updating the source code. -->

## aiTrace
Expand Down Expand Up @@ -46,4 +39,4 @@ Possible config options:

### Parameters

* `config` **any**
* `config` **any** (optional, default `{}`)
9 changes: 8 additions & 1 deletion lib/codecept.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolve } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { dirname } from 'path'
import { createRequire } from 'module'
import { isMainThread } from 'worker_threads'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
Expand All @@ -23,6 +24,7 @@ import loadTests from './mocha/loadTests.js'
import { validateTypeScriptSetup, getTSNodeESMWarning } from './utils/loaderCheck.js'
import recorder from './recorder.js'
import store from './store.js'
import WebServer from './webServer.js'

import storeListener from './listener/store.js'
import stepsListener from './listener/steps.js'
Expand Down Expand Up @@ -159,6 +161,7 @@ class Codecept {
* @returns {Promise<void>}
*/
async bootstrap() {
if (isMainThread && !this.opts?.child) await WebServer.startAll(this.config.webServer)
return runHook(this.config.bootstrap, 'bootstrap')
}

Expand All @@ -168,7 +171,11 @@ class Codecept {
* @returns {Promise<void>}
*/
async teardown() {
return runHook(this.config.teardown, 'teardown')
try {
return await runHook(this.config.teardown, 'teardown')
} finally {
await WebServer.stopAll()
}
}

/**
Expand Down
3 changes: 3 additions & 0 deletions lib/command/run-multiple.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import crypto from 'crypto'
import { fileURLToPath } from 'url'

import runHook from '../hooks.js'
import WebServer from '../webServer.js'
import event from '../event.js'
import { createRuns } from './run-multiple/collection.js'
import { clearString, replaceValueDeep } from '../utils.js'
Expand Down Expand Up @@ -67,6 +68,7 @@ export default async function (selectedRuns, options) {
fail('No runs provided. Use --all option to run all configured runs')
}

await WebServer.startAll(config.webServer)
await runHook(config.bootstrapAll, 'bootstrapAll')

event.emit(event.multiple.before, null)
Expand Down Expand Up @@ -110,6 +112,7 @@ export default async function (selectedRuns, options) {
return childProcessesPromise.then(async () => {
// fire hook
await runHook(config.teardownAll, 'teardownAll')
await WebServer.stopAll()
event.emit(event.multiple.after, null)
})
}
Expand Down
3 changes: 1 addition & 2 deletions lib/command/run-workers.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default async function (workerCount, selectedRuns, options) {

output.print(`CodeceptJS v${Codecept.version()} ${output.standWithUkraine()}`)
output.print(`Running tests in ${output.styles.bold(numberOfWorkers)} workers...`)
if (options.verbose || options.debug) store.debugMode = true
store.hasWorkers = true
store.workerMode = true
process.env.RUNS_WITH_WORKERS = 'true'
Expand Down Expand Up @@ -76,8 +77,6 @@ export default async function (workerCount, selectedRuns, options) {
})

try {
if (options.verbose || options.debug) store.debugMode = true

if (options.verbose) {
await getMachineInfo()
}
Expand Down
193 changes: 193 additions & 0 deletions lib/webServer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { spawn, spawnSync } from 'child_process'
import path from 'path'
import output from './output.js'
import store from './store.js'
import { isWindows } from './utils.js'

const TAIL_SIZE = 20
const KILL_TIMEOUT = 5000
const SIGNALS = ['SIGINT', 'SIGTERM']

class WebServer {
static running = []

static signalHandlers = Object.fromEntries(SIGNALS.map(signal => [signal, () => WebServer.onSignal(signal)]))

constructor(config) {
const { command, url, enabled = true, timeout = 60000, cwd = '.', env = {} } = config
if (!command) throw new Error('webServer: "command" is required')
if (!url) throw new Error(`webServer: "url" is required for "${command}"`)
this.command = command
this.url = url
this.enabled = enabled
this.timeout = timeout
this.cwd = cwd
this.env = env
this.tail = []
this.proc = null
}

static async startAll(config) {
if (!config) return
const configs = Array.isArray(config) ? config : [config]
for (const serverConfig of configs) {
await new WebServer(serverConfig).start()
}
}

static async stopAll() {
while (WebServer.running.length) {
await WebServer.running[WebServer.running.length - 1].stop()
WebServer.running.pop()
}
WebServer.removeListeners()
}

static killAllSync() {
for (const server of WebServer.running) server.kill('SIGKILL')
}

static onSignal(signal) {
WebServer.killAllSync()
WebServer.removeListeners()
if (process.listenerCount(signal) === 0) process.kill(process.pid, signal)
}

static addListeners() {
if (WebServer.running.length) return
process.on('exit', WebServer.killAllSync)
for (const signal of SIGNALS) process.on(signal, WebServer.signalHandlers[signal])
}

static removeListeners() {
process.removeListener('exit', WebServer.killAllSync)
for (const signal of SIGNALS) process.removeListener(signal, WebServer.signalHandlers[signal])
}

async isUp() {
try {
const res = await fetch(this.url, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(1000) })
await res.body?.cancel().catch(() => {})
return true
} catch (err) {
return false
}
}

async start() {
if (!this.enabled) return

if (await this.isUp()) {
output.print(`Reusing web server at ${this.url}`)
return
}

output.print(`Starting web server: ${this.command}`)

this.proc = spawn(this.command, {
shell: true,
detached: !isWindows(),
cwd: path.resolve(store.codeceptDir || process.cwd(), this.cwd),
env: { ...process.env, ...this.env },
stdio: ['ignore', 'pipe', 'pipe'],
})

this.collect(this.proc.stdout)
this.collect(this.proc.stderr)

WebServer.addListeners()
WebServer.running.push(this)

let exited = null
const closed = new Promise(resolve => this.proc.once('close', resolve))
this.proc.once('error', err => {
exited = `failed to start: ${err.message}`
})
this.proc.once('exit', (code, signal) => {
exited = `exited with ${signal ? `signal ${signal}` : `code ${code}`}`
})

const deadline = Date.now() + this.timeout
let delay = 100
while (true) {
if (exited) {
await Promise.race([closed, new Promise(resolve => setTimeout(resolve, 500))])
await WebServer.stopAll()
throw new Error(`webServer "${this.command}" ${exited} before ${this.url} was ready.${this.formatTail()}`)
}
if (await this.isUp()) break
if (Date.now() >= deadline) {
await WebServer.stopAll()
throw new Error(`webServer "${this.command}" did not respond at ${this.url} within ${this.timeout}ms.${this.formatTail()}`)
}
await new Promise(resolve => setTimeout(resolve, delay))
delay = Math.min(delay * 2, 1000)
}
}

async stop() {
if (!this.proc) return
this.kill('SIGTERM')
await this.waitForExit()
this.proc.stdout?.destroy()
this.proc.stderr?.destroy()
}

kill(signal) {
const { proc } = this
if (!proc?.pid) return
try {
if (isWindows()) {
if (proc.exitCode !== null) return
spawnSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'])
} else {
process.kill(-proc.pid, signal)
}
} catch (err) {
try {
proc.kill(signal)
} catch (e) {}
}
}

waitForExit() {
const { proc } = this
return new Promise(resolve => {
if (proc.exitCode !== null || proc.signalCode !== null) return resolve()
let killTimer
let safetyTimer
const finish = () => {
clearTimeout(killTimer)
clearTimeout(safetyTimer)
resolve()
}
proc.once('exit', finish)
killTimer = setTimeout(() => this.kill('SIGKILL'), KILL_TIMEOUT)
safetyTimer = setTimeout(finish, KILL_TIMEOUT + 500)
})
}

collect(stream) {
let rest = ''
stream.on('data', chunk => {
const lines = (rest + chunk.toString()).split(/\r?\n/)
rest = lines.pop()
for (const line of lines) this.addLine(line)
})
stream.on('end', () => this.addLine(rest))
}

addLine(line) {
if (!line.trim()) return
this.tail.push(line)
if (this.tail.length > TAIL_SIZE) this.tail.shift()
if (store.debugMode || output.level() >= 2) output.print(output.styles.debug(`[webServer] ${line}`))
}

formatTail() {
if (!this.tail.length) return '\n(no output)'
return `\nLast output:\n${this.tail.join('\n')}`
}
}

export default WebServer
8 changes: 7 additions & 1 deletion lib/workers.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { deserializeSuite } from './mocha/suite.js'
import recorder from './recorder.js'
import store from './store.js'
import runHook from './hooks.js'
import WebServer from './webServer.js'
import WorkerStorage from './workerStorage.js'
import { createRuns } from './command/run-multiple/collection.js'

Expand Down Expand Up @@ -490,12 +491,17 @@ class Workers extends EventEmitter {

async bootstrapAll() {
await this._ensureInitialized()
await WebServer.startAll(this.codecept.config.webServer)
return runHook(this.codecept.config.bootstrapAll, 'bootstrapAll')
}

async teardownAll() {
await this._ensureInitialized()
return runHook(this.codecept.config.teardownAll, 'teardownAll')
try {
return await runHook(this.codecept.config.teardownAll, 'teardownAll')
} finally {
await WebServer.stopAll()
}
}

async run() {
Expand Down
21 changes: 21 additions & 0 deletions test/data/sandbox/configs/webServer/codecept.conf.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const port = process.env.WEB_SERVER_PORT

export const config = {
tests: './*_test.js',
output: './output',
helpers: {
FileSystem: {},
},
webServer: {
command: process.env.WEB_SERVER_COMMAND || 'node server.js',
url: `http://127.0.0.1:${port}`,
enabled: process.env.WEB_SERVER_ENABLED !== 'false',
timeout: Number(process.env.WEB_SERVER_TIMEOUT || 10000),
},
multiple: {
default: {
browsers: ['chrome', 'firefox'],
},
},
name: 'webServer',
}
Loading
Loading