Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
35bd113
strip stderr at main bin spawn
cacieprins Feb 23, 2026
d303782
tests
cacieprins Feb 23, 2026
f8b8f74
Merge branch 'develop' into fix/ct-stderr-tags-in-prod
cacieprins Feb 23, 2026
2bb4206
do not fail if there are no dependencies
cacieprins Feb 23, 2026
0d78521
tests for spawn piping
cacieprins Feb 23, 2026
4dc58a3
rm unused filtering from electron pkg
cacieprins Feb 23, 2026
5c76c6e
changelog
cacieprins Feb 23, 2026
cf2bda7
rm mockFilterWriter from electron open.spec
cacieprins Feb 23, 2026
94db40e
add stderr-filtering back to electron/open for system tests
cacieprins Feb 23, 2026
08ea53b
point system tests that need cli as a dep to built cli instead of roo…
cacieprins Feb 24, 2026
a55bf48
conform spawn/open
cacieprins Feb 24, 2026
1f2761f
make error tags dynamically settable via env var
cacieprins Feb 24, 2026
bd9d88a
make system test dep installer resolve file: by path instead of by mo…
cacieprins Feb 24, 2026
691ec3c
Merge branch 'develop' into fix/ct-stderr-tags-in-prod
cacieprins Feb 25, 2026
887aa03
raise errors again, fix test for LineDecoder
cacieprins Feb 25, 2026
de29c52
reset stderr-filtering, remove env-directed tags
cacieprins Feb 25, 2026
5d5b6ba
use correct path for file: deps; rm dead code; close passthrough stream
cacieprins Feb 25, 2026
d5abb07
determine absolute path for file: deps in system test projects
cacieprins Feb 25, 2026
9cd6695
changelog
cacieprins Feb 25, 2026
a0370a2
changelog
cacieprins Feb 25, 2026
57b17cd
revert pathToPackage
cacieprins Feb 25, 2026
bf70c7c
do not end primary stderr when source stream is ended; only write to …
cacieprins Feb 25, 2026
3918600
Merge branch 'develop' into fix/ct-stderr-tags-in-prod
cacieprins Feb 26, 2026
b491c31
Merge branch 'develop' into fix/ct-stderr-tags-in-prod
cacieprins Feb 27, 2026
0f5aa8f
Merge branch 'develop' into fix/ct-stderr-tags-in-prod
cacieprins Feb 27, 2026
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
8 changes: 8 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
## 15.11.1

_Released 03/10/2026 (PENDING)_

**Bugfixes:**

- Fixed an issue where internal tags on stderr streams were surfacing to the end user CLI during component testing. Addresses [#32769](https://github.com/cypress-io/cypress/issues/32769). Addressed in [#33400](https://github.com/cypress-io/cypress/pull/33400).

## 15.11.0

_Released 02/24/2026_
Expand Down
40 changes: 31 additions & 9 deletions cli/lib/exec/spawn.ts
Comment thread
mschile marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import { throwFormErrorText, getError, errors } from '../errors'
import readline from 'readline'
import { stdin, stdout, stderr } from 'process'
import { relativeToRepoRoot } from '../relative-to-repo-root'
const debug = Debug('cypress:cli')
import { filter, DEBUG_PREFIX } from '@packages/stderr-filtering'
import { PassThrough } from 'stream'

const DBUS_ERROR_PATTERN = /ERROR:dbus\/(bus|object_proxy)\.cc/
const debug = Debug('cypress:cli')
const debugElectron = Debug('cypress:electron')
const debugStderr = Debug('cypress:internal-stderr')

function isPlatform (platform: string): boolean {
return os.platform() === platform
Expand Down Expand Up @@ -184,22 +187,34 @@ function createSpawnFunction (
// to filter out the garbage
if (child.stderr) {
debug('piping child STDERR to process STDERR')

const sourceStream = new PassThrough()

child.on('close', () => {
sourceStream.end()
})

child.stderr.on('data', (data: any) => {
const str = data.toString()

// if we have a callback and this explicitly returns
// false then bail
if (onStderrData && onStderrData(str)) {
return
}

if (str.match(DBUS_ERROR_PATTERN)) {
debug(str)
} else {
// else pass it along!
stderr.write(data)
if (sourceStream.writable) {
sourceStream.write(data)
}
})

if (
(process.env.ELECTRON_ENABLE_LOGGING ?? '') === '1' ||
debugElectron.enabled ||
(process.env.CYPRESS_INTERNAL_ENV ?? '') === 'development'
) {
sourceStream.pipe(stderr, { end: false })
} else {
sourceStream.pipe(filter(stderr, debugStderr, DEBUG_PREFIX))
}
Comment thread
cacieprins marked this conversation as resolved.
Comment thread
cacieprins marked this conversation as resolved.
Comment thread
cacieprins marked this conversation as resolved.
}

// https://github.com/cypress-io/cypress/issues/1841
Expand Down Expand Up @@ -228,6 +243,7 @@ async function spawnInXvfb (spawn: ReturnType<typeof createSpawnFunction>): Prom
try {
await xvfb.start()

debug('xvfb started')
const code = await userFriendlySpawn(spawn)

return code
Expand Down Expand Up @@ -259,6 +275,7 @@ async function userFriendlySpawn (spawn: ReturnType<typeof createSpawnFunction>,
try {
const code: number = await spawn(overrides)

debug('tried spawning without xvfb, code', code, brokenGtkDisplay)
if (code !== 0 && brokenGtkDisplay) {
util.logBrokenGtkDisplayWarning()

Expand All @@ -267,6 +284,7 @@ async function userFriendlySpawn (spawn: ReturnType<typeof createSpawnFunction>,

return code
} catch (error: any) {
debug('error in userFriendlySpawn', error)
// we can format and handle an error message from the code above
// prevent wrapping error again by using "known: undefined" filter
if ((error as any).known === undefined) {
Expand Down Expand Up @@ -313,6 +331,8 @@ export async function start (args: string | string[], options: StartOptions = {}
const spawn = createSpawnFunction(executable, decoratedArgs, { stdio, dev, detached, env })

if (needsXvfb) {
debug('starting xvfb')

return spawnInXvfb(spawn)
}

Expand All @@ -321,5 +341,7 @@ export async function start (args: string | string[], options: StartOptions = {}
// spawning our own Xvfb server
const linuxWithDisplayEnv = util.isPossibleLinuxWithIncorrectDisplay()

debug('linuxWithDisplayEnv', linuxWithDisplayEnv)

return userFriendlySpawn(spawn, linuxWithDisplayEnv)
}
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"dependencies": {
"@cypress/request": "^3.0.10",
"@cypress/xvfb": "^1.2.4",
"@packages/stderr-filtering": "0.0.0-development",
"@types/sinonjs__fake-timers": "8.1.1",
"@types/sizzle": "^2.3.2",
"@types/tmp": "^0.2.3",
Expand Down
5 changes: 5 additions & 0 deletions cli/scripts/prepare-package-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ function preparePackageForNpmRelease (json: any, branchName?: string): any {
commitDate: new Date(getStdout('git show -s --format=%ci')).toISOString(),
stable: false,
},
// @packages/ dependencies are internal, and included in the cli bundle via rollup
...(json.dependencies ? { dependencies: Object.fromEntries(
Object.entries(json.dependencies || {})
.filter(([key]) => !key.startsWith('@packages/')),
) } : {}),
description,
homepage,
license,
Expand Down
125 changes: 97 additions & 28 deletions cli/test/lib/exec/spawn.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import si, { Systeminformation } from 'systeminformation'
import { EventEmitter as EE } from 'events'
import readline from 'readline'
import createDebug from 'debug'
import { PassThrough } from 'stream'
import { stdin, stdout, stderr } from 'process'

import state from '../../../lib/tasks/state'
import xvfb from '../../../lib/exec/xvfb'
import { start } from '../../../lib/exec/spawn'
import { needsSandbox } from '../../../lib/tasks/verify'
import util from '../../../lib/util'
import { filter as stderrFilter } from '@packages/stderr-filtering'

const flushPromises = () => {
return new Promise<void>((resolve) => {
Expand Down Expand Up @@ -125,6 +127,13 @@ vi.mock('tree-kill', () => {
}
})

vi.mock('@packages/stderr-filtering', () => {
return {
filter: vi.fn(),
DEBUG_PREFIX: 'DEBUG_PREFIX',
}
})

vi.mock('../../../lib/exec/xvfb', async (importActual) => {
const actual = await importActual()

Expand Down Expand Up @@ -180,6 +189,7 @@ const defaultBinaryDir = '/default/binary/dir'
describe('lib/exec/spawn', function () {
let spawnedProcess: any
let mockReadlineEE: any
let stderrFilterMock: PassThrough

beforeEach(function () {
vi.resetAllMocks()
Expand All @@ -206,7 +216,11 @@ describe('lib/exec/spawn', function () {
}

spawnedProcess.stderr = {
pipe: vi.fn().mockReturnValue(undefined),
pipe: vi.fn().mockImplementation(function (this: any, dest: any) {
this.on('data', (chunk: any) => dest?.write(chunk))

return undefined
}),
on: vi.fn().mockReturnValue(undefined),
}

Expand All @@ -225,6 +239,19 @@ describe('lib/exec/spawn', function () {
return '/path/to/cypress'
}
})

// Default: pass-through so tests that assert on stderr.write still see data; filtering behavior lives in @packages/stderr-filtering
// Must return a real stream (with .on) so sourceStream.pipe(filter(...)) in spawn.ts does not throw "dest.on is not a function"
vi.mocked(stderrFilter).mockImplementation((dest: NodeJS.WritableStream) => {
stderrFilterMock = new PassThrough()
stderrFilterMock.on('data', (chunk: any) => {
if (dest && typeof dest.write === 'function') dest.write(chunk)
})

vi.spyOn(stderrFilterMock, 'on')

return stderrFilterMock as any
})
})

describe('.start', function () {
Expand Down Expand Up @@ -489,8 +516,8 @@ describe('lib/exec/spawn', function () {

throw new Error('should have hit error handler but did not')
} catch (e) {
debug('error message', e.message)
expect(e.message).toMatch(msg)
debug('error message', (e as Error).message)
expect((e as Error).message).toMatch(msg)
}
})

Expand Down Expand Up @@ -686,66 +713,108 @@ describe('lib/exec/spawn', function () {
])
})

it('pipes child stderr through @packages/stderr-filtering when stderr is piped and not in dev/debug/logging', async () => {
vi.mocked(os.platform).mockReturnValue('darwin')
vi.mocked(xvfb.isNeeded).mockReturnValue(false)
vi.stubEnv('ELECTRON_ENABLE_LOGGING', undefined)
vi.stubEnv('CYPRESS_INTERNAL_ENV', undefined)

let stderrDataCallback: (data: Buffer) => void

spawnedProcess.stderr.on.mockImplementation((event, callback) => {
if (event === 'data') stderrDataCallback = callback
})

// @ts-expect-error - invalid number of arguments for given type
const startPromise = start()

await flushPromises()

expect(stderrFilter).toHaveBeenCalledWith(stderr, expect.any(Function), 'DEBUG_PREFIX')

// Data flows: child.stderr 'data' -> sourceStream -> filter return value -> stderr (async transform may need a tick)
const buf = Buffer.from('stderr via sourceStream')

stderrDataCallback!(buf)
await new Promise((r) => setImmediate(r))
await flushPromises()
expect(stderr.write).toHaveBeenCalledWith(buf)

spawnedProcess.emit('close', 0)
await startPromise
})

it('writes everything on win32', async () => {
vi.mocked(os.platform).mockReturnValue('win32')

const buf1 = Buffer.from('asdf')

// mock display missing
let stderrDataCallback: (data: Buffer) => void

spawnedProcess.stderr.on.mockImplementation((event, callback) => {
if (event === 'data') {
callback(buf1)
}
if (event === 'data') stderrDataCallback = callback
})

// @ts-expect-error - invalid number of arguments for given type
const startPromise = start()

spawnedProcess.emit('close', 0)
await flushPromises()

// Emit stderr data after sourceStream.pipe(filter()) is set up so it flows to stderr.write
stderrDataCallback!(buf1)
await new Promise((r) => setImmediate(r))
await flushPromises()

spawnedProcess.emit('close', 0)
await startPromise

// validates the child process stderr event handler was called
expect(stderr.write).toHaveBeenCalledWith(buf1)
expect(stdin.pipe).toHaveBeenCalledExactlyOnceWith(spawnedProcess.stdin)
expect(spawnedProcess.stdout.pipe).toHaveBeenCalledExactlyOnceWith(stdout)
})

it('filters out dbus errors on linux', async () => {
it('pipes stderr through @packages/stderr-filtering (filter can suppress or forward)', async () => {
vi.mocked(os.platform).mockReturnValue('linux')

const dbusErrors = [
Buffer.from('ERROR:dbus/bus.cc:123: Failed to connect to session bus'),
Buffer.from('[246:0820/083339.099956:ERROR:dbus/object_proxy.cc:590] Failed to call method: org.freedesktop.DBus.NameHasOwner: object_path= /org/freedesktop/DBus: unknown error type:'),
]
const filteredOut = Buffer.from('ERROR:dbus/bus.cc:123: noise')
const passedThrough = Buffer.from('Some other error message')

const normalError = Buffer.from('Some other error message')
const FILTER_PATTERN = /ERROR:dbus\/(bus|object_proxy)\.cc/

// Return a real stream (with .on) so sourceStream.pipe(filter(...)) works; apply same filter logic
vi.mocked(stderrFilter).mockImplementation((dest: NodeJS.WritableStream) => {
const pt = new PassThrough()

pt.on('data', (chunk: Buffer) => {
const str = Buffer.isBuffer(chunk) ? chunk.toString() : chunk

if (!FILTER_PATTERN.test(str)) dest.write(chunk)
})

return pt as any
})

let dataCallback: (data: Buffer) => void

// mock stderr data handler
spawnedProcess.stderr.on.mockImplementation((event, callback) => {
if (event === 'data') {
dataCallback = callback
}
if (event === 'data') dataCallback = callback
})

// @ts-expect-error - invalid number of arguments for given type
const startPromise = start()

// Emit dbus error - should be filtered out (not written to stderr)
dbusErrors.forEach((err) => {
dataCallback!(err)
expect(stderr.write).not.toHaveBeenCalledWith(err)
})
await flushPromises()

// Emit normal error - should be written to stderr
dataCallback!(normalError)
dataCallback!(filteredOut)
await flushPromises()
expect(stderr.write).not.toHaveBeenCalledWith('ERROR:dbus/bus.cc:123: noise')

expect(stderr.write).toHaveBeenCalledWith(normalError)
dataCallback!(passedThrough)
await flushPromises()
// sourceStream passes data through; filter dest.write receives Buffer
expect(stderr.write).toHaveBeenCalledWith(passedThrough)

spawnedProcess.emit('close', 0)

await startPromise
})

Expand Down
26 changes: 16 additions & 10 deletions system-tests/lib/dep-installer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,23 +119,29 @@ async function normalizeLockFileRelativePaths (opts: { project: string, projectD
* the Internet to obtain these packages once it runs in the temp dir.
* @returns a list of dependency names that were updated
*/
async function makeWorkspacePackagesAbsolute (pathToPkgJson: string): Promise<string[]> {
async function makeWorkspacePackagesAbsolute (pathToPkgJson: string, projectDir: string): Promise<string[]> {
const pkgJson = await fs.readJson(pathToPkgJson)

const updatedDeps: string[] = []

for (const deps of [pkgJson.dependencies, pkgJson.devDependencies, pkgJson.optionalDependencies]) {
for (const dep in deps) {
const version = deps[dep]
try {
for (const deps of [pkgJson.dependencies, pkgJson.devDependencies, pkgJson.optionalDependencies]) {
for (const dep in deps) {
const version = deps[dep]

if (version.startsWith('file:')) {
const absPath = pathToPackage(dep)
if (version.startsWith('file:')) {
const absPath = path.resolve(__dirname, '../../projects', projectDir, version.replace('file:', ''))

log(`Setting absolute path in package.json for ${dep}: ${absPath}.`)
log(`Setting absolute path in package.json for ${dep} @ ${version}: ${absPath}.`)

deps[dep] = `file:${absPath}`
updatedDeps.push(dep)
deps[dep] = `file:${absPath}`
updatedDeps.push(dep)
}
Comment thread
cacieprins marked this conversation as resolved.
}
}
} catch (err) {
log(`Error making workspace packages absolute: ${err}`)
throw err
}

await fs.writeJson(pathToPkgJson, pkgJson)
Expand Down Expand Up @@ -213,7 +219,7 @@ export async function scaffoldProjectNodeModules ({

// 2. Before running the package installer, resolve workspace deps to absolute paths.
// This is required to fix install for workspace-only packages.
const workspaceDeps = await makeWorkspacePackagesAbsolute(projectPkgJsonPath)
const workspaceDeps = await makeWorkspacePackagesAbsolute(projectPkgJsonPath, project)

// 3. Delete cached workspace packages since the pkg manager will create a fresh symlink during install.
await removeWorkspacePackages(workspaceDeps)
Expand Down
Loading
Loading