Files
cypress/system-tests/lib/docker.ts
T
Cacie Prins 39a7f2a743 fix: graceful teardown of file watchers and spawned processes when exiting (#33542)
* wip: graceful exit

* add types for signal-exit

* use graceful exit; properly forward signals to forked process from gulp

* rm extraneous

* wait for child processes to close before exiting parent processes

* do not teardown twice; allow for multiple will-quit messages

* wait for async child to exit, clean up

* bring back env vars rather than inlined undefines

* updated tests

* fix exit codes

* pingpong code

* system test

* rm extraneous debug entry

* fix various

* fix operator precedence w/ promise

* improper treekill usage, coerce null exitCode

* fix concurrent duplicate .close() on watchers

* changelog

* clear timeout

* refinements

* refine

* infer exit code from signal

* if graceful exit fails in before-quit handler, exit with code 1

* v8 build errors

* revert lockfile teardown to previous pattern, keep on graceful-exit

* exit code 1 no matter what the signal is

* system test snapshots

* snapshot

* integration test timing fixes, more robust teardown

* no eventmap type huh

* universal reset of graceful exit

* rm all listeners from studio lifecycle

* euid on windows

* fix posix code 112 exit criterion

* mock process.exit in interactive spec

* fix graceful exit step

* better handling of return types for exit codes

* fixes an issue where system tests were not tearing down correctly

* rm dead code

* 112

* Update record_spec.js

* better messaging, debugging

* additional debugging for child process; fix CT teardown

* changelog

* fix run child fixture failing to exit

* rm console

* add proper error listener so test does not fail prematurely

* system test snapshots; no longer emit err from async child

* add back snapshots that got removed?

* only resolve on signals, not reject

* shut down gql ws server correctly

* properly await project shutdown in unit test

* prevent repeated graphql-ws .dispose() calls

* fix spawn unit test

* debounce signals in graceful exit signal handlers to account for duplicate kills from signal-exit

* only emit sigint/term message once, in case signal-exit goes overboard

* changelog

* Apply suggestion from @cacieprins

* do not double-resolve

* rm unused types

* rm all listeners on child process even if already killed

* sigkill = exit with 137

* prevent zombie plugin child processes

* prevent morgan output during shutdown

* surround sigint messaging with newlines to improve visual experience

* correct changelog

* fix ci-only issues

* enable mockery for morgan in before each instead of outside of test closure

* changelog

* fix out of order

* ensure stdin is no longer set to raw mode when signals are received

* fix spawn unit tests for setRawMode(false)

* check tty in gulp script

* lock, cache

* wsp

* prevent orphaned steps in graceful shutdown

* dont stack sigint/term handlers in pkgs/electron open

* changelog
2026-05-15 12:36:16 -04:00

165 lines
4.2 KiB
TypeScript

import type { SpawnerResult, Spawner } from './system-tests'
import Docker from 'dockerode'
import stream from 'stream'
import EventEmitter from 'events'
import path from 'path'
import { promises as fs } from 'fs'
import execa from 'execa'
import Fixtures from './fixtures'
import { nock } from './spec_helper'
let docker: Docker | null = null
const getDocker = () => {
return docker || (docker = new Docker())
}
const log = (...args) => {
console.error('🐋', ...args)
}
class DockerProcess extends EventEmitter implements SpawnerResult {
stdout = new stream.PassThrough()
stderr = new stream.PassThrough()
/** Placeholder; Cypress runs in Docker — harness interrupt does not tree-kill this pid. */
pid = 0
constructor (private dockerImage: string) {
super()
}
pull () {
return new Promise<void>((resolve, reject) => {
log('Pulling image', this.dockerImage)
getDocker().pull(this.dockerImage, null, (err, stream) => {
if (err) return reject(err)
const onFinished = (err) => {
log('Pull complete', { err })
if (err) return reject(err)
resolve()
}
const onProgress = (event) => {
log('Pull progress', JSON.stringify(event))
}
docker.modem.followProgress(stream, onFinished, onProgress)
}, null)
})
}
run (opts: {
cmd: string
args: string[]
env: Record<string, string>
}) {
const containerCreateEnv = []
for (const k in opts.env) {
// skip problematic env vars that we don't wanna preserve from `process.env`
if (
['DISPLAY', 'USER', 'HOME', 'USERNAME', 'PATH'].includes(k)
|| k.startsWith('npm_')
) {
continue
}
containerCreateEnv.push([k, opts.env[k]].join('='))
}
log('Running image', this.dockerImage)
const cmd = [opts.cmd, ...opts.args]
log('Running cmd', cmd.join(' '))
getDocker().run(
this.dockerImage,
cmd,
[this.stdout, this.stderr],
// option docs: https://docs.docker.com/engine/api/v1.37/#operation/ContainerCreate
{
AutoRemove: true,
Entrypoint: 'bash',
Tty: false, // so we can use stdout and stderr
Env: containerCreateEnv,
Privileged: true,
Binds: [
[path.join(__dirname, '..', '..'), '/cypress'],
// map tmpDir to the same absolute path on the container to make it easier to reason about paths in tests
[Fixtures.cyTmpDir, Fixtures.cyTmpDir],
].map((a) => a.join(':')),
},
// option docs: https://docs.docker.com/engine/api/v1.37/#operation/ContainerStart
{},
(err, data) => {
if (err) {
log('Docker run errored:', { err, data })
return this.emit('error', err)
}
log('Docker run exited:', { err, data })
this.emit('exit', data.StatusCode, null)
},
)
}
kill (): boolean {
throw new Error('.kill not implemented for DockerProcess.')
}
}
const checkBuiltBinary = async () => {
try {
await fs.stat(path.join(__dirname, '..', '..', 'cypress.zip'))
} catch (err) {
throw new Error('Expected built cypress.zip at project root. Run `yarn binary-build`, `yarn binary-package`, and `yarn binary-zip`.')
}
try {
await fs.stat(path.join(__dirname, '..', '..', 'cli/build/package.json'))
} catch (err) {
throw new Error('Expected built CLI in /cli/build. Run `yarn build` in `cli`.')
}
}
export const dockerSpawner: Spawner = async (cmd, args, env, options) => {
await checkBuiltBinary()
const projectPath = Fixtures.projectPath(options.project)
log('Running chmod 0777 on', projectPath, 'to avoid Docker permissions issues.')
await execa('chmod', `-R 0777 ${projectPath}`.split(' '))
const proc = new DockerProcess(options.dockerImage)
nock.enableNetConnect('localhost')
await proc.pull()
if (options.withBinary) {
args = [cmd, ...args]
cmd = `/cypress/system-tests/scripts/bootstrap-docker-container.sh`
} else {
throw new Error('Docker testing is only supported with built binaries (withBinary: true)')
}
env = {
...env,
TEST_PROJECT_DIR: projectPath,
REPO_DIR: '/cypress',
}
proc.run({
cmd,
args,
env,
})
return proc
}