Merge branch 'develop' into build-binary-debug-skill

This commit is contained in:
Cacie Prins
2026-05-18 15:32:55 -04:00
106 changed files with 3861 additions and 2859 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
# Bump this version to force CI to re-create the cache from scratch.
03-20-2026
05-15-2026
+10 -10
View File
@@ -273,7 +273,7 @@ commands:
[ -d "$p" ] || continue
(cd "$(dirname "$p")" && npx patch-package --reverse) || true
done
yarn install --check-files
yarn install --check-files --frozen-lockfile
for p in patches cli/patches packages/*/patches; do
[ -d "$p" ] || continue
(cd "$(dirname "$p")" && npx patch-package) || true
@@ -2493,7 +2493,7 @@ jobs:
- restore_cached_workspace
- run:
name: Build
command: yarn lerna run build --scope=@cypress/webpack-preprocessor
command: node scripts/lerna-build.js --scope=@cypress/webpack-preprocessor
- run:
name: Run tests
command: yarn workspace @cypress/webpack-preprocessor test
@@ -2544,7 +2544,7 @@ jobs:
- restore_cached_workspace
- run:
name: Build
command: yarn lerna run build --scope=@cypress/vue
command: node scripts/lerna-build.js --scope=@cypress/vue
- run:
name: Run tests
command: yarn test
@@ -2560,7 +2560,7 @@ jobs:
- restore_cached_workspace
- run:
name: Build
command: yarn lerna run build --scope=@cypress/angular
command: node scripts/lerna-build.js --scope=@cypress/angular
npm-angular-zoneless:
<<: *defaults
@@ -2570,7 +2570,7 @@ jobs:
- restore_cached_workspace
- run:
name: Build
command: yarn lerna run build --scope @cypress/angular-zoneless
command: node scripts/lerna-build.js --scope @cypress/angular-zoneless
npm-puppeteer-unit-tests:
<<: *defaults
@@ -2580,7 +2580,7 @@ jobs:
- restore_cached_workspace
- run:
name: Build
command: yarn lerna run build --scope=@cypress/puppeteer
command: node scripts/lerna-build.js --scope=@cypress/puppeteer
- run:
name: Run tests
command: yarn test
@@ -2611,7 +2611,7 @@ jobs:
- restore_cached_workspace
- run:
name: Build
command: yarn lerna run build --scope=@cypress/react
command: node scripts/lerna-build.js --scope=@cypress/react
- run:
name: Run tests
command: yarn test
@@ -2627,7 +2627,7 @@ jobs:
- restore_cached_workspace
- run:
name: Build
command: yarn lerna run build --scope=@cypress/vite-plugin-cypress-esm
command: node scripts/lerna-build.js --scope=@cypress/vite-plugin-cypress-esm
- run:
name: Run tests
command: yarn test
@@ -2643,7 +2643,7 @@ jobs:
- restore_cached_workspace
- run:
name: Build
command: yarn lerna run build --scope=@cypress/mount-utils
command: node scripts/lerna-build.js --scope=@cypress/mount-utils
npm-grep:
<<: *defaults
@@ -2701,7 +2701,7 @@ jobs:
- run:
name: Build + Install
command: |
yarn lerna run build --scope=@cypress/schematic
node scripts/lerna-build.js --scope=@cypress/schematic
- run:
name: Run unit tests
command: |
+9
View File
@@ -1,4 +1,13 @@
<!-- See ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
## 15.15.1
**Bugfixes:**
- Fixed an issue where Cypress would abort the process and show a crash dialog when it received a SIGINT. Fixes [#29228](https://github.com/cypress-io/cypress/issues/29228). Fixed in [#33542](https://github.com/cypress-io/cypress/pull/33542/).
- Fixed an issue where the [`clientCertificates`](https://docs.cypress.io/guides/references/client-certificates) config option failed to load ECDSA (EC) PEM or PKCS#12 client certificates. Fixes [#33767](https://github.com/cypress-io/cypress/issues/33767). Fixed in [#33799](https://github.com/cypress-io/cypress/pull/33799).
- Fixed an issue where clicking "back to projects" or switching projects while a project's initial config load was still in flight could fail. Fixed in [#33810](https://github.com/cypress-io/cypress/pull/33810).
- Fixed an intermittent `ENOENT: no such file or directory, open <path>/bundle.tar-<rand>` error during `cy.prompt` and Studio bundle initialization. Fixed in [#33748](https://github.com/cypress-io/cypress/pull/33748).
## 15.15.0
**Deprecations:**
+25 -9
View File
@@ -9,7 +9,7 @@ import xvfb from './xvfb'
import { needsSandbox } from '../tasks/verify'
import { throwFormErrorText, getErrorSync, errors } from '../errors'
import readline from 'readline'
import { stdin, stdout, stderr } from 'process'
import process, { stdin, stdout, stderr } from 'process'
import { relativeToRepoRoot } from '../relative-to-repo-root'
import { filter, DEBUG_PREFIX } from '@packages/stderr-filtering'
import { PassThrough } from 'stream'
@@ -141,18 +141,17 @@ function createSpawnFunction (
return function (code: any, signal: NodeJS.Signals): void {
debug('child event fired %o', { event, code, signal })
if (code === null) {
const errorObject = errors.childProcessKilled(event, signal)
errorObject.platform = platform
const err = getErrorSync(errorObject, platform)
reject(err)
if (signal) {
if (signal === 'SIGINT') {
resolve(0)
} else {
resolve(128 + os.constants.signals[signal])
}
return
}
resolve(code)
resolve(code ?? 1)
}
}
@@ -177,6 +176,22 @@ function createSpawnFunction (
kill(child.pid as number, 'SIGINT')
})
} else {
// Adding listeners here prevents immediate process.exit() for these signals.
// Exiting when the child process exits instead will allow the child process
// to log during the exit process.
// Unlike in windows, we do not need to propagate these signals to the child process
// tree.
for (const signal of ['SIGINT', 'SIGTERM']) {
debug('adding message for signal listener for %s', signal)
process.once(signal, async function () {
console.log(`\n\n${signal} received; Attempting to exit gracefully. Force exit with ^C again if needed.\n\n`)
if (process.stdin.isTTY) {
process.stdin.setRawMode(false)
}
})
}
}
// if stdio options is set to 'pipe', then
@@ -236,6 +251,7 @@ function createSpawnFunction (
// to have any effect. so we're just catching the
// error here and not doing anything.
stdin.on('error', (err: any) => {
debug('error on stdin', err)
if (['EPIPE', 'ENOTCONN'].includes(err.code)) {
return
}
+5
View File
@@ -13,6 +13,9 @@ import getFolderSize from './get-folder-size'
dayjs.extend(relativeTime)
// Subdirs under the cache root that are not binary version dirs.
const EXTERNAL_CACHE_ENTRIES = new Set(['bundles'])
// output colors for the table
const colors = {
titles: chalk.white,
@@ -41,6 +44,8 @@ const prune = async (): Promise<void> => {
const versions = await fs.readdir(cacheDir)
for (const version of versions) {
if (EXTERNAL_CACHE_ENTRIES.has(version)) continue
if (version !== checkedInBinaryVersion) {
deletedBinary = true
+6 -2
View File
@@ -75,8 +75,12 @@ const isInstallingFromPostinstallHook = (): boolean => {
const getCacheDir = (): string => {
let cache_directory = util.getCacheDir()
if (util.getEnv('CYPRESS_CACHE_FOLDER')) {
const envVarCacheDir = untildify(util.getEnv('CYPRESS_CACHE_FOLDER') as string)
// Pass trim=true so we strip surrounding double quotes and whitespace.
// Windows CMD's `set CYPRESS_CACHE_FOLDER="C:\path"` embeds literal quotes
// into the env value; without dequoting, the resolved cache directory ends
// up with quote chars in its name (see cypress-io/cypress#4506).
if (util.getEnv('CYPRESS_CACHE_FOLDER', true)) {
const envVarCacheDir = untildify(util.getEnv('CYPRESS_CACHE_FOLDER', true) as string)
debug('using environment variable CYPRESS_CACHE_FOLDER %s', envVarCacheDir)
@@ -1,24 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`lib/exec/spawn > .start > detects kill signal > exits with error on SIGKILL 1`] = `
"The Test Runner unexpectedly exited via a exit event with signal SIGKILL
Please search Cypress documentation for possible solutions:
https://on.cypress.io
Check if there is a GitHub issue describing this crash:
https://github.com/cypress-io/cypress/issues
Consider opening a new issue.
----------
Platform: darwin-x64 (Foo - OsVersion)
Cypress Version: 0.0.0-development"
`;
exports[`lib/exec/spawn > .start > does not force colors and streams when not supported 1`] = `
{
"DEBUG_COLORS": "0",
+29 -8
View File
@@ -9,7 +9,7 @@ import { EventEmitter } from 'events'
import readline from 'readline'
import createDebug from 'debug'
import { PassThrough } from 'stream'
import { stdin, stdout, stderr } from 'process'
import process, { stdin, stdout, stderr } from 'process'
import state from '../../../lib/tasks/state'
import xvfb from '../../../lib/exec/xvfb'
@@ -72,6 +72,7 @@ vi.mock('process', async (importActual) => {
on: vi.fn(),
emit: vi.fn(),
pipe: vi.fn(),
setRawMode: vi.fn(),
},
stdout: vi.fn(),
stderr: {
@@ -88,6 +89,7 @@ vi.mock('process', async (importActual) => {
on: vi.fn(),
emit: vi.fn(),
pipe: vi.fn(),
setRawMode: vi.fn(),
},
stdout: vi.fn(),
stderr: {
@@ -95,6 +97,7 @@ vi.mock('process', async (importActual) => {
...actual.default.stderr,
write: vi.fn(),
},
once: vi.fn(),
},
}
})
@@ -428,20 +431,38 @@ describe('lib/exec/spawn', function () {
describe('detects kill signal', async () => {
it('exits with error on SIGKILL', async () => {
try {
const startPromise = start('--foo')
await vi.waitFor(() => expect(spawnedProcess.on).toHaveBeenCalledWith('exit', expect.any(Function)))
spawnedProcess.emit('exit', null, 'SIGKILL')
await expect(startPromise).resolves.toEqual(137)
})
})
describe('on signal exits', () => {
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
it(`disables raw mode on ${signal}`, async () => {
vi.mocked(process.stdin).isTTY = true
const startPromise = start('--foo')
await vi.waitFor(() => expect(spawnedProcess.on).toHaveBeenCalledWith('close', expect.any(Function)))
await vi.waitFor(() => {
expect(process.once).toHaveBeenCalledWith(signal, expect.any(Function))
})
const handler = vi.mocked(process.once).mock.calls.find((c) => c[0] === signal)?.[1] as () => void
expect(handler).toBeDefined()
await handler()
spawnedProcess.emit('exit', null, signal)
await startPromise
throw new Error('should have hit error handler but did not')
} catch (e) {
expect(e.message).toMatch(/SIGKILL/)
expect(e.message).toMatchSnapshot()
}
})
expect(process.stdin.setRawMode).toHaveBeenCalledWith(false)
})
}
})
it('does not start xvfb when its not needed', async () => {
+91 -1
View File
@@ -51,7 +51,7 @@ vi.mock('../../../lib/util', async (importActual) => {
describe('lib/tasks/cache', () => {
const createStdoutCapture = () => {
const logs: string[] = []
// eslint-disable-next-line no-console
const originalOut = process.stdout.write
vi.spyOn(process.stdout, 'write').mockImplementation((strOrBugger: string | Uint8Array<ArrayBufferLike>) => {
@@ -150,6 +150,34 @@ describe('lib/tasks/cache', () => {
expect(exists).toEqual(false)
expect(output()).toMatchSnapshot()
})
it('removes the bundles/ subdir alongside binary version dirs', async function () {
mockfs.restore()
mockfs({
'/.cache/Cypress': {
'1.2.3': { 'Cypress': { 'file1': 'binary' } },
'bundles': {
'cy-prompt': {
'abc123': {
'manifest.json': '{}',
'server': { 'index.js': '// ...' },
},
},
'studio': {
'def456': {
'manifest.json': '{}',
},
},
},
},
})
vi.mocked(state.getCacheDir).mockReturnValue('/.cache/Cypress')
await cache.clear()
expect(await fs.pathExists('/.cache/Cypress')).toEqual(false)
})
})
describe('.prune', () => {
@@ -201,6 +229,68 @@ describe('lib/tasks/cache', () => {
expect(output()).toMatchSnapshot()
})
it('preserves the bundles/ subdir while pruning old binary versions', async function () {
mockfs.restore()
mockfs({
'/.cache/Cypress': {
'1.2.3': { 'Cypress': { 'file1': 'current' } },
'2.3.4': { 'Cypress.app': {} },
'bundles': {
'cy-prompt': {
'abc123': {
'manifest.json': '{}',
'server': { 'index.js': '// hi' },
},
},
'studio': {
'def456': {
'manifest.json': '{}',
},
},
},
},
})
vi.mocked(state.getCacheDir).mockReturnValue('/.cache/Cypress')
vi.mocked(util.pkgVersion).mockReturnValue('1.2.3')
await cache.prune()
// Old binary version is removed, current one and bundles/ survive
expect(await fs.pathExists('/.cache/Cypress/2.3.4')).toEqual(false)
expect(await fs.pathExists('/.cache/Cypress/1.2.3')).toEqual(true)
expect(await fs.pathExists('/.cache/Cypress/bundles/cy-prompt/abc123/manifest.json')).toEqual(true)
expect(await fs.pathExists('/.cache/Cypress/bundles/studio/def456/manifest.json')).toEqual(true)
})
it('prunes beta/prerelease binary dirs produced by getVersionDir()', async function () {
// state.getVersionDir() formats non-stable builds as
// `beta-<version>-<branch>-<sha>`, which is not a valid semver. These
// must still be pruned alongside other non-current binary versions.
mockfs.restore()
mockfs({
'/.cache/Cypress': {
'1.2.3': { 'Cypress': { 'file1': 'current' } },
'beta-15.0.0-feat-abc12345': { 'Cypress.app': {} },
'beta-14.5.0-fix-deadbeef': { 'Cypress.app': {} },
'bundles': {
'cy-prompt': { 'abc123': { 'manifest.json': '{}' } },
},
},
})
vi.mocked(state.getCacheDir).mockReturnValue('/.cache/Cypress')
vi.mocked(util.pkgVersion).mockReturnValue('1.2.3')
await cache.prune()
// Beta dirs are pruned; current binary and bundles/ survive
expect(await fs.pathExists('/.cache/Cypress/beta-15.0.0-feat-abc12345')).toEqual(false)
expect(await fs.pathExists('/.cache/Cypress/beta-14.5.0-fix-deadbeef')).toEqual(false)
expect(await fs.pathExists('/.cache/Cypress/1.2.3')).toEqual(true)
expect(await fs.pathExists('/.cache/Cypress/bundles/cy-prompt/abc123/manifest.json')).toEqual(true)
})
})
describe('.list', () => {
+21
View File
@@ -385,6 +385,27 @@ describe('lib/tasks/state', function () {
expect(ret).toEqual(path.resolve('/cache/folder/Cypress'))
})
it('strips surrounding double quotes from CYPRESS_CACHE_FOLDER (Windows CMD)', () => {
vi.stubEnv('CYPRESS_CACHE_FOLDER', '"/path/to/dir"')
const ret = state.getCacheDir()
expect(ret).toEqual('/path/to/dir')
})
it('trims surrounding whitespace on CYPRESS_CACHE_FOLDER', () => {
vi.stubEnv('CYPRESS_CACHE_FOLDER', ' /path/to/dir ')
const ret = state.getCacheDir()
expect(ret).toEqual('/path/to/dir')
})
it('treats whitespace-only CYPRESS_CACHE_FOLDER as unset and falls back to cachedir()', () => {
vi.stubEnv('CYPRESS_CACHE_FOLDER', ' ')
const ret = state.getCacheDir()
expect(ret).toEqual(cacheDir)
})
it('resolves ~ with user home folder', () => {
const homeDir = os.homedir()
+1 -3
View File
@@ -99,9 +99,7 @@ describe('App: Settings', () => {
cy.findByTestId('spec-list-container').scrollTo('bottom')
// Visit the test to trigger the ws.off() for the TR websockets
cy.contains('test1.js').click()
cy.waitForSpecToFinish()
// Wait for the test to pass, so the test is completed
cy.get('.passed > .num').should('contain', 1)
cy.waitForSpecToFinish({ passCount: 1 })
cy.get(SidebarSettingsLinkSelector).click()
cy.contains('Cypress Cloud settings').click()
// Assert the data is not there before it arrives
@@ -5,7 +5,7 @@ describe('errorWarningChange subscription', () => {
})
function assertLoadingIntoErrorWorks (errorName: string) {
cy.contains('h2', errorName).should('be.visible')
cy.contains('h2', errorName, { timeout: 15000 }).should('be.visible')
cy.contains('[role="alert"]', 'Loading').should('not.exist')
}
@@ -181,7 +181,7 @@ e2e: {
}`)
})
cy.get('[data-cy="spec-item-link"]', { timeout: 7500 })
cy.get('[data-cy="spec-item-link"]', { timeout: 15000 })
.should('have.length', 3)
.should('contain', 'dom-container.spec.js')
.should('contain', 'dom-content.spec.js')
@@ -329,7 +329,7 @@ e2e: {
}`)
})
cy.get('[data-cy="spec-file-item"]', { timeout: 7500 })
cy.get('[data-cy="spec-file-item"]', { timeout: 15000 })
.should('have.length', 3)
.should('contain', 'dom-container.spec.js')
.should('contain', 'dom-content.spec.js')
@@ -453,7 +453,7 @@ e2e: {
}`)
})
cy.get('[data-cy="file-match-indicator"]', { timeout: 7500 })
cy.get('[data-cy="file-match-indicator"]', { timeout: 15000 })
.should('contain', '3 matches')
// Regression for https://github.com/cypress-io/cypress/issues/27103
@@ -487,7 +487,7 @@ module.exports = {
}`)
})
cy.get('[data-cy="create-spec-page-title"]', { timeout: 10000 })
cy.get('[data-cy="create-spec-page-title"]', { timeout: 15000 })
.should('contain', defaultMessages.createSpec.page.customPatternNoSpecs.title)
})
})
@@ -23,10 +23,13 @@ const IS_DEVELOPMENT = process.env.CYPRESS_INTERNAL_ENV !== 'production'
let gqlSocketServer: SocketIONamespace
let gqlServer: Server
/** Keeps graphql-ws teardown in sync when `reinitializeCypress` replaces ctx but reuses the same HTTP server */
let gqlGraphqlWsDispose: (() => Promise<void>) | undefined
globalPubSub.on('reset:data-context', (ctx) => {
ctx.actions.servers.setGqlServer(gqlServer)
ctx.actions.servers.setGqlSocketServer(gqlSocketServer)
ctx.actions.servers.setGqlGraphqlWsDispose(gqlGraphqlWsDispose)
})
export async function makeGraphQLServer () {
@@ -120,7 +123,10 @@ export async function makeGraphQLServer () {
gqlSocketServer = socketSrv.of('/data-context')
graphqlWS(srv, '/__launchpad/graphql-ws')
const gqlWs = graphqlWS(srv, '/__launchpad/graphql-ws')
gqlGraphqlWsDispose = gqlWs.dispose
ctx.actions.servers.setGqlGraphqlWsDispose(gqlWs.dispose)
gqlSocketServer.on('connection', (socket) => {
socket.on('graphql:request', handleGraphQLSocketRequest)
@@ -180,9 +186,14 @@ export async function handleGraphQLSocketRequest (uid: string, payload: string,
*
* @param httpServer The http server we are utilizing for the websocket
* @param targetRoute Route to target in the server upgrade event
* @returns Disposable Function to cleanup the created server resource
* @returns WebSocket server and graphql-ws dispose — call `dispose()` before destroying the HTTP server.
*/
export const graphqlWS = (httpServer: Server, targetRoute: string) => {
export interface GraphqlWsHandle {
server: WebSocketServer
dispose: () => Promise<void>
}
export const graphqlWS = (httpServer: Server, targetRoute: string): GraphqlWsHandle => {
const graphqlWs = new WebSocketServer({ noServer: true })
httpServer.on('upgrade', (req: Request, socket: Socket, head) => {
@@ -193,12 +204,15 @@ export const graphqlWS = (httpServer: Server, targetRoute: string) => {
}
})
useServer({
const { dispose } = useServer({
schema: graphqlSchema,
context: () => getCtx(),
}, graphqlWs)
return graphqlWs
return {
server: graphqlWs,
dispose: () => Promise.resolve(dispose()),
}
}
/**
+3
View File
@@ -30,6 +30,9 @@ export const graphqlSchema = makeSchema({
schema: remoteSchemaWrapped,
skipFields: {
Mutation: ['test'],
// Override so we can inject projectSlug from local context — needed for
// per-project feature-flag scoping. Local declaration in gql-Query.ts.
Query: ['cloudAppMessages'],
},
},
plugins: [
@@ -129,6 +129,24 @@ export const Query = objectType({
description: 'Unique node machine identifier for this instance - may be nil if unable to resolve',
resolve: async (source, args, ctx) => await ctx.coreData.machineId,
})
t.list.nonNull.field('cloudAppMessages', {
type: 'CloudAppMessage',
description: 'Cloud-driven in-app banner content. Local override of the merged cloud field so we can inject the current project slug for per-project feature-flag scoping.',
resolve: async (root, args, ctx, info) => {
const projectId = await ctx.project.projectId()
// Omit projectSlug entirely in global mode — passing null is not the
// same as omitting in GraphQL, and the remote field's doc contract
// says "When omitted, only globally-targeted messages are returned."
return ctx.cloud.delegateCloudField({
field: 'cloudAppMessages',
args: projectId ? { projectSlug: projectId } : {},
ctx,
info,
})
},
})
},
sourceType: {
module: path.join(__dirname, '../../'),
+9 -9
View File
@@ -37,15 +37,6 @@ type CloudAppMessageAnalytics {
utm: CloudAppMessageUtm
}
"""
Optional UTM params for CTA destination URLs. CTA-level overrides message-level. utm_source/medium/campaign are auto-injected by the binary.
"""
type CloudAppMessageUtm {
content: String
id: String
term: String
}
"""
Call-to-action button rendered alongside an in-app cloud message banner
"""
@@ -75,6 +66,15 @@ enum CloudAppMessageDismissalScope {
user
}
"""
Optional UTM params for CTA destination URLs. CTA-level overrides message-level. utm_source/medium/campaign are auto-injected by the binary.
"""
type CloudAppMessageUtm {
content: String
id: String
term: String
}
enum CloudAppMessageVisualStyle {
info
warning
+2 -7
View File
@@ -1865,14 +1865,9 @@ type Query {
cachedUser: CachedUser
"""
Cloud-driven in-app messages eligible to be rendered as banners in the binary. Pre-filtered server-side by channel feature flag, version range, and cohort allow-lists. Public accessible without authentication so logged-out users can still receive awareness messages. The binary may further filter on local state (run mode, env-var opt-out) before rendering.
Cloud-driven in-app banner content. Local override of the merged cloud field so we can inject the current project slug for per-project feature-flag scoping.
"""
cloudAppMessages(
"""
Optional project slug used to scope cohort and channel-flag lookups. When omitted, only globally-targeted messages are returned.
"""
projectSlug: String
): [CloudAppMessage!]
cloudAppMessages: [CloudAppMessage!]
"""
Polling query to determine when to refetch spec data. A null value here means no data available in the cloud.
+4 -4
View File
@@ -332,10 +332,10 @@ export class DataContext {
}
async destroy () {
return Promise.all([
this.actions.servers.destroyGqlServer(),
this._reset(),
])
// Close graphql-ws clients and the GQL HTTP server before lifecycle teardown so
// in-flight GraphQL/subscription work does not write to sockets mid-destroy (ERR_STREAM_DESTROYED).
await this.actions.servers.destroyGqlServer()
await this._reset()
}
/**
@@ -41,8 +41,25 @@ export class ServersActions {
})
}
setGqlGraphqlWsDispose (dispose: (() => Promise<void>) | undefined) {
this.ctx.update((d) => {
d.servers.gqlGraphqlWsDispose = dispose
})
}
async destroyGqlServer () {
const destroy = this.ctx.coreData.servers.gqlServer?.destroy
const { gqlGraphqlWsDispose, gqlSocketServer, gqlServer } = this.ctx.coreData.servers
if (gqlGraphqlWsDispose) {
await gqlGraphqlWsDispose().catch(this.ctx.logTraceError)
this.ctx.update((d) => {
d.servers.gqlGraphqlWsDispose = undefined
})
}
gqlSocketServer?.disconnectSockets(true)
const destroy = gqlServer?.destroy
if (!destroy) {
return
@@ -70,15 +70,17 @@ export class ProjectConfigIpc extends EventEmitter {
) {
super()
this._childProcess = this.forkConfigProcess()
this._childProcess.on('error', (err) => {
// this.emit('error', err)
this._childProcess.on('error', (err: any) => {
debug('child process error: %s', err)
})
this._childProcess.on('message', (msg: { event: string, args: any[] }) => {
debug('received %s message from child process %s with args %o', msg.event, this._childProcess.pid, msg.args)
this.emit(msg.event, ...msg.args)
})
this._childProcess.once('disconnect', () => {
debug('received disconnect event from child process %s', this._childProcess.pid)
this.emit('disconnect')
})
@@ -104,9 +106,13 @@ export class ProjectConfigIpc extends EventEmitter {
send(event: 'main:process:will:disconnect'): void
send (event: string, ...args: any[]) {
if (this._childProcess.killed || !this._childProcess.connected) {
debug('not sending %s message to child process. Killed? %s, Connected? %s', event, this._childProcess.killed, this._childProcess.connected)
return false
}
debug('sending %s message to child process %s with args %o', event, this._childProcess.pid, args)
return this._childProcess.send({ event, args })
}
@@ -114,6 +120,8 @@ export class ProjectConfigIpc extends EventEmitter {
on(evt: 'export:telemetry', listener: (data: string) => void): void
on(evt: 'main:process:will:disconnect:ack', listener: () => void): void
on(evt: 'warning', listener: (warningErr: CypressError) => void): this
on(evt: 'disconnect', listener: () => void): this
on(evt: 'exit', listener: (code: number, signal: string) => void): this
on (evt: string, listener: (...args: any[]) => void) {
return super.on(evt, listener)
}
@@ -168,6 +176,12 @@ export class ProjectConfigIpc extends EventEmitter {
reject(err)
})
this._childProcess.on('exit', (code, signal) => {
debug('child process %s exited with code %s and signal %s', this._childProcess.pid, code, signal)
this.emit('exit', code, signal)
this.cleanupIpc()
})
/**
* This reject cannot be caught anywhere??
*
@@ -399,14 +413,23 @@ export class ProjectConfigIpc extends EventEmitter {
}
cleanupIpc () {
debug('cleaning up IPC')
this.killChildProcess()
this.removeAllListeners()
}
private killChildProcess () {
this._childProcess.kill()
private killChildProcess (): void {
this._childProcess.stdout?.removeAllListeners()
this._childProcess.stderr?.removeAllListeners()
this._childProcess.removeAllListeners()
if (this._childProcess.killed || !this._childProcess.connected) {
debug('child process %s already killed', this._childProcess.pid)
return
}
debug('killing child process %s', this._childProcess.pid)
this._childProcess.kill()
}
}
@@ -640,15 +640,29 @@ export class ProjectConfigManager {
return
}
debug('sending main:process:will:disconnect message')
this._eventsIpc.send('main:process:will:disconnect')
// If for whatever reason we don't get an ack in 5s, bail.
const timeoutId = setTimeout(() => {
debug(`mainProcessWillDisconnect message timed out`)
reject()
reject(new Error('mainProcessWillDisconnect message timed out'))
}, 5000)
this._eventsIpc.on('main:process:will:disconnect:ack', () => {
debug('Received main:process:will:disconnect:ack')
clearTimeout(timeoutId)
resolve()
})
this._eventsIpc.on('exit', (code, signal) => {
debug('child process %s exited with code %s and signal %s', this._eventsIpc?.childProcessPid, code, signal)
clearTimeout(timeoutId)
resolve()
})
this._eventsIpc.on('disconnect', () => {
debug('received disconnect event from child process %s', this._eventsIpc?.childProcessPid)
clearTimeout(timeoutId)
resolve()
})
@@ -199,6 +199,12 @@ export class ProjectLifecycleManager {
this.ctx.emitter.toApp()
},
onFinalConfigLoaded: async (finalConfig: FullConfig, options: OnFinalConfigLoadedOptions) => {
// if we no longer have a project, just return
// can happen when the user clears the project while setupNodeEvents is in flight
if (!this._projectRoot) {
return
}
if (this._currentTestingType && finalConfig.specPattern) {
await this.ctx.actions.project.setSpecsFoundBySpecPattern({
projectRoot: this.projectRoot,
@@ -288,6 +294,12 @@ export class ProjectLifecycleManager {
* 4. The first browser found.
*/
async setInitialActiveBrowser () {
// if we no longer have a project, just return
// can happen when the user clears the project while we are setting up
if (!this._projectRoot) {
return
}
const configDefaultBrowser = this.loadedFullConfig?.defaultBrowser
// if we have a default browser from the config and a CLI browser wasn't passed and the active browser hasn't been set
@@ -31,6 +31,11 @@ interface ServersDataShape {
gqlServer?: Maybe<Server>
gqlServerPort?: Maybe<number>
gqlSocketServer?: Maybe<SocketIONamespace>
/**
* graphql-ws `useServer` dispose must run before `gqlServer.destroy()` so
* protocol clients close before the HTTP server tears down sockets.
*/
gqlGraphqlWsDispose?: Maybe<() => Promise<void>>
}
export interface DevStateShape {
+9
View File
@@ -1,5 +1,9 @@
import Debug from 'debug'
import type { DataContext } from './DataContext'
const debug = Debug('cypress:data-context')
export { DocumentNodeBuilder } from './util/DocumentNodeBuilder'
export {
@@ -23,9 +27,14 @@ let ctx: DataContext | null = null
export async function clearCtx () {
if (ctx) {
debug('signalling mainProcessWillDisconnect')
await ctx.lifecycleManager.mainProcessWillDisconnect()
debug('destroying data-context')
await ctx.destroy()
debug('data-context destroyed')
ctx = null
} else {
debug('no data-context to clear')
}
}
@@ -65,6 +65,9 @@ describe('ProjectConfigIpc', () => {
await projectConfigIpc.loadConfig()
await projectConfigIpc.registerSetupIpcHandlers()
// Constructor forwards child `error` to `this.emit('error')`; Node throws if nothing listens.
projectConfigIpc.on('error', () => {})
projectConfigIpc._childProcess.emit('error', err)
expect(globalThis.debugMessages.at(-2)).toEqual('EPIPE error in loadConfig() of child process %s')
@@ -209,6 +209,44 @@ describe('ProjectLifecycleManager', () => {
expect(ctx.coreData.cliBrowser).toEqual('chrome:beta')
expect(ctx.coreData.activeBrowser).toEqual(browsers[2])
})
it('returns early without throwing if the project was cleared mid-setup', async () => {
// Simulates the user clearing the project (e.g. clicking "back to projects"
// or switching projects) while setupNodeEvents is still in flight.
// @ts-expect-error - private field
ctx.lifecycleManager._projectRoot = undefined
ctx.coreData.activeBrowser = null
ctx.coreData.cliBrowser = null
await expect(ctx.lifecycleManager.setInitialActiveBrowser()).resolves.toBeUndefined()
expect(ctx.coreData.activeBrowser).toBeNull()
})
})
describe('onFinalConfigLoaded', () => {
it('returns early without throwing if the project was cleared mid-setup', async () => {
// Simulates the user clearing the project (e.g. clicking "back to projects"
// or switching projects) while setupNodeEvents is still in flight.
// Grab the onFinalConfigLoaded callback the way the ProjectConfigManager
// would when it finishes resolving setupNodeEvents.
// @ts-expect-error - private method
const configManager = ctx.lifecycleManager.createConfigManager()
const onFinalConfigLoaded = (configManager as any).options.onFinalConfigLoaded as (config: FullConfig, options: { shouldRestartBrowser: boolean }) => Promise<void>
const setSpecsSpy = jest.spyOn(ctx.actions.project, 'setSpecsFoundBySpecPattern').mockResolvedValue(undefined)
const setInitialActiveBrowserSpy = jest.spyOn(ctx.lifecycleManager, 'setInitialActiveBrowser').mockResolvedValue(undefined)
// @ts-expect-error - private field
ctx.lifecycleManager._projectRoot = undefined
await expect(onFinalConfigLoaded(fullConfig, { shouldRestartBrowser: false })).resolves.toBeUndefined()
expect(setSpecsSpy).not.toHaveBeenCalled()
expect(setInitialActiveBrowserSpy).not.toHaveBeenCalled()
// @ts-expect-error - private field
expect(ctx.lifecycleManager._cachedFullConfig).toBeUndefined()
})
})
describe('#eventProcessPid', () => {
+26 -6
View File
@@ -7,6 +7,7 @@ import inspector from 'inspector'
import { ChildProcess, spawn } from 'child_process'
import Debug from 'debug'
import os from 'os'
import pDefer from 'p-defer'
function getInspectFromUrl (url: string): string {
const flag = process.execArgv.some((f) => f === '--inspect' || f.startsWith('--inspect=')) ? '--inspect' : '--inspect-brk'
@@ -29,7 +30,10 @@ function getInspectFromOpts (argv: string[]): string | undefined {
return undefined
}
export async function open (appPath: string, argv: string[]): Promise<ChildProcess> {
export async function open (
appPath: string,
argv: string[],
): Promise<ChildProcess> {
const debugElectron = Debug('cypress:electron')
const debugStderr = Debug('cypress:internal-stderr')
@@ -76,23 +80,39 @@ export async function open (appPath: string, argv: string[]): Promise<ChildProce
{ stdio: 'pipe' },
)
const childClosed = pDefer<number>()
spawned.on('error', (err) => {
console.error(err)
process.exit(1)
childClosed.resolve(1)
})
spawned.on('close', (code, signal) => {
debugElectron('electron closing %o', { code, signal })
if (signal) {
debugElectron('electron exited with a signal, forcing code = 1 %o', { signal })
code = 1
debugElectron('electron exited with a signal %s', signal)
childClosed.resolve(128 + os.constants.signals[signal])
} else {
childClosed.resolve(code ?? 0)
}
process.exit(code)
})
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => {
try {
debugElectron('electron received signal %s', signal)
const code = await childClosed.promise
process.exit(code)
} catch (err) {
console.error(err)
process.exit(1)
}
})
}
if (
(process.env.ELECTRON_ENABLE_LOGGING ?? '') === '1' ||
debugElectron.enabled ||
+71 -7
View File
@@ -272,36 +272,100 @@ describe('open', () => {
})
describe('emits error', () => {
it('writes the error to stderr and exit with code 1', () => {
it('writes the error to stderr and exit with code 1 after SIGINT', async () => {
const err = new Error('test error')
errCb(err)
expect(process.exit).toHaveBeenCalledWith(1)
expect(console.error).toHaveBeenCalledWith(err)
process.emit('SIGINT')
await new Promise((resolve) => setImmediate(resolve))
expect(process.exit).toHaveBeenCalledWith(1)
})
})
describe('emits close', () => {
describe('with null signal', () => {
it('exits with code 0', () => {
it('exits with code 0 after SIGINT', async () => {
closeCb(0, null)
process.emit('SIGINT')
await new Promise((resolve) => setImmediate(resolve))
expect(process.exit).toHaveBeenCalledWith(0)
})
})
describe('with a signal', () => {
it('exits with code 1', () => {
closeCb(1, 'SIGKILL')
it('exits with code 128 + signal after SIGINT', async () => {
const signal = 'SIGKILL' as NodeJS.Signals
expect(process.exit).toHaveBeenCalledWith(1)
closeCb(1, signal)
process.emit('SIGINT')
await new Promise((resolve) => setImmediate(resolve))
expect(process.exit).toHaveBeenCalledWith(128 + os.constants.signals[signal])
})
})
})
})
/**
* `open` registers SIGINT/SIGTERM with `process.on`. If a second signal arrives
* while the first handler is still awaiting `childClosed.promise`, Node invokes
* the listener again there is no de-duplication. Both continuations then call
* `process.exit` with the same code (benign but redundant; `process.once` would
* match the CLI spawn path).
*/
describe('process signal handlers', () => {
let closeCb: (code: number, signal: NodeJS.Signals | null) => void
beforeEach(async () => {
process.removeAllListeners('SIGINT')
process.removeAllListeners('SIGTERM')
vi.spyOn(process, 'exit').mockImplementation(() => {})
vi.mocked(mockChildProcess.on).mockImplementation((event: string, fn) => {
if (event === 'close') {
closeCb = fn
}
return mockChildProcess
})
await open(appPath, argv)
})
afterEach(() => {
process.removeAllListeners('SIGINT')
process.removeAllListeners('SIGTERM')
})
it('calls process.exit once per stacked SIGINT while the child close promise is pending', async () => {
process.emit('SIGINT')
process.emit('SIGINT')
closeCb(0, null)
await new Promise((resolve) => setImmediate(resolve))
expect(process.exit).toHaveBeenCalledTimes(1)
})
it('calls process.exit once per stacked SIGTERM while the child close promise is pending', async () => {
process.emit('SIGTERM')
process.emit('SIGTERM')
closeCb(0, null)
await new Promise((resolve) => setImmediate(resolve))
expect(process.exit).toHaveBeenCalledTimes(1)
})
})
describe('when inspector.url() returns a URL', () => {
const port = 9229
const nextPort = 9230
@@ -641,6 +641,8 @@ describe('Launchpad: Setup Project', () => {
cy.withCtx(async (ctx) => {
Object.defineProperty(ctx.coreData, 'scaffoldedFiles', {
get () {
if (!this._scaffoldedFiles) return this._scaffoldedFiles
return this._scaffoldedFiles.map((scaffold) => {
if (scaffold.file.absolute.includes('cypress.config')) {
return { ...scaffold, status: 'changes' }
@@ -655,8 +657,9 @@ describe('Launchpad: Setup Project', () => {
})
})
cy.findByRole('button', { name: 'Skip' }).click()
cy.intercept('POST', 'mutation-ExternalLink_OpenExternal', { 'data': { 'openExternal': true } }).as('OpenExternal')
cy.findByRole('button', { name: 'Skip' }).click()
cy.findByText('Learn more', { timeout: 10000 }).click()
cy.wait('@OpenExternal')
.its('request.body.variables.url')
+23 -82
View File
@@ -2,7 +2,8 @@ import { URL, Url } from 'url'
import debugModule from 'debug'
import minimatch from 'minimatch'
import fs from 'fs-extra'
import { pki, asn1, pkcs12, util } from 'node-forge'
import { X509Certificate, createPrivateKey } from 'crypto'
import tls from 'tls'
const debug = debugModule('cypress:network:client-certificates')
@@ -70,24 +71,14 @@ export class UrlMatcher {
*/
export class UrlClientCertificates {
constructor (url: string) {
this.subjects = ''
this.url = url
this.pathnameLength = new URL(url).pathname.length
this.clientCertificates = new ClientCertificates()
}
clientCertificates: ClientCertificates
url: string
subjects: string
pathnameLength: number
matchRule: ParsedUrl | undefined
addSubject (subject: string) {
if (!this.subjects) {
this.subjects = subject
} else {
this.subjects = `${this.subjects} - ${subject}`
}
}
}
/**
@@ -159,9 +150,7 @@ export class ClientCertificateStore {
return null
case 1:
debug(
`using client certificate(s) '${matchingCerts[0].subjects}' for url '${requestUrl.href}'`,
)
debug(`using client certificate(s) for url '${requestUrl.href}'`)
return matchingCerts[0].clientCertificates
default:
@@ -169,9 +158,7 @@ export class ClientCertificateStore {
return b.pathnameLength - a.pathnameLength
})
debug(
`using client certificate(s) '${matchingCerts[0].subjects}' for url '${requestUrl.href}'`,
)
debug(`using client certificate(s) for url '${requestUrl.href}'`)
return matchingCerts[0].clientCertificates
}
@@ -232,7 +219,9 @@ export function loadClientCertificateConfig (config: Config) {
const caRaw = loadBinaryFromFile(ca)
try {
pki.certificateFromPem(caRaw.toString())
// construct to validate; throws on malformed PEM
// eslint-disable-next-line no-new
new X509Certificate(caRaw)
} catch (error: any) {
throw new Error(`Cannot parse CA cert: ${error.message}`)
}
@@ -262,10 +251,10 @@ export function loadClientCertificateConfig (config: Config) {
debug(`loading PEM cert from '${cert.cert}'`)
const pemRaw = loadBinaryFromFile(cert.cert)
let pemParsed: pki.Certificate | undefined = undefined
try {
pemParsed = pki.certificateFromPem(pemRaw.toString())
// eslint-disable-next-line no-new
new X509Certificate(pemRaw)
} catch (error: any) {
throw new Error(`Cannot parse PEM cert: ${error.message}`)
}
@@ -283,18 +272,14 @@ export function loadClientCertificateConfig (config: Config) {
const pemKeyRaw = loadBinaryFromFile(cert.key)
try {
if (passphrase) {
if (!pki.decryptRsaPrivateKey(pemKeyRaw.toString(), passphrase)) {
throw new Error(
`Cannot decrypt PEM key with supplied passphrase (check the passphrase file content and that it doesn't have unexpected whitespace at the end)`,
)
}
} else {
if (!pki.privateKeyFromPem(pemKeyRaw.toString())) {
throw new Error('Cannot load PEM key')
}
}
createPrivateKey({ key: pemKeyRaw, passphrase })
} catch (error: any) {
if (passphrase && error.code === 'ERR_OSSL_BAD_DECRYPT') {
throw new Error(
`Cannot decrypt PEM key with supplied passphrase (check the passphrase file content and that it doesn't have unexpected whitespace at the end)`,
)
}
throw new Error(`Cannot parse PEM key: ${error.message}`)
}
@@ -302,11 +287,8 @@ export function loadClientCertificateConfig (config: Config) {
new PemKey(pemKeyRaw, passphrase),
)
const subject = extractSubjectFromPem(pemParsed)
urlClientCertificates.addSubject(subject)
debug(
`loaded client PEM certificate: ${subject} for url: ${urlClientCertificates.url}`,
`loaded client PEM certificate for url: ${urlClientCertificates.url}`,
)
}
@@ -324,17 +306,15 @@ export function loadClientCertificateConfig (config: Config) {
debug(`loading PFX cert from '${cert.pfx}'`)
const pfxRaw = loadBinaryFromFile(cert.pfx)
const pfxParsed = loadPfx(pfxRaw, passphrase)
loadPfx(pfxRaw, passphrase)
urlClientCertificates.clientCertificates.pfx.push(
new PfxCertificate(pfxRaw, passphrase),
)
const subject = extractSubjectFromPfx(pfxParsed)
urlClientCertificates.addSubject(subject)
debug(
`loaded client PFX certificate: ${subject} for url: ${urlClientCertificates.url}`,
`loaded client PFX certificate for url: ${urlClientCertificates.url}`,
)
}
})
@@ -374,50 +354,11 @@ function loadTextFromFile (filepath: string): string {
return fs.readFileSync(filepath, 'utf8').toString()
}
/**
* Extract subject from supplied pem instance
*/
function extractSubjectFromPem (pem: pki.Certificate): string {
function loadPfx (pfx: Buffer, passphrase: string | undefined): void {
try {
return pem.subject.attributes
.map((attr) => [attr.shortName, attr.value].join('='))
.join(', ')
tls.createSecureContext({ pfx, passphrase })
} catch (e: any) {
throw new Error(`Unable to extract subject from PEM file: ${e.message}`)
}
}
/**
* Load PFX data from the supplied Buffer and passphrase
*/
function loadPfx (pfx: Buffer, passphrase: string | undefined) {
try {
const certDer = util.decode64(pfx.toString('base64'))
const certAsn1 = asn1.fromDer(certDer)
return pkcs12.pkcs12FromAsn1(certAsn1, passphrase)
} catch (e: any) {
debug(`loadPfx fail: ${e.message} ${e.stackTrace}`)
debug(`loadPfx fail: ${e.message} ${e.stack}`)
throw new Error(`Unable to load PFX file: ${e.message}`)
}
}
/**
* Extract subject from supplied pfx instance
*/
function extractSubjectFromPfx (pfx: pkcs12.Pkcs12Pfx) {
try {
const bags = pfx.getBags({ bagType: pki.oids.certBag })
const certBag = bags[pki.oids.certBag]
if (!certBag || certBag.length === 0) {
throw new Error('No certificate bag found in PFX file')
}
const certs = certBag.map((item) => item.cert) as pki.Certificate[]
return certs[0].subject.attributes.map((attr) => [attr.shortName, attr.value].join('=')).join(', ')
} catch (e: any) {
throw new Error(`Unable to extract subject from PFX file: ${e.message}`)
}
}
-1
View File
@@ -25,7 +25,6 @@
"fs-extra": "9.1.0",
"lodash": "^4.17.21",
"minimatch": "3.1.3",
"node-forge": "^1.4.0",
"proxy-from-env": "1.0.0"
},
"devDependencies": {
+20 -42
View File
@@ -23,7 +23,7 @@ import {
import { allowDestroy } from '../../lib/allow-destroy'
import { AsyncServer, Servers } from '../support/servers'
import { clientCertificateStoreSingleton, UrlClientCertificates, ClientCertificates, PemKey } from '../../lib/client-certificates'
import { pki } from 'node-forge'
import { execFileSync } from 'child_process'
import fetch from 'cross-fetch'
import os from 'os'
import path from 'path'
@@ -40,48 +40,27 @@ if (!fs.existsSync(tempDirPath)) {
fs.mkdirSync(tempDirPath)
}
function createCertAndKey (): [pki.Certificate, pki.rsa.PrivateKey] {
let keys = pki.rsa.generateKeyPair(2048)
let cert = pki.createCertificate()
function createCertAndKey (): { cert: string, key: string } {
const certTmp = path.join(tempDirPath, `agent-cert-${Date.now()}-${Math.random()}.pem`)
const keyTmp = path.join(tempDirPath, `agent-key-${Date.now()}-${Math.random()}.pem`)
cert.publicKey = keys.publicKey
cert.serialNumber = '01'
cert.validity.notBefore = new Date()
cert.validity.notAfter = new Date()
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1)
execFileSync('openssl', [
'req', '-x509',
'-newkey', 'rsa:2048',
'-nodes',
'-keyout', keyTmp,
'-out', certTmp,
'-days', '1',
'-subj', '/CN=example.org/C=US/ST=California/L=San Fran/O=Test/OU=Test',
], { stdio: 'ignore' })
let attrs = [
{
name: 'commonName',
value: 'example.org',
},
{
name: 'countryName',
value: 'US',
},
{
shortName: 'ST',
value: 'California',
},
{
name: 'localityName',
value: 'San Fran',
},
{
name: 'organizationName',
value: 'Test',
},
{
shortName: 'OU',
value: 'Test',
},
]
const cert = fs.readFileSync(certTmp, 'utf-8')
const key = fs.readFileSync(keyTmp, 'utf-8')
cert.setSubject(attrs)
cert.setIssuer(attrs)
cert.sign(keys.privateKey)
fs.unlinkSync(certTmp)
fs.unlinkSync(keyTmp)
return [cert, keys.privateKey]
return { cert, key }
}
describe('lib/agent', function () {
@@ -764,15 +743,14 @@ describe('lib/agent', function () {
if (testCase.presentClientCertificate) {
clientCertificateStoreSingleton.clear()
const certAndKey = createCertAndKey()
const pemCert = pki.certificateToPem(certAndKey[0])
const { cert: pemCert, key: pemKey } = createCertAndKey()
clientCert = pemCert
const testCerts = new UrlClientCertificates(`https://localhost`)
testCerts.clientCertificates = new ClientCertificates()
testCerts.clientCertificates.cert.push(Buffer.from(pemCert, 'utf-8'))
testCerts.clientCertificates.key.push(new PemKey(Buffer.from(pki.privateKeyToPem(certAndKey[1]), 'utf-8'), undefined))
testCerts.clientCertificates.key.push(new PemKey(Buffer.from(pemKey, 'utf-8'), undefined))
clientCertificateStoreSingleton.addClientCertificatesForUrl(testCerts)
} else {
clientCert = ''
@@ -5,9 +5,18 @@ import urllib from 'url'
import fs from 'fs-extra'
import os from 'os'
import path from 'path'
import Forge from 'node-forge'
import { execFileSync } from 'child_process'
import { randomUUID } from 'crypto'
const { pki, pkcs12, asn1 } = Forge
type CertAlgo = 'rsa' | 'ec'
function opensslNewkeyArgs (algo: CertAlgo): string[] {
return algo === 'ec'
? ['-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256']
: ['-newkey', 'rsa:2048']
}
const testSubject = '/CN=example.org/C=US/ST=California/L=San Fran/O=Test/OU=Test'
function urlShouldMatch (url: string, matcher: string) {
let rule = UrlMatcher.buildMatcherRule(matcher)
@@ -187,90 +196,87 @@ describe('lib/client-certificates', () => {
//
// Neither PEM nor PFX supplied
function createCertAndKey (): [object, object] {
let keys = pki.rsa.generateKeyPair(2048)
let cert = pki.createCertificate()
cert.publicKey = keys.publicKey
cert.serialNumber = '01'
cert.validity.notBefore = new Date()
cert.validity.notAfter = new Date()
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1)
let attrs = [
{
name: 'commonName',
value: 'example.org',
},
{
name: 'countryName',
value: 'US',
},
{
shortName: 'ST',
value: 'California',
},
{
name: 'localityName',
value: 'San Fran',
},
{
name: 'organizationName',
value: 'Test',
},
{
shortName: 'OU',
value: 'Test',
},
]
cert.setSubject(attrs)
cert.setIssuer(attrs)
cert.sign(keys.privateKey)
return [cert, keys.privateKey]
}
function createPemFiles (
certFilepath: string,
keyFilepath: string,
passphraseFilepath: string | undefined,
passphrase: string | undefined,
algo: CertAlgo = 'rsa',
) {
const certInfo = createCertAndKey()
const args = [
'req', '-x509',
...opensslNewkeyArgs(algo),
'-keyout', keyFilepath,
'-out', certFilepath,
'-days', '1',
'-subj', testSubject,
]
fs.writeFileSync(certFilepath, pki.certificateToPem(certInfo[0]))
const key = passphrase
? pki.encryptRsaPrivateKey(certInfo[1], passphrase)
: pki.privateKeyToPem(certInfo[1])
if (passphrase) {
args.push('-passout', `pass:${passphrase}`)
} else {
args.push('-nodes')
}
fs.writeFileSync(keyFilepath, key)
execFileSync('openssl', args, { stdio: 'ignore' })
if (passphraseFilepath) {
if (passphraseFilepath && passphrase) {
fs.writeFileSync(passphraseFilepath, passphrase)
}
}
function createPfxFiles (
certFilepath: string,
pfxFilepath: string,
passphraseFilepath: string | undefined,
passphrase: string | undefined,
passphrase: string,
algo: CertAlgo = 'rsa',
) {
const certInfo = createCertAndKey()
const certTmp = `${pfxFilepath}.cert.tmp`
const keyTmp = `${pfxFilepath}.key.tmp`
let p12Asn1 = pkcs12.toPkcs12Asn1(certInfo[1], [certInfo[0]], passphrase)
execFileSync('openssl', [
'req', '-x509',
...opensslNewkeyArgs(algo),
'-nodes',
'-keyout', keyTmp,
'-out', certTmp,
'-days', '1',
'-subj', testSubject,
], { stdio: 'ignore' })
fs.writeFileSync(certFilepath, asn1.toDer(p12Asn1).getBytes(), { encoding: 'binary' })
execFileSync('openssl', [
'pkcs12', '-export',
'-in', certTmp,
'-inkey', keyTmp,
'-out', pfxFilepath,
'-password', `pass:${passphrase}`,
'-keypbe', 'AES-256-CBC',
'-certpbe', 'AES-256-CBC',
'-macalg', 'sha256',
], { stdio: 'ignore' })
fs.removeSync(certTmp)
fs.removeSync(keyTmp)
if (passphraseFilepath) {
fs.writeFileSync(passphraseFilepath, passphrase)
}
}
function createCaFile (filepath: string) {
const certInfo = createCertAndKey()
function createCaFile (filepath: string, algo: CertAlgo = 'rsa') {
const keyTmp = `${filepath}.key.tmp`
fs.writeFileSync(filepath, pki.certificateToPem(certInfo[0]))
execFileSync('openssl', [
'req', '-x509',
...opensslNewkeyArgs(algo),
'-nodes',
'-keyout', keyTmp,
'-out', filepath,
'-days', '1',
'-subj', testSubject,
], { stdio: 'ignore' })
fs.removeSync(keyTmp)
}
function createUniqueUrl (): string {
@@ -526,6 +532,104 @@ describe('lib/client-certificates', () => {
expect(options.key[0].passphrase).toBeUndefined()
})
it('loads valid single EC PEM (no passphrase)', () => {
createPemFiles(pemFilepath, pemKeyFilepath, undefined, undefined, 'ec')
createCaFile(caFilepath, 'ec')
const url = createUniqueUrl()
const config = createSinglePemConfig(
url,
caFilepath,
pemFilepath,
pemKeyFilepath,
undefined,
)
const pemFileData = fs.readFileSync(pemFilepath)
const keyFileData = fs.readFileSync(pemKeyFilepath)
const caFileData = fs.readFileSync(caFilepath)
loadClientCertificateConfig(config)
const options = clientCertificateStoreSingleton.getClientCertificateAgentOptionsForUrl(
urllib.parse(url),
)
expect(options).not.toBeNull()
expect(options.ca.length).toEqual(1)
expect(options.ca[0]).toEqual(caFileData)
expect(options.pfx).toHaveLength(0)
expect(options.cert.length).toEqual(1)
expect(options.cert[0]).toEqual(pemFileData)
expect(options.key.length).toEqual(1)
expect(options.key[0].passphrase).toBeUndefined()
expect(options.key[0].pem).toEqual(keyFileData)
})
it('loads valid single EC PEM (with passphrase)', () => {
const passphrase = 'ec_phrase'
createPemFiles(
pemFilepath,
pemKeyFilepath,
pemPassphraseFilepath,
passphrase,
'ec',
)
const url = createUniqueUrl()
const config = createSinglePemConfig(
url,
undefined,
pemFilepath,
pemKeyFilepath,
pemPassphraseFilepath,
)
const pemFileData = fs.readFileSync(pemFilepath)
const keyFileData = fs.readFileSync(pemKeyFilepath)
loadClientCertificateConfig(config)
const options = clientCertificateStoreSingleton.getClientCertificateAgentOptionsForUrl(
urllib.parse(url),
)
expect(options).not.toBeNull()
expect(options.ca.length).toEqual(0)
expect(options.pfx).toHaveLength(0)
expect(options.cert.length).toEqual(1)
expect(options.cert[0]).toEqual(pemFileData)
expect(options.key.length).toEqual(1)
expect(options.key[0].passphrase).toEqual(passphrase)
expect(options.key[0].pem).toEqual(keyFileData)
})
it('detects invalid EC PEM passphrase', () => {
const passphrase = 'ec_phrase'
createPemFiles(
pemFilepath,
pemKeyFilepath,
pemPassphraseFilepath,
passphrase,
'ec',
)
fs.writeFileSync(pemPassphraseFilepath, 'not-the-passphrase')
const url = createUniqueUrl()
const config = createSinglePemConfig(
url,
undefined,
pemFilepath,
pemKeyFilepath,
pemPassphraseFilepath,
)
expect(() => {
loadClientCertificateConfig(config)
}).toThrow(
`Cannot decrypt PEM key with supplied passphrase (check the passphrase file content and that it doesn't have unexpected whitespace at the end)`,
)
})
// TODO: fix this flaky test
it.skip('detects invalid PEM key passphrase', () => {
const passphrase = 'a_phrase'
@@ -570,7 +674,7 @@ describe('lib/client-certificates', () => {
expect(() => {
loadClientCertificateConfig(config)
}).toThrow('Invalid PEM formatted message')
}).toThrow('Cannot parse PEM key')
})
it('detects invalid PEM key file (with passphrase)', () => {
@@ -596,7 +700,7 @@ describe('lib/client-certificates', () => {
expect(() => {
loadClientCertificateConfig(config)
}).toThrow('Invalid PEM formatted message')
}).toThrow('Cannot parse PEM key')
})
it('detects invalid PEM cert file', () => {
@@ -730,6 +834,33 @@ describe('lib/client-certificates', () => {
}).toThrow('no such file or directory')
})
it('loads valid single EC PFX', () => {
const passphrase = 'ec_pfx_passphrase'
createPfxFiles(pfxFilepath, pfxPassphraseFilepath, passphrase, 'ec')
const url = createUniqueUrl()
const config = createSinglePfxConfig(
url,
undefined,
pfxFilepath,
pfxPassphraseFilepath,
)
const pfxFileData = fs.readFileSync(pfxFilepath)
loadClientCertificateConfig(config)
const options = clientCertificateStoreSingleton.getClientCertificateAgentOptionsForUrl(
urllib.parse(url),
)
expect(options).not.toBeNull()
expect(options.cert).toHaveLength(0)
expect(options.pfx.length).toEqual(1)
expect(options.pfx[0].buf).toEqual(pfxFileData)
expect(options.pfx[0].passphrase).toEqual(passphrase)
})
it('loads valid single PFX', () => {
const passphrase = 'a_passphrase'
@@ -772,7 +903,7 @@ describe('lib/client-certificates', () => {
expect(() => {
loadClientCertificateConfig(config)
}).toThrow('Invalid password?')
}).toThrow('mac verify failure')
})
it('detects missing PFX passphrase file', () => {
@@ -789,7 +920,7 @@ describe('lib/client-certificates', () => {
expect(() => {
loadClientCertificateConfig(config)
}).toThrow('Invalid password?')
}).toThrow('mac verify failure')
})
it('detects invalid PFX file', () => {
@@ -804,7 +935,7 @@ describe('lib/client-certificates', () => {
expect(() => {
loadClientCertificateConfig(config)
}).toThrow('Unable to load PFX file: Too few bytes to read ASN.1 value')
}).toThrow('Unable to load PFX file: not enough data')
})
it('detects missing PFX file', () => {
@@ -817,7 +948,7 @@ describe('lib/client-certificates', () => {
expect(() => {
loadClientCertificateConfig(config)
}).toThrow('Unable to load PFX file: Too few bytes to read ASN.1 value')
}).toThrow('Unable to load PFX file: not enough data')
})
it('detects neither PEM nor PFX supplied', () => {
@@ -217,6 +217,7 @@ describe('runnables', () => {
})
it('adds a scroll listener in open mode', () => {
appState.autoScrollingEnabled = false
appState.startRunning()
cy.get('.container')
.trigger('scroll')
@@ -1,90 +0,0 @@
import { asyncRetry, linearDelay } from '../../../util/async_retry'
import { isRetryableError } from '../../network/is_retryable_error'
import fetch from 'cross-fetch'
import os from 'os'
import { strictAgent } from '@packages/network'
import { PUBLIC_KEY_VERSION } from '../../constants'
import { createWriteStream } from 'fs'
import { verifySignatureFromFile } from '../../encryption'
import { HttpError } from '../../network/http_error'
import { SystemError } from '../../network/system_error'
const pkg = require('@packages/root')
const _delay = linearDelay(500)
export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }): Promise<string> => {
let responseSignature: string | null = null
let responseManifestSignature: string | null = null
await (asyncRetry(async () => {
try {
const response = await fetch(cyPromptUrl, {
// @ts-expect-error - this is supported
agent: strictAgent,
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': PUBLIC_KEY_VERSION,
...(projectId ? { 'x-cypress-project-slug': projectId } : {}),
'x-os-name': os.platform(),
'x-cypress-version': pkg.version,
},
encrypt: 'signed',
})
if (!response.ok) {
const err = await HttpError.fromResponse(response)
throw err
}
responseSignature = response.headers.get('x-cypress-signature')
responseManifestSignature = response.headers.get('x-cypress-manifest-signature')
await new Promise<void>((resolve, reject) => {
const writeStream = createWriteStream(bundlePath)
writeStream.on('error', reject)
writeStream.on('finish', () => {
resolve()
})
// @ts-expect-error - this is supported
response.body?.pipe(writeStream)
})
} catch (error) {
if (HttpError.isHttpError(error)) {
throw error
}
if (error.errno || error.code) {
const sysError = new SystemError(error, cyPromptUrl, error.code, error.errno)
sysError.stack = error.stack
throw sysError
}
throw error
}
}, {
maxAttempts: 3,
retryDelay: _delay,
shouldRetry: (err) => isRetryableError(err, 'GET'),
}))()
if (!responseSignature) {
throw new Error('Unable to get cy-prompt signature')
}
if (!responseManifestSignature) {
throw new Error('Unable to get cy-prompt manifest signature')
}
const verified = await verifySignatureFromFile(bundlePath, responseSignature)
if (!verified) {
throw new Error('Unable to verify cy-prompt signature')
}
return responseManifestSignature
}
@@ -1,122 +0,0 @@
import { asyncRetry, linearDelay } from '../../../util/async_retry'
import { isRetryableError } from '../../network/is_retryable_error'
import fetch from 'cross-fetch'
import os from 'os'
import { strictAgent } from '@packages/network'
import { PUBLIC_KEY_VERSION } from '../../constants'
import { createWriteStream } from 'fs'
import { verifySignatureFromFile } from '../../encryption'
import { HttpError } from '../../network/http_error'
import { SystemError } from '../../network/system_error'
import Debug from 'debug'
const pkg = require('@packages/root')
const _delay = linearDelay(500)
const debug = Debug('cypress:server:cloud:api:studio:get_studio_bundle')
const DEFAULT_TIMEOUT = 25000
export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise<string> => {
let responseSignature: string | null = null
let responseManifestSignature: string | null = null
await (asyncRetry(async () => {
const controller = new AbortController()
const fetchTimeout = setTimeout(() => {
controller.abort()
}, DEFAULT_TIMEOUT)
try {
debug('Fetching studio bundle from %s', studioUrl)
const response = await fetch(studioUrl, {
// @ts-expect-error - this is supported
agent: strictAgent,
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': PUBLIC_KEY_VERSION,
'x-os-name': os.platform(),
'x-cypress-version': pkg.version,
},
encrypt: 'signed',
signal: controller.signal,
})
if (!response.ok) {
const err = await HttpError.fromResponse(response)
throw err
}
responseSignature = response.headers.get('x-cypress-signature')
responseManifestSignature = response.headers.get('x-cypress-manifest-signature')
await new Promise<void>((resolve, reject) => {
const writeStream = createWriteStream(bundlePath)
writeStream.on('error', (err) => {
writeStream.destroy()
reject(err)
})
writeStream.on('finish', () => {
resolve()
})
// @ts-expect-error - this is supported
response.body?.pipe(writeStream)
})
// Check if the operation was aborted due to timeout
if (controller.signal.aborted) {
throw new Error('Studio bundle fetch timed out')
}
clearTimeout(fetchTimeout)
} catch (error) {
debug('Error fetching studio bundle from %s: %o', studioUrl, error)
clearTimeout(fetchTimeout)
if (error.name === 'AbortError') {
throw new Error('Studio bundle fetch timed out')
}
if (HttpError.isHttpError(error)) {
throw error
}
if (error.errno || error.code) {
const sysError = new SystemError(error, studioUrl, error.code, error.errno)
sysError.stack = error.stack
throw sysError
}
throw error
}
}, {
maxAttempts: 3,
retryDelay: _delay,
shouldRetry: (err) => isRetryableError(err, 'GET'),
}))()
if (!responseSignature) {
throw new Error('Unable to get studio signature')
}
if (!responseManifestSignature) {
throw new Error('Unable to get studio manifest signature')
}
const verified = await verifySignatureFromFile(bundlePath, responseSignature)
if (!verified) {
throw new Error('Unable to verify studio signature')
}
debug('Studio bundle fetched successfully from %s', studioUrl)
return responseManifestSignature
}
@@ -0,0 +1,36 @@
export type BundleKind = 'cy-prompt' | 'studio'
export type BundleErrorStage =
| 'network'
| 'signature'
| 'extract'
| 'manifest'
| 'publish'
export class BundleError extends Error {
public readonly name = 'BundleError'
public readonly kind: BundleKind
public readonly stage: BundleErrorStage
// Mirrored from `cause.code` so consumers reading `error.code` keep working.
public readonly code?: string
constructor (options: { kind: BundleKind, stage: BundleErrorStage, message: string, cause?: unknown }) {
super(options.message)
this.kind = options.kind
this.stage = options.stage
if (options.cause !== undefined) {
(this as Error & { cause?: unknown }).cause = options.cause
const causeCode = (options.cause as { code?: unknown } | null | undefined)?.code
if (typeof causeCode === 'string') {
this.code = causeCode
}
}
}
static isBundleError (err: unknown): err is BundleError {
return err instanceof Error && (err as Error).name === 'BundleError'
}
}
@@ -0,0 +1,54 @@
import path from 'path'
import cachedir from 'cachedir'
import untildify from 'untildify'
const BUNDLES_DIRNAME = 'bundles'
// Matches the CLI's getEnv(varName, /* trim */ true) / dequote() in
// cli/lib/tasks/state.ts so the two sides resolve identical paths.
const dequote = (str: string): string => {
if (str.length > 1 && str[0] === '"' && str[str.length - 1] === '"') {
return str.slice(1, -1)
}
return str
}
const readEnvVar = (varName: string): string | undefined => {
const candidates = [
varName,
`npm_config_${varName}`,
`npm_config_${varName.toLowerCase()}`,
`npm_package_config_${varName}`,
]
for (const candidate of candidates) {
if (Object.prototype.hasOwnProperty.call(process.env, candidate)) {
const raw = process.env[candidate]
if (raw === undefined) continue
return dequote(raw.trim())
}
}
return undefined
}
const resolveCypressCacheRoot = (): string => {
const override = readEnvVar('CYPRESS_CACHE_FOLDER')
if (override) {
return path.resolve(untildify(override))
}
return cachedir('Cypress')
}
const getBundleCacheRoot = (): string => {
return path.join(resolveCypressCacheRoot(), BUNDLES_DIRNAME)
}
export const getBundleCacheDir = (kind: 'cy-prompt' | 'studio'): string => {
return path.join(getBundleCacheRoot(), kind)
}
@@ -0,0 +1,96 @@
import { ensureDir, readFile, remove } from 'fs-extra'
import path from 'path'
import Debug from 'debug'
import { verifySignature } from '../encryption'
import { getBundleCacheDir } from './cache_root'
import { parseHashFromBundleUrl } from './parse_hash_from_bundle_url'
import { sweepOrphanStaging } from './sweep_orphan_staging'
import { streamDownloadVerifyExtract } from './stream_download_verify_extract'
import { publishStagingToFinal } from './publish_staging_to_final'
import { BundleError, BundleKind } from './bundle_error'
const debug = Debug('cypress:server:cloud:bundles:ensure-signed-bundle')
const ORPHAN_STAGING_TTL_MS = 60 * 60 * 1000
const STAGING_PREFIX = '.staging-'
interface EnsureSignedBundleOptions {
url: string
projectId?: string
kind: BundleKind
}
interface EnsureSignedBundleResult {
manifest: Record<string, string>
bundleDir: string
}
const randomSuffix = (): string => Math.random().toString(36).substring(2, 15)
export const ensureSignedBundle = async ({
url,
projectId,
kind,
}: EnsureSignedBundleOptions): Promise<EnsureSignedBundleResult> => {
const hash = parseHashFromBundleUrl(url)
const baseDir = getBundleCacheDir(kind)
const finalDir = path.join(baseDir, hash)
const staging = path.join(baseDir, `${STAGING_PREFIX}${randomSuffix()}`)
debug('ensuring %s bundle hash=%s baseDir=%s', kind, hash, baseDir)
await ensureDir(baseDir)
await ensureDir(finalDir)
const sweptCount = await sweepOrphanStaging(baseDir, ORPHAN_STAGING_TTL_MS).catch(() => 0)
if (sweptCount > 0) debug('swept %d orphan staging dir(s) under %s', sweptCount, baseDir)
try {
const manifestSig = await streamDownloadVerifyExtract({ url, projectId, staging, kind })
const manifestPath = path.join(staging, 'manifest.json')
let manifestText: string
try {
manifestText = await readFile(manifestPath, 'utf8')
} catch (err: any) {
throw new BundleError({
kind,
stage: 'manifest',
message: `Unable to read ${kind} manifest from staging`,
cause: err,
})
}
const verified = await verifySignature(manifestText, manifestSig)
if (!verified) {
throw new BundleError({
kind,
stage: 'manifest',
message: `Unable to verify ${kind} manifest signature`,
})
}
try {
await publishStagingToFinal(staging, finalDir)
} catch (err: any) {
throw new BundleError({
kind,
stage: 'publish',
message: `Failed to publish ${kind} bundle from staging to ${finalDir}`,
cause: err,
})
}
debug('%s bundle published to %s', kind, finalDir)
return {
manifest: JSON.parse(manifestText) as Record<string, string>,
bundleDir: finalDir,
}
} finally {
await remove(staging).catch(() => { /* ignore */ })
}
}
@@ -0,0 +1,10 @@
export const parseHashFromBundleUrl = (url: string): string => {
const fileName = url.split('/').pop()
const hash = fileName?.split('.')[0]
if (!hash) {
throw new Error(`Unable to parse bundle hash from URL: ${url}`)
}
return hash
}
@@ -0,0 +1,51 @@
import { readdir, stat, ensureDir } from 'fs-extra'
import path from 'path'
import { renameAtomicWithRetry } from '../extract_atomic'
const MANIFEST_REL = 'manifest.json'
const walkFiles = async (root: string, currentRel: string = ''): Promise<string[]> => {
const fullDir = path.join(root, currentRel)
const entries = await readdir(fullDir)
const nested = await Promise.all(entries.map(async (entry): Promise<string[]> => {
const entryRel = path.join(currentRel, entry)
const entryFull = path.join(root, entryRel)
const entryStat = await stat(entryFull)
if (entryStat.isDirectory()) return walkFiles(root, entryRel)
if (entryStat.isFile()) return [entryRel]
return []
}))
return nested.flat()
}
const publishOne = async (staging: string, finalDir: string, rel: string): Promise<void> => {
const src = path.join(staging, rel)
const dst = path.join(finalDir, rel)
await ensureDir(path.dirname(dst))
await renameAtomicWithRetry(src, dst)
}
export const publishStagingToFinal = async (staging: string, finalDir: string): Promise<void> => {
const allFiles = await walkFiles(staging)
const others = allFiles.filter((rel) => rel !== MANIFEST_REL)
const hasManifest = allFiles.includes(MANIFEST_REL)
const otherPromises = others.map((rel) => publishOne(staging, finalDir, rel))
try {
await Promise.all(otherPromises)
} catch (err) {
await Promise.allSettled(otherPromises)
throw err
}
if (hasManifest) {
await publishOne(staging, finalDir, MANIFEST_REL)
}
}
@@ -0,0 +1,273 @@
import { createWriteStream } from 'fs'
import { ensureDir, remove } from 'fs-extra'
import { pipeline } from 'stream/promises'
import { Transform } from 'stream'
import path from 'path'
import os from 'os'
import tar from 'tar'
import fetch from 'cross-fetch'
import Debug from 'debug'
import { strictAgent } from '@packages/network'
import { asyncRetry, linearDelay } from '../../util/async_retry'
import { isRetryableError } from '../network/is_retryable_error'
import { HttpError } from '../network/http_error'
import { SystemError } from '../network/system_error'
import { PUBLIC_KEY_VERSION } from '../constants'
import { createStreamingSignatureVerifier } from '../encryption'
import { BundleError, BundleErrorStage, BundleKind } from './bundle_error'
const pkg = require('@packages/root')
const debug = Debug('cypress:server:cloud:bundles:stream-download-verify-extract')
const FETCH_TIMEOUT_MS = 25000
const MAX_ATTEMPTS = 3
const RETRY_DELAY_MS = 500
interface StreamDownloadVerifyExtractOptions {
url: string
projectId?: string
staging: string
kind: BundleKind
}
const isInsideDir = (parent: string, child: string): boolean => {
const rel = path.relative(parent, child)
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)
}
const buildHeaders = (projectId: string | undefined): Record<string, string> => {
return {
'x-route-version': '1',
'x-cypress-signature': PUBLIC_KEY_VERSION,
...(projectId ? { 'x-cypress-project-slug': projectId } : {}),
'x-os-name': os.platform(),
'x-cypress-version': pkg.version,
}
}
// POSIX-style errno codes (ECONNRESET, ETIMEDOUT, EAI_AGAIN, ...).
// Excludes Node's ERR_* codes so a thrown TypeError isn't misclassified.
const isPosixSyscallError = (err: any): boolean => {
if (err?.errno !== undefined) return true
const code = err?.code
if (typeof code !== 'string') return false
if (code.startsWith('ERR_')) return false
return /^E[A-Z]/.test(code)
}
// Network-class POSIX codes that can surface mid-pipeline from the response
// body stream (TCP drop, DNS flake, etc.). Everything else syscall-y during
// extract (ENOSPC, EACCES, EROFS, EIO, ...) is a filesystem error from the
// tar-entry write — non-transient, must not be retried.
const NETWORK_SYSCALL_CODES = new Set([
'ECONNRESET',
'ECONNREFUSED',
'ECONNABORTED',
'ETIMEDOUT',
'EAI_AGAIN',
'EHOSTUNREACH',
'EHOSTDOWN',
'ENETUNREACH',
'ENETDOWN',
'ENOTFOUND',
'EPIPE',
'EAGAIN',
])
const isNetworkSyscallError = (err: any): boolean => {
const code = err?.code
return typeof code === 'string' && NETWORK_SYSCALL_CODES.has(code)
}
// Wraps fetch/pipeline errors as BundleError. `cause` carries the underlying
// HttpError/SystemError so asyncRetry's shouldRetry can classify via
// isRetryableError. `defaultStage` is used when the err has no HTTP/syscall
// shape: 'network' for the fetch phase, 'extract' for the pipeline phase.
const wrapAsBundleError = (
err: any,
url: string,
kind: BundleKind,
defaultStage: BundleErrorStage,
): BundleError => {
if (err?.name === 'AbortError') {
const message = `${kind} bundle fetch timed out after ${FETCH_TIMEOUT_MS}ms`
const sysError = new SystemError(new Error(message), url, 'ETIMEDOUT', undefined)
return new BundleError({ kind, stage: 'network', message, cause: sysError })
}
if (HttpError.isHttpError(err)) {
return new BundleError({
kind,
stage: 'network',
message: `${kind} bundle fetch failed with HTTP ${err.status} ${err.statusText ?? ''}`.trim(),
cause: err,
})
}
if (isPosixSyscallError(err)) {
if (defaultStage === 'network' || isNetworkSyscallError(err)) {
const sysError = SystemError.isSystemError(err)
? err
: Object.assign(new SystemError(err, url, err.code, err.errno), { stack: err.stack })
return new BundleError({
kind,
stage: 'network',
message: `${kind} bundle network error: ${err.message ?? err.code}`,
cause: sysError,
})
}
return new BundleError({
kind,
stage: defaultStage,
message: `${kind} bundle ${defaultStage} failed: ${err.message ?? err.code}`,
cause: err,
})
}
return new BundleError({
kind,
stage: defaultStage,
message: `${kind} bundle ${defaultStage} failed: ${err?.message ?? String(err)}`,
cause: err,
})
}
const runDownloadAttempt = async ({ url, projectId, staging, kind }: StreamDownloadVerifyExtractOptions): Promise<string> => {
// Each attempt starts from a clean staging dir so retries can't see
// partial bytes from the previous attempt.
await remove(staging).catch(() => { /* ignore */ })
await ensureDir(staging)
const verifier = createStreamingSignatureVerifier()
const tee = new Transform({
transform (chunk, _enc, cb) {
verifier.update(chunk)
cb(null, chunk)
},
})
const parser = new tar.Parse({ strict: true })
const entryPromises: Promise<void>[] = []
parser.on('entry', (entry) => {
if (entry.type !== 'File') {
entry.resume()
return
}
const targetPath = path.resolve(staging, entry.path)
if (!isInsideDir(staging, targetPath)) {
debug('rejecting entry outside staging: %s', entry.path)
entry.resume()
return
}
const writePromise = (async () => {
await ensureDir(path.dirname(targetPath))
const ws = createWriteStream(targetPath, { mode: entry.mode || 0o644 })
await pipeline(entry, ws)
})()
entryPromises.push(writePromise)
})
const controller = new AbortController()
const fetchTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
let bundleSig: string | null = null
let manifestSig: string | null = null
try {
debug('fetching %s bundle from %s', kind, url)
let response: Awaited<ReturnType<typeof fetch>>
try {
response = await fetch(url, {
// @ts-expect-error - this is supported
agent: strictAgent,
method: 'GET',
headers: buildHeaders(projectId),
encrypt: 'signed',
signal: controller.signal,
})
if (!response.ok) {
throw await HttpError.fromResponse(response)
}
} catch (err: any) {
throw wrapAsBundleError(err, url, kind, 'network')
}
bundleSig = response.headers.get('x-cypress-signature')
manifestSig = response.headers.get('x-cypress-manifest-signature')
try {
// @ts-expect-error - response.body is a Node Readable in cross-fetch's Node runtime
await pipeline(response.body, tee, parser)
await Promise.all(entryPromises)
} catch (err: any) {
// Drain any in-flight entry writes so they don't surface as unhandled
// rejections after the pipeline has already errored.
await Promise.allSettled(entryPromises)
throw wrapAsBundleError(err, url, kind, 'extract')
}
} finally {
clearTimeout(fetchTimeout)
}
if (!bundleSig) {
throw new BundleError({ kind, stage: 'signature', message: `Unable to get ${kind} bundle signature` })
}
if (!manifestSig) {
throw new BundleError({ kind, stage: 'signature', message: `Unable to get ${kind} manifest signature` })
}
if (!verifier.verify(bundleSig)) {
throw new BundleError({ kind, stage: 'signature', message: `Unable to verify ${kind} bundle signature` })
}
debug('%s bundle stream verified', kind)
return manifestSig
}
// All bundle fetches are GET, so pass that through to isRetryableError so it
// retries HTTP 500 in addition to the always-retryable statuses.
const shouldRetryBundleError = (err: unknown): boolean => {
if (BundleError.isBundleError(err)) {
const cause = (err as Error & { cause?: unknown }).cause
return cause !== undefined && isRetryableError(cause, 'GET')
}
return isRetryableError(err, 'GET')
}
export const streamDownloadVerifyExtract = async (options: StreamDownloadVerifyExtractOptions): Promise<string> => {
return asyncRetry(runDownloadAttempt, {
maxAttempts: MAX_ATTEMPTS,
retryDelay: linearDelay(RETRY_DELAY_MS),
shouldRetry: shouldRetryBundleError,
onRetry: (delayMs, err) => {
debug('retrying %s bundle download in %dms after error: %o', options.kind, delayMs, err)
},
})(options)
}
@@ -0,0 +1,45 @@
import { readdir, stat, remove } from 'fs-extra'
import path from 'path'
import Debug from 'debug'
const debug = Debug('cypress:server:cloud:bundles:sweep-orphan-staging')
const STAGING_PREFIX = '.staging-'
export const sweepOrphanStaging = async (baseDir: string, olderThanMs: number): Promise<number> => {
let entries: string[]
try {
entries = await readdir(baseDir)
} catch (err) {
debug('readdir failed for %s: %o', baseDir, err)
return 0
}
const now = Date.now()
const results = await Promise.all(entries.map(async (entry): Promise<boolean> => {
if (!entry.startsWith(STAGING_PREFIX)) return false
const fullPath = path.join(baseDir, entry)
try {
const stats = await stat(fullPath)
const age = now - stats.mtimeMs
if (age < olderThanMs) return false
await remove(fullPath)
debug('removed orphan staging dir %s (age %dms)', fullPath, age)
return true
} catch (err) {
debug('failed to sweep %s: %o', fullPath, err)
return false
}
}))
return results.filter(Boolean).length
}
@@ -7,20 +7,28 @@ import { isRetryableError } from '../network/is_retryable_error'
import { asyncRetry } from '../../util/async_retry'
import { postCyPromptSession } from '../api/cy-prompt/post_cy_prompt_session'
import path from 'path'
import os from 'os'
import { readFile } from 'fs-extra'
import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle'
import { parseHashFromBundleUrl } from '../bundles/parse_hash_from_bundle_url'
import chokidar from 'chokidar'
import { getCloudMetadata } from '../get_cloud_metadata'
import type { CyPromptAuthenticatedUserShape, CyPromptServerOptions } from '@packages/types'
import crypto from 'crypto'
import { reportCyPromptError } from '../api/cy-prompt/report_cy_prompt_error'
import { GracefulExit } from '../../util/graceful-exit'
import type { ExitStepKey } from '../../util/graceful-exit'
const debug = Debug('cypress:server:cy-prompt-lifecycle-manager')
export class CyPromptLifecycleManager {
private static hashLoadingMap: Map<string, Promise<Record<string, string>>> = new Map()
private static hashLoadingMap: Map<string, Promise<{ manifest: Record<string, string>, cyPromptPath: string }>> = new Map()
private static watcher: chokidar.FSWatcher | null = null
private static teardown: ExitStepKey | null = null
static async close () {
CyPromptLifecycleManager.watcher?.removeAllListeners()
await CyPromptLifecycleManager.watcher?.close().catch(() => {})
}
private cyPromptManagerPromise?: Promise<{
cyPromptManager?: CyPromptManager
error?: Error
@@ -178,9 +186,7 @@ export class CyPromptLifecycleManager {
})
if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) {
// The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension
this.cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] as string
cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', this.cyPromptHash)
this.cyPromptHash = parseHashFromBundleUrl(cyPromptSession.cyPromptUrl)
let hashLoadingPromise = CyPromptLifecycleManager.hashLoadingMap.get(this.cyPromptHash)
@@ -188,13 +194,15 @@ export class CyPromptLifecycleManager {
hashLoadingPromise = ensureCyPromptBundle({
cyPromptUrl: cyPromptSession.cyPromptUrl,
projectId,
cyPromptPath,
})
CyPromptLifecycleManager.hashLoadingMap.set(this.cyPromptHash, hashLoadingPromise)
}
manifest = await hashLoadingPromise
const result = await hashLoadingPromise
manifest = result.manifest
cyPromptPath = result.cyPromptPath
} else {
cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH
this.cyPromptHash = 'local'
@@ -282,10 +290,18 @@ export class CyPromptLifecycleManager {
// Close the watcher if a previous watcher exists
if (CyPromptLifecycleManager.watcher) {
CyPromptLifecycleManager.watcher.removeAllListeners()
CyPromptLifecycleManager.watcher.close().catch(() => {})
CyPromptLifecycleManager.close().catch(() => {})
}
if (CyPromptLifecycleManager.teardown) {
GracefulExit.removeStep(CyPromptLifecycleManager.teardown)
CyPromptLifecycleManager.teardown = null
}
CyPromptLifecycleManager.teardown = GracefulExit.addStep(async () => {
await CyPromptLifecycleManager.close()
}, 'close cy prompt watcher')
// Watch for changes to the cy prompt bundle
CyPromptLifecycleManager.watcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server', 'index.js'), {
awaitWriteFinish: true,
@@ -1,51 +1,27 @@
import { ensureDir, readFile, pathExists, remove } from 'fs-extra'
import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle'
import path from 'path'
import { verifySignature } from '../encryption'
import { extractAtomic } from '../extract_atomic'
import { ensureSignedBundle } from '../bundles/ensure_signed_bundle'
interface EnsureCyPromptBundleOptions {
cyPromptPath: string
cyPromptUrl: string
projectId?: string
}
/**
* Ensures that the cy prompt bundle is downloaded and extracted into the given path
* @param options - The options for the ensure cy prompt bundle operation
* @param options.cyPromptPath - The path to extract the cy prompt bundle to
* @param options.cyPromptUrl - The URL of the cy prompt bundle
* @param options.projectId - The project ID of the cy prompt bundle
*/
export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId }: EnsureCyPromptBundleOptions): Promise<Record<string, string>> => {
const bundlePath = path.join(cyPromptPath, 'bundle.tar')
await ensureDir(cyPromptPath)
const uniqueBundlePath = `${bundlePath}-${Math.random().toString(36).substring(2, 15)}`
const responseManifestSignature: string = await getCyPromptBundle({
cyPromptUrl,
projectId,
bundlePath: uniqueBundlePath,
})
await extractAtomic(uniqueBundlePath, cyPromptPath).finally(async () => {
await remove(uniqueBundlePath).catch(() => { /* ignore */ })
})
const manifestPath = path.join(cyPromptPath, 'manifest.json')
if (!(await pathExists(manifestPath))) {
throw new Error('Unable to find cy-prompt manifest')
}
const manifestContents = await readFile(manifestPath, 'utf8')
const verified = await verifySignature(manifestContents, responseManifestSignature)
if (!verified) {
throw new Error('Unable to verify cy-prompt signature')
}
return JSON.parse(manifestContents)
interface EnsureCyPromptBundleResult {
manifest: Record<string, string>
cyPromptPath: string
}
export const ensureCyPromptBundle = async ({
cyPromptUrl,
projectId,
}: EnsureCyPromptBundleOptions): Promise<EnsureCyPromptBundleResult> => {
const { manifest, bundleDir } = await ensureSignedBundle({
url: cyPromptUrl,
projectId,
kind: 'cy-prompt',
})
return {
manifest,
cyPromptPath: bundleDir,
}
}
+9 -15
View File
@@ -55,23 +55,17 @@ export function verifySignature (body: BinaryLike, signature: string, publicKey?
return verify.verify(publicKey || getPublicKey(), signature, 'base64')
}
export function verifySignatureFromFile (file: string, signature: string, publicKey?: crypto.KeyObject): Promise<boolean> {
export function createStreamingSignatureVerifier (publicKey?: crypto.KeyObject) {
const verify = crypto.createVerify('SHA256')
const stream = fs.createReadStream(file)
stream.on('data', (chunk: crypto.BinaryLike) => {
verify.update(chunk)
})
return new Promise<boolean>((resolve, reject) => {
stream.on('end', () => {
verify.end()
resolve(verify.verify(publicKey || getPublicKey(), signature, 'base64'))
})
stream.on('error', reject)
})
return {
update (chunk: BinaryLike) {
verify.update(chunk)
},
verify (signature: string): boolean {
return verify.verify(publicKey || getPublicKey(), signature, 'base64')
},
}
}
// Implements the https://www.rfc-editor.org/rfc/rfc7516 spec
+6 -64
View File
@@ -1,8 +1,4 @@
import { createReadStream } from 'fs'
import tar from 'tar'
import { ensureDir } from 'fs-extra'
import path from 'path'
import writeFileAtomic from 'write-file-atomic'
import { rename } from 'fs-extra'
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 100
@@ -12,27 +8,19 @@ function isRetryableError (err: unknown): err is NodeJS.ErrnoException {
? (err as NodeJS.ErrnoException).code
: undefined
return code === 'EPERM' || code === 'EACCES'
return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY'
}
function delay (ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function writeFileAtomicWithRetry (
filePath: string,
content: Buffer,
options: { mode?: number },
): Promise<void> {
async function retryOnRenameError<T> (op: () => Promise<T>): Promise<T> {
let lastError: unknown
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
await writeFileAtomic(filePath, content, {
mode: options.mode || 0o644,
})
return
return await op()
} catch (err) {
lastError = err
if (attempt < MAX_RETRIES && isRetryableError(err)) {
@@ -46,52 +34,6 @@ async function writeFileAtomicWithRetry (
throw lastError
}
export const extractAtomic = async (archivePath: string, destinationPath: string) => {
const entryPromises: Promise<void>[] = []
const parser = new tar.Parse()
parser.on('entry', (entry) => {
if (entry.type !== 'File') {
entry.resume() // skip non-files
return
}
const targetPath = path.join(destinationPath, entry.path)
const p = (async () => {
await ensureDir(path.dirname(targetPath))
const chunks: Buffer[] = []
for await (const chunk of entry) {
chunks.push(chunk)
}
const content = Buffer.concat(chunks)
await writeFileAtomicWithRetry(targetPath, content, {
mode: entry.mode || 0o644,
})
})()
entryPromises.push(p)
})
// Pipe archive into parser
const stream = createReadStream(archivePath)
stream.pipe(parser)
// Wait for parser to finish and all entry writes to complete
await new Promise<void>((resolve, reject) => {
parser.on('end', resolve)
// @ts-expect-error Parser extends NodeJS.ReadWriteStream (EventEmitter), so it supports 'error' events
// even though the types don't explicitly declare it
parser.on('error', reject)
stream.on('error', reject)
})
await Promise.all(entryPromises)
export async function renameAtomicWithRetry (src: string, dst: string): Promise<void> {
await retryOnRenameError(() => rename(src, dst))
}
@@ -13,7 +13,7 @@ import { asyncRetry } from '../../util/async_retry'
import { postStudioSession } from '../api/studio/post_studio_session'
import type { StudioServerOptions, StudioStatus } from '@packages/types'
import path from 'path'
import os from 'os'
import { parseHashFromBundleUrl } from '../bundles/parse_hash_from_bundle_url'
import { ensureStudioBundle } from './ensure_studio_bundle'
import chokidar from 'chokidar'
import { readFile } from 'fs/promises'
@@ -26,12 +26,15 @@ import crypto from 'crypto'
import { logError } from '@packages/stderr-filtering'
import { isNonRetriableCertErrorCode } from '../network/non_retriable_cert_error_codes'
import type { DebugData } from '@packages/types'
import { GracefulExit } from '../../util/graceful-exit'
import type { ExitStepKey } from '../../util/graceful-exit'
const debug = Debug('cypress:server:studio-lifecycle-manager')
const routes = require('../routes')
export class StudioLifecycleManager {
private static hashLoadingMap: Map<string, Promise<Record<string, string>>> = new Map()
private static teardown: ExitStepKey | null = null
private static hashLoadingMap: Map<string, Promise<{ manifest: Record<string, string>, studioPath: string }>> = new Map()
private static watcher: chokidar.FSWatcher | null = null
private studioManagerPromise?: Promise<StudioManager | null>
private studioManager?: StudioManager
@@ -56,7 +59,7 @@ export class StudioLifecycleManager {
* @param debugData Debug data for the configuration
* @param ctx Data context to register this instance with
*/
initializeStudioManager ({
async initializeStudioManager ({
cloudDataSource,
cfg,
debugData,
@@ -66,7 +69,7 @@ export class StudioLifecycleManager {
cfg: Cfg
debugData: any
ctx: DataContext
}): void {
}): Promise<void> {
debug('Initializing studio manager')
// Store initialization parameters for retry
@@ -134,7 +137,7 @@ export class StudioLifecycleManager {
this.studioManagerPromise = studioManagerPromise
this.setupWatcher({
await this.setupWatcher({
cloudDataSource,
cfg,
debugData,
@@ -198,10 +201,7 @@ export class StudioLifecycleManager {
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.ENSURE_STUDIO_BUNDLE_START)
if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) {
// The studio hash is the last part of the studio URL, after the last slash and before the extension
const studioHash = studioSession.studioUrl.split('/').pop()?.split('.')[0] as string
studioPath = path.join(os.tmpdir(), 'cypress', 'studio', studioHash)
const studioHash = parseHashFromBundleUrl(studioSession.studioUrl)
debug('Setting current studio hash: %s', studioHash)
// Store the current studio hash so that we can clear the cache entry when retrying
@@ -214,14 +214,16 @@ export class StudioLifecycleManager {
hashLoadingPromise = ensureStudioBundle({
studioUrl: studioSession.studioUrl,
studioPath,
projectId: currentProjectOptions.projectSlug,
})
StudioLifecycleManager.hashLoadingMap.set(studioHash, hashLoadingPromise)
}
manifest = await hashLoadingPromise
const result = await hashLoadingPromise
manifest = result.manifest
studioPath = result.studioPath
debug('Manifest: %o', manifest)
} else {
@@ -337,7 +339,12 @@ export class StudioLifecycleManager {
}
}
private setupWatcher ({
static async close () {
StudioLifecycleManager.watcher?.removeAllListeners()
await StudioLifecycleManager.watcher?.close().catch(() => {})
}
private async setupWatcher ({
cloudDataSource,
cfg,
debugData,
@@ -356,9 +363,18 @@ export class StudioLifecycleManager {
// Close the watcher if a previous watcher exists
if (StudioLifecycleManager.watcher) {
StudioLifecycleManager.watcher.removeAllListeners()
StudioLifecycleManager.watcher.close().catch(() => {})
await StudioLifecycleManager.close().catch(() => {})
}
if (StudioLifecycleManager.teardown) {
GracefulExit.removeStep(StudioLifecycleManager.teardown)
StudioLifecycleManager.teardown = null
}
StudioLifecycleManager.teardown = GracefulExit.addStep(async () => {
await StudioLifecycleManager.close()
}, 'close studio watcher')
// Watch for changes to the studio bundle
StudioLifecycleManager.watcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH, 'server', 'index.js'), {
awaitWriteFinish: true,
@@ -412,7 +428,7 @@ export class StudioLifecycleManager {
return !!(this.lastStatus === 'IN_ERROR' && this.lastErrorCode && isNonRetriableCertErrorCode(this.lastErrorCode))
}
public retry (): void {
public async retry (): Promise<void> {
if (!this.ctx) {
debug('No ctx available, cannot retry studio initialization')
@@ -439,7 +455,7 @@ export class StudioLifecycleManager {
// Re-initialize with the same parameters we stored
if (this.initializationParams) {
this.initializeStudioManager(this.initializationParams)
await this.initializeStudioManager(this.initializationParams)
} else {
debug('No initialization parameters available for retry')
this.updateStatus('IN_ERROR')
@@ -1,54 +1,27 @@
import { remove, ensureDir, readFile, pathExists } from 'fs-extra'
import { getStudioBundle } from '../api/studio/get_studio_bundle'
import path from 'path'
import { verifySignature } from '../encryption'
import { extractAtomic } from '../extract_atomic'
import { ensureSignedBundle } from '../bundles/ensure_signed_bundle'
interface EnsureStudioBundleOptions {
studioUrl: string
projectId?: string
}
interface EnsureStudioBundleResult {
manifest: Record<string, string>
studioPath: string
}
/**
* Ensures that the studio bundle is downloaded and extracted into the given path
* @param options - The options for the ensure studio bundle operation
* @param options.studioUrl - The URL of the studio bundle
* @param options.projectId - The project ID of the studio bundle
* @param options.studioPath - The path to extract the studio bundle to
*/
export const ensureStudioBundle = async ({
studioUrl,
projectId,
studioPath,
}: EnsureStudioBundleOptions): Promise<Record<string, string>> => {
const bundlePath = path.join(studioPath, 'bundle.tar')
await ensureDir(studioPath)
const uniqueBundlePath = `${bundlePath}-${Math.random().toString(36).substring(2, 15)}`
const responseManifestSignature: string = await getStudioBundle({
studioUrl,
bundlePath: uniqueBundlePath,
}: EnsureStudioBundleOptions): Promise<EnsureStudioBundleResult> => {
const { manifest, bundleDir } = await ensureSignedBundle({
url: studioUrl,
projectId,
kind: 'studio',
})
await extractAtomic(uniqueBundlePath, studioPath).finally(async () => {
await remove(uniqueBundlePath).catch(() => { /* ignore */ })
})
const manifestPath = path.join(studioPath, 'manifest.json')
if (!(await pathExists(manifestPath))) {
throw new Error('Unable to find studio manifest')
return {
manifest,
studioPath: bundleDir,
}
const manifestContents = await readFile(manifestPath, 'utf8')
const verified = await verifySignature(manifestContents, responseManifestSignature)
if (!verified) {
throw new Error('Unable to verify studio signature')
}
return JSON.parse(manifestContents)
}
+64 -60
View File
@@ -6,44 +6,39 @@
// synchronous requires the first go around just to
// essentially do it all again when we boot the correct
// mode.
import os from 'os'
import type { ChildProcess } from 'child_process'
import Debug from 'debug'
import { getPublicConfigKeys } from '@packages/config'
import { toObject, toArray } from './util/args'
import { telemetry } from '@packages/telemetry'
import { getCtx, hasCtx } from '@packages/data-context'
import { warning as errorsWarning } from './errors'
import { getCwd } from './cwd'
import type { CypressError } from '@packages/errors'
import { toNumber } from 'lodash'
import { GracefulExit } from './util/graceful-exit'
import type { BrowserWindow } from 'electron'
import type { CypressRunResult } from './modes/results'
const debug = Debug('cypress:server:cypress')
type Mode = 'exit' | 'info' | 'interactive' | 'pkg' | 'record' | 'results' | 'run' | 'smokeTest' | 'version' | 'returnPkg' | 'exitWithCode'
const exit = async (code = 0) => {
// TODO: we shouldn't have to do this
// but cannot figure out how null is
// being passed into exit
debug('about to exit with code', code)
interface MinimalRunResult {
totalFailed: number
}
if (hasCtx()) {
await getCtx().lifecycleManager.mainProcessWillDisconnect().catch((err: any) => {
debug('mainProcessWillDisconnect errored with: ', err)
})
}
/** Resolved value from {@link runElectron} (in-process Electron vs spawned child). */
type RunElectronResult =
| number
| CypressRunResult
| MinimalRunResult
| BrowserWindow
const span = telemetry.getSpan('cypress')
span?.setAttribute('exitCode', code)
span?.end()
await telemetry.shutdown().catch((err: any) => {
debug('telemetry shutdown errored with: ', err)
})
debug('process.exit', code)
return process.exit(code)
function isCypressRunResult (result: any): result is CypressRunResult {
return result && typeof result === 'object' && 'runs' in result && Array.isArray(result.runs)
}
function isMinimalRunResult (result: any): result is MinimalRunResult {
return result && typeof result === 'object' && 'totalFailed' in result
}
const showWarningForInvalidConfig = (options: any) => {
@@ -63,10 +58,6 @@ const showWarningForInvalidConfig = (options: any) => {
return undefined
}
const exit0 = () => {
return exit(0)
}
function isCypressError (err: unknown): err is CypressError {
return (err as CypressError).isCypressErr
}
@@ -85,11 +76,11 @@ async function exitErr (err: unknown, posixExitCodes?: boolean) {
err.type === 'CLOUD_CANNOT_PROCEED_IN_PARALLEL_NETWORK' ||
err.type === 'CLOUD_CANNOT_PROCEED_IN_SERIAL_NETWORK'
)) {
return exit(112)
return GracefulExit.exitGracefully(112)
}
}
return exit(1)
return GracefulExit.exitGracefully(1)
}
export = {
@@ -97,7 +88,7 @@ export = {
return require('./util/electron-app').isRunning()
},
runElectron (mode: Mode, options: any) {
runElectron (mode: Mode, options: any): Promise<RunElectronResult> {
// wrap all of this in a promise to force the
// promise interface - even if it doesn't matter
// in dev mode due to cp.spawn
@@ -118,30 +109,29 @@ export = {
return require('./modes')(mode, options)
}
return new Promise((resolve) => {
return new Promise(async (resolve) => {
debug('starting Electron')
const cypressElectron = require('@packages/electron')
const fn = (code: number) => {
// juggle up the totalFailed since our outer
// promise is expecting this object structure
debug('electron finished with', code)
if (mode === 'smokeTest') {
return resolve(code)
}
return resolve({ totalFailed: code })
}
const args = toArray(options)
const args = require('./util/args').toArray(options)
debug('electron open arguments %o', args)
// const mainEntryFile = require.main.filename
const serverMain = getCwd()
return cypressElectron.open(serverMain, args, fn)
const child: ChildProcess = await cypressElectron.open(serverMain, args)
child.on('close', (exitCode, signal) => {
debug('electron closed with', { code: exitCode, signal })
const code = signal ? 1 : (exitCode ?? 0)
if (mode === 'smokeTest') {
resolve(code)
} else {
resolve({ totalFailed: code })
}
})
})
})
},
@@ -242,10 +232,16 @@ export = {
case 'smokeTest': {
const pong = await this.runElectron(mode, options)
const code = typeof pong === 'number'
? pong
: typeof pong === 'object' && 'totalFailed' in pong
? pong.totalFailed
: Number(pong ?? 0)
if (!this.isCurrentlyRunningElectron()) {
return exit(pong)
return GracefulExit.exitGracefully(code)
} else if (pong !== options.ping) {
return exit(1)
return GracefulExit.exitGracefully(1)
}
break
@@ -258,30 +254,38 @@ export = {
break
}
case 'exitWithCode': {
return exit(toNumber(options.exitWithCode))
return GracefulExit.exitGracefully(toNumber(options.exitWithCode))
break
}
case 'run': {
const results = await this.runElectron(mode, options)
if (results.runs) {
const isCanceled = results.runs.filter((run) => run.skippedSpec).length
if (isCanceled) {
if (
isCypressRunResult(results) &&
(results.runs.filter((run) => run.skippedSpec).length)
) {
// eslint-disable-next-line no-console
console.log(require('chalk').magenta('\n Exiting with non-zero exit code because the run was canceled.'))
return exit(1)
return GracefulExit.exitGracefully(1)
}
if (isCypressRunResult(results) || isMinimalRunResult(results)) {
// Exit code 112 is reserved for network errors in parallel mode
// All other exit codes are "number of tests that failed," so collapse
// them to 0/1.
if (options.posixExitCodes && results.totalFailed !== 112) {
return GracefulExit.exitGracefully(results.totalFailed ? 1 : 0)
} else {
return GracefulExit.exitGracefully(results.totalFailed ?? 0)
}
}
debug('results.totalFailed, posix?', results.totalFailed, options.posixExitCodes)
if (options.posixExitCodes) {
return exit(results.totalFailed ? 1 : 0)
if (typeof results === 'number') {
return GracefulExit.exitGracefully(results)
}
return exit(results.totalFailed ?? 0)
throw new Error('unexpected runElectron result for run mode')
}
default: {
throw new Error(`Cannot start. Invalid mode: '${mode}'`)
@@ -292,6 +296,6 @@ export = {
}
debug('end of startInMode, exit 0')
return exit(0)
return GracefulExit.exitGracefully(0)
},
}
+5
View File
@@ -29,6 +29,7 @@ import appData from './util/app_data'
import browsers from './browsers'
import devServer from './plugins/dev-server'
import { remoteSchemaWrapped } from '@packages/data-context/graphql'
import { GracefulExit } from './util/graceful-exit'
const { getBrowsers, ensureAndGetByNameOrPath } = browserUtils
@@ -223,5 +224,9 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext {
},
})
GracefulExit.addStep(async () => {
await clearCtx()
}, 'clear data context')
return ctx
}
+10 -25
View File
@@ -7,6 +7,7 @@ import * as cyIcons from '@packages/icons'
import * as savedState from '../saved_state'
import menu from '../gui/menu'
import * as Windows from '../gui/windows'
import { GracefulExit } from '../util/graceful-exit'
import { makeGraphQLServer } from '@packages/data-context/graphql/makeGraphQLServer'
import { globalPubSub, getCtx, clearCtx } from '@packages/data-context'
import { telemetry } from '@packages/telemetry'
@@ -173,38 +174,22 @@ export = {
makeGraphQLServer(),
])
// Before the electron app quits, we interrupt and ensure the current
// DataContext is completely destroyed prior to quitting the process.
// Parts of the DataContext teardown are asynchronous, particularly the
// closing of open file watchers, and not awaiting these can cause
// the electron process to throw.
// Ctrl+C (SIGINT) on macOS/Unix is routed to app.quit() in Electron; the
// Node process.on('SIGINT') path often does not run. Use before-quit (not
// will-quit), which still fires for that quit path.
// The dev Node launcher relays SIGINT to this process and must not exit first
// or quit lifecycle never completes (see cypress runElectron + GracefulExit.detachProcessSignalHandlers).
// https://github.com/cypress-io/cypress/issues/22026
app.once('will-quit', (event: Electron.Event) => {
// We must call synchronously call preventDefault on the will-quit event
// to halt the current quit lifecycle
app.on('before-quit', async (event: Electron.Event) => {
event.preventDefault()
debug('clearing DataContext prior to quit')
// We use setImmediate to guarantee that app.quit will be called asynchronously;
// synchronously calling app.quit in the will-quit handler prevent the subsequent
// close from occurring
setImmediate(async () => {
try {
await clearCtx()
await GracefulExit.exitGracefully(0)
} catch (e) {
// Silently handle clearCtx errors, we still need to quit the app
debug(`DataContext cleared with error: ${e?.message}`)
debug(`graceful exit errored during quit: ${(e as Error)?.message}`)
process.exit(1)
}
debug('DataContext cleared, quitting app')
telemetry.getSpan('cypress')?.end()
await telemetry.shutdown()
app.quit()
})
})
+6 -3
View File
@@ -1,9 +1,8 @@
import _ from 'lodash'
import la from 'lazy-ass'
import Debug from 'debug'
import Bluebird from 'bluebird'
import assert from 'assert'
import { EventEmitter } from 'events'
import { ProjectBase } from './project-base'
import browsers from './browsers'
import * as errors from './errors'
@@ -19,13 +18,15 @@ import type { BrowserInstance, Browser } from './browsers/types'
const debug = Debug('cypress:server:open_project')
export class OpenProject {
export class OpenProject extends EventEmitter {
private projectBase: ProjectBase | null = null
relaunchBrowser: (() => Promise<BrowserInstance | null>) = () => {
throw new Error('bad relaunch')
}
constructor () {
super()
return autoBindDebug(this)
}
@@ -349,6 +350,8 @@ export class OpenProject {
}
}
this.emit('ready')
return this
}
@@ -1,9 +1,15 @@
process.title = 'Cypress: Config Manager'
const os = require('os')
const pDefer = require('p-defer')
const Debug = require('debug')
const debug = Debug('cypress:lifecycle:require_async_child')
const { telemetry, OTLPTraceExporterIpc, decodeTelemetryContext } = require('@packages/telemetry')
const { file, projectRoot, telemetryCtx } = require('minimist')(process.argv.slice(2))
debug('initializing telemetry')
const { context, version } = decodeTelemetryContext(telemetryCtx)
const exporter = new OTLPTraceExporterIpc()
@@ -14,26 +20,67 @@ if (version && context) {
const span = telemetry.startSpan({ name: 'child:process', active: true })
debug('child:process span initialized')
require('../../util/suppress_warnings').suppress()
process.on('disconnect', () => {
process.exit()
})
require('graceful-fs').gracefulify(require('fs'))
const util = require('../util')
const ipc = util.wrapIpc(process)
const run = require('./run_require_async_child')
exporter.attachIPC(ipc)
let disconnection = null
let willDisconnect = pDefer()
process.on('disconnect', async () => {
try {
debug('received disconnect event')
willDisconnect.resolve()
await Promise.resolve() // allow for diconnect teardown to complete, if in process
} finally {
process.exit()
}
})
debug('registering main:process:will:disconnect listener')
ipc.on('main:process:will:disconnect', async () => {
debug('received main:process:will:disconnect')
if (span) {
debug('ending span')
span.end()
}
await telemetry.shutdown()
debug('existing disconnection?', disconnection)
debug('waiting telemetry shutdown')
const p = disconnection ?? (disconnection = telemetry.shutdown())
await p
debug('telemetry shutdown complete')
debug('sending main:process:will:disconnect:ack')
ipc.send('main:process:will:disconnect:ack')
willDisconnect.resolve()
})
;['SIGINT', 'SIGTERM'].forEach((signal) => {
process.on(signal, async () => {
debug('received signal', signal)
await Promise.race([
willDisconnect.promise,
new Promise((resolve) => {
setTimeout(() => {
debug('timeout waiting for main:process:will:disconnect signal')
resolve()
}, 5000)
}),
])
process.exit(128 + os.constants.signals[signal])
})
})
const run = require('./run_require_async_child')
debug('run')
run(ipc, file, projectRoot)
debug('run complete')
+1 -1
View File
@@ -180,7 +180,7 @@ export class ProjectBase extends EE {
if ((!cfg.isTextTerminal || process.env.CYPRESS_INTERNAL_SIMULATE_OPEN_MODE) && this.testingType === 'e2e') {
const studioLifecycleManager = new StudioLifecycleManager()
studioLifecycleManager.initializeStudioManager({
await studioLifecycleManager.initializeStudioManager({
cloudDataSource: this.ctx.cloud,
cfg,
debugData: this.configDebugData,
+23 -10
View File
@@ -30,7 +30,6 @@ import type { Cfg } from './project-base'
import type { Browser } from './browsers/types'
import { InitializeRoutes, createCommonRoutes } from './routes'
import type { FoundSpec, ProtocolManagerShape, TestingType } from '@packages/types'
import type { Server as WebSocketServer } from 'ws'
import { RemoteStates } from '@packages/network-tools'
import type { RemoteState } from '@packages/network-tools'
import { cookieJar, SerializableAutomationCookie } from './util/cookies'
@@ -38,6 +37,7 @@ import * as fileServer from './file_server'
import type { FileServer } from './file_server'
import appData from './util/app_data'
import { graphqlWS } from '@packages/data-context/graphql/makeGraphQLServer'
import type { GraphqlWsHandle } from '@packages/data-context/graphql/makeGraphQLServer'
import * as statusCode from './util/status_code'
import { getContentType } from './util/headers'
import stream from 'stream'
@@ -47,6 +47,7 @@ import type { ServiceWorkerClientEvent } from '@packages/proxy/lib/http/util/ser
import type { Automation } from './automation'
import type { AutomationCookie } from './automation/cookies'
import type { ResourceType, RequestCredentialLevel } from '@packages/proxy'
import { GracefulExit } from './util/graceful-exit'
const debug = Debug('cypress:server:server-base')
@@ -162,7 +163,7 @@ export class ServerBase<TSocket extends SocketE2E | SocketCt> {
// @ts-ignore - this is currently affecting the v8-snapshot type checking job as we are importing the file directly from the server package
// After some package refactoring, we should be able to remove this.
protected _httpsProxy?: httpsProxy
protected _graphqlWS?: WebSocketServer
protected _graphqlWS?: GraphqlWsHandle
protected _eventBus: EventEmitter
protected _remoteStates: RemoteStates
private getCurrentBrowser: undefined | (() => Browser)
@@ -409,7 +410,9 @@ export class ServerBase<TSocket extends SocketE2E | SocketCt> {
}
useMorgan () {
return require('morgan')('dev')
return require('morgan')('dev', {
skip: () => GracefulExit.isShuttingDown,
})
}
getHttpServer () {
@@ -657,13 +660,23 @@ export class ServerBase<TSocket extends SocketE2E | SocketCt> {
}
close () {
return Bluebird.all([
this._close(),
this._socket?.close(),
this._fileServer?.close(),
this._httpsProxy?.close(),
this._graphqlWS?.close(),
])
// graphql-ws clients must be closed before the HTTP server is destroyed.
const graphqlDispose = this._graphqlWS?.dispose
? Bluebird.resolve(this._graphqlWS.dispose()).finally(() => {
// graphql-ws dispose() closes the ws server; repeating close() rejects with
// "The server is not running". Clear handle so subsequent close() is a no-op for gql.
this._graphqlWS = undefined
})
: Bluebird.resolve()
return graphqlDispose.then(() => {
return Bluebird.all([
this._close(),
this._socket?.close(),
this._fileServer?.close(),
this._httpsProxy?.close(),
])
})
.then((res) => {
this._middleware = null
+4 -6
View File
@@ -114,14 +114,12 @@ module.exports = {
},
ensure () {
// ensureSymlinkAsync lstats its src, so the appData dir must exist
// before symlink() runs — these can't be parallelized.
const ensure = () => {
return this.removeSymlink()
.then(() => {
return Promise.join(
fs.ensureDirAsync(this.path()),
!isProduction() ? this.symlink() : undefined,
)
})
.then(() => fs.ensureDirAsync(this.path()))
.then(() => (!isProduction() ? this.symlink() : undefined))
}
// try twice to ensure the dir
-7
View File
@@ -1,7 +0,0 @@
import { onExit as onExitSignalExit } from 'signal-exit'
// NOTE: this is much easier to test with sinon.stub() as we can stub the export object
// while we convert to TypeScript. Once we migrate to vitest, we can import `signal-exit` directly.
export default {
ensure: onExitSignalExit,
}
+30 -5
View File
@@ -7,8 +7,10 @@ import Promise from 'bluebird'
import lockFileModule from 'lockfile'
import { fs } from './fs'
import * as env from './env'
import exit from './exit'
import pQueue from 'p-queue'
import { GracefulExit } from './graceful-exit'
import type { ExitStepKey } from './graceful-exit'
const lockFile = Promise.promisifyAll(lockFileModule)
const debugVerbose = debugModule('cypress-verbose:server:util:file')
@@ -18,8 +20,8 @@ const LOCK_TIMEOUT = 2000
function getUid () {
try {
// @ts-expect-error - process.geteuid is defined
return process.geteuid()
// eslint-disable-next-line no-restricted-properties
return process.geteuid?.() ?? 1
} catch (err) {
// process.geteuid() can fail, return a constant
// @see https://github.com/cypress-io/cypress/issues/17415
@@ -33,6 +35,8 @@ export class File {
_queue!: pQueue
_cache!: Record<string, any>
_lastRead!: number
/** Set while a lock may be held; removed in `_unlock` so GracefulExit steps do not accumulate per unused File. */
_gracefulExitStepKey: ExitStepKey | null = null
path: string
static noopFile = {
@@ -56,9 +60,13 @@ export class File {
this.path = options.path
this.initialize()
exit.ensure(() => {
// Preserve prior behavior of invoking GracefulExit.addStep from the constructor (see file_spec),
// but do not leave a registered step until we actually take a lock — avoids orphaned teardown steps.
const ctorExitKey = GracefulExit.addStep(async () => {
return lockFile.unlockSync(this._lockFilePath)
})
}, 'unlock lockfile')
GracefulExit.removeStep(ctorExitKey)
}
initialize () {
@@ -74,6 +82,11 @@ export class File {
}
__resetForTest () {
if (this._gracefulExitStepKey) {
GracefulExit.removeStep(this._gracefulExitStepKey)
this._gracefulExitStepKey = null
}
this._queue.clear()
lockFile.unlockSync(this._lockFilePath)
this.initialize()
@@ -251,6 +264,13 @@ export class File {
// polls every 100ms up to 2000ms to obtain lock, otherwise rejects
return lockFile.lockAsync(this._lockFilePath, { wait: LOCK_TIMEOUT })
})
.then(() => {
if (!this._gracefulExitStepKey) {
this._gracefulExitStepKey = GracefulExit.addStep(async () => {
return lockFile.unlockSync(this._lockFilePath)
}, 'unlock lockfile')
}
})
.finally(() => {
return debugVerbose('getting lock succeeded or failed for %s', this.path)
})
@@ -266,6 +286,11 @@ export class File {
debugVerbose(`unlock timeout error for %s`, this._lockFilePath)
})
.finally(() => {
if (this._gracefulExitStepKey) {
GracefulExit.removeStep(this._gracefulExitStepKey)
this._gracefulExitStepKey = null
}
return debugVerbose('unlock succeeded or failed for %s', this.path)
})
}
+207
View File
@@ -0,0 +1,207 @@
/* eslint-disable no-console */
import Debug from 'debug'
import { randomUUID } from 'crypto'
import os from 'os'
/** Window after teardown starts in which extra signals are treated as duplicate delivery, not a second user interrupt. */
const SIGNAL_DEDUP_MS = 200
function getTeardownTimeoutMs (): number {
const n = Number(process.env.CYPRESS_INTERNAL_TEARDOWN_TIMEOUT)
return Number.isFinite(n) && n > 0 ? n : 5000
}
export interface ExitStep {
name: string
fn: (code: number) => Promise<number | void> | void
}
export type ExitStepKey = string
export class GracefulExit {
private static instance: GracefulExit | null = null
private static get singleton () {
return this.instance ?? (this.instance = new GracefulExit())
}
private readonly handledSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']
private readonly signalHandlers: Array<{ signal: NodeJS.Signals, listener: (sig: NodeJS.Signals) => void }> = []
private processTeardown: Promise<number | void> | null = null
private teardownStartedAt: number | null = null
private steps: Map<string, ExitStep> = new Map()
private debug: Debug.Debugger
/**
* Handles SIGINT/SIGTERM for this registration (see constructor loop).
*
* **Why debounce:** The same OS interrupt can surface multiple times on `process` in quick succession
* e.g. `signal-exit` (used by subprocess tooling) may call `process.kill(process.pid, sig)` after its
* own handler runs; multiple copies of `signal-exit` or other global handlers stack; or the CLI and
* Electron child share process-group semantics. Without a short dedup window, that second delivery
* arrived while `processTeardown` was already set and was misread as user pressed interrupt again to
* force quit, skipping in-flight teardown or exiting with code 1. We treat signals within
* `SIGNAL_DEDUP_MS` of teardown start as the same burst and only join the in-flight teardown promise;
* a later interrupt still forces exit so a hung teardown can be escaped by the user.
*/
private readonly handleProcessSignal = async (
registeredSignal: NodeJS.Signals,
received?: NodeJS.Signals,
): Promise<void> => {
const resolvedSig = received ?? registeredSignal
if (this.processTeardown) {
const elapsedMs = this.teardownStartedAt == null
? Infinity
: Date.now() - this.teardownStartedAt
if (elapsedMs < SIGNAL_DEDUP_MS) {
await this.processTeardown
return
}
console.log(`\n\n${resolvedSig} received during graceful exit. Forcing exit.`)
process.exit(1)
} else {
await GracefulExit.exitGracefully(128 + os.constants.signals[resolvedSig])
}
}
constructor () {
this.debug = Debug(`cypress:server:graceful-exit:${process.pid}`)
this.debug('initializing graceful exit in process %s', process.pid)
for (const sig of this.handledSignals) {
const listener = async (received?: NodeJS.Signals): Promise<void> => {
await this.handleProcessSignal(sig, received)
}
process.on(sig, listener)
this.signalHandlers.push({ signal: sig, listener })
}
}
/**
* Clears singleton state and signal listeners. Only for use from server unit tests
* (when `global.IS_TEST` is set by spec_helper).
*/
static resetForTesting (): void {
if (!(globalThis as { IS_TEST?: boolean }).IS_TEST) {
console.warn('GracefulExit.resetForTesting is a static harness only for unit tests')
return
}
const inst = GracefulExit.instance
if (!inst) {
return
}
for (const { signal, listener } of inst.signalHandlers) {
process.removeListener(signal, listener)
}
inst.steps.clear()
inst.processTeardown = null
inst.teardownStartedAt = null
GracefulExit.instance = null
}
static addStep (teardownFn: ExitStep['fn'], stepName?: string): ExitStepKey {
GracefulExit.singleton.debug('adding step to graceful exit: %s', stepName)
const key = randomUUID()
const name = stepName ?? teardownFn.name ?? key
GracefulExit.singleton.steps.set(key, { name, fn: teardownFn })
return key
}
static removeStep (key: ExitStepKey): void {
GracefulExit.singleton.steps.delete(key)
}
static get isShuttingDown (): boolean {
return GracefulExit.singleton.processTeardown != null
}
private async flushSteps (code: number): Promise<number> {
let hadErrors = false
await Promise.all(Array.from(this.steps.entries()).map(async ([key, { name, fn }]) => {
try {
this.debug(`<${key}> executing teardown step: %s`, name)
await fn(code)
this.debug(`<${key}> teardown step completed: %s`, name)
} catch (error) {
console.error(error)
this.debug(`<${key}> Error executing teardown step: ${name}`, error)
hadErrors = true
}
}))
if (hadErrors) {
console.error('Additional errors occurred during teardown. Exiting with code 1.')
return 1
}
return code
}
private async flushAndExit (code: number): Promise<number | void> {
let finalExitCode = code ?? 0
try {
finalExitCode = await this.flushSteps(code)
this.debug('steps flushed successfully', code, finalExitCode)
} catch (error) {
this.debug('Error flushing steps: ', error)
finalExitCode = 1
} finally {
this.processTeardown = null
this.teardownStartedAt = null
this.steps.clear()
process.exit(finalExitCode)
}
}
static async exitGracefully (code: number): Promise<number | void> {
const exit = GracefulExit.singleton
if (exit.processTeardown) {
return exit.processTeardown
}
let forceExitTimeout: NodeJS.Timeout | undefined = undefined
exit.teardownStartedAt = Date.now()
exit.processTeardown = Promise.race([
GracefulExit.singleton.flushAndExit(code).then(() => {
clearTimeout(forceExitTimeout)
}),
new Promise<void>((resolve) => {
forceExitTimeout = setTimeout(() => {
try {
const ms = getTeardownTimeoutMs()
console.error(`Failed to gracefully exit after ${ms}ms. Exiting with code 1. Configure with CYPRESS_INTERNAL_TEARDOWN_TIMEOUT (milliseconds).`)
} catch (e) {
console.error('Error forcing exit: ', e)
} finally {
clearTimeout(forceExitTimeout)
resolve()
process.exit(1)
}
}, getTeardownTimeoutMs())
}),
])
return exit.processTeardown
}
}
+3 -3
View File
@@ -39,6 +39,7 @@
"black-hole-stream": "0.0.1",
"bluebird": "3.7.2",
"body-parser": "1.20.4",
"cachedir": "^2.4.0",
"chalk": "2.4.2",
"chokidar": "3.5.1",
"chrome-remote-interface": "0.33.3",
@@ -107,7 +108,6 @@
"send": "0.19.0",
"serialize-error": "^7.0.1",
"shell-env": "4.0.1",
"signal-exit": "4.1.0",
"squirrelly": "^9.1.0",
"strip-ansi": "6.0.1",
"syntax-error": "1.4.0",
@@ -118,11 +118,11 @@
"trash": "7.2.0",
"tslib": "2.3.1",
"tsx": "4.20.6",
"untildify": "^4.0.0",
"url-parse": "1.5.10",
"webdriver": "9.14.0",
"webpack-virtual-modules": "0.5.0",
"widest-line": "3.1.0",
"write-file-atomic": "7.0.0"
"widest-line": "3.1.0"
},
"devDependencies": {
"@babel/core": "7.28.0",
+17
View File
@@ -1,12 +1,16 @@
const Debug = require('debug')
const electronApp = require('./lib/util/electron-app')
const { telemetry, OTLPTraceExporterCloud } = require('@packages/telemetry')
const { apiRoutes } = require('./lib/cloud/routes')
const encryption = require('./lib/cloud/encryption')
const { override: overrideTty } = require('./lib/util/tty')
const { GracefulExit } = require('./lib/util/graceful-exit')
const { NetProfiler } = require('./lib/util/net_profiler')
const { calculateCypressInternalEnv, configureLongStackTraces } = require('./lib/environment')
const debug = Debug('cypress:server:start-cypress')
process.env['CYPRESS_INTERNAL_ENV'] = calculateCypressInternalEnv()
configureLongStackTraces(process.env['CYPRESS_INTERNAL_ENV'])
process.env['CYPRESS'] = 'true'
@@ -52,6 +56,19 @@ if (isRunningElectron) {
telemetry.startSpan({ name: 'cypress', attachType: 'root', active: true, opts: { startTime: global.cypressBinaryStartTime } })
GracefulExit.addStep(async (code) => {
try {
const span = telemetry.getSpan('cypress')
span?.setAttribute('exitCode', code)
span?.end()
} catch (error) {
debug('Error during cleanup of telemetry span on exit: %o', error)
} finally {
await telemetry.shutdown()
}
}, 'finalize telemetry')
const v8SnapshotSpan = telemetry.startSpan({ name: 'v8snapshot:startup', opts: { startTime: global.cypressServerStartTime } })
v8SnapshotSpan?.end(endTime)
@@ -1,238 +0,0 @@
import path from 'path'
import os from 'os'
import fs from 'fs-extra'
import tar from 'tar'
import { extractAtomic } from '../../../lib/cloud/extract_atomic'
import { expect } from '../../spec_helper'
describe('extractAtomic integration', () => {
let tempDir: string
let archivePath: string
let destinationPath: string
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cypress-extract-atomic-'))
archivePath = path.join(tempDir, 'archive.tar')
destinationPath = path.join(tempDir, 'extracted')
})
afterEach(async () => {
if (tempDir) {
await fs.remove(tempDir).catch(() => {
// Ignore cleanup errors
})
}
})
it('should extract a single file from tar archive', async () => {
const sourceDir = path.join(tempDir, 'source')
const testFile = path.join(sourceDir, 'test.txt')
const fileContent = 'Hello, World!'
await fs.ensureDir(sourceDir)
await fs.writeFile(testFile, fileContent)
// Create tar archive
await tar.create(
{
file: archivePath,
cwd: sourceDir,
},
['test.txt'],
)
// Extract archive
await extractAtomic(archivePath, destinationPath)
// Verify extracted file
const extractedFile = path.join(destinationPath, 'test.txt')
expect(await fs.pathExists(extractedFile)).to.be.true
const content = await fs.readFile(extractedFile, 'utf8')
expect(content).to.equal(fileContent)
})
it('should extract multiple files from tar archive', async () => {
const sourceDir = path.join(tempDir, 'source')
const file1 = path.join(sourceDir, 'file1.txt')
const file2 = path.join(sourceDir, 'file2.txt')
const file3 = path.join(sourceDir, 'file3.txt')
await fs.ensureDir(sourceDir)
await fs.writeFile(file1, 'Content 1')
await fs.writeFile(file2, 'Content 2')
await fs.writeFile(file3, 'Content 3')
// Create tar archive
await tar.create(
{
file: archivePath,
cwd: sourceDir,
},
['file1.txt', 'file2.txt', 'file3.txt'],
)
// Extract archive
await extractAtomic(archivePath, destinationPath)
// Verify all files extracted
expect(await fs.readFile(path.join(destinationPath, 'file1.txt'), 'utf8')).to.equal('Content 1')
expect(await fs.readFile(path.join(destinationPath, 'file2.txt'), 'utf8')).to.equal('Content 2')
expect(await fs.readFile(path.join(destinationPath, 'file3.txt'), 'utf8')).to.equal('Content 3')
})
it('should extract files with nested directory structure', async () => {
const sourceDir = path.join(tempDir, 'source')
const nestedFile = path.join(sourceDir, 'nested', 'path', 'to', 'file.txt')
await fs.ensureDir(path.dirname(nestedFile))
await fs.writeFile(nestedFile, 'Nested content')
// Create tar archive
await tar.create(
{
file: archivePath,
cwd: sourceDir,
},
['nested'],
)
// Extract archive
await extractAtomic(archivePath, destinationPath)
// Verify nested file extracted
const extractedFile = path.join(destinationPath, 'nested', 'path', 'to', 'file.txt')
expect(await fs.pathExists(extractedFile)).to.be.true
expect(await fs.readFile(extractedFile, 'utf8')).to.equal('Nested content')
})
it('should skip non-file entries (directories, symlinks)', async () => {
const sourceDir = path.join(tempDir, 'source')
const testFile = path.join(sourceDir, 'test.txt')
const subDir = path.join(sourceDir, 'subdir')
await fs.ensureDir(subDir)
await fs.writeFile(testFile, 'File content')
await fs.writeFile(path.join(subDir, 'subfile.txt'), 'Sub file')
// Create tar archive
await tar.create(
{
file: archivePath,
cwd: sourceDir,
},
['.'],
)
// Extract archive
await extractAtomic(archivePath, destinationPath)
// Verify file was extracted
expect(await fs.pathExists(path.join(destinationPath, 'test.txt'))).to.be.true
expect(await fs.pathExists(path.join(destinationPath, 'subdir', 'subfile.txt'))).to.be.true
// Verify directories are not extracted as files (they should be created as directories)
const subdirPath = path.join(destinationPath, 'subdir')
const stats = await fs.stat(subdirPath)
expect(stats.isDirectory()).to.be.true
})
it('should preserve file permissions', async () => {
const sourceDir = path.join(tempDir, 'source')
const executableFile = path.join(sourceDir, 'script.sh')
const normalFile = path.join(sourceDir, 'normal.txt')
await fs.ensureDir(sourceDir)
await fs.writeFile(executableFile, '#!/bin/bash\necho "hello"')
await fs.writeFile(normalFile, 'Normal content')
await fs.chmod(executableFile, 0o755)
await fs.chmod(normalFile, 0o644)
// Create tar archive with preserve mode
await tar.create(
{
file: archivePath,
cwd: sourceDir,
preservePaths: true,
},
['script.sh', 'normal.txt'],
)
// Extract archive
await extractAtomic(archivePath, destinationPath)
// Verify file permissions (on Unix-like systems)
if (process.platform !== 'win32') {
const executableStats = await fs.stat(path.join(destinationPath, 'script.sh'))
const normalStats = await fs.stat(path.join(destinationPath, 'normal.txt'))
// Check that executable file has execute permission
expect(executableStats.mode & 0o111).to.not.equal(0)
// Check that normal file doesn't have execute permission
expect(normalStats.mode & 0o111).to.equal(0)
}
})
it('should handle binary files correctly', async () => {
const sourceDir = path.join(tempDir, 'source')
const binaryFile = path.join(sourceDir, 'binary.bin')
// Create a binary file with various byte values
const binaryContent = Buffer.from([0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD, 0x7F, 0x80])
await fs.ensureDir(sourceDir)
await fs.writeFile(binaryFile, binaryContent)
// Create tar archive
await tar.create(
{
file: archivePath,
cwd: sourceDir,
},
['binary.bin'],
)
// Extract archive
await extractAtomic(archivePath, destinationPath)
// Verify binary file extracted correctly
const extractedFile = path.join(destinationPath, 'binary.bin')
const extractedContent = await fs.readFile(extractedFile)
expect(extractedContent).to.deep.equal(binaryContent)
})
it('should handle large files', async () => {
const sourceDir = path.join(tempDir, 'source')
const largeFile = path.join(sourceDir, 'large.txt')
// Create a file with 1MB of data
const largeContent = 'A'.repeat(1024 * 1024)
await fs.ensureDir(sourceDir)
await fs.writeFile(largeFile, largeContent)
// Create tar archive
await tar.create(
{
file: archivePath,
cwd: sourceDir,
},
['large.txt'],
)
// Extract archive
await extractAtomic(archivePath, destinationPath)
// Verify large file extracted correctly
const extractedFile = path.join(destinationPath, 'large.txt')
const extractedContent = await fs.readFile(extractedFile, 'utf8')
expect(extractedContent.length).to.equal(1024 * 1024)
expect(extractedContent).to.equal(largeContent)
})
})
+121 -81
View File
@@ -147,6 +147,28 @@ function mockEE () {
return ee
}
/**
* `cypress.start` resolves only after graceful exit runs `clearCtx`, so
* `openProject.getProject()` is null in a `.then` on that promise.
* Register assertions that need `cfg` / `ProjectBase` in `assertWhileOpen`; it runs
* when `openProject` emits `ready` (project fully opened), before the run finishes and exit runs.
*
* @param {Promise<unknown>} startPromise - return value of `cypress.start(...)`
* @param {(project: import('../../lib/project-base').ProjectBase) => void | Promise<void>} assertWhileOpen
* @returns {Promise<void>}
*/
async function assertWithOpenProjectAfterReady (startPromise, assertWhileOpen) {
const whenReady = new Promise((resolve, reject) => {
openProject.once('ready', () => {
Promise.resolve(assertWhileOpen(openProject.getProject()))
.then(resolve)
.catch(reject)
})
})
await Promise.all([whenReady, startPromise])
}
let ctx
describe('lib/cypress', () => {
@@ -197,6 +219,8 @@ describe('lib/cypress', () => {
expect(process.exit).to.be.calledWith(code)
}
this.assertWithOpenProjectAfterReady = assertWithOpenProjectAfterReady
// returns error object
this.expectExitWithErr = (type, msg1, msg2) => {
expect(errors.log, 'error was logged').to.be.calledWithMatch({ type })
@@ -940,14 +964,16 @@ describe('lib/cypress', () => {
})
it('does not save project state', function () {
return cypress.start([`--run-project=${this.todosPath}`, `--spec=${this.todosPath}/tests/test2.coffee`])
.then(() => {
return assertWithOpenProjectAfterReady(
cypress.start([`--run-project=${this.todosPath}`, `--spec=${this.todosPath}/tests/test2.coffee`]),
(project) => {
// this should not save the project's state
// because its a noop in 'cypress run' mode
return project.saveState()
},
).then(() => {
this.expectExitWith(0)
// this should not save the project's state
// because its a noop in 'cypress run' mode
return openProject.getProject().saveState()
}).then(() => {
return fs.statAsync(this.statePath)
.then(() => {
throw new Error(`saved state should not exist but it did here: ${this.statePath}`)
@@ -958,9 +984,12 @@ describe('lib/cypress', () => {
describe('morgan', () => {
it('sets morgan to false', function () {
return cypress.start([`--run-project=${this.todosPath}`])
.then(() => {
expect(openProject.getProject().cfg.morgan).to.be.false
return assertWithOpenProjectAfterReady(
cypress.start([`--run-project=${this.todosPath}`]),
(project) => {
expect(project.cfg.morgan).to.be.false
},
).then(() => {
this.expectExitWith(0)
})
})
@@ -972,62 +1001,66 @@ describe('lib/cypress', () => {
})
it('can override default values', function () {
return cypress.start([`--run-project=${this.todosPath}`, '--config=requestTimeout=1234,videoCompression=true'])
.then(() => {
const { cfg } = openProject.getProject()
return assertWithOpenProjectAfterReady(
cypress.start([`--run-project=${this.todosPath}`, '--config=requestTimeout=1234,videoCompression=true']),
(project) => {
const { cfg } = project
expect(cfg.videoCompression).to.be.true
expect(cfg.requestTimeout).to.eq(1234)
expect(cfg.videoCompression).to.be.true
expect(cfg.requestTimeout).to.eq(1234)
expect(cfg.resolved.videoCompression).to.deep.eq({
value: true,
from: 'cli',
})
expect(cfg.resolved.requestTimeout).to.deep.eq({
value: 1234,
from: 'cli',
})
expect(cfg.resolved.videoCompression).to.deep.eq({
value: true,
from: 'cli',
})
expect(cfg.resolved.requestTimeout).to.deep.eq({
value: 1234,
from: 'cli',
})
},
).then(() => {
this.expectExitWith(0)
})
})
it('can override values in plugins', function () {
return cypress.start([
`--run-project=${this.pluginConfig}`, '--config=requestTimeout=1234,videoCompression=true',
'--env=foo=foo,bar=bar',
])
.then(() => {
const { cfg } = openProject.getProject()
return assertWithOpenProjectAfterReady(
cypress.start([
`--run-project=${this.pluginConfig}`, '--config=requestTimeout=1234,videoCompression=true',
'--env=foo=foo,bar=bar',
]),
(project) => {
const { cfg } = project
expect(cfg.videoCompression).to.eq(20)
expect(cfg.defaultCommandTimeout).to.eq(500)
expect(cfg.env).to.deep.eq({
foo: 'bar',
bar: 'bar',
})
expect(cfg.videoCompression).to.eq(20)
expect(cfg.defaultCommandTimeout).to.eq(500)
expect(cfg.env).to.deep.eq({
foo: 'bar',
bar: 'bar',
})
expect(cfg.resolved.videoCompression).to.deep.eq({
value: 20,
from: 'plugin',
})
expect(cfg.resolved.videoCompression).to.deep.eq({
value: 20,
from: 'plugin',
})
expect(cfg.resolved.requestTimeout).to.deep.eq({
value: 1234,
from: 'cli',
})
expect(cfg.resolved.requestTimeout).to.deep.eq({
value: 1234,
from: 'cli',
})
expect(cfg.resolved.env.foo).to.deep.eq({
value: 'bar',
from: 'plugin',
})
expect(cfg.resolved.env.bar).to.deep.eq({
value: 'bar',
from: 'cli',
})
expect(cfg.resolved.env.foo).to.deep.eq({
value: 'bar',
from: 'plugin',
})
expect(cfg.resolved.env.bar).to.deep.eq({
value: 'bar',
from: 'cli',
})
},
).then(() => {
this.expectExitWith(0)
})
})
@@ -1162,9 +1195,12 @@ describe('lib/cypress', () => {
const listen = sinon.spy(http.Server.prototype, 'listen')
const open = sinon.spy(ServerBase.prototype, 'open')
return cypress.start([`--run-project=${this.todosPath}`, '--port=5544'])
.then(() => {
expect(openProject.getProject().cfg.port).to.eq(5544)
return assertWithOpenProjectAfterReady(
cypress.start([`--run-project=${this.todosPath}`, '--port=5544']),
(project) => {
expect(project.cfg.port).to.eq(5544)
},
).then(() => {
expect(listen).to.be.calledWith(5544)
expect(open).to.be.calledWithMatch({ port: 5544 })
this.expectExitWith(0)
@@ -1195,37 +1231,41 @@ describe('lib/cypress', () => {
})
it('can set specific environment variables', function () {
return cypress.start([
`--run-project=${this.todosPath}`,
'--video=false',
'--env',
'version=0.12.1,foo=bar,host=http://localhost:8888,baz=quux=dolor',
])
.then(() => {
expect(openProject.getProject().cfg.env).to.deep.eq({
version: '0.12.1',
foo: 'bar',
host: 'http://localhost:8888',
baz: 'quux=dolor',
})
return assertWithOpenProjectAfterReady(
cypress.start([
`--run-project=${this.todosPath}`,
'--video=false',
'--env',
'version=0.12.1,foo=bar,host=http://localhost:8888,baz=quux=dolor',
]),
(project) => {
expect(project.cfg.env).to.deep.eq({
version: '0.12.1',
foo: 'bar',
host: 'http://localhost:8888',
baz: 'quux=dolor',
})
},
).then(() => {
this.expectExitWith(0)
})
})
it('parses environment variables with empty values', function () {
return cypress.start([
`--run-project=${this.todosPath}`,
'--video=false',
'--env=FOO=,BAR=,BAZ=ipsum',
])
.then(() => {
expect(openProject.getProject().cfg.env).to.deep.eq({
FOO: '',
BAR: '',
BAZ: 'ipsum',
})
return assertWithOpenProjectAfterReady(
cypress.start([
`--run-project=${this.todosPath}`,
'--video=false',
'--env=FOO=,BAR=,BAZ=ipsum',
]),
(project) => {
expect(project.cfg.env).to.deep.eq({
FOO: '',
BAR: '',
BAZ: 'ipsum',
})
},
).then(() => {
this.expectExitWith(0)
})
})
@@ -1,12 +1,11 @@
require('../spec_helper')
const { makeDataContext, setCtx, getCtx } = require('../../lib/makeDataContext')
setCtx(makeDataContext({}))
const { getCtx, setCtx, makeDataContext, clearCtx } = require('../../lib/makeDataContext')
const cp = require('child_process')
const fse = require('fs-extra')
const os = require('os')
const fs = require('fs')
const path = require('path')
const _ = require('lodash')
const { expect } = require('chai')
@@ -26,7 +25,72 @@ const { ServerBase } = require('../../lib/server-base')
const { SocketE2E } = require('../../lib/socket-e2e')
const { _getArgs } = require('../../lib/browsers/chrome')
const CHROME_PATH = 'google-chrome'
/**
* Resolves an absolute or PATH-resolvable Chrome/Chromium binary for `cp.spawn`.
* CI/Linux often exposes `google-chrome`; macOS and Windows need well-known install
* locations or an explicit env override.
*
* Resolution order:
* 1. `PROXY_PERF_CHROME` preferred override for this spec only.
* 2. `CHROME_PATH` generic override if the first is unset.
* 3. macOS: `/Applications/Google Chrome.app/...` then Chrome Canary.
* 4. Windows: standard `Program Files` Chrome paths.
* 5. Unix: first hit from `which` for `google-chrome`, `google-chrome-stable`, `chromium`, `chromium-browser`.
* 6. Fallback `google-chrome` (relies on PATH; matches typical Linux CI images).
*/
const resolveChromePathForProxyPerformance = () => {
const fromEnv = process.env.PROXY_PERF_CHROME || process.env.CHROME_PATH
if (fromEnv) {
return fromEnv
}
const platform = os.platform()
if (platform === 'darwin') {
const macPaths = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
]
for (const macPath of macPaths) {
if (fs.existsSync(macPath)) {
return macPath
}
}
}
if (platform === 'win32') {
const winPaths = [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
]
for (const winPath of winPaths) {
if (fs.existsSync(winPath)) {
return winPath
}
}
}
const nixNames = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']
for (const nixName of nixNames) {
const r = cp.spawnSync('which', [nixName], { encoding: 'utf8' })
if (r.status === 0) {
const found = String(r.stdout || '').trim().split('\n')[0]
if (found) {
return found
}
}
}
return 'google-chrome'
}
const CHROME_PATH = resolveChromePathForProxyPerformance()
const URLS_UNDER_TEST = [
'https://test-page-speed.cypress.io/index1000.html',
'http://test-page-speed.cypress.io/index1000.html',
@@ -334,49 +398,56 @@ describe('Proxy Performance', function () {
})
before(function () {
setCtx(makeDataContext({}))
// When this file runs after other specs (e.g. cy_visit_performance_spec.js loads first
// alphabetically), the prior test's spec_helper `afterEach` has already run `clearCtx`.
// Nested suite `before` runs before the next test's root `beforeEach`, so the global
// DataContext is still unset here unless we call `setCtx` again.
return Promise.resolve(clearCtx()).then(() => {
setCtx(makeDataContext({}))
const getFilesByGlob = getCtx().file.getFilesByGlob
const getFilesByGlob = getCtx().file.getFilesByGlob
return CA.create()
.then((ca) => {
return ca.generateServerCertificateKeys('localhost')
})
.then(([cert, key]) => {
return Promise.join(
new DebuggingProxy().start(PROXY_PORT),
return CA.create()
.then((ca) => {
return ca.generateServerCertificateKeys('localhost')
})
.spread((cert, key) => {
return Promise.join(
new DebuggingProxy().start(PROXY_PORT),
new DebuggingProxy({
https: { cert, key },
}).start(HTTPS_PROXY_PORT),
new DebuggingProxy({
https: { cert, key },
}).start(HTTPS_PROXY_PORT),
setupFullConfigWithDefaults({
projectRoot: '/tmp/a',
config: {
supportFile: false,
},
}, getFilesByGlob).then((config) => {
config.port = CY_PROXY_PORT
setupFullConfigWithDefaults({
projectRoot: '/tmp/a',
config: {
supportFile: false,
},
}, getFilesByGlob).then((config) => {
config.port = CY_PROXY_PORT
// turn off morgan
config.morgan = false
// turn off morgan
config.morgan = false
cyServer = new ServerBase(config)
cyServer = new ServerBase()
return cyServer.open(config, {
SocketCtor: SocketE2E,
createRoutes,
testingType: 'e2e',
getCurrentBrowser: () => null,
})
}),
)
return cyServer.open(config, {
SocketCtor: SocketE2E,
createRoutes,
testingType: 'e2e',
getCurrentBrowser: () => null,
})
}),
)
})
})
})
URLS_UNDER_TEST.map((urlUnderTest) => {
// TODO: fix flaky tests https://github.com/cypress-io/cypress/issues/23214
describe(urlUnderTest, { retries: 15 }, function () {
describe(urlUnderTest, function () {
this.retries(15)
let baseline
const testCases = _.cloneDeep(TEST_CASES)
+3
View File
@@ -25,6 +25,8 @@ require('chai')
.use(require('chai-uuid'))
.use(require('chai-as-promised'))
const { GracefulExit } = require('../lib/util/graceful-exit')
if (process.env.UPDATE) {
throw new Error('You\'re using UPDATE=1 which is the old way of updating snapshots.\n\nThe correct environment variable is SNAPSHOT_UPDATE=1')
}
@@ -102,6 +104,7 @@ before(async () => {
})
beforeEach(async function () {
GracefulExit.resetForTesting()
await clearCtx()
setCtx(makeDataContext({}))
this.originalEnv = originalEnv
@@ -0,0 +1,18 @@
import { publishStagingToFinal } from '../../lib/cloud/bundles/publish_staging_to_final'
const [staging, finalDir] = process.argv.slice(2)
if (!staging || !finalDir) {
// eslint-disable-next-line no-console
console.error('usage: cross_process_publish_worker.ts <staging> <finalDir>')
process.exit(2)
}
publishStagingToFinal(staging, finalDir).then(
() => process.exit(0),
(err) => {
// eslint-disable-next-line no-console
console.error(err)
process.exit(1)
},
)
@@ -1,291 +0,0 @@
import { proxyquire } from '../../../../spec_helper'
import { Readable, Writable } from 'stream'
import { HttpError } from '../../../../../lib/cloud/network/http_error'
import sinon from 'sinon'
describe('getCyPromptBundle', () => {
let writeResult: string
let readStream: Readable
let createWriteStreamStub: sinon.SinonStub
let crossFetchStub: sinon.SinonStub
let verifySignatureFromFileStub: sinon.SinonStub
let getCyPromptBundle: typeof import('../../../../../lib/cloud/api/cy-prompt/get_cy_prompt_bundle').getCyPromptBundle
beforeEach(() => {
createWriteStreamStub = sinon.stub()
crossFetchStub = sinon.stub()
verifySignatureFromFileStub = sinon.stub()
readStream = Readable.from('console.log("cy-prompt script")')
writeResult = ''
const writeStream = new Writable({
write: (chunk, encoding, callback) => {
writeResult += chunk.toString()
callback()
},
})
createWriteStreamStub.returns(writeStream)
getCyPromptBundle = proxyquire('../lib/cloud/api/cy-prompt/get_cy_prompt_bundle', {
'fs': {
createWriteStream: createWriteStreamStub,
},
'cross-fetch': crossFetchStub,
'os': {
platform: () => 'linux',
},
'@packages/root': {
version: '1.2.3',
},
'../../encryption': {
verifySignatureFromFile: verifySignatureFromFileStub,
},
}).getCyPromptBundle
})
it('downloads the cy-prompt bundle and extracts it', async () => {
crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') {
return '159'
}
if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})
verifySignatureFromFileStub.resolves(true)
const projectId = '12345'
const responseSignature = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-cypress-project-slug': '12345',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
})
expect(writeResult).to.eq('console.log("cy-prompt script")')
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159')
expect(responseSignature).to.eq('160')
})
it('downloads the cy-prompt bundle and extracts it after 1 fetch failure', async () => {
crossFetchStub.onFirstCall().rejects(new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub() as any))
crossFetchStub.onSecondCall().resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') {
return '159'
}
if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})
verifySignatureFromFileStub.resolves(true)
const projectId = '12345'
const responseSignature = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-cypress-project-slug': '12345',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
})
expect(writeResult).to.eq('console.log("cy-prompt script")')
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159')
expect(responseSignature).to.eq('160')
})
it('throws an error and returns a cy-prompt manager in error state if the fetch fails more than twice', async () => {
const error = new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub() as any)
crossFetchStub.rejects(error)
const projectId = '12345'
await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected
expect(crossFetchStub).to.be.calledThrice
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-cypress-project-slug': '12345',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
})
})
it('throws an error and returns a cy-prompt manager in error state if the response status is not ok', async () => {
crossFetchStub.resolves({
ok: false,
statusText: 'Some failure',
})
const projectId = '12345'
await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-cypress-project-slug': '12345',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
})
})
it('throws an error and returns a cy-prompt manager in error state if the signature verification fails', async () => {
verifySignatureFromFileStub.resolves(false)
crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') {
return '159'
}
if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})
verifySignatureFromFileStub.resolves(false)
const projectId = '12345'
await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected
expect(writeResult).to.eq('console.log("cy-prompt script")')
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-cypress-project-slug': '12345',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
})
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159')
})
it('throws an error if there is no signature in the response headers', async () => {
crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})
const projectId = '12345'
await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejectedWith('Unable to get cy-prompt signature')
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-cypress-project-slug': '12345',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
})
})
it('throws an error if there is no manifest signature in the response headers', async () => {
crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') {
return '159'
}
},
},
})
const projectId = '12345'
await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejectedWith('Unable to get cy-prompt manifest signature')
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-cypress-project-slug': '12345',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
})
})
})
@@ -1,322 +0,0 @@
import { proxyquire } from '../../../../spec_helper'
import { Readable, Writable } from 'stream'
import { HttpError } from '../../../../../lib/cloud/network/http_error'
import sinon from 'sinon'
describe('getStudioBundle', () => {
let writeResult: string
let readStream: Readable
let createWriteStreamStub: sinon.SinonStub
let crossFetchStub: sinon.SinonStub
let verifySignatureFromFileStub: sinon.SinonStub
let getStudioBundle: typeof import('../../../../../lib/cloud/api/studio/get_studio_bundle').getStudioBundle
beforeEach(() => {
createWriteStreamStub = sinon.stub()
crossFetchStub = sinon.stub()
verifySignatureFromFileStub = sinon.stub()
readStream = Readable.from('console.log("studio bundle")')
writeResult = ''
const writeStream = new Writable({
write: (chunk, encoding, callback) => {
writeResult += chunk.toString()
callback()
},
})
createWriteStreamStub.returns(writeStream)
getStudioBundle = proxyquire('../lib/cloud/api/studio/get_studio_bundle', {
'fs': {
createWriteStream: createWriteStreamStub,
},
'cross-fetch': crossFetchStub,
'os': {
platform: () => 'linux',
},
'@packages/root': {
version: '1.2.3',
},
'../../encryption': {
verifySignatureFromFile: verifySignatureFromFileStub,
},
}).getStudioBundle
})
it('downloads the studio bundle and extracts it', async () => {
crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') {
return '159'
}
if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})
verifySignatureFromFileStub.resolves(true)
const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
signal: sinon.match.any,
})
expect(writeResult).to.eq('console.log("studio bundle")')
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159')
expect(responseSignature).to.eq('160')
})
it('downloads the studio bundle and extracts it after 1 fetch failure', async () => {
crossFetchStub.onFirstCall().rejects(new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub() as any))
crossFetchStub.onSecondCall().resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') {
return '159'
}
if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})
verifySignatureFromFileStub.resolves(true)
const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
signal: sinon.match.any,
})
expect(writeResult).to.eq('console.log("studio bundle")')
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159')
expect(responseSignature).to.eq('160')
})
it('throws an error and returns a studio manager in error state if the fetch fails more than twice', async () => {
const error = new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub() as any)
crossFetchStub.rejects(error)
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
expect(crossFetchStub).to.be.calledThrice
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
signal: sinon.match.any,
})
})
it('throws an error and returns a studio manager in error state if the response status is not ok', async () => {
crossFetchStub.resolves({
ok: false,
statusText: 'Some failure',
})
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
signal: sinon.match.any,
})
})
it('throws an error and returns a cy-prompt manager in error state if the signature verification fails', async () => {
verifySignatureFromFileStub.resolves(false)
crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') {
return '159'
}
if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})
verifySignatureFromFileStub.resolves(false)
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
expect(writeResult).to.eq('console.log("studio bundle")')
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
signal: sinon.match.any,
})
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159')
})
it('throws an error if there is no signature in the response headers', async () => {
crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-manifest-signature') {
return '160'
}
},
},
})
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejectedWith('Unable to get studio signature')
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
signal: sinon.match.any,
})
})
it('throws an error if there is no manifest signature in the response headers', async () => {
crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') {
return '159'
}
},
},
})
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejectedWith('Unable to get studio manifest signature')
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
method: 'GET',
headers: {
'x-route-version': '1',
'x-cypress-signature': '1',
'x-os-name': 'linux',
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
signal: sinon.match.any,
})
})
it('handles AbortError and converts to timeout message', async () => {
const abortError = new Error('AbortError')
abortError.name = 'AbortError'
crossFetchStub.rejects(abortError)
await expect(getStudioBundle({
studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz',
bundlePath: '/tmp/cypress/studio/abc/bundle.tar',
})).to.be.rejectedWith('Studio bundle fetch timed out')
})
it('calls cleanup function when pipe operation errors', async () => {
const errorStream = new Writable({
write: (chunk, encoding, callback) => {
callback(new Error('Write error'))
},
})
const destroySpy = sinon.spy(errorStream, 'destroy')
createWriteStreamStub.returns(errorStream)
crossFetchStub.resolves({
ok: true,
statusText: 'OK',
body: readStream,
headers: {
get: (header) => {
if (header === 'x-cypress-signature') return '159'
if (header === 'x-cypress-manifest-signature') return '160'
},
},
})
await expect(getStudioBundle({
studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz',
bundlePath: '/tmp/cypress/studio/abc/bundle.tar',
})).to.be.rejected
expect(destroySpy).to.have.been.called
})
})
@@ -0,0 +1,50 @@
import '../../../spec_helper'
import { BundleError } from '../../../../lib/cloud/bundles/bundle_error'
describe('BundleError', () => {
it('carries kind and stage tags', () => {
const err = new BundleError({ kind: 'cy-prompt', stage: 'signature', message: 'bad sig' })
expect(err).to.be.instanceOf(Error)
expect(err.name).to.equal('BundleError')
expect(err.kind).to.equal('cy-prompt')
expect(err.stage).to.equal('signature')
expect(err.message).to.equal('bad sig')
})
it('preserves the cause when provided', () => {
const cause = new Error('upstream')
const err = new BundleError({ kind: 'studio', stage: 'network', message: 'wrapper', cause })
expect((err as Error & { cause?: unknown }).cause).to.equal(cause)
})
it('isBundleError narrows the type and returns true for own instances', () => {
const err = new BundleError({ kind: 'studio', stage: 'extract', message: 'm' })
expect(BundleError.isBundleError(err)).to.equal(true)
expect(BundleError.isBundleError(new Error('plain'))).to.equal(false)
expect(BundleError.isBundleError(undefined)).to.equal(false)
})
it('mirrors the cause syscall code on the BundleError itself', () => {
const cause = Object.assign(new Error('cert err'), { code: 'CERT_HAS_EXPIRED' })
const err = new BundleError({ kind: 'studio', stage: 'network', message: 'wrapper', cause })
expect(err.code).to.equal('CERT_HAS_EXPIRED')
})
it('leaves code undefined when cause has no string code', () => {
const err1 = new BundleError({ kind: 'studio', stage: 'extract', message: 'm' })
expect(err1.code).to.equal(undefined)
const err2 = new BundleError({ kind: 'studio', stage: 'extract', message: 'm', cause: new Error('plain') })
expect(err2.code).to.equal(undefined)
const err3 = new BundleError({ kind: 'studio', stage: 'extract', message: 'm', cause: { code: 42 } })
expect(err3.code).to.equal(undefined)
})
})
@@ -0,0 +1,85 @@
import '../../../spec_helper'
import path from 'path'
describe('getBundleCacheDir', () => {
const ENV_KEYS = [
'CYPRESS_CACHE_FOLDER',
'npm_config_CYPRESS_CACHE_FOLDER',
'npm_config_cypress_cache_folder',
'npm_package_config_CYPRESS_CACHE_FOLDER',
]
const snapshot: Record<string, string | undefined> = {}
beforeEach(() => {
for (const k of ENV_KEYS) {
snapshot[k] = process.env[k]
delete process.env[k]
}
})
afterEach(() => {
for (const k of ENV_KEYS) {
if (snapshot[k] === undefined) delete process.env[k]
else process.env[k] = snapshot[k]
}
})
// Bypass require cache so each test sees the current env state.
const loadCacheRoot = () => {
delete require.cache[require.resolve('../../../../lib/cloud/bundles/cache_root')]
return require('../../../../lib/cloud/bundles/cache_root')
}
it('honors CYPRESS_CACHE_FOLDER as-is for a clean absolute path', () => {
process.env.CYPRESS_CACHE_FOLDER = '/tmp/cypress-cache-test'
const { getBundleCacheDir } = loadCacheRoot()
expect(getBundleCacheDir('cy-prompt')).to.equal(path.resolve('/tmp/cypress-cache-test/bundles/cy-prompt'))
})
it('strips surrounding double quotes (Windows CMD `set FOO="C:\\path"` style)', () => {
process.env.CYPRESS_CACHE_FOLDER = '"/tmp/cypress-cache-test"'
const { getBundleCacheDir } = loadCacheRoot()
expect(getBundleCacheDir('studio')).to.equal(path.resolve('/tmp/cypress-cache-test/bundles/studio'))
})
it('trims whitespace around the env var value', () => {
process.env.CYPRESS_CACHE_FOLDER = ' /tmp/cypress-cache-test '
const { getBundleCacheDir } = loadCacheRoot()
expect(getBundleCacheDir('cy-prompt')).to.equal(path.resolve('/tmp/cypress-cache-test/bundles/cy-prompt'))
})
it('falls back to npm_config_CYPRESS_CACHE_FOLDER when the bare var is not set', () => {
process.env.npm_config_CYPRESS_CACHE_FOLDER = '/tmp/from-npmrc'
const { getBundleCacheDir } = loadCacheRoot()
expect(getBundleCacheDir('cy-prompt')).to.equal(path.resolve('/tmp/from-npmrc/bundles/cy-prompt'))
})
it('falls back to lowercase npm_config variant', () => {
process.env.npm_config_cypress_cache_folder = '/tmp/from-npmrc-lower'
const { getBundleCacheDir } = loadCacheRoot()
expect(getBundleCacheDir('studio')).to.equal(path.resolve('/tmp/from-npmrc-lower/bundles/studio'))
})
it('prefers the bare env var over npm_config_* when both are set', () => {
process.env.CYPRESS_CACHE_FOLDER = '/tmp/bare'
process.env.npm_config_CYPRESS_CACHE_FOLDER = '/tmp/npmrc'
const { getBundleCacheDir } = loadCacheRoot()
expect(getBundleCacheDir('cy-prompt')).to.equal(path.resolve('/tmp/bare/bundles/cy-prompt'))
})
it('treats empty / whitespace-only override as unset and falls back to cachedir()', () => {
process.env.CYPRESS_CACHE_FOLDER = ' '
const { getBundleCacheDir } = loadCacheRoot()
// cachedir('Cypress') varies by OS; just assert the bundles/<kind> tail.
expect(getBundleCacheDir('studio')).to.match(/[/\\]bundles[/\\]studio$/)
expect(getBundleCacheDir('studio')).to.not.equal(path.resolve('bundles/studio'))
})
})
@@ -0,0 +1,212 @@
import { proxyquire, sinon } from '../../../spec_helper'
import { ensureDir, mkdtemp, pathExists, readFile, remove, writeFile } from 'fs-extra'
import os from 'os'
import path from 'path'
import { BundleError } from '../../../../lib/cloud/bundles/bundle_error'
const FIXTURE_MANIFEST = { version: 1, entrypoint: 'server/index.js' }
const MANIFEST_TEXT = JSON.stringify(FIXTURE_MANIFEST)
const writeFixtureToStaging = async (staging: string) => {
await ensureDir(path.join(staging, 'server'))
await writeFile(path.join(staging, 'manifest.json'), MANIFEST_TEXT)
await writeFile(path.join(staging, 'server', 'index.js'), '// server entrypoint\n')
}
interface SetupResult {
ensureSignedBundle: typeof import('../../../../lib/cloud/bundles/ensure_signed_bundle').ensureSignedBundle
streamStub: sinon.SinonStub
verifySignatureStub: sinon.SinonStub
}
describe('ensureSignedBundle', () => {
let cacheRoot: string
let originalCacheFolder: string | undefined
beforeEach(async () => {
cacheRoot = await mkdtemp(path.join(os.tmpdir(), 'cy-ensure-bundle-'))
originalCacheFolder = process.env.CYPRESS_CACHE_FOLDER
process.env.CYPRESS_CACHE_FOLDER = cacheRoot
})
afterEach(async () => {
if (originalCacheFolder === undefined) {
delete process.env.CYPRESS_CACHE_FOLDER
} else {
process.env.CYPRESS_CACHE_FOLDER = originalCacheFolder
}
await remove(cacheRoot).catch(() => { /* ignore */ })
})
const setup = (overrides: Partial<{
streamImpl: (opts: { staging: string }) => Promise<string>
verifyResult: boolean
}> = {}): SetupResult => {
const streamStub = sinon.stub().callsFake(async (opts: { staging: string }) => {
if (overrides.streamImpl) return overrides.streamImpl(opts)
await writeFixtureToStaging(opts.staging)
return 'fake-manifest-sig'
})
const verifySignatureStub = sinon.stub().resolves(overrides.verifyResult ?? true)
const ensureSignedBundleModule = proxyquire('../lib/cloud/bundles/ensure_signed_bundle', {
'./stream_download_verify_extract': {
streamDownloadVerifyExtract: streamStub,
},
'../encryption': {
verifySignature: verifySignatureStub,
},
})
return {
ensureSignedBundle: ensureSignedBundleModule.ensureSignedBundle,
streamStub,
verifySignatureStub,
}
}
it('publishes verified bundle into <cache>/bundles/<kind>/<hash>/ and returns the manifest', async () => {
const { ensureSignedBundle, streamStub, verifySignatureStub } = setup()
const result = await ensureSignedBundle({
url: 'https://cdn.cypress.io/cy-prompt/abc123.tar',
projectId: 'proj-1',
kind: 'cy-prompt',
})
const expectedBundleDir = path.join(cacheRoot, 'bundles', 'cy-prompt', 'abc123')
expect(result.bundleDir).to.equal(expectedBundleDir)
expect(result.manifest).to.deep.equal(FIXTURE_MANIFEST)
expect(await readFile(path.join(expectedBundleDir, 'manifest.json'), 'utf8')).to.equal(MANIFEST_TEXT)
expect(await readFile(path.join(expectedBundleDir, 'server', 'index.js'), 'utf8')).to.equal('// server entrypoint\n')
expect(streamStub).to.be.calledOnce
expect(verifySignatureStub).to.be.calledWith(MANIFEST_TEXT, 'fake-manifest-sig')
// Staging dir is cleaned up
const baseDir = path.dirname(expectedBundleDir)
const fs = require('fs-extra')
const remaining: string[] = await fs.readdir(baseDir)
expect(remaining.filter((n: string) => n.startsWith('.staging-'))).to.deep.equal([])
})
it('throws BundleError(stage=manifest) when the manifest signature fails to verify', async () => {
const { ensureSignedBundle } = setup({ verifyResult: false })
let caught: unknown
try {
await ensureSignedBundle({
url: 'https://cdn.cypress.io/studio/badsig.tar',
kind: 'studio',
})
} catch (err) {
caught = err
}
expect(BundleError.isBundleError(caught)).to.equal(true)
expect((caught as BundleError).stage).to.equal('manifest')
expect((caught as BundleError).kind).to.equal('studio')
// finalDir was created (empty) but no files published
const finalDir = path.join(cacheRoot, 'bundles', 'studio', 'badsig')
expect(await pathExists(path.join(finalDir, 'manifest.json'))).to.equal(false)
expect(await pathExists(path.join(finalDir, 'server', 'index.js'))).to.equal(false)
})
it('throws BundleError(stage=manifest) when manifest.json is missing from staging', async () => {
const { ensureSignedBundle } = setup({
streamImpl: async ({ staging }) => {
// populate everything except manifest.json
await ensureDir(path.join(staging, 'server'))
await writeFile(path.join(staging, 'server', 'index.js'), '// orphan\n')
return 'sig'
},
})
let caught: unknown
try {
await ensureSignedBundle({
url: 'https://cdn.cypress.io/cy-prompt/no-manifest.tar',
kind: 'cy-prompt',
})
} catch (err) {
caught = err
}
expect(BundleError.isBundleError(caught)).to.equal(true)
expect((caught as BundleError).stage).to.equal('manifest')
const finalDir = path.join(cacheRoot, 'bundles', 'cy-prompt', 'no-manifest')
expect(await pathExists(path.join(finalDir, 'server', 'index.js'))).to.equal(false)
})
it('propagates network errors raised by streamDownloadVerifyExtract without touching finalDir', async () => {
const networkError = Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' })
const { ensureSignedBundle } = setup({
streamImpl: async () => {
throw networkError
},
})
let caught: unknown
try {
await ensureSignedBundle({
url: 'https://cdn.cypress.io/studio/net-fail.tar',
kind: 'studio',
})
} catch (err) {
caught = err
}
expect(caught).to.equal(networkError)
const finalDir = path.join(cacheRoot, 'bundles', 'studio', 'net-fail')
expect(await pathExists(path.join(finalDir, 'manifest.json'))).to.equal(false)
})
it('cleans up staging dir even when publish fails', async () => {
const { ensureSignedBundle } = setup()
// Force publish failure by making finalDir un-renamable: pre-create an immutable
// file at the target manifest path (we'll simulate by removing write permission
// only on POSIX; on Windows skip with a comment).
if (process.platform === 'win32') return // simpler skip than juggling ACLs
const finalDir = path.join(cacheRoot, 'bundles', 'cy-prompt', 'pubfail')
await ensureDir(finalDir)
// Make finalDir read-only so renames inside it fail with EACCES
const fs = require('fs-extra')
await fs.chmod(finalDir, 0o500)
try {
await ensureSignedBundle({
url: 'https://cdn.cypress.io/cy-prompt/pubfail.tar',
kind: 'cy-prompt',
}).catch(() => { /* expected */ })
} finally {
await fs.chmod(finalDir, 0o755)
}
const baseDir = path.dirname(finalDir)
const remaining: string[] = await fs.readdir(baseDir)
expect(remaining.filter((n: string) => n.startsWith('.staging-'))).to.deep.equal([])
})
})
@@ -0,0 +1,24 @@
import '../../../spec_helper'
import { parseHashFromBundleUrl } from '../../../../lib/cloud/bundles/parse_hash_from_bundle_url'
describe('parseHashFromBundleUrl', () => {
it('returns the hash portion of a typical bundle url', () => {
expect(parseHashFromBundleUrl('https://cdn.cypress.io/cy-prompt/abc123def456.tar')).to.equal('abc123def456')
})
it('strips multiple extension segments', () => {
expect(parseHashFromBundleUrl('https://cdn.cypress.io/studio/abc123.tar.gz')).to.equal('abc123')
})
it('handles a url without an extension', () => {
expect(parseHashFromBundleUrl('https://cdn.cypress.io/cy-prompt/abc123')).to.equal('abc123')
})
it('throws when the url has no path segment', () => {
expect(() => parseHashFromBundleUrl('https://cdn.cypress.io/')).to.throw(/Unable to parse bundle hash/)
})
it('throws when the url is empty', () => {
expect(() => parseHashFromBundleUrl('')).to.throw(/Unable to parse bundle hash/)
})
})
@@ -0,0 +1,196 @@
import { proxyquire, sinon } from '../../../spec_helper'
import { ensureDir, mkdtemp, pathExists, readFile, remove, writeFile } from 'fs-extra'
import { spawn } from 'child_process'
import os from 'os'
import path from 'path'
import * as extractAtomic from '../../../../lib/cloud/extract_atomic'
const TS_REGISTER = require.resolve('@packages/ts/register')
const WORKER_PATH = path.resolve(__dirname, '../../../support/cross_process_publish_worker.ts')
const populateStaging = async (staging: string, files: Record<string, string>) => {
await ensureDir(staging)
for (const [rel, content] of Object.entries(files)) {
const full = path.join(staging, rel)
await ensureDir(path.dirname(full))
await writeFile(full, content)
}
}
const FIXTURE_FILES = (() => {
const files: Record<string, string> = {
'manifest.json': JSON.stringify({ version: 1, files: {} }),
'server/index.js': '// hello cypress\n'.repeat(50),
'README.md': 'bundle readme\n',
}
for (let i = 0; i < 30; i++) {
files[`assets/file_${String(i).padStart(3, '0')}.txt`] = `payload-${i}\n`.repeat(20)
}
return files
})()
describe('publishStagingToFinal', () => {
let tmp: string
let staging: string
let finalDir: string
beforeEach(async () => {
tmp = await mkdtemp(path.join(os.tmpdir(), 'cy-publish-'))
staging = path.join(tmp, 'staging')
finalDir = path.join(tmp, 'final')
await ensureDir(finalDir)
})
afterEach(async () => {
await remove(tmp).catch(() => { /* ignore */ })
})
it('publishes all files from staging into finalDir', async () => {
await populateStaging(staging, FIXTURE_FILES)
const { publishStagingToFinal } = require('../../../../lib/cloud/bundles/publish_staging_to_final')
await publishStagingToFinal(staging, finalDir)
for (const [rel, expected] of Object.entries(FIXTURE_FILES)) {
const actual = await readFile(path.join(finalDir, rel), 'utf8')
expect(actual).to.equal(expected)
}
})
it('renames manifest.json last', async () => {
await populateStaging(staging, FIXTURE_FILES)
const renamedOrder: string[] = []
const renameSpy = sinon.stub().callsFake(async (src: string, dst: string) => {
await extractAtomic.renameAtomicWithRetry(src, dst)
renamedOrder.push(path.relative(finalDir, dst).split(path.sep).join('/'))
})
const { publishStagingToFinal } = proxyquire('../lib/cloud/bundles/publish_staging_to_final', {
'../extract_atomic': {
renameAtomicWithRetry: renameSpy,
},
})
await publishStagingToFinal(staging, finalDir)
expect(renamedOrder.length).to.equal(Object.keys(FIXTURE_FILES).length)
expect(renamedOrder[renamedOrder.length - 1]).to.equal('manifest.json')
// every non-manifest entry must precede manifest in the order
const manifestIdx = renamedOrder.indexOf('manifest.json')
expect(manifestIdx).to.equal(renamedOrder.length - 1)
})
it('drains in-flight renames before throwing when one rejects (no unhandled-rejection leakage)', async () => {
await populateStaging(staging, FIXTURE_FILES)
const fastReject = sinon.stub().rejects(Object.assign(new Error('EACCES: denied'), { code: 'EACCES' }))
let slowResolved = false
const slowResolve = sinon.stub().callsFake(async () => {
await new Promise((r) => setTimeout(r, 50))
slowResolved = true
})
const renameStub = sinon.stub().callsFake(async (src: string, _dst: string) => {
if (src.endsWith('assets/file_000.txt')) return fastReject(src, _dst)
return slowResolve(src, _dst)
})
const { publishStagingToFinal } = proxyquire('../lib/cloud/bundles/publish_staging_to_final', {
'../extract_atomic': { renameAtomicWithRetry: renameStub },
})
const unhandled: unknown[] = []
const onUnhandled = (reason: unknown) => unhandled.push(reason)
process.on('unhandledRejection', onUnhandled)
try {
await expect(publishStagingToFinal(staging, finalDir)).to.be.rejectedWith(/EACCES/)
await new Promise((r) => setTimeout(r, 100))
} finally {
process.off('unhandledRejection', onUnhandled)
}
expect(slowResolved, 'slow renames should have settled before the function returned').to.equal(true)
expect(unhandled, 'no unhandled rejections from in-flight publishOne calls').to.deep.equal([])
})
it('cross-process: parallel publishers + reader sees no absent or partial bytes', async function () {
this.timeout(30000)
const stagingA = path.join(tmp, 'staging-a')
const stagingB = path.join(tmp, 'staging-b')
const watchedFile = 'assets/file_010.txt'
const expectedContent = FIXTURE_FILES[watchedFile]
// Pre-place the watched file so the reader has a baseline before either
// publisher renames over it.
await ensureDir(path.dirname(path.join(finalDir, watchedFile)))
await writeFile(path.join(finalDir, watchedFile), expectedContent)
await populateStaging(stagingA, FIXTURE_FILES)
await populateStaging(stagingB, FIXTURE_FILES)
let stop = false
let reads = 0
let enoentObserved = 0
const corruptObserved: string[] = []
const readerLoop = (async () => {
while (!stop) {
try {
const buf = await readFile(path.join(finalDir, watchedFile), 'utf8')
reads++
if (buf !== expectedContent) corruptObserved.push(buf.slice(0, 50))
} catch (err: any) {
if (err?.code === 'ENOENT') enoentObserved++
else throw err
}
}
})()
const runChild = (staging: string) => {
return new Promise<{ code: number, stderr: string }>((resolve, reject) => {
const child = spawn(process.execPath, ['-r', TS_REGISTER, WORKER_PATH, staging, finalDir], {
stdio: ['ignore', 'ignore', 'pipe'],
})
let stderr = ''
child.stderr.on('data', (chunk) => stderr += chunk.toString('utf8'))
child.once('error', reject)
child.once('exit', (code) => resolve({ code: code ?? -1, stderr }))
})
}
const [childA, childB] = await Promise.all([runChild(stagingA), runChild(stagingB)])
stop = true
await readerLoop
if (childA.code !== 0) throw new Error(`child A exited with ${childA.code}: ${childA.stderr}`)
if (childB.code !== 0) throw new Error(`child B exited with ${childB.code}: ${childB.stderr}`)
expect(enoentObserved, 'reader must never see ENOENT for an already-published file').to.equal(0)
expect(corruptObserved, 'reader must always read complete bytes').to.deep.equal([])
expect(reads, 'reader loop should run at least once').to.be.greaterThan(0)
// After both publishers exit, finalDir must contain every file from the bundle.
for (const rel of Object.keys(FIXTURE_FILES)) {
const dst = path.join(finalDir, rel)
expect(await pathExists(dst), `${rel} must exist in finalDir`).to.equal(true)
const actual = await readFile(dst, 'utf8')
expect(actual, `${rel} content must match expected`).to.equal(FIXTURE_FILES[rel])
}
})
})
@@ -0,0 +1,267 @@
import { proxyquire, sinon } from '../../../spec_helper'
import { mkdtemp, remove } from 'fs-extra'
import { Readable } from 'stream'
import os from 'os'
import path from 'path'
import { BundleError } from '../../../../lib/cloud/bundles/bundle_error'
import { SystemError } from '../../../../lib/cloud/network/system_error'
import { HttpError } from '../../../../lib/cloud/network/http_error'
const proxyquireWithFastDelay = (fetchStub: sinon.SinonStub) => {
// Collapse the retry delay so the budget burns in milliseconds.
const { asyncRetry } = require('../../../../lib/util/async_retry')
return proxyquire('../lib/cloud/bundles/stream_download_verify_extract', {
'cross-fetch': fetchStub,
'../../util/async_retry': {
asyncRetry,
linearDelay: () => () => 1,
},
})
}
const callIt = async (fn: any, kind: 'cy-prompt' | 'studio', staging: string) => {
let caught: any
try {
await fn({
url: `https://cdn.cypress.io/${kind}/abc123.tar`,
staging,
kind,
})
} catch (err) {
caught = err
}
return caught
}
const collectErrors = (caught: any): Error[] => caught?.errors ?? [caught]
describe('streamDownloadVerifyExtract', () => {
let tmp: string
beforeEach(async () => {
tmp = await mkdtemp(path.join(os.tmpdir(), 'cy-stream-test-'))
})
afterEach(async () => {
await remove(tmp).catch(() => { /* ignore */ })
})
describe('error tagging + retry', () => {
it('wraps fetch timeout as BundleError(stage=network, cause: SystemError ETIMEDOUT) and burns full retry budget', async () => {
const abortError = Object.assign(new Error('The user aborted a request.'), { name: 'AbortError' })
const fetchStub = sinon.stub().rejects(abortError)
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
const caught = await callIt(streamDownloadVerifyExtract, 'cy-prompt', path.join(tmp, 'staging'))
// Full retry budget consumed via cause-based shouldRetry
expect(fetchStub.callCount).to.equal(3)
const errs = collectErrors(caught)
expect(errs.length).to.equal(3)
for (const e of errs) {
expect(BundleError.isBundleError(e)).to.equal(true)
expect((e as BundleError).stage).to.equal('network')
expect((e as BundleError).kind).to.equal('cy-prompt')
const cause = (e as Error & { cause?: unknown }).cause
expect(SystemError.isSystemError(cause as any), `${e?.message} cause should be SystemError`).to.equal(true)
expect((cause as SystemError).code).to.equal('ETIMEDOUT')
}
})
it('wraps a non-retryable HTTP 404 as BundleError(stage=network, cause: HttpError) and does NOT retry', async () => {
const response = {
ok: false,
url: 'https://cdn.cypress.io/studio/abc123.tar',
status: 404,
statusText: 'Not Found',
text: async () => 'not found',
}
const fetchStub = sinon.stub().resolves(response)
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
const caught = await callIt(streamDownloadVerifyExtract, 'studio', path.join(tmp, 'staging'))
// 4xx is not retryable per isRetryableError, so only one attempt
expect(fetchStub.callCount).to.equal(1)
expect(BundleError.isBundleError(caught)).to.equal(true)
expect((caught as BundleError).stage).to.equal('network')
expect((caught as BundleError).kind).to.equal('studio')
const cause = (caught as Error & { cause?: unknown }).cause
expect(HttpError.isHttpError(cause as any)).to.equal(true)
expect((cause as HttpError).status).to.equal(404)
})
it('retries on HTTP 500 (idempotent GET) and burns full retry budget', async () => {
const response = {
ok: false,
url: 'https://cdn.cypress.io/cy-prompt/abc123.tar',
status: 500,
statusText: 'Internal Server Error',
text: async () => 'boom',
}
const fetchStub = sinon.stub().resolves(response)
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
await callIt(streamDownloadVerifyExtract, 'cy-prompt', path.join(tmp, 'staging'))
expect(fetchStub.callCount).to.equal(3)
})
it('wraps a retryable HTTP 503 as BundleError(stage=network, cause: HttpError) and burns full retry budget', async () => {
const response = {
ok: false,
url: 'https://cdn.cypress.io/cy-prompt/abc123.tar',
status: 503,
statusText: 'Service Unavailable',
text: async () => 'busy',
}
const fetchStub = sinon.stub().resolves(response)
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
await callIt(streamDownloadVerifyExtract, 'cy-prompt', path.join(tmp, 'staging'))
expect(fetchStub.callCount).to.equal(3)
})
it('wraps a filesystem-class syscall (ENOSPC) from the pipeline as BundleError(stage=extract) and does NOT retry', async () => {
const enospc = Object.assign(new Error('no space left on device'), { code: 'ENOSPC', errno: -28 })
const makeBody = () => new Readable({
read () {
this.destroy(enospc)
},
})
const response = {
ok: true,
status: 200,
headers: {
get: (h: string) => {
if (h === 'x-cypress-signature') return 'sig'
if (h === 'x-cypress-manifest-signature') return 'manifest-sig'
return null
},
},
body: makeBody(),
}
const fetchStub = sinon.stub().callsFake(async () => ({ ...response, body: makeBody() }))
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
const caught = await callIt(streamDownloadVerifyExtract, 'cy-prompt', path.join(tmp, 'staging'))
// Filesystem syscall is non-transient — must not retry
expect(fetchStub.callCount).to.equal(1)
expect(BundleError.isBundleError(caught)).to.equal(true)
expect((caught as BundleError).stage).to.equal('extract')
expect((caught as BundleError).kind).to.equal('cy-prompt')
// Cause is the raw error, NOT a SystemError (which would flag retryable)
const cause = (caught as Error & { cause?: unknown }).cause
expect(SystemError.isSystemError(cause as any)).to.equal(false)
expect((cause as any).code).to.equal('ENOSPC')
})
it('still treats network-class syscalls (ECONNRESET) mid-pipeline as stage=network and retries', async () => {
const econnreset = Object.assign(new Error('socket hang up'), { code: 'ECONNRESET', errno: -54 })
const makeBody = () => new Readable({
read () {
this.destroy(econnreset)
},
})
const response = {
ok: true,
status: 200,
headers: {
get: (h: string) => {
if (h === 'x-cypress-signature') return 'sig'
if (h === 'x-cypress-manifest-signature') return 'manifest-sig'
return null
},
},
body: makeBody(),
}
const fetchStub = sinon.stub().callsFake(async () => ({ ...response, body: makeBody() }))
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
const caught = await callIt(streamDownloadVerifyExtract, 'cy-prompt', path.join(tmp, 'staging'))
// Network-class syscall → retryable → full retry budget
expect(fetchStub.callCount).to.equal(3)
const errs = collectErrors(caught)
for (const e of errs) {
expect(BundleError.isBundleError(e)).to.equal(true)
expect((e as BundleError).stage).to.equal('network')
const cause = (e as Error & { cause?: unknown }).cause
expect(SystemError.isSystemError(cause as any)).to.equal(true)
expect((cause as SystemError).code).to.equal('ECONNRESET')
}
})
it('wraps a non-syscall pipeline error as BundleError(stage=extract, cause preserved) and does NOT retry', async () => {
// Body that yields bytes which tar.Parse({ strict: true }) will reject.
const makeBody = () => Readable.from([Buffer.from('this is not a tar archive at all')])
const response = {
ok: true,
status: 200,
headers: {
get: (h: string) => {
if (h === 'x-cypress-signature') return 'sig'
if (h === 'x-cypress-manifest-signature') return 'manifest-sig'
return null
},
},
body: makeBody(),
}
const fetchStub = sinon.stub().callsFake(async () => {
// fresh body per attempt in case asyncRetry retries
return { ...response, body: makeBody() }
})
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
const caught = await callIt(streamDownloadVerifyExtract, 'cy-prompt', path.join(tmp, 'staging'))
// Tar parse error is not retryable (no errno/code, not Http/SystemError)
expect(fetchStub.callCount).to.equal(1)
expect(BundleError.isBundleError(caught)).to.equal(true)
expect((caught as BundleError).stage).to.equal('extract')
expect((caught as BundleError).kind).to.equal('cy-prompt')
// The original (tar) error is preserved as cause
const cause = (caught as Error & { cause?: unknown }).cause
expect(cause).to.be.instanceOf(Error)
expect(SystemError.isSystemError(cause as any)).to.equal(false)
expect(HttpError.isHttpError(cause as any)).to.equal(false)
})
})
})
@@ -0,0 +1,67 @@
import '../../../spec_helper'
import { ensureDir, mkdtemp, pathExists, remove, utimes, writeFile } from 'fs-extra'
import os from 'os'
import path from 'path'
import { sweepOrphanStaging } from '../../../../lib/cloud/bundles/sweep_orphan_staging'
const ageOf = (ms: number) => (Date.now() - ms) / 1000
describe('sweepOrphanStaging', () => {
let baseDir: string
beforeEach(async () => {
baseDir = await mkdtemp(path.join(os.tmpdir(), 'cy-sweep-'))
})
afterEach(async () => {
await remove(baseDir).catch(() => { /* ignore */ })
})
it('removes staging dirs older than the threshold', async () => {
const stale = path.join(baseDir, '.staging-stale')
await ensureDir(stale)
await writeFile(path.join(stale, 'a'), 'x')
const oldSeconds = ageOf(2 * 60 * 60 * 1000) // 2h ago
await utimes(stale, oldSeconds, oldSeconds)
const removed = await sweepOrphanStaging(baseDir, 60 * 60 * 1000)
expect(removed).to.equal(1)
expect(await pathExists(stale)).to.equal(false)
})
it('leaves staging dirs younger than the threshold alone', async () => {
const fresh = path.join(baseDir, '.staging-fresh')
await ensureDir(fresh)
await writeFile(path.join(fresh, 'a'), 'x')
const removed = await sweepOrphanStaging(baseDir, 60 * 60 * 1000)
expect(removed).to.equal(0)
expect(await pathExists(fresh)).to.equal(true)
})
it('ignores non-staging entries even when old', async () => {
const final = path.join(baseDir, 'somehash')
await ensureDir(final)
const oldSeconds = ageOf(2 * 60 * 60 * 1000)
await utimes(final, oldSeconds, oldSeconds)
const removed = await sweepOrphanStaging(baseDir, 60 * 60 * 1000)
expect(removed).to.equal(0)
expect(await pathExists(final)).to.equal(true)
})
it('returns 0 and swallows errors when baseDir does not exist', async () => {
const removed = await sweepOrphanStaging(path.join(baseDir, 'does-not-exist'), 60 * 60 * 1000)
expect(removed).to.equal(0)
})
})
@@ -138,13 +138,12 @@ describe('CyPromptLifecycleManager', () => {
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
}
ensureCyPromptBundleStub.resolves(mockManifest)
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
await cyPromptReadyPromise
expect(mockCtx.update).to.be.calledOnce
expect(ensureCyPromptBundleStub).to.be.calledWith({
cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'),
cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz',
projectId: 'test-project-id',
})
@@ -208,13 +207,12 @@ describe('CyPromptLifecycleManager', () => {
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
}
ensureCyPromptBundleStub.resolves(mockManifest)
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
await cyPromptReadyPromise
expect(mockCtx.update).to.be.calledOnce
expect(ensureCyPromptBundleStub).to.be.calledWith({
cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'),
cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz',
projectId: 'test-project-id',
})
@@ -267,13 +265,12 @@ describe('CyPromptLifecycleManager', () => {
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
}
ensureCyPromptBundleStub.resolves(mockManifest)
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
await cyPromptReadyPromise
expect(mockCtx.update).to.be.calledOnce
expect(ensureCyPromptBundleStub).to.be.calledWith({
cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'),
cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz',
projectId: 'test-project-id',
})
@@ -328,7 +325,7 @@ describe('CyPromptLifecycleManager', () => {
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
}
ensureCyPromptBundleStub.resolves(mockManifest)
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
await cyPromptReadyPromise
@@ -358,7 +355,7 @@ describe('CyPromptLifecycleManager', () => {
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
}
ensureCyPromptBundleStub.resolves(mockManifest)
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
const cyPromptManager1 = await cyPromptReadyPromise1
@@ -381,7 +378,6 @@ describe('CyPromptLifecycleManager', () => {
expect(ensureCyPromptBundleStub).to.be.calledOnce
expect(ensureCyPromptBundleStub).to.be.calledWith({
cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'),
cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz',
projectId: 'test-project-id',
})
@@ -516,7 +512,7 @@ describe('CyPromptLifecycleManager', () => {
const mockManifest = {}
ensureCyPromptBundleStub.resolves(mockManifest)
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
cyPromptLifecycleManager.initializeCyPromptManager({
cloudDataSource: mockCloudDataSource,
@@ -562,7 +558,7 @@ describe('CyPromptLifecycleManager', () => {
'server/index.js': 'a1',
}
ensureCyPromptBundleStub.resolves(mockManifest)
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
cyPromptLifecycleManager.initializeCyPromptManager({
cloudDataSource: mockCloudDataSource,
@@ -722,7 +718,7 @@ describe('CyPromptLifecycleManager', () => {
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
}
ensureCyPromptBundleStub.resolves(mockManifest)
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
})
it('registers a listener that will be called when cy-prompt is ready', () => {
@@ -1,104 +1,64 @@
import path from 'path'
import os from 'os'
import { proxyquire, sinon } from '../../../spec_helper'
describe('ensureCyPromptBundle', () => {
let ensureCyPromptBundle: typeof import('../../../../lib/cloud/cy-prompt/ensure_cy_prompt_bundle').ensureCyPromptBundle
let tmpdir: string = '/tmp'
let rmStub: sinon.SinonStub = sinon.stub()
let ensureStub: sinon.SinonStub = sinon.stub()
let extractStub: sinon.SinonStub = sinon.stub()
let getCyPromptBundleStub: sinon.SinonStub = sinon.stub()
let readFileStub: sinon.SinonStub = sinon.stub()
let verifySignatureStub: sinon.SinonStub = sinon.stub()
let pathExistsStub: sinon.SinonStub = sinon.stub()
const mockRandom: number = 0.123
const mockRandomString: string = mockRandom.toString(36).substring(2, 15)
const mockResponseSignature = '159'
const mockManifest = {
'server/index.js': 'abcdefg',
}
let ensureSignedBundleStub: sinon.SinonStub
beforeEach(() => {
rmStub = sinon.stub()
ensureStub = sinon.stub()
extractStub = sinon.stub()
getCyPromptBundleStub = sinon.stub()
readFileStub = sinon.stub()
verifySignatureStub = sinon.stub()
pathExistsStub = sinon.stub()
sinon.stub(Math, 'random').returns(mockRandom)
ensureSignedBundleStub = sinon.stub()
ensureCyPromptBundle = (proxyquire('../lib/cloud/cy-prompt/ensure_cy_prompt_bundle', {
os: {
tmpdir: () => tmpdir,
platform: () => 'linux',
},
'fs-extra': {
remove: rmStub.resolves(),
ensureDir: ensureStub.resolves(),
readFile: readFileStub.resolves(JSON.stringify(mockManifest)),
pathExists: pathExistsStub.resolves(true),
},
'../api/cy-prompt/get_cy_prompt_bundle': {
getCyPromptBundle: getCyPromptBundleStub.resolves(mockResponseSignature),
},
'../encryption': {
verifySignature: verifySignatureStub.resolves(true),
},
'../extract_atomic': {
extractAtomic: extractStub.resolves(),
'../bundles/ensure_signed_bundle': {
ensureSignedBundle: ensureSignedBundleStub,
},
})).ensureCyPromptBundle
})
it('should ensure the cy prompt bundle', async () => {
const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', '123')
const bundlePath = path.join(cyPromptPath, 'bundle.tar')
it('delegates to ensureSignedBundle with kind=cy-prompt and unwraps the bundleDir', async () => {
const mockManifest = { 'server/index.js': 'abc123' }
const mockBundleDir = '/cache/bundles/cy-prompt/abc'
const manifest = await ensureCyPromptBundle({
cyPromptPath,
cyPromptUrl: 'https://cypress.io/cy-prompt',
projectId: '123',
ensureSignedBundleStub.resolves({
manifest: mockManifest,
bundleDir: mockBundleDir,
})
expect(ensureStub).to.be.calledWith(cyPromptPath)
expect(readFileStub).to.be.calledWith(path.join(cyPromptPath, 'manifest.json'), 'utf8')
expect(getCyPromptBundleStub).to.be.calledWith({
cyPromptUrl: 'https://cypress.io/cy-prompt',
projectId: '123',
bundlePath: `${bundlePath}-${mockRandomString}`,
const result = await ensureCyPromptBundle({
cyPromptUrl: 'https://cdn.cypress.io/cy-prompt/abc.tar',
projectId: 'proj-1',
})
expect(extractStub).to.be.calledWith(`${bundlePath}-${mockRandomString}`, cyPromptPath)
expect(rmStub).to.be.calledWith(`${bundlePath}-${mockRandomString}`)
expect(ensureSignedBundleStub).to.be.calledOnce
expect(ensureSignedBundleStub).to.be.calledWith({
url: 'https://cdn.cypress.io/cy-prompt/abc.tar',
projectId: 'proj-1',
kind: 'cy-prompt',
})
expect(verifySignatureStub).to.be.calledWith(JSON.stringify(mockManifest), mockResponseSignature)
expect(manifest).to.deep.eq(mockManifest)
expect(result).to.deep.equal({
manifest: mockManifest,
cyPromptPath: mockBundleDir,
})
})
it('should throw an error if the cy prompt bundle signature is invalid', async () => {
verifySignatureStub.resolves(false)
it('forwards an undefined projectId without injecting one', async () => {
ensureSignedBundleStub.resolves({ manifest: {}, bundleDir: '/cache/bundles/cy-prompt/x' })
const ensureCyPromptBundlePromise = ensureCyPromptBundle({
cyPromptPath: '/tmp/cypress/cy-prompt/123',
cyPromptUrl: 'https://cypress.io/cy-prompt',
projectId: '123',
await ensureCyPromptBundle({ cyPromptUrl: 'https://cdn.cypress.io/cy-prompt/x.tar' })
expect(ensureSignedBundleStub).to.be.calledWith({
url: 'https://cdn.cypress.io/cy-prompt/x.tar',
projectId: undefined,
kind: 'cy-prompt',
})
await expect(ensureCyPromptBundlePromise).to.be.rejectedWith('Unable to verify cy-prompt signature')
})
it('should throw an error if the cy prompt bundle manifest is not found', async () => {
pathExistsStub.resolves(false)
it('propagates errors from ensureSignedBundle', async () => {
const err = new Error('boom')
const ensureCyPromptBundlePromise = ensureCyPromptBundle({
cyPromptPath: '/tmp/cypress/cy-prompt/123',
cyPromptUrl: 'https://cypress.io/cy-prompt',
projectId: '123',
})
ensureSignedBundleStub.rejects(err)
await expect(ensureCyPromptBundlePromise).to.be.rejectedWith('Unable to find cy-prompt manifest')
await expect(ensureCyPromptBundle({ cyPromptUrl: 'https://cdn.cypress.io/cy-prompt/abc.tar' }))
.to.be.rejectedWith(err)
})
})
@@ -3,8 +3,6 @@ const jose = require('jose')
const crypto = require('crypto')
const encryption = require('../../../lib/cloud/encryption')
const { expect } = require('chai')
const fs = require('fs')
const path = require('path')
const TEST_BODY = {
test: 'string',
@@ -105,40 +103,4 @@ describe('encryption', () => {
expect(roundtripResponse).to.eql(LARGE_RESPONSE)
})
describe('verifySignatureFromFile', () => {
it('verifies a valid signature from a file', async () => {
const filePath = path.join(__dirname, '..', '..', 'support', 'fixtures', 'cloud', 'encryption', 'index.js')
const fixtureContents = await fs.promises.readFile(filePath)
const sign = crypto.createSign('sha256', {
defaultEncoding: 'base64',
}).update(fixtureContents).end()
const signature = sign.sign(privateKey, 'base64')
expect(
await encryption.verifySignatureFromFile(
filePath,
signature,
publicKey,
),
).to.eql(true)
})
it('does not verify an invalid signature from a file', async () => {
const filePath = path.join(__dirname, '..', '..', 'support', 'fixtures', 'cloud', 'encryption', 'index.js')
const fixtureContents = await fs.promises.readFile(filePath)
const sign = crypto.createSign('sha256', {
defaultEncoding: 'base64',
}).update(fixtureContents).end()
const signature = sign.sign(privateKey, 'base64')
expect(
await encryption.verifySignatureFromFile(
filePath,
`a ${signature}`,
publicKey,
),
).to.eql(false)
})
})
})
@@ -1,578 +1,87 @@
import path from 'path'
import { proxyquire, sinon } from '../../spec_helper'
import { EventEmitter } from 'events'
import { Readable } from 'stream'
describe('extractAtomic', () => {
let extractAtomic: typeof import('../../../lib/cloud/extract_atomic').extractAtomic
let createReadStreamStub: sinon.SinonStub
let ParseStub: sinon.SinonStub
let ensureDirStub: sinon.SinonStub
let writeFileAtomicStub: sinon.SinonStub
let mockStream: Readable
let mockParser: EventEmitter & { pipe: sinon.SinonStub }
describe('renameAtomicWithRetry', () => {
let renameAtomicWithRetry: typeof import('../../../lib/cloud/extract_atomic').renameAtomicWithRetry
let renameStub: sinon.SinonStub
beforeEach(() => {
createReadStreamStub = sinon.stub()
ParseStub = sinon.stub()
ensureDirStub = sinon.stub().resolves()
writeFileAtomicStub = sinon.stub().resolves()
renameStub = sinon.stub()
// Create a mock parser first (needed for stream pipe)
mockParser = Object.assign(new EventEmitter(), {
pipe: sinon.stub().returns(mockParser),
})
// Create a mock stream
mockStream = Object.assign(new Readable({
read () {
// Empty implementation
},
}), {
pipe: sinon.stub().returns(mockParser),
}) as Readable & { pipe: sinon.SinonStub }
ParseStub.returns(mockParser)
createReadStreamStub.returns(mockStream)
extractAtomic = (proxyquire('../lib/cloud/extract_atomic', {
fs: {
createReadStream: createReadStreamStub,
},
tar: {
Parse: ParseStub,
},
renameAtomicWithRetry = (proxyquire('../lib/cloud/extract_atomic', {
'fs-extra': {
ensureDir: ensureDirStub,
ensureDir: sinon.stub().resolves(),
rename: renameStub,
},
'write-file-atomic': writeFileAtomicStub,
})).extractAtomic
})).renameAtomicWithRetry
})
it('should extract a single file from tar archive', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const fileContent = Buffer.from('file content')
const fileMode = 0o755
it('should rename once when the operation succeeds', async () => {
renameStub.resolves()
// Create a mock entry
const mockEntry = Object.assign(new Readable({
read () {
this.push(fileContent)
this.push(null) // End stream
},
}), {
type: 'File',
path: 'file.txt',
mode: fileMode,
resume: sinon.stub(),
})
await renameAtomicWithRetry('/src/file', '/dst/file')
const extractPromise = extractAtomic(archivePath, destinationPath)
// Simulate entry event
setImmediate(() => {
mockParser.emit('entry', mockEntry)
})
// Simulate parser end
setImmediate(() => {
mockParser.emit('end')
})
await extractPromise
expect(createReadStreamStub).to.be.calledWith(archivePath)
expect(ParseStub).to.be.calledOnce
expect((mockStream as any).pipe).to.be.calledWith(mockParser)
expect(ensureDirStub).to.be.calledWith(destinationPath)
expect(writeFileAtomicStub).to.be.calledWith(
path.join(destinationPath, 'file.txt'),
fileContent,
{ mode: fileMode },
)
expect(renameStub).to.be.calledOnce
expect(renameStub).to.be.calledWith('/src/file', '/dst/file')
})
it('should extract multiple files from tar archive', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const file1Content = Buffer.from('file 1 content')
const file2Content = Buffer.from('file 2 content')
it('should retry on EPERM and succeed on a subsequent attempt', async () => {
const epermError = Object.assign(new Error('EPERM: operation not permitted, rename'), { code: 'EPERM' })
const mockEntry1 = Object.assign(new Readable({
read () {
this.push(file1Content)
this.push(null)
},
}), {
type: 'File',
path: 'file1.txt',
mode: 0o644,
resume: sinon.stub(),
})
renameStub.onFirstCall().rejects(epermError)
renameStub.onSecondCall().resolves()
const mockEntry2 = Object.assign(new Readable({
read () {
this.push(file2Content)
this.push(null)
},
}), {
type: 'File',
path: 'file2.txt',
mode: 0o644,
resume: sinon.stub(),
})
await renameAtomicWithRetry('/src/file', '/dst/file')
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockParser.emit('entry', mockEntry1)
mockParser.emit('entry', mockEntry2)
mockParser.emit('end')
})
await extractPromise
expect(writeFileAtomicStub).to.be.calledTwice
expect(writeFileAtomicStub).to.be.calledWith(
path.join(destinationPath, 'file1.txt'),
file1Content,
{ mode: 0o644 },
)
expect(writeFileAtomicStub).to.be.calledWith(
path.join(destinationPath, 'file2.txt'),
file2Content,
{ mode: 0o644 },
)
expect(renameStub).to.be.calledTwice
})
it('should skip non-file entries', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const mockDirectoryEntry = {
type: 'Directory',
path: 'directory',
resume: sinon.stub(),
}
const mockSymlinkEntry = {
type: 'SymbolicLink',
path: 'symlink',
resume: sinon.stub(),
}
const mockFileEntry = Object.assign(new Readable({
read () {
this.push(Buffer.from('content'))
this.push(null)
},
}), {
type: 'File',
path: 'file.txt',
mode: 0o644,
resume: sinon.stub(),
})
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockParser.emit('entry', mockDirectoryEntry)
mockParser.emit('entry', mockSymlinkEntry)
mockParser.emit('entry', mockFileEntry)
mockParser.emit('end')
})
await extractPromise
expect(mockDirectoryEntry.resume).to.be.called
expect(mockSymlinkEntry.resume).to.be.called
expect(mockFileEntry.resume).not.to.be.called
expect(writeFileAtomicStub).to.be.calledOnce
expect(writeFileAtomicStub).to.be.calledWith(
path.join(destinationPath, 'file.txt'),
Buffer.from('content'),
{ mode: 0o644 },
)
})
it('should create nested directories for files in subdirectories', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const fileContent = Buffer.from('content')
const mockEntry = Object.assign(new Readable({
read () {
this.push(fileContent)
this.push(null)
},
}), {
type: 'File',
path: 'nested/path/to/file.txt',
mode: 0o644,
resume: sinon.stub(),
})
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockParser.emit('entry', mockEntry)
mockParser.emit('end')
})
await extractPromise
expect(ensureDirStub).to.be.calledWith(
path.join(destinationPath, 'nested/path/to'),
)
expect(writeFileAtomicStub).to.be.calledWith(
path.join(destinationPath, 'nested/path/to/file.txt'),
fileContent,
{ mode: 0o644 },
)
})
it('should use default mode 0o644 when entry mode is not provided', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const fileContent = Buffer.from('content')
const mockEntry = Object.assign(new Readable({
read () {
this.push(fileContent)
this.push(null)
},
}), {
type: 'File',
path: 'file.txt',
mode: undefined,
resume: sinon.stub(),
})
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockParser.emit('entry', mockEntry)
mockParser.emit('end')
})
await extractPromise
expect(writeFileAtomicStub).to.be.calledWith(
path.join(destinationPath, 'file.txt'),
fileContent,
{ mode: 0o644 },
)
})
it('should handle files with multiple chunks', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const chunk1 = Buffer.from('chunk 1')
const chunk2 = Buffer.from('chunk 2')
const chunk3 = Buffer.from('chunk 3')
const expectedContent = Buffer.concat([chunk1, chunk2, chunk3])
let chunkIndex = 0
const mockEntry = Object.assign(new Readable({
read () {
if (chunkIndex === 0) {
this.push(chunk1)
chunkIndex++
} else if (chunkIndex === 1) {
this.push(chunk2)
chunkIndex++
} else if (chunkIndex === 2) {
this.push(chunk3)
chunkIndex++
} else {
this.push(null)
}
},
}), {
type: 'File',
path: 'file.txt',
mode: 0o644,
resume: sinon.stub(),
})
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockParser.emit('entry', mockEntry)
mockParser.emit('end')
})
await extractPromise
expect(writeFileAtomicStub).to.be.calledWith(
path.join(destinationPath, 'file.txt'),
expectedContent,
{ mode: 0o644 },
)
})
it('should handle stream errors', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const streamError = new Error('Stream error')
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockStream.emit('error', streamError)
})
await expect(extractPromise).to.be.rejectedWith('Stream error')
})
it('should handle parser errors', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const parserError = new Error('Parser error')
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockParser.emit('error', parserError)
})
await expect(extractPromise).to.be.rejectedWith('Parser error')
})
it('should handle write errors', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const writeError = new Error('Write error')
const fileContent = Buffer.from('content')
writeFileAtomicStub.rejects(writeError)
const mockEntry = Object.assign(new Readable({
read () {
this.push(fileContent)
this.push(null)
},
}), {
type: 'File',
path: 'file.txt',
mode: 0o644,
resume: sinon.stub(),
})
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockParser.emit('entry', mockEntry)
mockParser.emit('end')
})
await expect(extractPromise).to.be.rejectedWith('Write error')
})
it('should retry on EPERM and succeed when write succeeds on retry', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const fileContent = Buffer.from('content')
const epermError = Object.assign(new Error('EPERM: operation not permitted, rename \'file.txt.123\' -> \'file.txt\''), { code: 'EPERM' })
writeFileAtomicStub.onFirstCall().rejects(epermError)
writeFileAtomicStub.onSecondCall().resolves()
const mockEntry = Object.assign(new Readable({
read () {
this.push(fileContent)
this.push(null)
},
}), {
type: 'File',
path: 'file.txt',
mode: 0o644,
resume: sinon.stub(),
})
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockParser.emit('entry', mockEntry)
mockParser.emit('end')
})
await extractPromise
expect(writeFileAtomicStub).to.be.calledTwice
expect(writeFileAtomicStub).to.be.calledWith(
path.join(destinationPath, 'file.txt'),
fileContent,
{ mode: 0o644 },
)
})
it('should retry on EACCES and succeed when write succeeds on retry', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const fileContent = Buffer.from('content')
it('should retry on EACCES and succeed on a subsequent attempt', async () => {
const eaccesError = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })
writeFileAtomicStub.onFirstCall().rejects(eaccesError)
writeFileAtomicStub.onSecondCall().resolves()
renameStub.onFirstCall().rejects(eaccesError)
renameStub.onSecondCall().resolves()
const mockEntry = Object.assign(new Readable({
read () {
this.push(fileContent)
this.push(null)
},
}), {
type: 'File',
path: 'file.txt',
mode: 0o644,
resume: sinon.stub(),
})
await renameAtomicWithRetry('/src/file', '/dst/file')
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockParser.emit('entry', mockEntry)
mockParser.emit('end')
})
await extractPromise
expect(writeFileAtomicStub).to.be.calledTwice
expect(renameStub).to.be.calledTwice
})
it('should throw when EPERM persists after all retries', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const fileContent = Buffer.from('content')
const epermError = Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
it('should retry on EBUSY (Windows: file in use by AV / another process) and succeed on a subsequent attempt', async () => {
const ebusyError = Object.assign(new Error('EBUSY: resource busy or locked'), { code: 'EBUSY' })
writeFileAtomicStub.rejects(epermError)
renameStub.onFirstCall().rejects(ebusyError)
renameStub.onSecondCall().resolves()
const mockEntry = Object.assign(new Readable({
read () {
this.push(fileContent)
this.push(null)
},
}), {
type: 'File',
path: 'file.txt',
mode: 0o644,
resume: sinon.stub(),
})
await renameAtomicWithRetry('/src/file', '/dst/file')
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockParser.emit('entry', mockEntry)
mockParser.emit('end')
})
await expect(extractPromise).to.be.rejectedWith(epermError)
expect(writeFileAtomicStub.callCount).to.equal(4) // initial + 3 retries
expect(renameStub).to.be.calledTwice
})
it('should not retry on non-retryable errors', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const fileContent = Buffer.from('content')
const enospcError = Object.assign(new Error('ENOSPC: no space left'), { code: 'ENOSPC' })
it('should throw the last error when EPERM persists past the retry budget', async () => {
const epermError = Object.assign(new Error('EPERM: operation not permitted, rename'), { code: 'EPERM' })
writeFileAtomicStub.rejects(enospcError)
renameStub.rejects(epermError)
const mockEntry = Object.assign(new Readable({
read () {
this.push(fileContent)
this.push(null)
},
}), {
type: 'File',
path: 'file.txt',
mode: 0o644,
resume: sinon.stub(),
})
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockParser.emit('entry', mockEntry)
mockParser.emit('end')
})
await expect(extractPromise).to.be.rejectedWith(enospcError)
expect(writeFileAtomicStub).to.be.calledOnce
await expect(renameAtomicWithRetry('/src/file', '/dst/file')).to.be.rejectedWith(epermError)
// MAX_RETRIES = 3, so 4 total attempts (initial + 3 retries)
expect(renameStub.callCount).to.equal(4)
})
it('should wait for all file writes to complete before resolving', async () => {
const archivePath = '/path/to/archive.tar'
const destinationPath = '/path/to/destination'
const file1Content = Buffer.from('file 1')
const file2Content = Buffer.from('file 2')
it('should not retry on non-retryable errors such as ENOENT', async () => {
const enoentError = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' })
let resolveWrite1: () => void
let resolveWrite2: () => void
const write1Promise = new Promise<void>((resolve) => {
resolveWrite1 = resolve
})
const write2Promise = new Promise<void>((resolve) => {
resolveWrite2 = resolve
})
renameStub.rejects(enoentError)
writeFileAtomicStub.onFirstCall().returns(write1Promise)
writeFileAtomicStub.onSecondCall().returns(write2Promise)
await expect(renameAtomicWithRetry('/src/file', '/dst/file')).to.be.rejectedWith(enoentError)
expect(renameStub).to.be.calledOnce
})
const mockEntry1 = Object.assign(new Readable({
read () {
this.push(file1Content)
this.push(null)
},
}), {
type: 'File',
path: 'file1.txt',
mode: 0o644,
resume: sinon.stub(),
})
it('should not retry on errors without an EPERM/EACCES code', async () => {
const opaqueError = new Error('something else went wrong')
const mockEntry2 = Object.assign(new Readable({
read () {
this.push(file2Content)
this.push(null)
},
}), {
type: 'File',
path: 'file2.txt',
mode: 0o644,
resume: sinon.stub(),
})
renameStub.rejects(opaqueError)
const extractPromise = extractAtomic(archivePath, destinationPath)
setImmediate(() => {
mockParser.emit('entry', mockEntry1)
mockParser.emit('entry', mockEntry2)
mockParser.emit('end')
})
// Wait a bit to ensure parser has finished but writes haven't
await new Promise((resolve) => setTimeout(resolve, 10))
// Extract should not have resolved yet
let resolved = false
extractPromise.then(() => {
resolved = true
})
expect(resolved).to.be.false
// Resolve writes
resolveWrite1!()
resolveWrite2!()
await extractPromise
expect(resolved).to.be.true
await expect(renameAtomicWithRetry('/src/file', '/dst/file')).to.be.rejectedWith(opaqueError)
expect(renameStub).to.be.calledOnce
})
})
@@ -189,13 +189,6 @@ describe('StudioLifecycleManager', () => {
return Promise.resolve()
})
studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
debugData,
})
const studioReadyPromise = new Promise((resolve) => {
studioLifecycleManager?.registerStudioReadyListener((studioManager) => {
resolve(studioManager)
@@ -206,13 +199,19 @@ describe('StudioLifecycleManager', () => {
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
}
ensureStudioBundleStub.resolves(mockManifest)
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
await studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
debugData,
})
await studioReadyPromise
expect(mockCtx.update).to.be.calledOnce
expect(ensureStudioBundleStub).to.be.calledWith({
studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc'),
studioUrl: 'https://cloud.cypress.io/studio/bundle/abc.tgz',
projectId: 'abc123',
})
@@ -292,13 +291,6 @@ describe('StudioLifecycleManager', () => {
return Promise.resolve()
})
studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
debugData: {},
})
const studioReadyPromise = new Promise((resolve) => {
studioLifecycleManager?.registerStudioReadyListener((studioManager) => {
resolve(studioManager)
@@ -309,7 +301,14 @@ describe('StudioLifecycleManager', () => {
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
}
ensureStudioBundleStub.resolves(mockManifest)
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
await studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
debugData: {},
})
await studioReadyPromise
@@ -407,9 +406,9 @@ describe('StudioLifecycleManager', () => {
const mockManifest = {}
ensureStudioBundleStub.resolves(mockManifest)
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
studioLifecycleManager.initializeStudioManager({
await studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -453,9 +452,9 @@ describe('StudioLifecycleManager', () => {
'server/index.js': 'a1',
}
ensureStudioBundleStub.resolves(mockManifest)
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
studioLifecycleManager.initializeStudioManager({
await studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -606,7 +605,7 @@ describe('StudioLifecycleManager', () => {
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
}
ensureStudioBundleStub.resolves(mockManifest)
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
})
it('registers a listener that will be called when studio is ready', () => {
@@ -697,7 +696,7 @@ describe('StudioLifecycleManager', () => {
}),
])
studioLifecycleManager.initializeStudioManager({
await studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -721,7 +720,7 @@ describe('StudioLifecycleManager', () => {
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
}
ensureStudioBundleStub.resolves(mockManifest)
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
})
it('updates status and emits events when status changes', async () => {
@@ -768,7 +767,7 @@ describe('StudioLifecycleManager', () => {
it('handles status updates properly during initialization', async () => {
const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus')
studioLifecycleManager.initializeStudioManager({
await studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
cfg: mockCfg,
debugData: {},
@@ -794,7 +793,7 @@ describe('StudioLifecycleManager', () => {
const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus')
studioLifecycleManager.initializeStudioManager({
await studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
cfg: mockCfg,
debugData: {},
@@ -952,10 +951,10 @@ describe('StudioLifecycleManager', () => {
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
}
ensureStudioBundleStub.resolves(mockManifest)
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
// First initialize with some state
studioLifecycleManager.initializeStudioManager({
await studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -975,7 +974,7 @@ describe('StudioLifecycleManager', () => {
const initialCallCount = postStudioSessionStub.callCount
studioLifecycleManager.retry()
await studioLifecycleManager.retry()
// Verify state was cleared
expect(studioLifecycleManager.getCurrentStatus()).to.equal('INITIALIZING')
@@ -997,22 +996,22 @@ describe('StudioLifecycleManager', () => {
expect(ensureStudioBundleStub.callCount).to.equal(initialCallCount + 1)
})
it('sets status to IN_ERROR when no initialization parameters are available', () => {
it('sets status to IN_ERROR when no initialization parameters are available', async () => {
// Set up ctx so retry doesn't return early
// @ts-expect-error - accessing private property
studioLifecycleManager.ctx = mockCtx
// Don't initialize first, so no params are stored
studioLifecycleManager.retry()
await studioLifecycleManager.retry()
expect(studioLifecycleManager.getCurrentStatus()).to.equal('IN_ERROR')
})
it('does nothing when no ctx is available', () => {
it('does nothing when no ctx is available', async () => {
const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus')
// Call retry without ctx
studioLifecycleManager.retry()
await studioLifecycleManager.retry()
// Should not have updated status
expect(statusChangesSpy).not.to.be.called
@@ -1023,11 +1022,14 @@ describe('StudioLifecycleManager', () => {
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
}
ensureStudioBundleStub.resolves(mockManifest)
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
// Add some cached promises to the static map
const dummyPromise = Promise.resolve({
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
manifest: {
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
},
studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc'),
})
// @ts-expect-error - accessing private static property
@@ -1036,7 +1038,7 @@ describe('StudioLifecycleManager', () => {
StudioLifecycleManager.hashLoadingMap.set('abc', dummyPromise) // This should be the current hash (from studioUrl)
// Initialize with ctx so retry will work
studioLifecycleManager.initializeStudioManager({
await studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -1053,7 +1055,7 @@ describe('StudioLifecycleManager', () => {
})
})
studioLifecycleManager.retry()
await studioLifecycleManager.retry()
// Verify only the current studio hash was cleared (abc from the studioUrl)
// @ts-expect-error - accessing private static property
@@ -1086,7 +1088,7 @@ describe('StudioLifecycleManager', () => {
expect(StudioLifecycleManager.hashLoadingMap.size).to.equal(2)
// Initialize with ctx so retry will work
studioLifecycleManager.initializeStudioManager({
await studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -1100,7 +1102,7 @@ describe('StudioLifecycleManager', () => {
})
})
studioLifecycleManager.retry()
await studioLifecycleManager.retry()
// Wait for retry to complete
await new Promise((resolve) => {
@@ -1,106 +1,64 @@
import path from 'path'
import os from 'os'
import { proxyquire, sinon } from '../../../spec_helper'
describe('ensureStudioBundle', () => {
let ensureStudioBundle: typeof import('../../../../lib/cloud/studio/ensure_studio_bundle').ensureStudioBundle
let tmpdir: string = '/tmp'
let rmStub: sinon.SinonStub = sinon.stub()
let ensureStub: sinon.SinonStub = sinon.stub()
let copyStub: sinon.SinonStub = sinon.stub()
let extractStub: sinon.SinonStub = sinon.stub()
let getStudioBundleStub: sinon.SinonStub = sinon.stub()
let readFileStub: sinon.SinonStub = sinon.stub()
let verifySignatureStub: sinon.SinonStub = sinon.stub()
let pathExistsStub: sinon.SinonStub = sinon.stub()
const mockRandom: number = 0.123
const mockRandomString: string = mockRandom.toString(36).substring(2, 15)
const mockResponseSignature = '159'
const mockManifest = {
'server/index.js': 'abcdefg',
}
let ensureSignedBundleStub: sinon.SinonStub
beforeEach(() => {
rmStub = sinon.stub()
ensureStub = sinon.stub()
copyStub = sinon.stub()
readFileStub = sinon.stub()
extractStub = sinon.stub()
getStudioBundleStub = sinon.stub()
verifySignatureStub = sinon.stub()
pathExistsStub = sinon.stub()
sinon.stub(Math, 'random').returns(mockRandom)
ensureSignedBundleStub = sinon.stub()
ensureStudioBundle = (proxyquire('../lib/cloud/studio/ensure_studio_bundle', {
os: {
tmpdir: () => tmpdir,
platform: () => 'linux',
},
'fs-extra': {
remove: rmStub.resolves(),
ensureDir: ensureStub.resolves(),
copy: copyStub.resolves(),
readFile: readFileStub.resolves(JSON.stringify(mockManifest)),
pathExists: pathExistsStub.resolves(true),
},
'../api/studio/get_studio_bundle': {
getStudioBundle: getStudioBundleStub.resolves(mockResponseSignature),
},
'../encryption': {
verifySignature: verifySignatureStub.resolves(true),
},
'../extract_atomic': {
extractAtomic: extractStub.resolves(),
'../bundles/ensure_signed_bundle': {
ensureSignedBundle: ensureSignedBundleStub,
},
})).ensureStudioBundle
})
it('should ensure the studio bundle', async () => {
const studioPath = path.join(os.tmpdir(), 'cypress', 'studio', '123')
const bundlePath = path.join(studioPath, 'bundle.tar')
it('delegates to ensureSignedBundle with kind=studio and unwraps the bundleDir', async () => {
const mockManifest = { 'server/index.js': 'abc123' }
const mockBundleDir = '/cache/bundles/studio/abc'
const manifest = await ensureStudioBundle({
studioPath,
studioUrl: 'https://cypress.io/studio',
projectId: '123',
ensureSignedBundleStub.resolves({
manifest: mockManifest,
bundleDir: mockBundleDir,
})
expect(ensureStub).to.be.calledWith(studioPath)
expect(readFileStub).to.be.calledWith(path.join(studioPath, 'manifest.json'), 'utf8')
expect(getStudioBundleStub).to.be.calledWith({
studioUrl: 'https://cypress.io/studio',
bundlePath: `${bundlePath}-${mockRandomString}`,
const result = await ensureStudioBundle({
studioUrl: 'https://cdn.cypress.io/studio/abc.tar',
projectId: 'proj-1',
})
expect(extractStub).to.be.calledWith(`${bundlePath}-${mockRandomString}`, studioPath)
expect(rmStub).to.be.calledWith(`${bundlePath}-${mockRandomString}`)
expect(ensureSignedBundleStub).to.be.calledOnce
expect(ensureSignedBundleStub).to.be.calledWith({
url: 'https://cdn.cypress.io/studio/abc.tar',
projectId: 'proj-1',
kind: 'studio',
})
expect(verifySignatureStub).to.be.calledWith(JSON.stringify(mockManifest), mockResponseSignature)
expect(manifest).to.deep.eq(mockManifest)
expect(result).to.deep.equal({
manifest: mockManifest,
studioPath: mockBundleDir,
})
})
it('should throw an error if the studio bundle signature is invalid', async () => {
verifySignatureStub.resolves(false)
it('forwards an undefined projectId without injecting one', async () => {
ensureSignedBundleStub.resolves({ manifest: {}, bundleDir: '/cache/bundles/studio/x' })
const ensureStudioBundlePromise = ensureStudioBundle({
studioPath: '/tmp/cypress/studio/123',
studioUrl: 'https://cypress.io/studio',
projectId: '123',
await ensureStudioBundle({ studioUrl: 'https://cdn.cypress.io/studio/x.tar' })
expect(ensureSignedBundleStub).to.be.calledWith({
url: 'https://cdn.cypress.io/studio/x.tar',
projectId: undefined,
kind: 'studio',
})
await expect(ensureStudioBundlePromise).to.be.rejectedWith('Unable to verify studio signature')
})
it('should throw an error if the studio bundle manifest is not found', async () => {
pathExistsStub.resolves(false)
it('propagates errors from ensureSignedBundle', async () => {
const err = new Error('boom')
const ensureStudioBundlePromise = ensureStudioBundle({
studioPath: '/tmp/cypress/studio/123',
studioUrl: 'https://cypress.io/studio',
projectId: '123',
})
ensureSignedBundleStub.rejects(err)
await expect(ensureStudioBundlePromise).to.be.rejectedWith('Unable to find studio manifest')
await expect(ensureStudioBundle({ studioUrl: 'https://cdn.cypress.io/studio/abc.tar' }))
.to.be.rejectedWith(err)
})
})
@@ -5,9 +5,12 @@ import {
import { expect } from 'chai'
import { proxyquire, sinon } from '../../../../spec_helper'
proxyquire.noPreserveCache()
describe('TelemetryReporter', () => {
// noPreserveCache() mutates the global proxyquire instance; scope it so it
// doesn't leak into specs loaded after this one.
before(() => proxyquire.noPreserveCache())
after(() => proxyquire.preserveCache())
let TelemetryReporter: typeof import('../../../../../lib/cloud/studio/telemetry/TelemetryReporter').TelemetryReporter
let initializeTelemetryReporter: typeof import('../../../../../lib/cloud/studio/telemetry/TelemetryReporter').initializeTelemetryReporter
let reportTelemetry: typeof import('../../../../../lib/cloud/studio/telemetry/TelemetryReporter').reportTelemetry
@@ -3,11 +3,11 @@ require('../../spec_helper')
const _ = require('lodash')
const os = require('os')
const electron = require('electron')
const DataContext = require('@packages/data-context')
const savedState = require(`../../../lib/saved_state`)
const menu = require(`../../../lib/gui/menu`)
const Windows = require(`../../../lib/gui/windows`)
const interactiveMode = require(`../../../lib/modes/interactive`)
const { GracefulExit } = require(`../../../lib/util/graceful-exit`)
describe('gui/interactive', () => {
context('.isMac', () => {
@@ -186,8 +186,8 @@ describe('gui/interactive', () => {
})
describe('data context management', () => {
let willQuitHandler
let clearCtxImmediateCallback
let beforeQuitHandler
let quitTeardownImmediateCallback
let mockEvent = {
preventDefault: sinon.stub(),
@@ -199,49 +199,59 @@ describe('gui/interactive', () => {
return interactiveMode.run(opts).then(() => {
expect(interactiveMode.ready).to.be.calledWith(opts)
}).then(async () => {
expect(willQuitHandler).to.be.defined
expect(beforeQuitHandler).to.be.defined
willQuitHandler(mockEvent)
beforeQuitHandler(mockEvent)
expect(mockEvent.preventDefault).to.have.been.called
expect(clearCtxImmediateCallback).to.be.defined
expect(quitTeardownImmediateCallback).to.be.defined
await clearCtxImmediateCallback()
await quitTeardownImmediateCallback()
expect(DataContext.clearCtx).to.have.been.called
expect(electron.app.quit).to.have.been.called
expect(GracefulExit.exitGracefully).to.have.been.calledWith(0)
})
}
beforeEach(() => {
willQuitHandler = undefined
clearCtxImmediateCallback = undefined
beforeQuitHandler = undefined
quitTeardownImmediateCallback = undefined
sinon.stub(interactiveMode, 'ready')
sinon.stub(electron.app, 'once').callsFake((eventName, handler) => {
if (eventName === 'will-quit') {
willQuitHandler = handler
sinon.stub(electron.app, 'on').callsFake((eventName, handler) => {
if (eventName === 'before-quit') {
beforeQuitHandler = handler
}
})
sinon.stub(GracefulExit, 'exitGracefully').resolves()
sinon.stub(global, 'setImmediate').callsFake((callback) => {
// we intercept the setImmediate call so we can synchronously
// execute the callback in the test and await its result
clearCtxImmediateCallback = callback
quitTeardownImmediateCallback = callback
})
electron.app.quit = sinon.stub()
})
it('uses will-quit listener to destroy DataContext before exiting', () => {
sinon.stub(DataContext, 'clearCtx').resolves()
it('uses before-quit listener and invokes graceful exit', () => {
return performAssertions()
})
it('still quits if destroying DataContext throws error', () => {
sinon.stub(DataContext, 'clearCtx').rejects()
it('exits with code 1 when graceful exit fails during quit teardown', () => {
GracefulExit.exitGracefully.restore()
sinon.stub(GracefulExit, 'exitGracefully').rejects(new Error('teardown failed'))
sinon.stub(process, 'exit')
return performAssertions()
const opts = {}
return interactiveMode.run(opts).then(() => {
expect(interactiveMode.ready).to.be.calledWith(opts)
}).then(async () => {
beforeQuitHandler(mockEvent)
await quitTeardownImmediateCallback()
expect(process.exit).to.have.been.calledWith(1)
})
})
})
})
@@ -2,8 +2,64 @@ const childProcess = require('child_process')
const path = require('path')
const PROJECT_ROOT = path.join(path.dirname(require.resolve('@tooling/system-tests/package.json')), 'projects/kill-child-process')
const REQUIRE_ASYNC_CHILD_PATH = require.resolve('@packages/server/lib/plugins/child/require_async_child')
const CONFIG_FILE = path.join(PROJECT_ROOT, 'cypress.config.js')
describe('require_async_child', () => {
it('exits with code 0 when the parent closes the IPC channel (disconnect handler)', function (done) {
this.timeout(15_000)
const child = childProcess.fork(REQUIRE_ASYNC_CHILD_PATH, ['--projectRoot', PROJECT_ROOT, '--file', CONFIG_FILE], {
env: {
...process.env,
// Match real config-child loading (see run_child_fixture / ProjectConfigIpc)
NODE_OPTIONS: '--import tsx',
},
})
let settled = false
const finish = (err) => {
if (settled) {
return
}
settled = true
clearTimeout(watchdog)
done(err)
}
const watchdog = setTimeout(() => {
child.kill('SIGKILL')
finish(new Error('timed out waiting for require_async_child to exit after IPC disconnect'))
}, 12_000)
child.on('exit', (code, signal) => {
if (settled) {
return
}
if (signal) {
return finish(new Error(`Expected exit without signal after graceful IPC disconnect, got signal ${signal}`))
}
if (code !== 0) {
return finish(new Error(`Expected exit code 0 after disconnect teardown (process.exit()), got ${code}`))
}
finish()
})
child.on('error', finish)
child.on('message', (msg) => {
if (msg?.event === 'ready') {
// Closing the IPC channel triggers `process.on('disconnect')` in require_async_child,
// which must call process.exit() so the child cannot run orphaned.
child.disconnect()
}
})
})
it('disconnects if the parent ipc is closed', (done) => {
const child = childProcess.fork(path.join(__dirname, 'run_child_fixture'))
@@ -20,7 +76,7 @@ describe('require_async_child', () => {
event: 'setupTestingType',
args: ['e2e', {
...JSON.parse(msg.childMessage.args[0].initialConfig),
configFile: path.join(PROJECT_ROOT, 'cypress.config.js'),
configFile: CONFIG_FILE,
projectRoot: PROJECT_ROOT,
testingType: 'e2e',
env: {},
@@ -5,6 +5,23 @@ const REQUIRE_ASYNC_CHILD_PATH = require.resolve('@packages/server/lib/plugins/c
let proc
function killGrandchild () {
if (proc && !proc.killed) {
proc.kill('SIGKILL')
}
}
;['SIGINT', 'SIGTERM'].forEach((signal) => {
process.on(signal, () => {
killGrandchild()
process.exit(signal === 'SIGINT' ? 130 : 143)
})
})
process.on('exit', () => {
killGrandchild()
})
process.on('message', (msg) => {
if (msg.msg === 'spawn') {
proc = childProcess.fork(REQUIRE_ASYNC_CHILD_PATH, ['--projectRoot', msg.data.projectRoot, '--file', path.join(msg.data.projectRoot, 'cypress.config.js')], {
@@ -26,7 +43,8 @@ process.on('message', (msg) => {
}
})
// Just incase the test exits
// If the parent test process goes away, tear down the grandchild so it cannot keep running (e.g. Ping... loop).
process.on('disconnect', () => {
killGrandchild()
process.exit()
})
@@ -61,11 +61,11 @@ describe('lib/project-base', () => {
this.project._cfg = this.config
})
afterEach(function () {
afterEach(async function () {
Fixtures.remove()
if (this.project) {
this.project.close()
await this.project.close()
}
})
+59 -2
View File
@@ -1,10 +1,18 @@
require('../spec_helper')
const mockery = require('mockery')
const { enable: enableMockery, mockElectron } = require('../mockery_helper')
const morganFn = function () {}
mockery.registerMock('morgan', () => {
// Set by the morgan mock when `useMorgan` runs.
let lastMorganFactoryArgs
function morganMockFactory (format, options) {
lastMorganFactoryArgs = { format, options }
return morganFn
})
}
const _ = require('lodash')
const os = require('os')
@@ -17,6 +25,7 @@ const { SocketE2E } = require(`../../lib/socket-e2e`)
const fileServer = require(`../../lib/file_server`)
const ensureUrl = require(`../../lib/util/ensure-url`)
const { getCtx } = require('@packages/data-context')
const { GracefulExit } = require('../../lib/util/graceful-exit')
function getOpenOptions (overrides = {}) {
return {
@@ -33,6 +42,12 @@ function getOpenOptions (overrides = {}) {
describe('lib/server-base', () => {
beforeEach(function () {
// put_protocol_artifact_spec and others call mockery.deregisterAll(); re-enable and
// re-register per test so require('morgan') is always our mock.
enableMockery(mockery)
mockElectron(mockery)
mockery.registerMock('morgan', morganMockFactory)
this.fileServer = {
close () {},
port () {
@@ -78,6 +93,48 @@ describe('lib/server-base', () => {
})
})
context('#useMorgan', () => {
beforeEach(function () {
GracefulExit.resetForTesting()
sinon.stub(process, 'exit')
lastMorganFactoryArgs = undefined
// CI or other specs may set a low timeout; if the race timer wins before
// flushAndExit clears processTeardown, skip() still mirrors isShuttingDown
// and the post-await assertion flakes (see graceful_exit_spec teardown test).
delete process.env.CYPRESS_INTERNAL_TEARDOWN_TIMEOUT
})
afterEach(function () {
GracefulExit.resetForTesting()
delete process.env.CYPRESS_INTERNAL_TEARDOWN_TIMEOUT
process.exit.restore()
})
it('passes dev format and skip that mirrors GracefulExit.isShuttingDown', async function () {
this.server.useMorgan()
expect(lastMorganFactoryArgs.format).to.eq('dev')
expect(lastMorganFactoryArgs.options.skip()).to.be.false
let resolveStep
const stepPromise = new Promise((resolve) => {
resolveStep = resolve
})
GracefulExit.addStep(() => stepPromise, 'slow-step')
const exitPromise = GracefulExit.exitGracefully(0)
expect(lastMorganFactoryArgs.options.skip()).to.be.true
resolveStep()
await exitPromise
expect(lastMorganFactoryArgs.options.skip()).to.be.false
})
})
context('#open', () => {
beforeEach(function () {
sinon.stub(this.server, 'createServer').resolves()
@@ -2,7 +2,9 @@ require('../../spec_helper')
const os = require('os')
const osPath = require('ospath')
const path = require('path')
const Promise = require('bluebird')
const { fs } = require('../../../lib/util/fs')
const AppData = require(`../../../lib/util/app_data`)
describe('lib/util/app_data', () => {
@@ -31,6 +33,30 @@ describe('lib/util/app_data', () => {
})
})
context('#ensure', () => {
it('does not create the symlink until the appData directory exists', async () => {
let ensureDirCompleted = false
let ensureDirCompletedAtSymlinkCall = null
sinon.stub(fs, 'removeAsync').resolves()
sinon.stub(fs, 'ensureDirAsync').callsFake(() => {
return Promise.delay(50).then(() => {
ensureDirCompleted = true
})
})
sinon.stub(fs, 'ensureSymlinkAsync').callsFake(() => {
ensureDirCompletedAtSymlinkCall = ensureDirCompleted
return Promise.resolve()
})
await AppData.ensure()
expect(ensureDirCompletedAtSymlinkCall, 'ensureSymlinkAsync was called before the appData directory existed').to.be.true
})
})
context('#findCommonAncestor', () => {
it('posix', () => {
expect(AppData.findCommonAncestor('/a/b/c/d', '/a/b/c/d/')).to.equal('/a/b/c/d')
+40 -6
View File
@@ -1,14 +1,23 @@
import '../../spec_helper'
import os from 'os'
import path from 'path'
import Promise from 'bluebird'
import lockFileModule from 'lockfile'
import { fs } from '../../../lib/util/fs'
import * as env from '../../../lib/util/env'
import exit from '../../../lib/util/exit'
import { GracefulExit } from '../../../lib/util/graceful-exit'
import { File as FileUtil } from '../../../lib/util/file'
const lockFile = Promise.promisifyAll(lockFileModule)
/** Introspect GracefulExit for regressions on File teardown registration (not public API). */
function countUnlockLockfileSteps (): number {
const singleton = (GracefulExit as unknown as { singleton: { steps: Map<string, { name: string }> } }).singleton
return [...singleton.steps.values()].filter((s) => s.name === 'unlock lockfile').length
}
describe('lib/util/file', () => {
beforeEach(function () {
this.dir = path.join(os.tmpdir(), 'cypress', 'file_spec')
@@ -25,12 +34,37 @@ describe('lib/util/file', () => {
})
it('unlocks file on exit', function () {
sinon.spy(lockFile, 'unlockSync')
sinon.stub(exit, 'ensure')
new FileUtil({ path: this.path })
exit.ensure.yield()
const unlockSpy = sinon.spy(lockFile, 'unlockSync')
let teardownStep: () => Promise<void>
const addStepStub = sinon.stub(GracefulExit, 'addStep').callsFake((fn: () => Promise<void> | void) => {
teardownStep = fn as () => Promise<void>
expect(lockFile.unlockSync).to.be.called
return 'test-step'
})
new FileUtil({ path: this.path })
return teardownStep!().then(() => {
expect(lockFile.unlockSync).to.be.called
}).finally(() => {
addStepStub.restore()
unlockSpy.restore()
})
})
it('does not leave orphaned GracefulExit unlock steps when ephemeral File instances are discarded', function () {
const before = countUnlockLockfileSteps()
for (let i = 0; i < 3; i++) {
new FileUtil({ path: path.join(this.dir, `ephemeral-${i}.json`) })
}
// Each File should remove its GracefulExit step when the instance is no longer needed, so
// unreferenced instances must not accumulate unlock handlers (see lib/util/file.ts).
expect(
countUnlockLockfileSteps(),
'ephemeral File instances must not leave GracefulExit unlock steps registered after they are discarded',
).to.equal(before)
})
context('#transaction', () => {
@@ -0,0 +1,184 @@
import '../../spec_helper'
import { GracefulExit } from '../../../lib/util/graceful-exit'
/**
* Other packages (e.g. firefox-profile) register SIGINT handlers that call
* process.exit(130). process.emit('SIGINT') invokes every listener, so a stub
* on process.exit counts unrelated exits and flakes in CI when many listeners
* are present. Snapshot listeners, clear them, run the callback, then restore.
*/
function withoutForeignSigHandlers<T> (fn: () => Promise<T>): Promise<T> {
const sigintListeners = process.listeners('SIGINT').slice()
const sigtermListeners = process.listeners('SIGTERM').slice()
process.removeAllListeners('SIGINT')
process.removeAllListeners('SIGTERM')
return Promise.resolve()
.then(fn)
.finally(() => {
GracefulExit.resetForTesting()
process.removeAllListeners('SIGINT')
process.removeAllListeners('SIGTERM')
sigintListeners.forEach((listener) => process.on('SIGINT', listener))
sigtermListeners.forEach((listener) => process.on('SIGTERM', listener))
})
}
describe('lib/util/graceful-exit', () => {
beforeEach(() => {
GracefulExit.resetForTesting()
})
afterEach(() => {
GracefulExit.resetForTesting()
delete process.env.CYPRESS_INTERNAL_TEARDOWN_TIMEOUT
})
it('isShuttingDown is false when idle', () => {
expect(GracefulExit.isShuttingDown).to.be.false
})
it('isShuttingDown is true while exitGracefully is in progress and false after teardown completes', async () => {
const exitStub = sinon.stub(process, 'exit')
expect(GracefulExit.isShuttingDown).to.be.false
let resolveStep: () => void
const stepPromise = new Promise<void>((resolve) => {
resolveStep = resolve
})
GracefulExit.addStep(async () => {
await stepPromise
}, 'slow-step')
const exitPromise = GracefulExit.exitGracefully(0)
expect(GracefulExit.isShuttingDown).to.be.true
resolveStep!()
await exitPromise
expect(GracefulExit.isShuttingDown).to.be.false
expect(exitStub).to.have.been.calledOnce
exitStub.restore()
})
it('runs registered teardown steps then exits with the requested code', async () => {
const exitStub = sinon.stub(process, 'exit')
const step = sinon.stub().resolves()
GracefulExit.addStep(step as any, 'test-step')
await GracefulExit.exitGracefully(0)
expect(step).to.have.been.calledOnce
expect(exitStub).to.have.been.calledWith(0)
})
it('exits with code 1 when a step throws', async () => {
const exitStub = sinon.stub(process, 'exit')
GracefulExit.addStep(async () => {
throw new Error('step failed')
}, 'failing-step')
await GracefulExit.exitGracefully(0)
expect(exitStub).to.have.been.calledWith(1)
})
it('returns the same in-flight promise when exitGracefully is called twice', async () => {
const exitStub = sinon.stub(process, 'exit')
let resolveStep: () => void
const stepPromise = new Promise<void>((resolve) => {
resolveStep = resolve
})
GracefulExit.addStep(async () => {
await stepPromise
}, 'slow-step')
const p1 = GracefulExit.exitGracefully(3)
const p2 = GracefulExit.exitGracefully(7)
resolveStep!()
await Promise.all([p1, p2])
expect(exitStub).to.have.been.calledOnce
expect(exitStub).to.have.been.calledWith(3)
})
it('debounces duplicate SIGINT soon after teardown starts (single graceful exit)', async () => {
const exitStub = sinon.stub(process, 'exit')
await withoutForeignSigHandlers(async () => {
GracefulExit.resetForTesting()
let resolveStep: () => void
const stepPromise = new Promise<void>((resolve) => {
resolveStep = resolve
})
GracefulExit.addStep(async () => {
await stepPromise
}, 'slow-step')
process.emit('SIGINT' as NodeJS.Signals)
process.emit('SIGINT' as NodeJS.Signals)
resolveStep!()
await new Promise((r) => setImmediate(r))
expect(exitStub).to.have.been.calledOnce
expect(exitStub).to.have.been.calledWith(130)
})
exitStub.restore()
})
it('SIGINT after dedup window during hung teardown forces exit 1', async function () {
this.timeout(5000)
const exitStub = sinon.stub(process, 'exit')
await withoutForeignSigHandlers(async () => {
GracefulExit.resetForTesting()
GracefulExit.addStep(() => new Promise(() => {}), 'hang')
process.emit('SIGINT' as NodeJS.Signals)
await new Promise((r) => setTimeout(r, 250))
process.emit('SIGINT' as NodeJS.Signals)
await new Promise((r) => setTimeout(r, 50))
expect(exitStub).to.have.been.calledWith(1)
})
exitStub.restore()
})
it('force exits after teardown timeout when a step never completes', async function () {
this.timeout(5000)
process.env.CYPRESS_INTERNAL_TEARDOWN_TIMEOUT = '50'
const exitStub = sinon.stub(process, 'exit')
GracefulExit.addStep(() => new Promise(() => {}), 'hang')
void GracefulExit.exitGracefully(0)
await new Promise((r) => setTimeout(r, 200))
expect(exitStub).to.have.been.calledWith(1)
})
})
+12 -16
View File
@@ -3,7 +3,6 @@ import pDefer from 'p-defer'
import treeKill from 'tree-kill'
import gulp from 'gulp'
import os from 'os'
import { psTreeSync } from '../utils/psTreeSync'
const childProcesses = new Set<ChildProcess>()
const exitedPids = new Set<number>()
@@ -90,23 +89,20 @@ async function exitHandler (exitCode: number) {
process.exit(exitCode)
}
function signalHandler (signal: NodeJS.Signals, code: number) {
hasExited = true
try {
// Windows doesn't automatically send signals to the whole process tree
if (os.platform().startsWith('win')) {
// signal handlers must be synchronous, so we can't use tree-kill
const tree = psTreeSync(process.pid).filter((pid) => pid !== process.pid)
for (const pid of tree) {
process.kill(pid, signal)
}
}
} catch (error) {
console.error(`An error occurred while handling signal ${signal}: ${error}`)
async function signalHandler (signal: NodeJS.Signals) {
if (process.stdin.isTTY) {
process.stdin.setRawMode(false)
}
process.exit(128 + code)
hasExited = true
try {
await exitAllProcesses()
} catch (error) {
console.error(`An error occurred while handling signal ${signal}: ${error}`)
process.exit(1)
}
process.exit(128 + os.constants.signals[signal])
}
async function uncaughtExceptionHandler (error: Error) {
-63
View File
@@ -1,63 +0,0 @@
import { execSync } from 'child_process'
function linuxOutput (pid: number): string {
try {
return execSync(`pgrep -P ${pid}`).toString().trim()
} catch (error) {
return ''
}
}
function windowsOutput (pid: number): string {
try {
return execSync(`wmic process where (ParentProcessId=${pid}) get ProcessId`, { stdio: 'ignore' })
.toString()
.replace('ProcessId', '') // Remove the header row
.trim()
} catch (error) {
return ''
}
}
function isValidPid (pid: unknown): pid is number {
return !Number.isNaN(pid)
}
// Treated as unknown to force validation before passing to the platform
export function psTreeSync (pid: unknown): number[] {
const root = pid ? Number(pid) : process.pid
if (!pid) {
// check if pgrep is installed on linux/osx, and wmic is installed on windows
if (process.platform.startsWith('win')) {
try {
execSync('which wmic')
} catch (error) {
console.warn('wmic is not available, unable to determine process tree')
return []
}
} else {
try {
execSync('which pgrep')
} catch (error) {
console.warn('pgrep is not available, unable to determine process tree')
return []
}
}
}
if (!isValidPid(root)) {
throw new TypeError('pid must be a number')
}
const output = process.platform.startsWith('win') ? windowsOutput(root) : linuxOutput(root)
const childPids = output.split('\n').filter(Boolean).map(Number)
if (root === process.pid) {
return childPids.flatMap(psTreeSync)
}
return [root, ...childPids.flatMap(psTreeSync)]
}
+13 -2
View File
@@ -4,13 +4,24 @@
// arm.medium (2 CPU) runs 4 simultaneous tsc startups and intermittently
// segfaults inside V8's bytecode-cache deserializer (#33730).
const { spawn } = require('child_process')
const path = require('path')
const os = require('os')
const minimist = require('minimist')
const lerna = path.resolve(__dirname, '..', 'node_modules', '.bin', 'lerna')
const argv = minimist(process.argv.slice(2), { string: ['scope'] })
const concurrency = Math.min(4, os.availableParallelism())
const args = ['run', 'build', '--stream', `--concurrency=${concurrency}`]
if (argv.scope) {
args.push('--scope', argv.scope)
}
const child = spawn(
'lerna',
['run', 'build', '--stream', `--concurrency=${concurrency}`],
lerna,
args,
{ stdio: 'inherit', shell: true },
)
@@ -189,59 +189,3 @@ exports['e2e forms / submissions with jquery XHR POST / failing'] = `
1 of 1 failed (100%) XX:XX 1
`
exports['e2e forms / submissions with jquery XHR POST / passing'] = `
====================================================================================================
(Run Starting)
Cypress: 1.2.3
Browser: FooBrowser 88
Specs: 1 found (form_submission_passing.cy.js)
Searched: cypress/e2e/form_submission_passing.cy.js
Running: form_submission_passing.cy.js (1 of 1)
form submissions
will find 'form success' message by default (after retrying)
needs an explicit should when an element is immediately found
2 passing
(Results)
Tests: 2
Passing: 2
Failing: 0
Pending: 0
Skipped: 0
Screenshots: 0
Video: false
Duration: X seconds
Spec Ran: form_submission_passing.cy.js
====================================================================================================
(Run Finished)
Spec Tests Passing Failing Pending Skipped
form_submission_passing.cy.js XX:XX 2 2 - - -
All specs passed! XX:XX 2 2 - - -
`
@@ -1,59 +1,3 @@
exports['e2e headless / tests in headless mode pass'] = `
====================================================================================================
(Run Starting)
Cypress: 1.2.3
Browser: FooBrowser 88
Specs: 1 found (headless.cy.js)
Searched: cypress/e2e/headless.cy.js
Running: headless.cy.js (1 of 1)
e2e headless spec
has the expected values for Cypress.browser
has expected launch args
2 passing
(Results)
Tests: 2
Passing: 2
Failing: 0
Pending: 0
Skipped: 0
Screenshots: 0
Video: false
Duration: X seconds
Spec Ran: headless.cy.js
====================================================================================================
(Run Finished)
Spec Tests Passing Failing Pending Skipped
headless.cy.js XX:XX 2 2 - - -
All specs passed! XX:XX 2 2 - - -
`
exports['e2e headless / tests in headed mode pass in !electron'] = `
====================================================================================================
+4 -1
View File
@@ -22,6 +22,9 @@ 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()
}
@@ -100,7 +103,7 @@ class DockerProcess extends EventEmitter implements SpawnerResult {
}
log('Docker run exited:', { err, data })
this.emit('exit', data.StatusCode)
this.emit('exit', data.StatusCode, null)
},
)
}
+149 -33
View File
@@ -29,6 +29,9 @@ const human = require('human-interval')
const morgan = require('morgan')
const Bluebird = require('bluebird')
const debug = require('debug')('cypress:system-tests')
const treeKill = require('tree-kill')
const { once } = require('events')
const os = require('os')
const { create: createHttpsServer } = require('@packages/https-proxy/test/helpers/https_server')
const { allowDestroy } = require(`@packages/server/lib/util/server_destroy`)
@@ -43,8 +46,9 @@ type CypressConfig = { [key: string]: any }
export type BrowserName = 'electron' | 'firefox' | 'chrome' | 'chrome-for-testing' | 'webkit'
| '!electron' | '!chrome' | '!chrome-for-testing' | '!firefox' | '!webkit'
type ExecResult = {
code: number
export type ExecResult = {
code: number | null
signal: NodeJS.Signals | null
stdout: string
stderr: string
}
@@ -250,6 +254,11 @@ type ExecOptions = {
* Run Cypress with POSIX exit codes.
*/
posixExitCodes?: boolean
/**
* If true, skip asserting the Cypress child exited without a termination signal (e.g. SIGABRT).
* @default false
*/
skipExitSignalAssertion?: boolean
}
type Server = {
@@ -289,11 +298,75 @@ export type SpawnerResult = {
stdout: stream.Readable
stderr: stream.Readable
on(event: 'error', cb: (err: Error) => void): void
on(event: 'exit', cb: (exitCode: number) => void): void
on(event: 'exit', cb: (exitCode: number | null, signal?: NodeJS.Signals | null) => void): void
kill: ChildProcess['kill']
pid: number
}
/** Active Cypress child from {@link systemTests.exec} (tree-kill; Docker has no host pid). */
let activeCypressSpawn: SpawnerResult | null = null
let activeSpawnIsDocker = false
let interruptRequested = false
let interruptInProgress = false
const INTERRUPT_CHILD_WAIT_MS = 30000
async function handleHarnessInterrupt (signal: NodeJS.Signals) {
if (interruptInProgress) {
process.exit(1)
return
}
interruptInProgress = true
interruptRequested = true
// So Mocha (or others) cannot exit this process before we tear down Cypress.
process.removeAllListeners(signal)
try {
if (activeCypressSpawn && !activeSpawnIsDocker) {
await new Promise<void>((resolve) => {
treeKill(activeCypressSpawn!.pid, signal, (err?: Error) => {
if (err) {
debug('tree-kill error: %o', err)
}
resolve()
})
})
try {
await Promise.race([
once(activeCypressSpawn, 'exit'),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('timeout waiting for Cypress exit')), INTERRUPT_CHILD_WAIT_MS)
}),
])
} catch (e) {
debug('waiting for Cypress exit after interrupt: %o', e)
}
}
} finally {
activeCypressSpawn = null
activeSpawnIsDocker = false
const code = 128 + os.constants.signals[signal]
process.exit(code)
}
}
function installHarnessInterruptHandlers () {
for (const sig of (['SIGINT', 'SIGTERM'] as const)) {
process.prependListener(sig, () => {
void handleHarnessInterrupt(sig)
})
}
}
installHarnessInterruptHandlers()
const cpSpawner: Spawner = (cmd, args, env, options) => {
if (options.withBinary) {
throw new Error('withBinary is not supported without the use of dockerImage')
@@ -812,6 +885,10 @@ const systemTests = {
debug('systemTests.exec options %o', options)
options = this.options(ctx, options)
if (interruptRequested) {
ctx.skip()
}
debug('processed options %o', options)
const args = options.args || this.args(options)
@@ -848,10 +925,23 @@ const systemTests = {
let stdout = ''
let stderr = ''
const exit = function (code) {
const { expectedExitCode } = options
const exit = function (code: number | null, signal: NodeJS.Signals | null | undefined) {
if (interruptRequested) {
return {
code,
signal: signal == null ? null : signal,
stdout,
stderr,
}
}
const { expectedExitCode, skipExitSignalAssertion } = options
maybeVerifyExitCode(expectedExitCode, () => {
if (!skipExitSignalAssertion) {
expect(signal == null, `Cypress process exited by signal: ${signal} (exit code ${code})`).to.be.true
}
if (expectedExitCode === 0) {
expect(code).to.eq(expectedExitCode, `Process errored: Exit code ${code}`)
} else {
@@ -934,6 +1024,7 @@ const systemTests = {
return {
code,
signal: signal == null ? null : signal,
stdout,
stderr,
}
@@ -982,41 +1073,66 @@ const systemTests = {
.extend(options.processEnv)
.value()
if (interruptRequested) {
ctx.skip()
}
const spawnerFn: Spawner = options.dockerImage ? dockerSpawner : cpSpawner
const sp: SpawnerResult = await spawnerFn(cmd, args, env, options)
options.onSpawn && options.onSpawn(sp)
activeSpawnIsDocker = !!options.dockerImage
activeCypressSpawn = sp
const ColorOutput = function () {
const colorOutput = new stream.Transform()
try {
options.onSpawn && options.onSpawn(sp)
colorOutput._transform = (chunk, encoding, cb) => cb(null, chalk.magenta(chunk.toString()))
const ColorOutput = function () {
const colorOutput = new stream.Transform()
return colorOutput
colorOutput._transform = (chunk, encoding, cb) => cb(null, chalk.magenta(chunk.toString()))
return colorOutput
}
// pipe these to our current process
// so we can see them in the terminal
// color it so we can tell which is test output
sp.stdout
.pipe(ColorOutput())
.pipe(process.stdout)
sp.stderr
.pipe(ColorOutput())
.pipe(process.stderr)
sp.stdout.on('data', (buf) => stdout += buf.toString())
sp.stderr.on('data', (buf) => stderr += buf.toString())
const [exitCode, exitSignal] = await new Promise<[number | null, NodeJS.Signals | null | undefined]>((resolve, reject) => {
sp.on('error', reject)
sp.on('exit', (code, sig) => {
resolve([code, sig])
})
})
await copy(projectPath)
if (interruptRequested) {
return {
code: exitCode,
signal: exitSignal == null ? null : exitSignal,
stdout,
stderr,
}
}
return exit(exitCode, exitSignal)
} finally {
if (activeCypressSpawn === sp) {
activeCypressSpawn = null
activeSpawnIsDocker = false
}
}
// pipe these to our current process
// so we can see them in the terminal
// color it so we can tell which is test output
sp.stdout
.pipe(ColorOutput())
.pipe(process.stdout)
sp.stderr
.pipe(ColorOutput())
.pipe(process.stderr)
sp.stdout.on('data', (buf) => stdout += buf.toString())
sp.stderr.on('data', (buf) => stderr += buf.toString())
const exitCode = await new Promise((resolve, reject) => {
sp.on('error', reject)
sp.on('exit', resolve)
})
await copy(projectPath)
return exit(exitCode)
},
sendHtml (contents) {

Some files were not shown because too many files have changed in this diff Show More