Files
cypress/cli/test/lib/exec/spawn_spec.js
Cacie Prins e463fdbc61 fix: Redirect spammy electron stderr to a debug sink (#32188)
* wip: system test to reproduce

* system test for alsa stderr

* split cypress from 3rd party stderr at parent process to electron child

* rm garbage warning regexes

* fix newline behavior when parsing internal stderr

* migrate left over console errors

* clean up system test name

* fix typed import

* extract stderr splitting to separate pkg so runner can use @packages/error

* rm new err log from packherd-quire

* handle backpressure

* docs

* some unit tests & coverage for stderr-filtering

* unit tests

* no longer test regexp specific output in spawn unit tests

* filter enabled debug namespaces rather than just cypress namespacesc

* revise stream splitting et al

* try to fix v8 snapshot build??

* fix console.log assertion

* add missing eslint config

* rm unused spies

* fix regexp for optional leading wsp and ansi on debug entries

* update unit tests because sinon

* lint

* colon..

* build stderr-filtering before checking if binary exists

* adds TagStream transform stream, fixes stderr from child proc config

* add build-prod script for stderr-filtering

* changelog

* properly handle backpressure in prefixed content transform stream

* use standard tsconfig?

* better tsconfig

* Add pkgStderrFiltering to monorepoPaths

* add \"files\" manifest

* pipe all stderr to stderr when CYPRESS_INTERNAL_DEBUG_ELECTRON is enabled

* rm explicit build of stderr-filtering in check-if-binary-exists step

* ensure all dependencies of scripts/ are built before scripts are executed in the check-if-binary-exists command

* fix dev version ref

* swap logic

* add stdin piping

* fix exec name on the run-on-dependencies command to be more useful

* use correct env

* rm obsolete type refs

* simplify stderr-filtering public iface, pipe cy-in-cy stderr through filtering tx

* bust cache

* fix mocks

* fix v8-snapshot

* move stderrfiltering to dev pkg in cli

* skip integrity check in ci, if they are out of date things should fail anyway

* copypasta over a portion of stderr-filtering to cli, since cli cannot import @packages

* Delete issues.md

* rm special filtering for cy in cy

* rm too narrow rules file

---------

Co-authored-by: Jennifer Shehane <shehane.jennifer@gmail.com>
Co-authored-by: Jennifer Shehane <jennifer@cypress.io>
Co-authored-by: Bill Glesias <bglesias@gmail.com>
2025-08-19 17:05:53 -04:00

501 lines
14 KiB
JavaScript

require('../../spec_helper')
const cp = require('child_process')
const os = require('os')
const tty = require('tty')
const path = require('path')
const EE = require('events')
const mockedEnv = require('mocked-env')
const readline = require('readline')
const proxyquire = require('proxyquire')
const debug = require('debug')('test')
const state = require(`${lib}/tasks/state`)
const xvfb = require(`${lib}/exec/xvfb`)
const spawn = require(`${lib}/exec/spawn`)
const verify = require(`${lib}/tasks/verify`)
const util = require(`${lib}/util.js`)
const expect = require('chai').expect
const snapshot = require('../../support/snapshot')
const cwd = process.cwd()
const execPath = process.execPath
const nodeVersion = process.versions.node
const defaultBinaryDir = '/default/binary/dir'
let mockReadlineEE
describe('lib/exec/spawn', function () {
beforeEach(function () {
os.platform.returns('darwin')
sinon.stub(process, 'exit')
this.spawnedProcess = {
on: sinon.stub().returns(undefined),
unref: sinon.stub().returns(undefined),
stdin: {
on: sinon.stub().returns(undefined),
pipe: sinon.stub().returns(undefined),
},
stdout: {
on: sinon.stub().returns(undefined),
pipe: sinon.stub().returns(undefined),
},
stderr: {
pipe: sinon.stub().returns(undefined),
on: sinon.stub().returns(undefined),
},
kill: sinon.stub(),
// expected by sinon
cancel: sinon.stub(),
}
// process.stdin is both an event emitter and a readable stream
this.processStdin = new EE()
mockReadlineEE = new EE()
this.processStdin.pipe = sinon.stub().returns(undefined)
sinon.stub(process, 'stdin').value(this.processStdin)
sinon.stub(readline, 'createInterface').returns(mockReadlineEE)
sinon.stub(cp, 'spawn').returns(this.spawnedProcess)
sinon.stub(xvfb, 'start').resolves()
sinon.stub(xvfb, 'stop').resolves()
sinon.stub(xvfb, 'isNeeded').returns(false)
sinon.stub(state, 'getBinaryDir').returns(defaultBinaryDir)
sinon.stub(state, 'getPathToExecutable').withArgs(defaultBinaryDir).returns('/path/to/cypress')
})
context('.start', function () {
// ️️⚠️ NOTE ⚠️
// when asserting the calls made to spawn the child Cypress process
// we have to be _very_ careful. Spawn uses process.env object, if an assertion
// fails, it will print the entire process.env object to the logs, which
// might contain sensitive environment variables. Think about what the
// failed assertion might print to the public CI logs and limit
// the environment variables when running tests on CI.
it('passes args + options to spawn', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
sinon.stub(verify, 'needsSandbox').returns(false)
return spawn.start('--foo', { foo: 'bar' })
.then(() => {
expect(cp.spawn).to.be.calledWithMatch('/path/to/cypress', [
'--',
'--foo',
'--cwd',
cwd,
'--userNodePath',
execPath,
'--userNodeVersion',
nodeVersion,
], {
detached: false,
stdio: ['inherit', 'inherit', 'pipe'],
})
})
})
it('uses --no-sandbox when needed', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
sinon.stub(verify, 'needsSandbox').returns(true)
return spawn.start('--foo', { foo: 'bar' })
.then(() => {
// skip the options argument: we do not need anything about it
// and also less risk that a failed assertion would dump the
// entire ENV object with possible sensitive variables
const args = cp.spawn.firstCall.args.slice(0, 2)
// it is important for "--no-sandbox" to appear before "--" separator
const expectedCliArgs = [
'--no-sandbox',
'--',
'--foo',
'--cwd',
cwd,
'--userNodePath',
execPath,
'--userNodeVersion',
nodeVersion,
]
expect(args).to.deep.equal(['/path/to/cypress', expectedCliArgs])
})
})
it('uses npm command when running in dev mode', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
sinon.stub(verify, 'needsSandbox').returns(false)
const p = path.resolve('..', 'scripts', 'start.js')
return spawn.start('--foo', { dev: true, foo: 'bar' })
.then(() => {
expect(cp.spawn).to.be.calledWithMatch('node', [
p,
'--',
'--foo',
'--cwd',
cwd,
'--userNodePath',
execPath,
'--userNodeVersion',
nodeVersion,
], {
detached: false,
stdio: ['inherit', 'inherit', 'pipe'],
})
})
})
it('does not pass --no-sandbox when running in dev mode', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
sinon.stub(verify, 'needsSandbox').returns(true)
const p = path.resolve('..', 'scripts', 'start.js')
return spawn.start('--foo', { dev: true, foo: 'bar' })
.then(() => {
expect(cp.spawn).to.be.calledWithMatch('node', [
p,
'--',
'--foo',
'--cwd',
cwd,
'--userNodePath',
execPath,
'--userNodeVersion',
nodeVersion,
], {
detached: false,
stdio: ['inherit', 'inherit', 'pipe'],
})
})
})
it('starts xvfb when needed', function () {
xvfb.isNeeded.returns(true)
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
return spawn.start('--foo')
.then(() => {
expect(xvfb.start).to.be.calledOnce
})
})
context('closes', function () {
['close', 'exit'].forEach((event) => {
it(`if '${event}' event fired`, function () {
this.spawnedProcess.on.withArgs(event).yieldsAsync(0)
return spawn.start('--foo')
})
})
it('if exit event fired and close event fired', function () {
this.spawnedProcess.on.withArgs('exit').yieldsAsync(0)
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
return spawn.start('--foo')
})
})
context('detects kill signal', function () {
it('exits with error on SIGKILL', function () {
this.spawnedProcess.on.withArgs('exit').yieldsAsync(null, 'SIGKILL')
return spawn.start('--foo')
.then(() => {
throw new Error('should have hit error handler but did not')
}, (e) => {
debug('error message', e.message)
snapshot(e.message)
})
})
})
it('does not start xvfb when its not needed', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
return spawn.start('--foo')
.then(() => {
expect(xvfb.start).not.to.be.called
})
})
it('stops xvfb when spawn closes', function () {
xvfb.isNeeded.returns(true)
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
this.spawnedProcess.on.withArgs('close').yields()
return spawn.start('--foo')
.then(() => {
expect(xvfb.stop).to.be.calledOnce
})
})
it('resolves with spawned close code in the message', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(10)
return spawn.start('--foo')
.then((code) => {
expect(code).to.equal(10)
})
})
describe('Linux display', () => {
let restore
beforeEach(() => {
restore = mockedEnv({
DISPLAY: 'test-display',
})
})
afterEach(() => {
restore()
})
it('retries with xvfb if fails with display exit code', function () {
this.spawnedProcess.on.withArgs('close').onFirstCall().yieldsAsync(1)
this.spawnedProcess.on.withArgs('close').onSecondCall().yieldsAsync(0)
const buf1 = '[some noise here] Gtk: cannot open display: 987'
this.spawnedProcess.stderr.on
.withArgs('data')
.yields(buf1)
os.platform.returns('linux')
return spawn.start('--foo')
.then((code) => {
expect(xvfb.start).to.have.been.calledOnce
expect(xvfb.stop).to.have.been.calledOnce
expect(cp.spawn).to.have.been.calledTwice
// second code should be 0 after successfully running with Xvfb
expect(code).to.equal(0)
})
})
})
it('rejects with error from spawn', function () {
const msg = 'the error message'
this.spawnedProcess.on.withArgs('error').yieldsAsync(new Error(msg))
return spawn.start('--foo')
.then(() => {
throw new Error('should have hit error handler but did not')
}, (e) => {
debug('error message', e.message)
expect(e.message).to.include(msg)
})
})
it('unrefs if options.detached is true', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
return spawn.start(null, { detached: true })
.then(() => {
expect(this.spawnedProcess.unref).to.be.calledOnce
})
})
it('does not unref by default', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
return spawn.start()
.then(() => {
expect(this.spawnedProcess.unref).not.to.be.called
})
})
it('sets process.env to options.env', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
process.env.FOO = 'bar'
return spawn.start()
.then(() => {
expect(cp.spawn.firstCall.args[2].env.FOO).to.eq('bar')
})
})
it('forces colors and streams when supported', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
sinon.stub(util, 'supportsColor').returns(true)
sinon.stub(tty, 'isatty').returns(true)
return spawn.start([], { env: {} })
.then(() => {
snapshot(cp.spawn.firstCall.args[2].env)
})
})
it('sets windowsHide:false property in windows', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
os.platform.returns('win32')
return spawn.start([], { env: {} })
.then(() => {
expect(cp.spawn.firstCall.args[2].windowsHide).to.be.false
})
})
it('propagates treeKill if SIGINT is detected in windows console', async function () {
this.spawnedProcess.pid = 7
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
os.platform.returns('win32')
const treeKillMock = sinon.stub().returns(0)
const spawn = proxyquire(`${lib}/exec/spawn`, { 'tree-kill': treeKillMock })
await spawn.start([], { env: {} })
mockReadlineEE.emit('SIGINT')
expect(treeKillMock).to.have.been.calledWith(7, 'SIGINT')
})
it('does not set windowsHide property when in darwin', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
return spawn.start([], { env: {} })
.then(() => {
expect(cp.spawn.firstCall.args[2].windowsHide).to.be.undefined
})
})
it('does not force colors and streams when not supported', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
sinon.stub(util, 'supportsColor').returns(false)
sinon.stub(tty, 'isatty').returns(false)
return spawn.start([], { env: {} })
.then(() => {
snapshot(cp.spawn.firstCall.args[2].env)
})
})
it('pipes when on win32', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
os.platform.returns('win32')
xvfb.isNeeded.returns(false)
return spawn.start()
.then(() => {
expect(cp.spawn.firstCall.args[2].stdio).to.deep.eq('pipe')
// parent process STDIN was piped to child process STDIN
expect(this.processStdin.pipe, 'process.stdin').to.have.been.calledOnce
.and.to.have.been.calledWith(this.spawnedProcess.stdin)
})
})
it('inherits when on linux and xvfb isn\'t needed', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
os.platform.returns('linux')
xvfb.isNeeded.returns(false)
return spawn.start()
.then(() => {
expect(cp.spawn.firstCall.args[2].stdio).to.deep.eq('inherit')
})
})
it('uses [inherit, inherit, pipe] when linux and xvfb is needed', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
xvfb.isNeeded.returns(true)
os.platform.returns('linux')
return spawn.start()
.then(() => {
expect(cp.spawn.firstCall.args[2].stdio).to.deep.eq([
'inherit', 'inherit', 'pipe',
])
})
})
it('uses [inherit, inherit, pipe] on darwin', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
xvfb.isNeeded.returns(false)
os.platform.returns('darwin')
return spawn.start()
.then(() => {
expect(cp.spawn.firstCall.args[2].stdio).to.deep.eq([
'inherit', 'inherit', 'pipe',
])
})
})
it('writes everything on win32', function () {
const buf1 = Buffer.from('asdf')
this.spawnedProcess.stdin.pipe.withArgs(process.stdin)
this.spawnedProcess.stdout.pipe.withArgs(process.stdout)
this.spawnedProcess.stderr.on
.withArgs('data')
.yields(buf1)
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
sinon.stub(process.stderr, 'write').withArgs(buf1)
os.platform.returns('win32')
return spawn.start()
})
// https://github.com/cypress-io/cypress/issues/1841
// https://github.com/cypress-io/cypress/issues/5241
;['EPIPE', 'ENOTCONN'].forEach((errCode) => {
it(`catches process.stdin errors and returns when code=${errCode}`, function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
return spawn.start()
.then(() => {
let called = false
const fn = () => {
called = true
const err = new Error()
err.code = errCode
return process.stdin.emit('error', err)
}
expect(fn).not.to.throw()
expect(called).to.be.true
})
})
})
it('throws process.stdin errors code!=EPIPE', function () {
this.spawnedProcess.on.withArgs('close').yieldsAsync(0)
return spawn.start()
.then(() => {
const fn = () => {
const err = new Error('wattttt')
err.code = 'FAILWHALE'
return process.stdin.emit('error', err)
}
expect(fn).to.throw(/wattttt/)
})
})
})
})