diff --git a/.circleci/cache-version.txt b/.circleci/cache-version.txt index 476987003e..95ae2add67 100644 --- a/.circleci/cache-version.txt +++ b/.circleci/cache-version.txt @@ -1,2 +1,2 @@ # Bump this version to force CI to re-create the cache from scratch. -03-20-2026 +05-15-2026 diff --git a/.circleci/src/pipeline/@pipeline.yml b/.circleci/src/pipeline/@pipeline.yml index 5be8b975f4..456171015b 100644 --- a/.circleci/src/pipeline/@pipeline.yml +++ b/.circleci/src/pipeline/@pipeline.yml @@ -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: | diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 0d1a5fa11c..7df234e280 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,4 +1,13 @@ +## 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 /bundle.tar-` error during `cy.prompt` and Studio bundle initialization. Fixed in [#33748](https://github.com/cypress-io/cypress/pull/33748). + ## 15.15.0 **Deprecations:** diff --git a/cli/lib/exec/spawn.ts b/cli/lib/exec/spawn.ts index 01a25122f0..d0056e2f6c 100644 --- a/cli/lib/exec/spawn.ts +++ b/cli/lib/exec/spawn.ts @@ -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 } diff --git a/cli/lib/tasks/cache.ts b/cli/lib/tasks/cache.ts index 9dff14998e..8e883462ba 100644 --- a/cli/lib/tasks/cache.ts +++ b/cli/lib/tasks/cache.ts @@ -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 => { const versions = await fs.readdir(cacheDir) for (const version of versions) { + if (EXTERNAL_CACHE_ENTRIES.has(version)) continue + if (version !== checkedInBinaryVersion) { deletedBinary = true diff --git a/cli/lib/tasks/state.ts b/cli/lib/tasks/state.ts index 88daa49d28..58740fa3f7 100644 --- a/cli/lib/tasks/state.ts +++ b/cli/lib/tasks/state.ts @@ -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) diff --git a/cli/test/lib/exec/__snapshots__/spawn.spec.ts.snap b/cli/test/lib/exec/__snapshots__/spawn.spec.ts.snap index bbd373f1bf..6e2d1e835f 100644 --- a/cli/test/lib/exec/__snapshots__/spawn.spec.ts.snap +++ b/cli/test/lib/exec/__snapshots__/spawn.spec.ts.snap @@ -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", diff --git a/cli/test/lib/exec/spawn.spec.ts b/cli/test/lib/exec/spawn.spec.ts index 55586cf93b..0ab4bf3075 100644 --- a/cli/test/lib/exec/spawn.spec.ts +++ b/cli/test/lib/exec/spawn.spec.ts @@ -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 () => { diff --git a/cli/test/lib/tasks/cache.spec.ts b/cli/test/lib/tasks/cache.spec.ts index 4e9b8d5d97..065c5227c7 100644 --- a/cli/test/lib/tasks/cache.spec.ts +++ b/cli/test/lib/tasks/cache.spec.ts @@ -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) => { @@ -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---`, 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', () => { diff --git a/cli/test/lib/tasks/state.spec.ts b/cli/test/lib/tasks/state.spec.ts index e65d683d9d..c317384a6a 100644 --- a/cli/test/lib/tasks/state.spec.ts +++ b/cli/test/lib/tasks/state.spec.ts @@ -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() diff --git a/packages/app/cypress/e2e/settings.cy.ts b/packages/app/cypress/e2e/settings.cy.ts index a54a524f83..a7de393b82 100644 --- a/packages/app/cypress/e2e/settings.cy.ts +++ b/packages/app/cypress/e2e/settings.cy.ts @@ -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 diff --git a/packages/app/cypress/e2e/subscriptions/errorWarningChange-subscription.cy.ts b/packages/app/cypress/e2e/subscriptions/errorWarningChange-subscription.cy.ts index 0ff66adcc1..d4ddb3da0b 100644 --- a/packages/app/cypress/e2e/subscriptions/errorWarningChange-subscription.cy.ts +++ b/packages/app/cypress/e2e/subscriptions/errorWarningChange-subscription.cy.ts @@ -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') } diff --git a/packages/app/cypress/e2e/subscriptions/specChange-subscription.cy.ts b/packages/app/cypress/e2e/subscriptions/specChange-subscription.cy.ts index 48c5d40c20..e00899b087 100644 --- a/packages/app/cypress/e2e/subscriptions/specChange-subscription.cy.ts +++ b/packages/app/cypress/e2e/subscriptions/specChange-subscription.cy.ts @@ -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) }) }) diff --git a/packages/data-context/graphql/makeGraphQLServer.ts b/packages/data-context/graphql/makeGraphQLServer.ts index e1884286ae..1ca23c36bd 100644 --- a/packages/data-context/graphql/makeGraphQLServer.ts +++ b/packages/data-context/graphql/makeGraphQLServer.ts @@ -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) | 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 +} + +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()), + } } /** diff --git a/packages/data-context/graphql/schema.ts b/packages/data-context/graphql/schema.ts index 23754dbdf5..4a080cf2f4 100644 --- a/packages/data-context/graphql/schema.ts +++ b/packages/data-context/graphql/schema.ts @@ -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: [ diff --git a/packages/data-context/graphql/schemaTypes/objectTypes/gql-Query.ts b/packages/data-context/graphql/schemaTypes/objectTypes/gql-Query.ts index 6f82dbaddb..8396aa8e56 100644 --- a/packages/data-context/graphql/schemaTypes/objectTypes/gql-Query.ts +++ b/packages/data-context/graphql/schemaTypes/objectTypes/gql-Query.ts @@ -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, '../../'), diff --git a/packages/data-context/schemas/cloud.graphql b/packages/data-context/schemas/cloud.graphql index 2e23442e55..886ecb90dc 100644 --- a/packages/data-context/schemas/cloud.graphql +++ b/packages/data-context/schemas/cloud.graphql @@ -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 diff --git a/packages/data-context/schemas/schema.graphql b/packages/data-context/schemas/schema.graphql index 12d148df19..356c2cb92b 100644 --- a/packages/data-context/schemas/schema.graphql +++ b/packages/data-context/schemas/schema.graphql @@ -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. diff --git a/packages/data-context/src/DataContext.ts b/packages/data-context/src/DataContext.ts index 81e05b7c5d..486898e9c3 100644 --- a/packages/data-context/src/DataContext.ts +++ b/packages/data-context/src/DataContext.ts @@ -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() } /** diff --git a/packages/data-context/src/actions/ServersActions.ts b/packages/data-context/src/actions/ServersActions.ts index d7e8309c8c..c615ce0a54 100644 --- a/packages/data-context/src/actions/ServersActions.ts +++ b/packages/data-context/src/actions/ServersActions.ts @@ -41,8 +41,25 @@ export class ServersActions { }) } + setGqlGraphqlWsDispose (dispose: (() => Promise) | 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 diff --git a/packages/data-context/src/data/ProjectConfigIpc.ts b/packages/data-context/src/data/ProjectConfigIpc.ts index fe11ec8cb8..52dddb4a93 100644 --- a/packages/data-context/src/data/ProjectConfigIpc.ts +++ b/packages/data-context/src/data/ProjectConfigIpc.ts @@ -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() } } diff --git a/packages/data-context/src/data/ProjectConfigManager.ts b/packages/data-context/src/data/ProjectConfigManager.ts index 572ea386e9..6a042bf23f 100644 --- a/packages/data-context/src/data/ProjectConfigManager.ts +++ b/packages/data-context/src/data/ProjectConfigManager.ts @@ -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() }) diff --git a/packages/data-context/src/data/ProjectLifecycleManager.ts b/packages/data-context/src/data/ProjectLifecycleManager.ts index 9017c8d9ac..c1c496fa21 100644 --- a/packages/data-context/src/data/ProjectLifecycleManager.ts +++ b/packages/data-context/src/data/ProjectLifecycleManager.ts @@ -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 diff --git a/packages/data-context/src/data/coreDataShape.ts b/packages/data-context/src/data/coreDataShape.ts index 3825e919f7..7b9dcc7145 100644 --- a/packages/data-context/src/data/coreDataShape.ts +++ b/packages/data-context/src/data/coreDataShape.ts @@ -31,6 +31,11 @@ interface ServersDataShape { gqlServer?: Maybe gqlServerPort?: Maybe gqlSocketServer?: Maybe + /** + * graphql-ws `useServer` dispose — must run before `gqlServer.destroy()` so + * protocol clients close before the HTTP server tears down sockets. + */ + gqlGraphqlWsDispose?: Maybe<() => Promise> } export interface DevStateShape { diff --git a/packages/data-context/src/index.ts b/packages/data-context/src/index.ts index 31c28ec628..cc1863698c 100644 --- a/packages/data-context/src/index.ts +++ b/packages/data-context/src/index.ts @@ -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') } } diff --git a/packages/data-context/test/unit/data/ProjectConfigIpc-real-child-process.spec.ts b/packages/data-context/test/unit/data/ProjectConfigIpc-real-child-process.spec.ts index 43617b6de2..9d16aecc53 100644 --- a/packages/data-context/test/unit/data/ProjectConfigIpc-real-child-process.spec.ts +++ b/packages/data-context/test/unit/data/ProjectConfigIpc-real-child-process.spec.ts @@ -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') diff --git a/packages/data-context/test/unit/data/ProjectLifecycleManager.spec.ts b/packages/data-context/test/unit/data/ProjectLifecycleManager.spec.ts index 1409fef2d0..7b52b1930c 100644 --- a/packages/data-context/test/unit/data/ProjectLifecycleManager.spec.ts +++ b/packages/data-context/test/unit/data/ProjectLifecycleManager.spec.ts @@ -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 + + 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', () => { diff --git a/packages/electron/src/open.ts b/packages/electron/src/open.ts index f3f5417d57..7488c98359 100644 --- a/packages/electron/src/open.ts +++ b/packages/electron/src/open.ts @@ -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 { +export async function open ( + appPath: string, + argv: string[], +): Promise { const debugElectron = Debug('cypress:electron') const debugStderr = Debug('cypress:internal-stderr') @@ -76,23 +80,39 @@ export async function open (appPath: string, argv: string[]): Promise() + 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 || diff --git a/packages/electron/test/open.spec.ts b/packages/electron/test/open.spec.ts index 1eca3650cf..89712196fa 100644 --- a/packages/electron/test/open.spec.ts +++ b/packages/electron/test/open.spec.ts @@ -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 diff --git a/packages/launchpad/cypress/e2e/project-setup.cy.ts b/packages/launchpad/cypress/e2e/project-setup.cy.ts index aff77376d8..ee55429eb4 100644 --- a/packages/launchpad/cypress/e2e/project-setup.cy.ts +++ b/packages/launchpad/cypress/e2e/project-setup.cy.ts @@ -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') diff --git a/packages/network/lib/client-certificates.ts b/packages/network/lib/client-certificates.ts index 98ec5dc681..7f2bfb89ee 100644 --- a/packages/network/lib/client-certificates.ts +++ b/packages/network/lib/client-certificates.ts @@ -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}`) - } -} diff --git a/packages/network/package.json b/packages/network/package.json index 23ddd37871..15e70bab18 100644 --- a/packages/network/package.json +++ b/packages/network/package.json @@ -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": { diff --git a/packages/network/test/unit/agent.spec.ts b/packages/network/test/unit/agent.spec.ts index a3627975f3..4e4604efff 100644 --- a/packages/network/test/unit/agent.spec.ts +++ b/packages/network/test/unit/agent.spec.ts @@ -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 = '' diff --git a/packages/network/test/unit/client_certificates.spec.ts b/packages/network/test/unit/client_certificates.spec.ts index c0dd4133b3..9a01ff55eb 100644 --- a/packages/network/test/unit/client_certificates.spec.ts +++ b/packages/network/test/unit/client_certificates.spec.ts @@ -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', () => { diff --git a/packages/reporter/cypress/e2e/runnables.cy.ts b/packages/reporter/cypress/e2e/runnables.cy.ts index ff1a510229..c8a4ff4cad 100644 --- a/packages/reporter/cypress/e2e/runnables.cy.ts +++ b/packages/reporter/cypress/e2e/runnables.cy.ts @@ -217,6 +217,7 @@ describe('runnables', () => { }) it('adds a scroll listener in open mode', () => { + appState.autoScrollingEnabled = false appState.startRunning() cy.get('.container') .trigger('scroll') diff --git a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts deleted file mode 100644 index 8799df6d09..0000000000 --- a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts +++ /dev/null @@ -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 => { - 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((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 -} diff --git a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts deleted file mode 100644 index d07d300db6..0000000000 --- a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts +++ /dev/null @@ -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 => { - 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((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 -} diff --git a/packages/server/lib/cloud/bundles/bundle_error.ts b/packages/server/lib/cloud/bundles/bundle_error.ts new file mode 100644 index 0000000000..d144947581 --- /dev/null +++ b/packages/server/lib/cloud/bundles/bundle_error.ts @@ -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' + } +} diff --git a/packages/server/lib/cloud/bundles/cache_root.ts b/packages/server/lib/cloud/bundles/cache_root.ts new file mode 100644 index 0000000000..191ec3dcb7 --- /dev/null +++ b/packages/server/lib/cloud/bundles/cache_root.ts @@ -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) +} diff --git a/packages/server/lib/cloud/bundles/ensure_signed_bundle.ts b/packages/server/lib/cloud/bundles/ensure_signed_bundle.ts new file mode 100644 index 0000000000..648c736c3f --- /dev/null +++ b/packages/server/lib/cloud/bundles/ensure_signed_bundle.ts @@ -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 + bundleDir: string +} + +const randomSuffix = (): string => Math.random().toString(36).substring(2, 15) + +export const ensureSignedBundle = async ({ + url, + projectId, + kind, +}: EnsureSignedBundleOptions): Promise => { + 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, + bundleDir: finalDir, + } + } finally { + await remove(staging).catch(() => { /* ignore */ }) + } +} diff --git a/packages/server/lib/cloud/bundles/parse_hash_from_bundle_url.ts b/packages/server/lib/cloud/bundles/parse_hash_from_bundle_url.ts new file mode 100644 index 0000000000..5aa01ba2d5 --- /dev/null +++ b/packages/server/lib/cloud/bundles/parse_hash_from_bundle_url.ts @@ -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 +} diff --git a/packages/server/lib/cloud/bundles/publish_staging_to_final.ts b/packages/server/lib/cloud/bundles/publish_staging_to_final.ts new file mode 100644 index 0000000000..d2e160a671 --- /dev/null +++ b/packages/server/lib/cloud/bundles/publish_staging_to_final.ts @@ -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 => { + const fullDir = path.join(root, currentRel) + const entries = await readdir(fullDir) + + const nested = await Promise.all(entries.map(async (entry): Promise => { + 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 => { + 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 => { + 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) + } +} diff --git a/packages/server/lib/cloud/bundles/stream_download_verify_extract.ts b/packages/server/lib/cloud/bundles/stream_download_verify_extract.ts new file mode 100644 index 0000000000..d7796577e4 --- /dev/null +++ b/packages/server/lib/cloud/bundles/stream_download_verify_extract.ts @@ -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 => { + 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 => { + // 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[] = [] + + 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> + + 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 => { + 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) +} diff --git a/packages/server/lib/cloud/bundles/sweep_orphan_staging.ts b/packages/server/lib/cloud/bundles/sweep_orphan_staging.ts new file mode 100644 index 0000000000..634c290555 --- /dev/null +++ b/packages/server/lib/cloud/bundles/sweep_orphan_staging.ts @@ -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 => { + 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 => { + 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 +} diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index ac97165682..97ca4d38f0 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -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>> = new Map() + private static hashLoadingMap: Map, 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, diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index fdac46e531..34db4fda92 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -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> => { - 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 + cyPromptPath: string +} + +export const ensureCyPromptBundle = async ({ + cyPromptUrl, + projectId, +}: EnsureCyPromptBundleOptions): Promise => { + const { manifest, bundleDir } = await ensureSignedBundle({ + url: cyPromptUrl, + projectId, + kind: 'cy-prompt', + }) + + return { + manifest, + cyPromptPath: bundleDir, + } } diff --git a/packages/server/lib/cloud/encryption.ts b/packages/server/lib/cloud/encryption.ts index 00176ad8eb..3ea46729e3 100644 --- a/packages/server/lib/cloud/encryption.ts +++ b/packages/server/lib/cloud/encryption.ts @@ -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 { +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((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 diff --git a/packages/server/lib/cloud/extract_atomic.ts b/packages/server/lib/cloud/extract_atomic.ts index ffa72afbfc..884a90de68 100644 --- a/packages/server/lib/cloud/extract_atomic.ts +++ b/packages/server/lib/cloud/extract_atomic.ts @@ -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 { return new Promise((resolve) => setTimeout(resolve, ms)) } -async function writeFileAtomicWithRetry ( - filePath: string, - content: Buffer, - options: { mode?: number }, -): Promise { +async function retryOnRenameError (op: () => Promise): Promise { 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[] = [] - - 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((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 { + await retryOnRenameError(() => rename(src, dst)) } diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index 29cc082e84..244fd92502 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -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>> = new Map() + private static teardown: ExitStepKey | null = null + private static hashLoadingMap: Map, studioPath: string }>> = new Map() private static watcher: chokidar.FSWatcher | null = null private studioManagerPromise?: Promise 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 { 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 { 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') diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts index c7d83242b7..60ba7718c0 100644 --- a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -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 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> => { - 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 => { + 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) } diff --git a/packages/server/lib/cypress.ts b/packages/server/lib/cypress.ts index e07535838e..f06cb23407 100644 --- a/packages/server/lib/cypress.ts +++ b/packages/server/lib/cypress.ts @@ -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 { // 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) }, } diff --git a/packages/server/lib/makeDataContext.ts b/packages/server/lib/makeDataContext.ts index 5167d8ed6c..089a42031c 100644 --- a/packages/server/lib/makeDataContext.ts +++ b/packages/server/lib/makeDataContext.ts @@ -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 } diff --git a/packages/server/lib/modes/interactive.ts b/packages/server/lib/modes/interactive.ts index 484eeb45b4..695b69970f 100644 --- a/packages/server/lib/modes/interactive.ts +++ b/packages/server/lib/modes/interactive.ts @@ -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() }) }) diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index 4f9cfaf40b..e104181f46 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -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) = () => { throw new Error('bad relaunch') } constructor () { + super() + return autoBindDebug(this) } @@ -349,6 +350,8 @@ export class OpenProject { } } + this.emit('ready') + return this } diff --git a/packages/server/lib/plugins/child/require_async_child.js b/packages/server/lib/plugins/child/require_async_child.js index 7e15208fc5..5aa190f1c5 100644 --- a/packages/server/lib/plugins/child/require_async_child.js +++ b/packages/server/lib/plugins/child/require_async_child.js @@ -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') diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index c01b732990..537be53d6b 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -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, diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index d2cecdb3c5..a0bc0128c1 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -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 { // @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 { } useMorgan () { - return require('morgan')('dev') + return require('morgan')('dev', { + skip: () => GracefulExit.isShuttingDown, + }) } getHttpServer () { @@ -657,13 +660,23 @@ export class ServerBase { } 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 diff --git a/packages/server/lib/util/app_data.js b/packages/server/lib/util/app_data.js index 6bb844e00a..3fe2e76572 100644 --- a/packages/server/lib/util/app_data.js +++ b/packages/server/lib/util/app_data.js @@ -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 diff --git a/packages/server/lib/util/exit.ts b/packages/server/lib/util/exit.ts deleted file mode 100644 index 0349a6b491..0000000000 --- a/packages/server/lib/util/exit.ts +++ /dev/null @@ -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, -} diff --git a/packages/server/lib/util/file.ts b/packages/server/lib/util/file.ts index cc72e6795d..138dced41e 100644 --- a/packages/server/lib/util/file.ts +++ b/packages/server/lib/util/file.ts @@ -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 _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) }) } diff --git a/packages/server/lib/util/graceful-exit.ts b/packages/server/lib/util/graceful-exit.ts new file mode 100644 index 0000000000..c4356fb6ae --- /dev/null +++ b/packages/server/lib/util/graceful-exit.ts @@ -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 | 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 | null = null + private teardownStartedAt: number | null = null + private steps: Map = 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 => { + 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 => { + 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 { + 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 { + 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 { + 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((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 + } +} diff --git a/packages/server/package.json b/packages/server/package.json index 7ec3cfb461..f0583b16bb 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/patches/axios+1.15.0.patch b/packages/server/patches/axios+1.15.2.patch similarity index 100% rename from packages/server/patches/axios+1.15.0.patch rename to packages/server/patches/axios+1.15.2.patch diff --git a/packages/server/start-cypress.js b/packages/server/start-cypress.js index ed1a75fa26..ab09b18c26 100644 --- a/packages/server/start-cypress.js +++ b/packages/server/start-cypress.js @@ -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) diff --git a/packages/server/test/integration/cloud/extract_atomic_spec.ts b/packages/server/test/integration/cloud/extract_atomic_spec.ts deleted file mode 100644 index f9a9a77973..0000000000 --- a/packages/server/test/integration/cloud/extract_atomic_spec.ts +++ /dev/null @@ -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) - }) -}) diff --git a/packages/server/test/integration/cypress_spec.js b/packages/server/test/integration/cypress_spec.js index 75fb8dfc07..0180ea18cc 100644 --- a/packages/server/test/integration/cypress_spec.js +++ b/packages/server/test/integration/cypress_spec.js @@ -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} startPromise - return value of `cypress.start(...)` + * @param {(project: import('../../lib/project-base').ProjectBase) => void | Promise} assertWhileOpen + * @returns {Promise} + */ +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) }) }) diff --git a/packages/server/test/performance/proxy_performance_spec.js b/packages/server/test/performance/proxy_performance_spec.js index cd32ddcabf..77b3c46625 100644 --- a/packages/server/test/performance/proxy_performance_spec.js +++ b/packages/server/test/performance/proxy_performance_spec.js @@ -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) diff --git a/packages/server/test/spec_helper.js b/packages/server/test/spec_helper.js index 62f1bca5f3..366b3a2885 100644 --- a/packages/server/test/spec_helper.js +++ b/packages/server/test/spec_helper.js @@ -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 diff --git a/packages/server/test/support/cross_process_publish_worker.ts b/packages/server/test/support/cross_process_publish_worker.ts new file mode 100644 index 0000000000..bdf8b92fe1 --- /dev/null +++ b/packages/server/test/support/cross_process_publish_worker.ts @@ -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 ') + process.exit(2) +} + +publishStagingToFinal(staging, finalDir).then( + () => process.exit(0), + (err) => { + // eslint-disable-next-line no-console + console.error(err) + process.exit(1) + }, +) diff --git a/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts deleted file mode 100644 index 03b9c4bd7e..0000000000 --- a/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts +++ /dev/null @@ -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', - }) - }) -}) diff --git a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts deleted file mode 100644 index b7ecef13bd..0000000000 --- a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts +++ /dev/null @@ -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 - }) -}) diff --git a/packages/server/test/unit/cloud/bundles/bundle_error_spec.ts b/packages/server/test/unit/cloud/bundles/bundle_error_spec.ts new file mode 100644 index 0000000000..a489e93a06 --- /dev/null +++ b/packages/server/test/unit/cloud/bundles/bundle_error_spec.ts @@ -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) + }) +}) diff --git a/packages/server/test/unit/cloud/bundles/cache_root_spec.ts b/packages/server/test/unit/cloud/bundles/cache_root_spec.ts new file mode 100644 index 0000000000..35c18fb38b --- /dev/null +++ b/packages/server/test/unit/cloud/bundles/cache_root_spec.ts @@ -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 = {} + + 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/ tail. + expect(getBundleCacheDir('studio')).to.match(/[/\\]bundles[/\\]studio$/) + expect(getBundleCacheDir('studio')).to.not.equal(path.resolve('bundles/studio')) + }) +}) diff --git a/packages/server/test/unit/cloud/bundles/ensure_signed_bundle_spec.ts b/packages/server/test/unit/cloud/bundles/ensure_signed_bundle_spec.ts new file mode 100644 index 0000000000..02f4af8f7d --- /dev/null +++ b/packages/server/test/unit/cloud/bundles/ensure_signed_bundle_spec.ts @@ -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 + 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 /bundles/// 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([]) + }) +}) diff --git a/packages/server/test/unit/cloud/bundles/parse_hash_from_bundle_url_spec.ts b/packages/server/test/unit/cloud/bundles/parse_hash_from_bundle_url_spec.ts new file mode 100644 index 0000000000..3283da8609 --- /dev/null +++ b/packages/server/test/unit/cloud/bundles/parse_hash_from_bundle_url_spec.ts @@ -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/) + }) +}) diff --git a/packages/server/test/unit/cloud/bundles/publish_staging_to_final_spec.ts b/packages/server/test/unit/cloud/bundles/publish_staging_to_final_spec.ts new file mode 100644 index 0000000000..fae265ef00 --- /dev/null +++ b/packages/server/test/unit/cloud/bundles/publish_staging_to_final_spec.ts @@ -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) => { + 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 = { + '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]) + } + }) +}) diff --git a/packages/server/test/unit/cloud/bundles/stream_download_verify_extract_spec.ts b/packages/server/test/unit/cloud/bundles/stream_download_verify_extract_spec.ts new file mode 100644 index 0000000000..cdd15f1d58 --- /dev/null +++ b/packages/server/test/unit/cloud/bundles/stream_download_verify_extract_spec.ts @@ -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) + }) + }) +}) diff --git a/packages/server/test/unit/cloud/bundles/sweep_orphan_staging_spec.ts b/packages/server/test/unit/cloud/bundles/sweep_orphan_staging_spec.ts new file mode 100644 index 0000000000..5750e19a52 --- /dev/null +++ b/packages/server/test/unit/cloud/bundles/sweep_orphan_staging_spec.ts @@ -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) + }) +}) diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts index 9cfda51064..fb816cacba 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -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', () => { diff --git a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts index a28cac4b78..a5ccf67a41 100644 --- a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts @@ -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) }) }) diff --git a/packages/server/test/unit/cloud/encryption_spec.js b/packages/server/test/unit/cloud/encryption_spec.js index 6d5cbfb24e..0515f4d475 100644 --- a/packages/server/test/unit/cloud/encryption_spec.js +++ b/packages/server/test/unit/cloud/encryption_spec.js @@ -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) - }) - }) }) diff --git a/packages/server/test/unit/cloud/extract_atomic_spec.ts b/packages/server/test/unit/cloud/extract_atomic_spec.ts index 547122f69f..b34676ff2a 100644 --- a/packages/server/test/unit/cloud/extract_atomic_spec.ts +++ b/packages/server/test/unit/cloud/extract_atomic_spec.ts @@ -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((resolve) => { - resolveWrite1 = resolve - }) - const write2Promise = new Promise((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 }) }) diff --git a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index a0da7b3632..3e8a82599f 100644 --- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -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) => { diff --git a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts index 0d5da846eb..07abeed0be 100644 --- a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts @@ -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) }) }) diff --git a/packages/server/test/unit/cloud/studio/telemetry/TelemetryReporter_spec.ts b/packages/server/test/unit/cloud/studio/telemetry/TelemetryReporter_spec.ts index 961f7d3ef9..361b9a72ec 100644 --- a/packages/server/test/unit/cloud/studio/telemetry/TelemetryReporter_spec.ts +++ b/packages/server/test/unit/cloud/studio/telemetry/TelemetryReporter_spec.ts @@ -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 diff --git a/packages/server/test/unit/modes/interactive_spec.js b/packages/server/test/unit/modes/interactive_spec.js index ca5113db6a..2207b18af8 100644 --- a/packages/server/test/unit/modes/interactive_spec.js +++ b/packages/server/test/unit/modes/interactive_spec.js @@ -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) + }) }) }) }) diff --git a/packages/server/test/unit/plugins/child/require_async_child_spec.js b/packages/server/test/unit/plugins/child/require_async_child_spec.js index c50e2894ca..a4b8d717c1 100644 --- a/packages/server/test/unit/plugins/child/require_async_child_spec.js +++ b/packages/server/test/unit/plugins/child/require_async_child_spec.js @@ -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: {}, diff --git a/packages/server/test/unit/plugins/child/run_child_fixture.js b/packages/server/test/unit/plugins/child/run_child_fixture.js index 57175e7d06..7952381976 100644 --- a/packages/server/test/unit/plugins/child/run_child_fixture.js +++ b/packages/server/test/unit/plugins/child/run_child_fixture.js @@ -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() }) diff --git a/packages/server/test/unit/project-base_spec.js b/packages/server/test/unit/project-base_spec.js index 07f0c9da6d..47613a77d2 100644 --- a/packages/server/test/unit/project-base_spec.js +++ b/packages/server/test/unit/project-base_spec.js @@ -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() } }) diff --git a/packages/server/test/unit/server-base_spec.js b/packages/server/test/unit/server-base_spec.js index b25aad5a63..4c2fd967c7 100644 --- a/packages/server/test/unit/server-base_spec.js +++ b/packages/server/test/unit/server-base_spec.js @@ -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() diff --git a/packages/server/test/unit/util/app_data_spec.js b/packages/server/test/unit/util/app_data_spec.js index 6d58044b20..011f39b625 100644 --- a/packages/server/test/unit/util/app_data_spec.js +++ b/packages/server/test/unit/util/app_data_spec.js @@ -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') diff --git a/packages/server/test/unit/util/file_spec.ts b/packages/server/test/unit/util/file_spec.ts index 589118de97..917df202bc 100644 --- a/packages/server/test/unit/util/file_spec.ts +++ b/packages/server/test/unit/util/file_spec.ts @@ -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 } }).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 + const addStepStub = sinon.stub(GracefulExit, 'addStep').callsFake((fn: () => Promise | void) => { + teardownStep = fn as () => Promise - 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', () => { diff --git a/packages/server/test/unit/util/graceful_exit_spec.ts b/packages/server/test/unit/util/graceful_exit_spec.ts new file mode 100644 index 0000000000..4c08626e27 --- /dev/null +++ b/packages/server/test/unit/util/graceful_exit_spec.ts @@ -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 (fn: () => Promise): Promise { + 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((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((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((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) + }) +}) diff --git a/scripts/gulp/tasks/gulpRegistry.ts b/scripts/gulp/tasks/gulpRegistry.ts index 07da7b542e..7fb412d205 100644 --- a/scripts/gulp/tasks/gulpRegistry.ts +++ b/scripts/gulp/tasks/gulpRegistry.ts @@ -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() const exitedPids = new Set() @@ -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) { diff --git a/scripts/gulp/utils/psTreeSync.ts b/scripts/gulp/utils/psTreeSync.ts deleted file mode 100644 index 38139714ad..0000000000 --- a/scripts/gulp/utils/psTreeSync.ts +++ /dev/null @@ -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)] -} diff --git a/scripts/lerna-build.js b/scripts/lerna-build.js index 2a8dea829f..c9bd56802b 100644 --- a/scripts/lerna-build.js +++ b/scripts/lerna-build.js @@ -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 }, ) diff --git a/system-tests/__snapshots__/form_submissions_spec.js b/system-tests/__snapshots__/form_submissions_spec.js index cdcd2bedd9..c292b76702 100644 --- a/system-tests/__snapshots__/form_submissions_spec.js +++ b/system-tests/__snapshots__/form_submissions_spec.js @@ -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 - - - - - -` diff --git a/system-tests/__snapshots__/headless_spec.ts.js b/system-tests/__snapshots__/headless_spec.ts.js index 830e9fb71d..ebd35fad59 100644 --- a/system-tests/__snapshots__/headless_spec.ts.js +++ b/system-tests/__snapshots__/headless_spec.ts.js @@ -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'] = ` ==================================================================================================== diff --git a/system-tests/lib/docker.ts b/system-tests/lib/docker.ts index 194114e0f9..c6f026019a 100644 --- a/system-tests/lib/docker.ts +++ b/system-tests/lib/docker.ts @@ -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) }, ) } diff --git a/system-tests/lib/system-tests.ts b/system-tests/lib/system-tests.ts index cddb6b5c14..7a45680a46 100644 --- a/system-tests/lib/system-tests.ts +++ b/system-tests/lib/system-tests.ts @@ -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((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) { diff --git a/system-tests/package.json b/system-tests/package.json index b1bff289ea..f387bd689b 100644 --- a/system-tests/package.json +++ b/system-tests/package.json @@ -86,6 +86,7 @@ "supertest": "4.0.2", "systeminformation": "^5.27.7", "temp-dir": "^2.0.0", + "tree-kill": "1.2.2", "ws": "5.2.4" }, "types": "lib/fixtures.ts", diff --git a/system-tests/test/clean_process_exit_spec.ts b/system-tests/test/clean_process_exit_spec.ts new file mode 100644 index 0000000000..50027bb610 --- /dev/null +++ b/system-tests/test/clean_process_exit_spec.ts @@ -0,0 +1,50 @@ +import systemTests, { expect, type ExecResult } from '../lib/system-tests' + +describe('clean process exit (teardown)', () => { + systemTests.setup() + + // systemTests.exec asserts expected exit code and that the Cypress child did not + // terminate via a signal (e.g. SIGABRT during teardown). See exit handler in + // lib/system-tests.ts. + systemTests.it('exits with code 0 without terminating by signal after a passing run', { + spec: 'simple_passing.cy.js', + expectedExitCode: 0, + browser: ['electron'], + project: 'e2e', + }) + + // After a completed run with --no-exit, send SIGINT to the launcher process and + // assert teardown does not abort (SIGABRT). Exit may be graceful (code) or via + // SIGINT depending on platform/handlers; SIGABRT indicates a regression. + systemTests.it('exits without a sigabrt when sent a sigint', { + spec: 'simple_passing.cy.js', + project: 'e2e', + browser: ['electron'], + noExit: true, + expectedExitCode: null, + onSpawn: (cp) => { + let sent = false + let stdoutAcc = '' + + cp.stdout.on('data', (buf) => { + if (sent) { + return + } + + stdoutAcc += buf.toString() + + if (stdoutAcc.includes('not exiting due to options.exit being false')) { + sent = true + cp.kill('SIGINT') + } + }) + }, + onRun: async (execFn) => { + const result: ExecResult = await execFn() + + expect(result.signal, 'process should not terminate by SIGABRT').to.not.equal('SIGABRT') + + return result + }, + }) +}) diff --git a/system-tests/test/form_submissions_spec.js b/system-tests/test/form_submissions_spec.js index 84b4f5a426..f0fb72c2b8 100644 --- a/system-tests/test/form_submissions_spec.js +++ b/system-tests/test/form_submissions_spec.js @@ -97,7 +97,6 @@ describe('e2e forms', () => { systemTests.it('passing', { spec: 'form_submission_passing.cy.js', - snapshot: true, }) systemTests.it('failing', { diff --git a/system-tests/test/headless_spec.ts b/system-tests/test/headless_spec.ts index ac3ef0c269..de697d90f3 100644 --- a/system-tests/test/headless_spec.ts +++ b/system-tests/test/headless_spec.ts @@ -51,7 +51,6 @@ describe('e2e headless', function () { }, }, headed: false, - snapshot: true, }) // NOTE: cypress run --headed diff --git a/tooling/v8-snapshot/cache/darwin/snapshot-meta.json b/tooling/v8-snapshot/cache/darwin/snapshot-meta.json index ea420dd570..ad633f6472 100644 --- a/tooling/v8-snapshot/cache/darwin/snapshot-meta.json +++ b/tooling/v8-snapshot/cache/darwin/snapshot-meta.json @@ -816,10 +816,25 @@ "./packages/server/lib/plugins/run_events.ts", "./packages/server/lib/reporter.js", "./packages/server/lib/screenshots.ts", + "./packages/server/lib/util/chrome_policy_check.js", "./packages/server/lib/util/cookies.ts", "./packages/server/lib/util/fs.ts", - "./packages/server/lib/util/glob.ts", + "./packages/server/lib/util/glob.js", "./packages/server/lib/video_capture.ts", + "./packages/server/node_modules/axios/lib/adapters/adapters.js", + "./packages/server/node_modules/axios/lib/axios.js", + "./packages/server/node_modules/axios/lib/cancel/CanceledError.js", + "./packages/server/node_modules/axios/lib/core/Axios.js", + "./packages/server/node_modules/axios/lib/core/AxiosError.js", + "./packages/server/node_modules/axios/lib/core/AxiosHeaders.js", + "./packages/server/node_modules/axios/lib/defaults/index.js", + "./packages/server/node_modules/axios/lib/helpers/AxiosTransformStream.js", + "./packages/server/node_modules/axios/lib/helpers/ZlibHeaderTransformStream.js", + "./packages/server/node_modules/axios/lib/helpers/cookies.js", + "./packages/server/node_modules/axios/lib/helpers/formDataToStream.js", + "./packages/server/node_modules/axios/lib/helpers/isURLSameOrigin.js", + "./packages/server/node_modules/axios/lib/platform/node/index.js", + "./packages/server/node_modules/axios/lib/utils.js", "./packages/server/node_modules/body-parser/index.js", "./packages/server/node_modules/body-parser/node_modules/debug/src/browser.js", "./packages/server/node_modules/body-parser/node_modules/debug/src/index.js", @@ -885,9 +900,9 @@ "./packages/server/node_modules/serve-static/node_modules/debug/src/index.js", "./packages/server/node_modules/serve-static/node_modules/mime/mime.js", "./packages/server/node_modules/serve-static/node_modules/send/index.js", - "./packages/server/node_modules/signal-exit/dist/cjs/signals.js", "./packages/server/node_modules/supports-color/index.js", "./packages/server/node_modules/tough-cookie/lib/cookie.js", + "./packages/server/node_modules/write-file-atomic/node_modules/signal-exit/dist/cjs/signals.js", "./packages/server/start-cypress.js", "./packages/server/v8-snapshot-entry.js", "./packages/socket/cjs/node/cdp-socket.js", @@ -1169,7 +1184,6 @@ "./node_modules/@babel/types/lib/validators/react/isCompatTag.js", "./node_modules/@babel/types/lib/validators/react/isReactComponent.js", "./node_modules/@babel/types/lib/validators/validate.js", - "./node_modules/@cypress/get-windows-proxy/node_modules/debug/src/common.js", "./node_modules/@cypress/get-windows-proxy/node_modules/registry-js/dist/lib/index.js", "./node_modules/@cypress/get-windows-proxy/node_modules/registry-js/dist/lib/registry.js", "./node_modules/@cypress/get-windows-proxy/src/index.js", @@ -1539,8 +1553,6 @@ "./node_modules/@opentelemetry/semantic-conventions/build/src/resource/index.js", "./node_modules/@opentelemetry/semantic-conventions/build/src/trace/SemanticAttributes.js", "./node_modules/@opentelemetry/semantic-conventions/build/src/trace/index.js", - "./node_modules/@simple-git/args-pathspec/dist/index.cjs", - "./node_modules/@simple-git/argv-parser/dist/index.cjs", "./node_modules/@sindresorhus/df/node_modules/execa/index.js", "./node_modules/@sindresorhus/df/node_modules/execa/lib/command.js", "./node_modules/@sindresorhus/df/node_modules/execa/lib/error.js", @@ -1598,7 +1610,6 @@ "./node_modules/adm-zip/zipEntry.js", "./node_modules/adm-zip/zipFile.js", "./node_modules/aggregate-error/index.js", - "./node_modules/aggregate-error/node_modules/indent-string/index.js", "./node_modules/ansi-regex/index.js", "./node_modules/ansi-styles/index.js", "./node_modules/ansi_up/ansi_up.js", @@ -1715,6 +1726,7 @@ "./node_modules/chalk/templates.js", "./node_modules/charenc/charenc.js", "./node_modules/charset/index.js", + "./node_modules/check-more-types/dist/check-more-types.js", "./node_modules/chrome-remote-interface/lib/api.js", "./node_modules/chrome-remote-interface/lib/defaults.js", "./node_modules/chrome-remote-interface/lib/devtools.js", @@ -1992,7 +2004,6 @@ "./node_modules/firefox-profile/node_modules/fs-extra/lib/move/move.js", "./node_modules/firefox-profile/node_modules/fs-extra/lib/output-file/index.js", "./node_modules/firefox-profile/node_modules/fs-extra/lib/remove/index.js", - "./node_modules/firefox-profile/node_modules/fs-extra/lib/util/async.js", "./node_modules/firefox-profile/node_modules/fs-extra/lib/util/stat.js", "./node_modules/firefox-profile/node_modules/fs-extra/lib/util/utimes.js", "./node_modules/firefox-profile/node_modules/ini/lib/ini.js", @@ -2280,6 +2291,7 @@ "./node_modules/image-size/dist/types/webp.js", "./node_modules/image-size/dist/utils/bit-reader.js", "./node_modules/imurmurhash/imurmurhash.js", + "./node_modules/indent-string/index.js", "./node_modules/inflight/inflight.js", "./node_modules/inherits/inherits.js", "./node_modules/inherits/inherits_browser.js", @@ -3525,6 +3537,7 @@ "./packages/network-tools/cjs/accept-encoding.js", "./packages/network-tools/cjs/types.js", "./packages/network/cjs/allow-destroy.js", + "./packages/network/node_modules/proxy-from-env/index.js", "./packages/proxy/lib/http/error-middleware.ts", "./packages/proxy/lib/http/util/ast-rewriter.ts", "./packages/proxy/lib/http/util/buffers.ts", @@ -3693,7 +3706,8 @@ "./packages/server/lib/plugins/index.ts", "./packages/server/lib/privileged-commands/privileged-commands-manager.ts", "./packages/server/lib/project_utils.ts", - "./packages/server/lib/request.ts", + "./packages/server/lib/remote_states.ts", + "./packages/server/lib/request.js", "./packages/server/lib/routes.ts", "./packages/server/lib/saved_state.ts", "./packages/server/lib/server-base.ts", @@ -3706,20 +3720,19 @@ "./packages/server/lib/util/app_data.js", "./packages/server/lib/util/args.ts", "./packages/server/lib/util/async_retry.ts", - "./packages/server/lib/util/cache_buster.ts", - "./packages/server/lib/util/chrome_policy_check.ts", + "./packages/server/lib/util/cache_buster.js", "./packages/server/lib/util/chromium_flags.ts", - "./packages/server/lib/util/ci_provider.ts", + "./packages/server/lib/util/ci_provider.js", "./packages/server/lib/util/class-helpers.ts", "./packages/server/lib/util/commit-info.ts", - "./packages/server/lib/util/duration.ts", + "./packages/server/lib/util/duration.js", "./packages/server/lib/util/editors.ts", "./packages/server/lib/util/electron-app.js", "./packages/server/lib/util/ensure-url.ts", "./packages/server/lib/util/env-editors.ts", "./packages/server/lib/util/env.ts", "./packages/server/lib/util/escape_filename.ts", - "./packages/server/lib/util/exit.ts", + "./packages/server/lib/util/exit.js", "./packages/server/lib/util/file-opener.ts", "./packages/server/lib/util/file.ts", "./packages/server/lib/util/find_process.ts", @@ -3729,11 +3742,11 @@ "./packages/server/lib/util/human_time.ts", "./packages/server/lib/util/net_profiler.ts", "./packages/server/lib/util/network_failures.js", - "./packages/server/lib/util/newlines.ts", + "./packages/server/lib/util/newlines.js", "./packages/server/lib/util/patch-fs.ts", "./packages/server/lib/util/performance_benchmark.js", "./packages/server/lib/util/print-run.ts", - "./packages/server/lib/util/profile_cleaner.ts", + "./packages/server/lib/util/profile_cleaner.js", "./packages/server/lib/util/proxy.ts", "./packages/server/lib/util/random.ts", "./packages/server/lib/util/server_destroy.ts", @@ -3747,6 +3760,46 @@ "./packages/server/lib/util/tests_utils.ts", "./packages/server/lib/util/trash.ts", "./packages/server/lib/util/tty.ts", + "./packages/server/node_modules/axios/index.js", + "./packages/server/node_modules/axios/lib/adapters/fetch.js", + "./packages/server/node_modules/axios/lib/adapters/xhr.js", + "./packages/server/node_modules/axios/lib/cancel/CancelToken.js", + "./packages/server/node_modules/axios/lib/cancel/isCancel.js", + "./packages/server/node_modules/axios/lib/core/InterceptorManager.js", + "./packages/server/node_modules/axios/lib/core/buildFullPath.js", + "./packages/server/node_modules/axios/lib/core/dispatchRequest.js", + "./packages/server/node_modules/axios/lib/core/mergeConfig.js", + "./packages/server/node_modules/axios/lib/core/settle.js", + "./packages/server/node_modules/axios/lib/core/transformData.js", + "./packages/server/node_modules/axios/lib/defaults/transitional.js", + "./packages/server/node_modules/axios/lib/env/data.js", + "./packages/server/node_modules/axios/lib/helpers/AxiosURLSearchParams.js", + "./packages/server/node_modules/axios/lib/helpers/HttpStatusCode.js", + "./packages/server/node_modules/axios/lib/helpers/bind.js", + "./packages/server/node_modules/axios/lib/helpers/buildURL.js", + "./packages/server/node_modules/axios/lib/helpers/callbackify.js", + "./packages/server/node_modules/axios/lib/helpers/combineURLs.js", + "./packages/server/node_modules/axios/lib/helpers/composeSignals.js", + "./packages/server/node_modules/axios/lib/helpers/formDataToJSON.js", + "./packages/server/node_modules/axios/lib/helpers/fromDataURI.js", + "./packages/server/node_modules/axios/lib/helpers/isAbsoluteURL.js", + "./packages/server/node_modules/axios/lib/helpers/isAxiosError.js", + "./packages/server/node_modules/axios/lib/helpers/parseHeaders.js", + "./packages/server/node_modules/axios/lib/helpers/parseProtocol.js", + "./packages/server/node_modules/axios/lib/helpers/progressEventReducer.js", + "./packages/server/node_modules/axios/lib/helpers/readBlob.js", + "./packages/server/node_modules/axios/lib/helpers/resolveConfig.js", + "./packages/server/node_modules/axios/lib/helpers/speedometer.js", + "./packages/server/node_modules/axios/lib/helpers/spread.js", + "./packages/server/node_modules/axios/lib/helpers/throttle.js", + "./packages/server/node_modules/axios/lib/helpers/toFormData.js", + "./packages/server/node_modules/axios/lib/helpers/toURLEncodedForm.js", + "./packages/server/node_modules/axios/lib/helpers/trackStream.js", + "./packages/server/node_modules/axios/lib/helpers/validator.js", + "./packages/server/node_modules/axios/lib/platform/common/utils.js", + "./packages/server/node_modules/axios/lib/platform/index.js", + "./packages/server/node_modules/axios/lib/platform/node/classes/FormData.js", + "./packages/server/node_modules/axios/lib/platform/node/classes/URLSearchParams.js", "./packages/server/node_modules/body-parser/lib/read.js", "./packages/server/node_modules/body-parser/lib/types/json.js", "./packages/server/node_modules/body-parser/lib/types/raw.js", @@ -3836,7 +3889,6 @@ "./packages/server/node_modules/serve-static/node_modules/debug/src/debug.js", "./packages/server/node_modules/serve-static/node_modules/mime/types.json", "./packages/server/node_modules/serve-static/node_modules/ms/index.js", - "./packages/server/node_modules/signal-exit/dist/cjs/index.js", "./packages/server/node_modules/statuses/codes.json", "./packages/server/node_modules/statuses/index.js", "./packages/server/node_modules/tough-cookie/lib/memstore.js", @@ -3857,6 +3909,7 @@ "./packages/server/node_modules/whatwg-url/lib/url-state-machine.js", "./packages/server/node_modules/whatwg-url/lib/utils.js", "./packages/server/node_modules/write-file-atomic/lib/index.js", + "./packages/server/node_modules/write-file-atomic/node_modules/signal-exit/dist/cjs/index.js", "./packages/socket/cjs/node/index.js", "./packages/socket/cjs/utils.js", "./packages/socket/node_modules/engine.io-parser/build/cjs/commons.js", diff --git a/yarn.lock b/yarn.lock index 41eefaa057..0eb71680fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28683,7 +28683,7 @@ signal-exit@3.0.7, signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3, s resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== -signal-exit@4.1.0, signal-exit@^4.0.1, signal-exit@^4.1.0: +signal-exit@^4.0.1, signal-exit@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== @@ -32886,14 +32886,6 @@ write-file-atomic@5.0.1, write-file-atomic@^5.0.0, write-file-atomic@^5.0.1: imurmurhash "^0.1.4" signal-exit "^4.0.1" -write-file-atomic@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-7.0.0.tgz#f89def4f223e9bf8b06cc6fdb12bda3a917505c7" - integrity sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg== - dependencies: - imurmurhash "^0.1.4" - signal-exit "^4.0.1" - write-file-atomic@^2.4.2: version "2.4.3" resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481"